platform-mounts: port is_dangerous_path + get_mounts parsers (untyped)

ober

e272a246ab32d17f12417d99b8251aee4748a8f3

diff --git a/Makefile b/Makefile
index 2585da8..a4923eb 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 event-danger-check persistence-check file-change-check webshell-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 persistence-check file-change-check webshell-check platform-mounts-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)"
@@ -216,6 +216,13 @@ file-change-check:
 webshell-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/webshell_check.ss
 
+# Platform mount helpers (secmon platform/{linux,freebsd}.rs): is_dangerous_path
+# (exact membership in the per-platform set) + the get_mounts line parsers
+# (Linux split_whitespace field[1]; FreeBSD " on " split, drop " (opts)"). Pure —
+# the /proc read / `mount` exec is the deferred I/O.
+platform-mounts-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/platform_mounts_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
@@ -245,6 +252,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/persistence_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/file_change_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/webshell_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/platform_mounts_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 420bf3c..049c5a2 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,7 @@ make event-danger-check # mount is_dangerous + capability dangerous_caps predica
 make persistence-check # classify_path (-> persistence type) + suspicious-content line scan
 make file-change-check # is_suspicious_change: setuid/setgid added, critical files, sensitive dirs
 make webshell-check  # web-server-spawned suspicious child: name/cmdline classifier + reason
+make platform-mounts-check # is_dangerous_path (per-platform exact set) + get_mounts line parsers
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -116,6 +117,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor::dns::read_dns_servers` (resolver-set builder) | `jsecmon/dns-servers.ss` | ✅ **untyped layer** — `parse_dns_servers(content)`: collect each `nameserver <ip>` entry from resolv.conf text (the 2nd whitespace field of a trimmed line starting with `nameserver`) and union with the fixed public-resolver set (Google/Cloudflare/Quad9/OpenDNS). Pure parsing, so untyped; the `/etc/resolv.conf` read is the deferred I/O wrapper (split off like the selinux log tail). Folds tabs/CR to spaces to match Rust's `split_whitespace`. `make dns-servers-check` reproduces secmon's `test_read_dns_servers` (publics always present) + the nameserver parsing with multi-space/tab/indented lines and dedup. |
 | `dtrace::scripts::is_sensitive_path` (sensitive-path classifier) | `jsecmon/sensitive-path.ss` | ✅ **untyped layer** — `is_sensitive_path(path)`: a sensitive prefix (passwd/shadow/sudoers/ssh dirs/cron/periodic/ld.so.preload/`/boot/`/…), with `/home/` special-cased to only `/.ssh/` subpaths, else `authorized_keys` anywhere, else `/cron` or `/periodic`. Pure string classification, so untyped; obfstr!-hidden literals decode to these plaintexts. Ported with secmon's loop-with-early-return so the **load-bearing corner** holds: a `/home/` path short-circuits before the `authorized_keys` check, so `/home/user/authorized_keys` (no `/.ssh/`) is **not** sensitive. `make sensitive-path-check` reproduces secmon's `test_sensitive_path_detection` + each signal + that corner. |
 | `dtrace::consumer::EventParser` (DTrace line parser) | `jsecmon/dtrace-parse.ss` | ✅ **untyped layer** — `parse_dtrace_line(line)`: split a `SECMON\|TYPE\|…` DTrace line on `\|` and dispatch on `parts[1]` into a per-type structured record (EXEC/EXIT/CONNECT/LISTEN/OPEN/WRITE) with each handler's exact field extraction; a <2-field / unknown-type / too-few-fields line yields no record (`#f`), matching secmon's `return Ok(())` no-ops. A text parser yielding a structured record, like the DNS/SELinux parsers, so untyped (alist, since the per-type fields are disjoint). Numeric fields use `.parse().unwrap_or(0)` (u32 rejects negatives → 0; exit code is i32), and the EXEC cmdline is `split_whitespace`. **Composes** `(jsecmon sensitive-path)` for the OPEN `sensitive?` gate. `make dtrace-parse-check` reproduces secmon's `test_parse_exec_line` / `test_parse_exit_line` + the other four formats + the no-event and `unwrap_or(0)` corners. (The stateful parts — process cache, suspicious-exec dispatch, channel send — are the deferred consumer loop.) |
+| `platform::{linux,freebsd}` (`is_dangerous_path` + `get_mounts` parsers) | `jsecmon/platform-mounts.ss` | ✅ **untyped layer** — the pure halves of each `IsolationProvider`, reads stripped: `is_dangerous_path` is **exact** membership in the platform's dangerous-path set (Linux 12 entries incl. `/proc/kcore`, `/dev/mem`, the docker/crio/containerd sockets; FreeBSD 6 incl. `/dev/io`, `devd.pipe`; unknown platforms empty, per the `UnsupportedProvider` default), and `parse_mounts` reproduces each `get_mounts` line loop — Linux `split_whitespace` keeping field[1], FreeBSD `split(" on ")` keeping piece[1] minus a trailing ` (opts)`. Pins that membership is exact not prefix (`/host/foo` is clean), and that the FreeBSD split takes piece[1] of a multi-`" on "` split (not everything-after-first). obfstr!-hidden lists decode to these plaintexts. Pure — the `/proc/self/mounts` read / `mount` exec is the deferred I/O — no native lib; secmon has no `#[test]` here so `make platform-mounts-check` asserts against the Rust source. (The `container.rs` test-only mock `is_dangerous_path` is `#[cfg(test)]` scaffolding, not ported.) |
 | `platform::linux` (/proc parsers) | `jsecmon/proc-linux.ss` | ✅ **untyped layer** — the pure parsing helpers with the file reads stripped: `parse_stat` (comm between first `(` and **last** `)`, ppid the 2nd field after `") "`), `parse_uid` (first `Uid:` line, 2nd field), `hex_to_state` (TCP state table → `UNKNOWN`), `parse_ipv4` (little-endian hex → dotted quad), `parse_ipv6` (32-hex → 8 groups), `parse_addr` (`HEXADDR:HEXPORT`, ipv6 when protocol contains `6`). Pure text/number parsing, so untyped. `parse_stat`/`parse_uid` use `.parse::<u32>().ok()` so failure is `#f` (not 0) and negatives are rejected; `parse_ipv4` rejects >`0xFFFFFFFF`; ports are u16. `make proc-linux-check` reproduces secmon's five linux.rs tests + ipv6/parse-addr + a comm-with-paren corner. (The `/proc` reads and inode→pid scan are the deferred I/O.) |
 | `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.) |
diff --git a/examples/platform_mounts_check.ss b/examples/platform_mounts_check.ss
new file mode 100644
index 0000000..77a2969
--- /dev/null
+++ b/examples/platform_mounts_check.ss
@@ -0,0 +1,74 @@
+;;; Parity check for (jsecmon platform-mounts) against secmon
+;;; platform/{linux,freebsd}.rs (is_dangerous_path + the get_mounts line loops).
+;;; secmon has no #[test] here, so this derives expectations from the Rust source
+;;; and IS the spec for the port.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/platform_mounts_check.ss
+
+(import (jerboa prelude)
+        (jsecmon platform-mounts))
+
+(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)))))
+
+;; ── is-dangerous-path (EXACT membership, platform-keyed) ─────────────────────
+(displayln "is-dangerous-path linux:")
+(check "linux /dev/mem"      (is-dangerous-path "/dev/mem" 'linux) #t)
+(check "linux /host"         (is-dangerous-path "/host" 'linux) #t)
+(check "linux core_pattern"  (is-dangerous-path "/proc/sys/kernel/core_pattern" 'linux) #t)
+(check "linux containerd sock" (is-dangerous-path "/run/containerd/containerd.sock" 'linux) #t)
+(check "linux /dev/io (bsd-only) -> #f" (is-dangerous-path "/dev/io" 'linux) #f)
+(check "linux /etc/passwd -> #f" (is-dangerous-path "/etc/passwd" 'linux) #f)
+;; exact, not prefix: a child of /host is NOT itself dangerous
+(check "linux /host/foo exact -> #f" (is-dangerous-path "/host/foo" 'linux) #f)
+
+(displayln "is-dangerous-path freebsd:")
+(check "freebsd /dev/io"     (is-dangerous-path "/dev/io" 'freebsd) #t)
+(check "freebsd devd.pipe"   (is-dangerous-path "/var/run/devd.pipe" 'freebsd) #t)
+(check "freebsd /host"       (is-dangerous-path "/host" 'freebsd) #t)
+(check "freebsd /dev/mem (both)" (is-dangerous-path "/dev/mem" 'freebsd) #t)
+(check "freebsd /proc/kcore (linux-only) -> #f" (is-dangerous-path "/proc/kcore" 'freebsd) #f)
+
+(displayln "is-dangerous-path other:")
+(check "other platform empty set -> #f" (is-dangerous-path "/dev/mem" 'macos) #f)
+
+;; ── parse-mounts linux (/proc/self/mounts: field[1], split_whitespace) ───────
+(displayln "parse-mounts linux:")
+(check "two mounts"
+       (parse-mounts "proc /proc proc rw,nosuid 0 0\nsysfs /sys sysfs rw 0 0" 'linux)
+       '("/proc" "/sys"))
+(check "blank line skipped"
+       (parse-mounts "proc /proc proc\n\nsysfs /sys sysfs" 'linux)
+       '("/proc" "/sys"))
+(check "too few fields skipped"
+       (parse-mounts "single\n" 'linux) '())
+(check "multiple spaces collapse"
+       (parse-mounts "dev    /mnt   ext4 rw" 'linux) '("/mnt"))
+(check "tabs are whitespace"
+       (parse-mounts "dev\t/mnt\text4" 'linux) '("/mnt"))
+
+;; ── parse-mounts freebsd (`mount`: split " on ", piece[1], drop " (opts)") ───
+(displayln "parse-mounts freebsd:")
+(check "two mounts with opts"
+       (parse-mounts "/dev/ada0p2 on / (ufs, local)\ntmpfs on /tmp (tmpfs, local)" 'freebsd)
+       '("/" "/tmp"))
+(check "no opts -> whole tail"
+       (parse-mounts "devfs on /dev" 'freebsd) '("/dev"))
+(check "no ' on ' skipped"
+       (parse-mounts "garbage line" 'freebsd) '())
+;; multi-" on ": take piece[1] (between 1st and 2nd), not everything-after-first
+(check "multi on -> piece[1]"
+       (parse-mounts "a on /b on c (x)" 'freebsd) '("/b"))
+
+;; ── unknown platform: no mounts (UnsupportedProvider returns vec![]) ─────────
+(check "other platform -> no mounts"
+       (parse-mounts "proc /proc proc\nx on /y" 'macos) '())
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: platform-mounts matches secmon's linux/freebsd providers.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/platform-mounts.ss b/jsecmon/platform-mounts.ss
new file mode 100644
index 0000000..6b84efb
--- /dev/null
+++ b/jsecmon/platform-mounts.ss
@@ -0,0 +1,112 @@
+#!chezscheme
+;;; jsecmon platform mount helpers (secmon platform/{linux,freebsd}.rs), untyped.
+;;;
+;;; The pure parts of the per-platform IsolationProvider, with the file/command
+;;; reads (the deferred I/O) stripped off:
+;;;   is-dangerous-path  : path platform   -> #t | #f
+;;;   parse-mounts       : text platform   -> list of mount points
+;;; Two halves of one flow: read /proc/self/mounts (Linux) or `mount` output
+;;; (FreeBSD) -> parse-mounts -> classify each with is-dangerous-path (and feed
+;;; the same list to (jsecmon container) is-suspicious-mount).
+;;;
+;;; is-dangerous-path is EXACT membership in the platform's dangerous-path set
+;;;   (Rust `LIST.iter().any(|s| s == path)`); unknown platforms have an empty
+;;;   set (the UnsupportedProvider default returns false). obfstr!-hidden
+;;;   literals decode to these plaintexts.
+;;;
+;;; parse-mounts mirrors each get_mounts line loop exactly:
+;;;   * Linux: split_whitespace, keep field[1] (the mount point) when >=2 fields
+;;;     — blank lines yield 0 fields and are skipped.
+;;;   * FreeBSD: split on " on " (ALL occurrences, like Rust `split`), take
+;;;     piece[1] when >=2 pieces, then drop a trailing " (opts)" — field[1] is
+;;;     the piece between the 1st and 2nd " on ", not "everything after the
+;;;     first", so the split is faithful to Rust's multi-delimiter split.
+;;;   Rust `.lines()` is reproduced by splitting on \n and stripping a trailing
+;;;   \r (handles \r\n); the trailing empty after a final \n is harmless (it
+;;;   parses to nothing on both platforms).
+;;;
+;;; secmon has no #[test] for these, so examples/platform_mounts_check.ss
+;;; derives every expectation from the Rust source and IS the spec.
+
+(library (jsecmon platform-mounts)
+  (export is-dangerous-path parse-mounts
+          *linux-dangerous-paths* *freebsd-dangerous-paths*)
+  (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?))
+
+  ;; LINUX_DANGEROUS_PATHS (decoded obfstr! literals), exact-matched.
+  (def *linux-dangerous-paths*
+    '("/proc/sys/kernel/core_pattern" "/proc/sysrq-trigger" "/proc/kcore"
+      "/proc/kmem" "/dev/mem" "/dev/kmem" "/host" "/.dockerenv"
+      "/var/run/docker.sock" "/run/docker.sock"
+      "/var/run/crio/crio.sock" "/run/containerd/containerd.sock"))
+
+  ;; FREEBSD_DANGEROUS_PATHS (decoded), exact-matched.
+  (def *freebsd-dangerous-paths*
+    '("/dev/mem" "/dev/kmem" "/dev/io" "/host"
+      "/var/run/devd.pipe" "/var/run/devd.seqpacket.pipe"))
+
+  ;; the per-platform dangerous-path set (empty for unknown platforms — the
+  ;; UnsupportedProvider default). if-chain rather than cond/else: the MCP
+  ;; expander's pre-scan mis-flags this two-way switch as a misplaced `else`
+  ;; (real Chez is fine), and an if-chain sidesteps it entirely.
+  (def (dangerous-paths-for platform)
+    (if (eq? platform 'linux) *linux-dangerous-paths*
+        (if (eq? platform 'freebsd) *freebsd-dangerous-paths* '())))
+
+  (def (is-dangerous-path path platform)
+    (if (member path (dangerous-paths-for platform)) #t #f))
+
+  (def (strip-cr line)
+    (if (string-suffix? "\r" line)
+        (substring line 0 (- (string-length line) 1))
+        line))
+
+  (def (lines-of text) (map strip-cr (string-split text #\newline)))
+
+  ;; split on EVERY occurrence of `sub` (Rust str::split semantics).
+  (def (split-on-substring s sub)
+    (let ((sublen (string-length sub)))
+      (let loop ((s s) (acc '()))
+        (let ((idx (string-contains s sub)))
+          (if idx
+              (loop (substring s (+ idx sublen) (string-length s))
+                    (cons (substring s 0 idx) acc))
+              (reverse (cons s acc)))))))
+
+  ;; split_whitespace: fold tab/CR to space, split, drop empties.
+  (def (split-ws line)
+    (let ((folded (string-map (lambda (c)
+                                (if (or (char=? c #\tab) (char=? c #\return))
+                                    #\space c))
+                              line)))
+      (filter (lambda (s) (not (string-empty? s))) (string-split folded #\space))))
+
+  ;; LinuxContainerProvider::get_mounts line loop: field[1] when >=2 fields.
+  (def (parse-mount-line-linux line)
+    (let ((parts (split-ws line)))
+      (and (>= (length parts) 2) (list-ref parts 1))))
+
+  ;; FreeBSDJailProvider::get_mounts line loop: piece[1] of " on " split, minus
+  ;; a trailing " (opts)".
+  (def (parse-mount-line-freebsd line)
+    (let ((parts (split-on-substring line " on ")))
+      (and (>= (length parts) 2)
+           (let* ((mount-part (list-ref parts 1))
+                  (pos (string-contains mount-part " (")))
+             (if pos (substring mount-part 0 pos) mount-part)))))
+
+  ;; pick the per-line parser (if-chain, same reason as above), then filter-map
+  ;; once. Unknown platforms parse to no mounts (UnsupportedProvider vec![]).
+  (def (parse-mounts text platform)
+    (let ((parser (if (eq? platform 'linux) parse-mount-line-linux
+                      (if (eq? platform 'freebsd) parse-mount-line-freebsd #f))))
+      (if parser (filter-map parser (lines-of text)) '()))))