Port secmon dtrace::scripts::is_sensitive_path to (jsecmon sensitive-path)

ober

1155b70c585eb05fccc25e2cb57b3dc2192362f3

diff --git a/Makefile b/Makefile
index dd2bd08..1a50f3e 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 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 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)"
@@ -149,6 +149,12 @@ container-check:
 dns-servers-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_servers_check.ss
 
+# Sensitive-path classifier (secmon src/dtrace/scripts.rs is_sensitive_path):
+# sensitive prefixes (passwd/shadow/sudoers/ssh/cron/boot/...), the /home/-only-
+# .ssh special case, authorized_keys, cron/periodic. Pure, no native lib.
+sensitive-path-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/sensitive_path_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
@@ -168,6 +174,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/selinux_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/container_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_servers_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/sensitive_path_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 002c971..122aaa1 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ make kernmod-check   # kernel-module classifier: rootkit substring, short name, 
 make selinux-check   # SELinux audit-log parser: AVC + boolean/policy/role events
 make container-check # container/jail escape mount classifier (host bind, docker sock)
 make dns-servers-check # resolv.conf nameserver parse + public-resolver union
+make sensitive-path-check # DTrace sensitive-path classifier (passwd/ssh/cron/...)
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -104,5 +105,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor::selinux::SELinuxMonitor` (audit-log parser) | `jsecmon/selinux.ss` | ✅ **untyped layer** — the line-parsing core: `parse_audit_line` dispatches on the `type=` tag (AVC → `parse_avc_event`, MAC_CONFIG_CHANGE → boolean change, MAC_POLICY_LOAD → policy load, USER_ROLE_CHANGE → role change) into a `selinux-event` record mirroring `SELinuxEventInfo`, plus the `extract_field` helper. A text-format parser yielding a structured record, like the DNS parser, so untyped. secmon's AVC regex is reused verbatim through Jerboa's `(std pregexp)` `pregexp-match` (capture order 1=decision 2=permission 3=pid 4=comm 5=scontext 6=tcontext 7=tclass — verified identical). Pins `extract_field`'s quoting/empty/missing-quote corners and the `val=` default-empty. `make selinux-check` reproduces secmon's two selinux.rs tests + the dispatcher + all four event kinds. (The I/O — tailing the audit log, mode polling — is the deferred monitor loop.) |
 | `monitor::container::ContainerEscapeMonitor` (mount classifier) | `jsecmon/container.ss` | ✅ **untyped layer** — `is_suspicious_mount(mount)`: a mount that starts with `/host` or `/mnt/host`, is exactly `/`, or contains `/var/run/docker` / `/run/docker` / `devd.pipe` (FreeBSD jail), flagging a container/jail escape. Pure string classification like the other monitor classifiers, so untyped; obfstr!-hidden patterns decode to these plaintext literals. `make container-check` reproduces secmon's `test_suspicious_mount_detection` + each escape signal + negatives. (Isolation detection and mount/path/cap polling are provider-driven I/O — the deferred monitor loop.) |
 | `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. |
 | `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/sensitive_path_check.ss b/examples/sensitive_path_check.ss
new file mode 100644
index 0000000..a534560
--- /dev/null
+++ b/examples/sensitive_path_check.ss
@@ -0,0 +1,54 @@
+;;; Parity check for (jsecmon sensitive-path) against secmon's scripts.rs test
+;;; test_sensitive_path_detection, plus each signal and the load-bearing
+;;; /home/ early-return corner.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/sensitive_path_check.ss
+
+(import (jerboa prelude)
+        (jsecmon sensitive-path))
+
+(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)))))
+
+;; ── secmon test_sensitive_path_detection (sensitive) ─────────────────────────
+(displayln "secmon test_sensitive_path_detection — sensitive:")
+(check "/etc/passwd"                 (sensitive-path? "/etc/passwd") #t)
+(check "/etc/master.passwd"          (sensitive-path? "/etc/master.passwd") #t)
+(check "/etc/ssh/sshd_config"        (sensitive-path? "/etc/ssh/sshd_config") #t)
+(check "/root/.ssh/authorized_keys"  (sensitive-path? "/root/.ssh/authorized_keys") #t)
+(check "/home/user/.ssh/authorized_keys" (sensitive-path? "/home/user/.ssh/authorized_keys") #t)
+(check "/var/cron/tabs/root"         (sensitive-path? "/var/cron/tabs/root") #t)
+
+;; ── secmon test_sensitive_path_detection (non-sensitive) ─────────────────────
+(displayln "secmon — non-sensitive:")
+(check "/tmp/foo"                    (sensitive-path? "/tmp/foo") #f)
+(check "/var/log/messages"           (sensitive-path? "/var/log/messages") #f)
+(check "/home/user/documents/file.txt" (sensitive-path? "/home/user/documents/file.txt") #f)
+
+;; ── individual signals ───────────────────────────────────────────────────────
+(displayln "individual signals:")
+(check "/etc/shadow prefix"          (sensitive-path? "/etc/shadow") #t)
+(check "/boot/loader.conf prefix"    (sensitive-path? "/boot/loader.conf") #t)
+(check "/etc/ld.so.preload"          (sensitive-path? "/etc/ld.so.preload") #t)
+(check "/usr/local/etc/sudoers"      (sensitive-path? "/usr/local/etc/sudoers") #t)
+(check "authorized_keys anywhere"    (sensitive-path? "/srv/data/authorized_keys") #t)
+(check "/periodic anywhere"          (sensitive-path? "/usr/local/etc/periodic/daily/x") #t)
+(check "/cron substring"             (sensitive-path? "/var/spool/cron/root") #t)
+
+;; ── /home/ early-return corner (the load-bearing detail) ─────────────────────
+(displayln "/home/ early-return corner:")
+;; starts with /home/, no /.ssh/, but DOES contain authorized_keys — the /home/
+;; branch returns #f immediately, short-circuiting the authorized_keys check.
+(check "/home/user/authorized_keys -> #f (short-circuit)"
+       (sensitive-path? "/home/user/authorized_keys") #f)
+(check "/home/bob/.ssh/id_rsa -> #t" (sensitive-path? "/home/bob/.ssh/id_rsa") #t)
+(check "/home/ alone -> #f"          (sensitive-path? "/home/") #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: sensitive-path matches secmon's scripts.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/sensitive-path.ss b/jsecmon/sensitive-path.ss
new file mode 100644
index 0000000..a048b08
--- /dev/null
+++ b/jsecmon/sensitive-path.ss
@@ -0,0 +1,63 @@
+#!chezscheme
+;;; jsecmon sensitive-path classifier (secmon dtrace::scripts), untyped.
+;;;
+;;; Port of `is_sensitive_path` from secmon's src/dtrace/scripts.rs: decide
+;;; whether a filesystem path is security-sensitive (and so worth a DTrace file
+;;; event). The signals, in secmon's order:
+;;;   1. path starts with any SENSITIVE_PATHS prefix (passwd, shadow, sudoers,
+;;;      ssh dirs, cron/periodic dirs, ld.so.preload, /boot/, ...). The "/home/"
+;;;      entry is special: a /home/ path counts ONLY if it also contains
+;;;      "/.ssh/".
+;;;   2. otherwise, path contains "authorized_keys" anywhere
+;;;   3. otherwise, path contains "/cron" or "/periodic"
+;;;
+;;; Pure string classification returning a boolean, like the other classifiers,
+;;; so untyped. secmon hides every literal with obfstr!; they decode to these
+;;; plaintexts at runtime.
+;;;
+;;; Faithfulness note — the load-bearing detail: a prefix match RETURNS
+;;; immediately, so the "/home/" branch short-circuits before the
+;;; authorized_keys / cron checks. Thus "/home/user/authorized_keys" (no
+;;; "/.ssh/") is NOT sensitive, even though it contains authorized_keys. Ported
+;;; with the same loop-with-early-return structure rather than a flat `any`.
+;;;
+;;; Verified against secmon's scripts.rs test_sensitive_path_detection in
+;;; examples/sensitive_path_check.ss.
+
+(library (jsecmon sensitive-path)
+  (export sensitive-path?)
+  (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 *sensitive-paths*
+    '("/etc/passwd" "/etc/master.passwd" "/etc/shadow" "/etc/group"
+      "/etc/sudoers" "/etc/ssh/" "/etc/rc.conf" "/etc/rc.local"
+      "/etc/crontab" "/var/cron/" "/etc/periodic/" "/root/.ssh/"
+      "/home/"                       ;; special: only .ssh subdirs count
+      "/etc/ld.so.preload" "/etc/ld-elf.so.hints" "/boot/"
+      "/usr/local/etc/sudoers"))
+
+  ;; path: string -> #t if the path is security-sensitive, else #f.
+  (def (sensitive-path? path)
+    (let loop ((ps *sensitive-paths*))
+      (cond
+        ;; no prefix matched: authorized_keys anywhere, then cron/periodic
+        ((null? ps)
+         (cond
+           ((string-contains path "authorized_keys") #t)
+           ((or (string-contains path "/cron") (string-contains path "/periodic")) #t)
+           (else #f)))
+        ;; a prefix matched -> return its verdict NOW (early return)
+        ((string-prefix? (car ps) path)
+         (if (string=? (car ps) "/home/")
+             (and (string-contains path "/.ssh/") #t)
+             #t))
+        (else (loop (cdr ps)))))))