Port monitor/events mount+capability danger predicates to untyped Jerboa

ober

222852cca471b5725f073efc1acb2e75db573847

diff --git a/Makefile b/Makefile
index 92044c1..6f0d820 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme
 BUILD  ?= build/rust
 TYPED  := $(wildcard typed/*.ss)
 
-.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check checks clean
 # Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon
 # (a second --libdirs would replace, not append, the jerboa one).
 LIBDIRS := "$(JERBOA)/lib:$(CURDIR)"
@@ -188,6 +188,13 @@ event-meta-check:
 config-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss
 
+# Mount/capability danger predicates (secmon monitor/events.rs): MountEventInfo
+# is_dangerous (sensitive bind-mount sources / mount-over targets) and
+# CapabilityEventInfo dangerous_caps (cap_effective bit set -> cap names). These
+# feed event-meta's mount/capability severity. Pure, no native lib.
+event-danger-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_danger_check.ss
+
 # Everything that runs through the Jerboa side of the bridge, one shot.
 checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_check.ss
@@ -213,6 +220,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/freebsd_parse_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_meta_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_danger_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 5a3695a..6952669 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,7 @@ make proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net
 make freebsd-parse-check # FreeBSD kldstat row + sockstat addr:port (decimal, wildcard, v6)
 make event-meta-check # event-type -> display severity + coarse store-priority u8 tables
 make config-check    # AgentConfig defaults + from_env merge + platform db/key paths
+make event-danger-check # mount is_dangerous + capability dangerous_caps predicates
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -116,5 +117,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `platform::freebsd` (parsers) | `jsecmon/freebsd-parse.ss` | ✅ **untyped layer** — the pure parsing helpers with the command/file reads stripped: `parse_kldstat_line` (≥5 whitespace fields, name is `parts[4]`, size is `parts[3]` as hex with optional `0x`, size `None` on non-hex via `.ok()`, action always `Loaded`) and `parse_address` (`addr:port` split at the **last** `:`, `[ipv6]:port` split at the first `]`, `*` address → `0.0.0.0`, `*` port → `0`). Ports here are **DECIMAL** u16 (`.parse()`), unlike Linux's hex `/proc/net`. Pure text/number parsing, so untyped. `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives. (The `kldstat`/`sockstat` command runs are the deferred I/O.) |
 | `event_json` + `local_store` (tables) | `jsecmon/event-meta.ss` | ✅ **untyped layer** — the pure classification tables lifted out of the payload-carrying `EventType` enum: `event_json.rs` `get_event_json_data`'s **display severity** (25 constant arms as a name→severity table, + the 7 payload-dependent arms as named helpers taking the deciding field — `auth`/`privilege_change`/`mount`/`capability`/`podman`/`selinux`/`lateral_movement`), and `local_store.rs` `event_severity_u8`'s **coarse store priority** 0..3, which is an *independent* scale (e.g. `privilege_escalation` is `critical` for display but `0` for the store). secmon has no `#[test]` here, so `make event-meta-check` asserts both full tables arm-for-arm against the Rust source. (The JSON payload bodies stay with the I/O layer that owns the event structs.) |
 | `config` | `jsecmon/config.ss` | ✅ **untyped layer** — `AgentConfig`'s pure parts: the defaults (`0.0.0.0:31337`, poll `100`ms, buffer `10000`), the `from_env` merge (overwrites `listen_addr` on any present value but only overwrites poll/buffer when the value parses as strict u64 — a bad value **keeps the default**, it is not zeroed), and `local_db_path`/`local_key_path` (env override, else `/opt/secmon/{events.db,local.key}` on linux+freebsd, else the `./secmon_*` cwd fallback). Parameterized over a `getenv` callback + a `platform` symbol so the env reads stay deferred I/O; the build.rs-embedded secrets (`get_public_key`/`get_psk`/`is_debug_mode`) belong to the build/FFI phase, not this layer. secmon has no tests here, so `make config-check` asserts the behaviour against the Rust source. |
+| `monitor/events` (danger predicates) | `jsecmon/event-danger.ss` | ✅ **untyped layer** — the payload predicates that drive a mount/capability event's severity, lifted off their structs: `MountEventInfo::is_dangerous` (`mount-danger-reason source target` → reason string, with the faithful corner that the `/` source entry's prefix is `//` so a plain `/foo` is **not** flagged, and dangerous *targets* match exact-only) and `CapabilityEventInfo::dangerous_caps` (`cap_effective` bits → cap names in the Rust push order, full u64 so bits 38/39 work). These compute the booleans `event-meta`'s mount/capability severity helpers consume. Pure, no native lib; `make event-danger-check` asserts against the Rust source. |
 | `monitor::dns_sniffer` (DNS wire parser + dedup) | `jsecmon/dns-sniffer.ss` | ✅ **untyped layer** — the platform-independent half of secmon's `src/monitor/dns_sniffer.rs`: the DNS wire-format parser (QNAME decoding with compression-pointer chasing capped at 128 steps, QTYPE→string, question + A/AAAA answer-RR extraction) and the 5s dedup / 30s cleanup state machine. Every bounds check is preserved — a truncated/malformed/looping packet yields `#f`, never a bad read. Pure byte parsing → untyped, like geoip. The AF_PACKET raw-socket capture + `/proc` PID lookup stay for the monitor I/O driver. `make dns-sniffer-check` reproduces secmon's parser + dedup tests (+ AAAA, qtype table, pointer-loop/qdcount guards). |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/event_danger_check.ss b/examples/event_danger_check.ss
new file mode 100644
index 0000000..1fa90eb
--- /dev/null
+++ b/examples/event_danger_check.ss
@@ -0,0 +1,74 @@
+;;; Parity check for (jsecmon event-danger) against secmon monitor/events.rs
+;;; (MountEventInfo::is_dangerous, CapabilityEventInfo::dangerous_caps). secmon
+;;; has no #[test] for these, so this derives expectations from the Rust source
+;;; and IS the spec for the port.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/event_danger_check.ss
+
+(import (jerboa prelude)
+        (jsecmon event-danger))
+
+(def fails 0)
+(def (check name got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") name
+               (if ok "" (str "   got " got " want " want)))))
+
+;; ── MountEventInfo::is_dangerous: dangerous sources ──────────────────────────
+(displayln "mount danger (sources):")
+(check "root exact"   (mount-danger-reason "/" "/tmp/t")
+       "Bind mount of sensitive path: /")
+(check "/etc exact"   (mount-danger-reason "/etc" "/tmp/t")
+       "Bind mount of sensitive path: /etc")
+(check "/etc subpath" (mount-danger-reason "/etc/foo" "/tmp/t")
+       "Bind mount of sensitive path: /etc/foo")
+(check "/home subpath" (mount-danger-reason "/home/user" "/tmp/t")
+       "Bind mount of sensitive path: /home/user")
+(check "/proc subpath" (mount-danger-reason "/proc/1/mem" "/tmp/t")
+       "Bind mount of sensitive path: /proc/1/mem")
+(check "docker.sock"  (mount-danger-reason "/var/run/docker.sock" "/tmp/t")
+       "Bind mount of sensitive path: /var/run/docker.sock")
+;; KEY faithfulness: the "/" entry's prefix is "//", so "/foo" is NOT flagged
+(check "/foo NOT dangerous (slash prefix is //)" (mount-danger-reason "/foo" "/tmp/t") #f)
+(check "safe source+target -> #f" (mount-danger-reason "/tmp/x" "/tmp/y") #f)
+
+;; ── dangerous targets (EXACT match only) ─────────────────────────────────────
+(displayln "mount danger (targets):")
+(check "target passwd" (mount-danger-reason "/tmp/x" "/etc/passwd")
+       "Mount over sensitive path: /etc/passwd")
+(check "target /bin"   (mount-danger-reason "/tmp/x" "/bin")
+       "Mount over sensitive path: /bin")
+(check "target subpath NOT matched (exact only)"
+       (mount-danger-reason "/tmp/x" "/usr/bin/python") #f)
+;; a dangerous source wins over (is checked before) a dangerous target
+(check "source checked before target"
+       (mount-danger-reason "/etc/shadow" "/etc/passwd")
+       "Bind mount of sensitive path: /etc/shadow")
+
+;; ── CapabilityEventInfo::dangerous_caps ──────────────────────────────────────
+(displayln "dangerous caps:")
+(check "none"         (dangerous-caps 0) '())
+(check "non-dangerous bit 20 -> none" (dangerous-caps (ash 1 20)) '())
+(check "SYS_ADMIN"    (dangerous-caps (ash 1 21)) '("CAP_SYS_ADMIN"))
+(check "BPF (bit 39)" (dangerous-caps (ash 1 39)) '("CAP_BPF"))
+(check "PERFMON (bit 38)" (dangerous-caps (ash 1 38)) '("CAP_PERFMON"))
+;; output follows the table push order, NOT the bit order of the input
+(check "21|19 -> ADMIN then PTRACE"
+       (dangerous-caps (bitwise-ior (ash 1 19) (ash 1 21)))
+       '("CAP_SYS_ADMIN" "CAP_SYS_PTRACE"))
+(check "NET_ADMIN|NET_RAW"
+       (dangerous-caps (bitwise-ior (ash 1 12) (ash 1 13)))
+       '("CAP_NET_ADMIN" "CAP_NET_RAW"))
+;; all 11 dangerous bits set -> full list in push order
+(check "all dangerous bits"
+       (dangerous-caps (fold-left (lambda (acc b) (bitwise-ior acc (ash 1 b))) 0
+                                  '(21 19 16 12 13 17 22 23 25 38 39)))
+       '("CAP_SYS_ADMIN" "CAP_SYS_PTRACE" "CAP_SYS_MODULE" "CAP_NET_ADMIN"
+         "CAP_NET_RAW" "CAP_SYS_RAWIO" "CAP_SYS_BOOT" "CAP_SYS_NICE"
+         "CAP_SYS_TIME" "CAP_PERFMON" "CAP_BPF"))
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: event-danger matches secmon's monitor/events.rs predicates.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/event-danger.ss b/jsecmon/event-danger.ss
new file mode 100644
index 0000000..92d46c2
--- /dev/null
+++ b/jsecmon/event-danger.ss
@@ -0,0 +1,76 @@
+#!chezscheme
+;;; jsecmon mount/capability danger predicates (secmon monitor/events.rs), untyped.
+;;;
+;;; The pure payload predicates that decide a MountEvent's / CapabilityEvent's
+;;; severity, lifted off their structs so they can be checked and so they can
+;;; drive (jsecmon event-meta)'s mount-event-severity / capability-event-severity
+;;; (which take exactly the boolean these compute):
+;;;   mount-danger-reason : (source target) -> reason string | #f   (is_dangerous)
+;;;   dangerous-caps      : cap-effective-u64 -> (cap-name ...)      (dangerous_caps)
+;;;
+;;; Faithfulness notes:
+;;;   * is_dangerous tests each DANGEROUS_SOURCES path with
+;;;     `source == path || source.starts_with("{path}/")`. For the "/" entry the
+;;;     prefix is "//", so a plain absolute path like "/foo" is NOT flagged by
+;;;     "/" — only source exactly "/" is. The other prefixes ("/etc" -> "/etc/")
+;;;     match subpaths as expected. The reason string interpolates the *input*
+;;;     source/target, not the matched prefix, so source-list order can't change
+;;;     the output. Sources are checked before targets.
+;;;   * DANGEROUS_TARGETS match is EXACT (== only, no prefix).
+;;;   * dangerous_caps tests bits of cap_effective and returns the names in the
+;;;     Rust push order (SYS_ADMIN first ... BPF last); bits 38/39 exceed 32 so
+;;;     cap_effective is a full u64.
+;;;
+;;; secmon has no #[test] for these, so examples/event_danger_check.ss derives
+;;; every expectation from the Rust source and IS the spec for this port.
+
+(library (jsecmon event-danger)
+  (export mount-danger-reason dangerous-caps)
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?))
+
+  (def *dangerous-sources*
+    '("/" "/etc" "/etc/shadow" "/etc/passwd" "/etc/sudoers"
+      "/root" "/home" "/var/run/docker.sock" "/run/podman"
+      "/var/run/containerd" "/proc" "/sys" "/dev"))
+
+  (def *dangerous-targets*
+    '("/etc/passwd" "/etc/shadow" "/etc/sudoers"
+      "/bin" "/sbin" "/usr/bin" "/usr/sbin"))
+
+  ;; MountEventInfo::is_dangerous: a reason string, or #f when safe.
+  (def (mount-danger-reason source target)
+    (let loop-src ((paths *dangerous-sources*))
+      (cond
+        ((null? paths)
+         (let loop-tgt ((tps *dangerous-targets*))
+           (cond ((null? tps) #f)
+                 ((string=? target (car tps))
+                  (str "Mount over sensitive path: " target))
+                 (else (loop-tgt (cdr tps))))))
+        ((or (string=? source (car paths))
+             (string-prefix? (str (car paths) "/") source))
+         (str "Bind mount of sensitive path: " source))
+        (else (loop-src (cdr paths))))))
+
+  ;; (cap-bit . name) in the Rust push order — the returned list preserves it.
+  (def *dangerous-cap-bits*
+    '((21 . "CAP_SYS_ADMIN") (19 . "CAP_SYS_PTRACE") (16 . "CAP_SYS_MODULE")
+      (12 . "CAP_NET_ADMIN") (13 . "CAP_NET_RAW") (17 . "CAP_SYS_RAWIO")
+      (22 . "CAP_SYS_BOOT") (23 . "CAP_SYS_NICE") (25 . "CAP_SYS_TIME")
+      (38 . "CAP_PERFMON") (39 . "CAP_BPF")))
+
+  ;; CapabilityEventInfo::dangerous_caps: names whose bit is set in cap_effective.
+  (def (dangerous-caps cap-effective)
+    (filter-map
+     (lambda (pair)
+       (and (bitwise-bit-set? cap-effective (car pair)) (cdr pair)))
+     *dangerous-cap-bits*)))