Port secmon dtrace::consumer EventParser line parsing to (jsecmon dtrace-parse)

ober

050f57e5b39334c6a13d1b4697bbf55ded5813d7

diff --git a/Makefile b/Makefile
index 1a50f3e..3d6aef9 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 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 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)"
@@ -155,6 +155,12 @@ dns-servers-check:
 sensitive-path-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/sensitive_path_check.ss
 
+# DTrace output-line parser (secmon src/dtrace/consumer.rs EventParser): split
+# SECMON|TYPE|... lines into per-type structured records (EXEC/EXIT/CONNECT/
+# LISTEN/OPEN/WRITE), composing sensitive-path for the OPEN gate. Pure, no lib.
+dtrace-parse-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_parse_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
@@ -175,6 +181,7 @@ checks: kernels-check
 	$(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
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_parse_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 122aaa1..52fc25e 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ make selinux-check   # SELinux audit-log parser: AVC + boolean/policy/role event
 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 dtrace-parse-check # DTrace SECMON|TYPE|... line parser (exec/exit/connect/...)
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -106,5 +107,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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. |
+| `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.) |
 | `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/dtrace_parse_check.ss b/examples/dtrace_parse_check.ss
new file mode 100644
index 0000000..becb81c
--- /dev/null
+++ b/examples/dtrace_parse_check.ss
@@ -0,0 +1,86 @@
+;;; Parity check for (jsecmon dtrace-parse) against secmon's consumer.rs tests
+;;; (test_parse_exec_line, test_parse_exit_line), plus the other four event
+;;; formats and the no-event corners.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/dtrace_parse_check.ss
+
+(import (jerboa prelude)
+        (jsecmon dtrace-parse))
+
+(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)))))
+(def (field ev k) (cond ((assq k ev) => cdr) (else 'MISSING)))
+
+;; ── secmon test_parse_exec_line (the |-split layout) ─────────────────────────
+(displayln "secmon test_parse_exec_line:")
+(def exec-line "SECMON|EXEC|1234|1000|0|bash|bash -c echo hello")
+(def parts (dtrace-fields exec-line))
+(check "parts[1]=EXEC" (list-ref parts 1) "EXEC")
+(check "parts[2]=1234" (list-ref parts 2) "1234")
+(check "parts[3]=1000" (list-ref parts 3) "1000")
+(check "parts[4]=0"    (list-ref parts 4) "0")
+(check "parts[5]=bash" (list-ref parts 5) "bash")
+;; and the parsed record
+(def exec (parse-dtrace-line exec-line))
+(check "exec kind"     (field exec 'kind) 'exec)
+(check "exec pid"      (field exec 'pid) 1234)
+(check "exec ppid"     (field exec 'ppid) 1000)
+(check "exec uid"      (field exec 'uid) 0)
+(check "exec name"     (field exec 'name) "bash")
+(check "exec cmdline"  (field exec 'cmdline) '("bash" "-c" "echo" "hello"))
+
+;; ── secmon test_parse_exit_line ──────────────────────────────────────────────
+(displayln "secmon test_parse_exit_line:")
+(def exit-line "SECMON|EXIT|1234|0")
+(def eparts (dtrace-fields exit-line))
+(check "parts[1]=EXIT" (list-ref eparts 1) "EXIT")
+(check "parts[2]=1234" (list-ref eparts 2) "1234")
+(check "parts[3]=0"    (list-ref eparts 3) "0")
+(def ev-exit (parse-dtrace-line exit-line))
+(check "exit kind"      (field ev-exit 'kind) 'exit)
+(check "exit pid"       (field ev-exit 'pid) 1234)
+(check "exit exit-code" (field ev-exit 'exit-code) 0)
+(check "exit negative code"
+       (field (parse-dtrace-line "SECMON|EXIT|9|-1") 'exit-code) -1)
+
+;; ── connect / listen / open / write ──────────────────────────────────────────
+(displayln "connect / listen / open / write:")
+(def conn (parse-dtrace-line "SECMON|CONNECT|42|1000|curl|7"))
+(check "connect kind"  (field conn 'kind) 'connect)
+(check "connect pid"   (field conn 'pid) 42)
+(check "connect name"  (field conn 'process-name) "curl")
+(def lis (parse-dtrace-line "SECMON|LISTEN|99|0|nginx|3"))
+(check "listen kind"   (field lis 'kind) 'listen)
+(check "listen name"   (field lis 'process-name) "nginx")
+(def op (parse-dtrace-line "SECMON|OPEN|5|0|cat|/etc/shadow|3"))
+(check "open kind"        (field op 'kind) 'open)
+(check "open path"        (field op 'path) "/etc/shadow")
+(check "open sensitive?"  (field op 'sensitive?) #t)
+(check "open non-sensitive"
+       (field (parse-dtrace-line "SECMON|OPEN|5|0|cat|/tmp/foo|3") 'sensitive?) #f)
+(def wr (parse-dtrace-line "SECMON|WRITE|7|0|tee|4|512"))
+(check "write kind"    (field wr 'kind) 'write)
+(check "write fd"      (field wr 'fd) "4")
+(check "write bytes"   (field wr 'bytes) "512")
+
+;; ── no-event corners ─────────────────────────────────────────────────────────
+(displayln "no-event corners:")
+(check "single field -> #f"   (parse-dtrace-line "SECMON") #f)
+(check "unknown type -> #f"   (parse-dtrace-line "SECMON|REBOOT|1|2") #f)
+(check "EXEC too few -> #f"   (parse-dtrace-line "SECMON|EXEC|1|2|3|bash") #f)
+(check "EXIT too few -> #f"   (parse-dtrace-line "SECMON|EXIT|1") #f)
+;; non-numeric pid -> unwrap_or(0)
+(check "non-numeric pid -> 0"
+       (field (parse-dtrace-line "SECMON|EXIT|notapid|0") 'pid) 0)
+;; negative pid is rejected by u32 parse -> 0
+(check "negative pid -> 0 (u32)"
+       (field (parse-dtrace-line "SECMON|EXIT|-5|0") 'pid) 0)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: dtrace-parse matches secmon's consumer.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/dtrace-parse.ss b/jsecmon/dtrace-parse.ss
new file mode 100644
index 0000000..757adfc
--- /dev/null
+++ b/jsecmon/dtrace-parse.ss
@@ -0,0 +1,114 @@
+#!chezscheme
+;;; jsecmon DTrace output-line parser (secmon dtrace::consumer), untyped.
+;;;
+;;; Port of the pure parsing core of `EventParser` from secmon's
+;;; src/dtrace/consumer.rs: turn one pipe-delimited DTrace line into a
+;;; structured record. secmon's wire format is
+;;;   SECMON|<TYPE>|<fields...>
+;;; split on '|', with parts[1] the event type, dispatched in secmon's order:
+;;;   EXEC    (>=7): SECMON|EXEC|pid|ppid|uid|execname|args
+;;;   EXIT    (>=4): SECMON|EXIT|pid|exit_code
+;;;   CONNECT (>=6): SECMON|CONNECT|pid|uid|execname|fd
+;;;   LISTEN  (>=6): SECMON|LISTEN|pid|uid|execname|fd
+;;;   OPEN    (>=7): SECMON|OPEN|pid|uid|execname|path|fd
+;;;   WRITE   (>=7): SECMON|WRITE|pid|uid|execname|fd|bytes
+;;; A line with <2 fields, an unknown type, or too few fields for its type
+;;; yields no record (#f) — exactly secmon's "return Ok(())" no-ops.
+;;;
+;;; A text parser yielding a structured record, like the DNS and SELinux
+;;; parsers, so untyped. Returns an alist (the per-type fields are disjoint, so
+;;; an alist is cleaner than one wide struct). Numeric fields use Rust's
+;;; .parse().unwrap_or(0): a non-numeric / out-of-domain value becomes 0 (u32
+;;; rejects negatives; the exit code is i32 and may be negative). The EXEC
+;;; cmdline is args split on whitespace, matching split_whitespace().
+;;;
+;;; OPEN carries a `sensitive?` flag computed by (jsecmon sensitive-path) — the
+;;; same gate handle_open applies. (In secmon, handle_open / handle_write emit
+;;; no SecurityEvent, only a log line; the parser still surfaces their fields,
+;;; with OPEN's sensitivity decision exposed for the caller.)
+;;;
+;;; Verified against secmon's consumer.rs tests (test_parse_exec_line,
+;;; test_parse_exit_line) in examples/dtrace_parse_check.ss.
+
+(library (jsecmon dtrace-parse)
+  (export dtrace-fields parse-dtrace-line)
+  (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?)
+          (jsecmon sensitive-path))
+
+  ;; SECMON|TYPE|... split on '|', exactly secmon's line.split('|').
+  (def (dtrace-fields line) (string-split line #\|))
+
+  ;; Rust parts[n].parse::<u32>().unwrap_or(0): non-negative integer or 0.
+  (def (parse-u32 s)
+    (let ((n (string->number s)))
+      (if (and n (integer? n) (>= n 0)) n 0)))
+
+  ;; parts[n].parse::<i32>().unwrap_or(0): any integer or 0.
+  (def (parse-i32 s)
+    (let ((n (string->number s)))
+      (if (and n (integer? n)) n 0)))
+
+  ;; args.split_whitespace(): non-empty whitespace-delimited tokens.
+  (def (ws-tokens s)
+    (let ((normalized
+           (string-map (lambda (c)
+                         (if (or (char=? c #\tab) (char=? c #\return)) #\space c))
+                       s)))
+      (filter (lambda (x) (not (string-empty? x)))
+              (string-split normalized #\space))))
+
+  ;; one DTrace line -> alist record, or #f if the line yields no event.
+  (def (parse-dtrace-line line)
+    (let ((parts (dtrace-fields line)))
+      (if (< (length parts) 2)
+          #f
+          (let ((kind (list-ref parts 1)))
+            (cond
+              ((string=? kind "EXEC")
+               (and (>= (length parts) 7)
+                    (list (cons 'kind 'exec)
+                          (cons 'pid (parse-u32 (list-ref parts 2)))
+                          (cons 'ppid (parse-u32 (list-ref parts 3)))
+                          (cons 'uid (parse-u32 (list-ref parts 4)))
+                          (cons 'name (list-ref parts 5))
+                          (cons 'args (list-ref parts 6))
+                          (cons 'cmdline (ws-tokens (list-ref parts 6))))))
+              ((string=? kind "EXIT")
+               (and (>= (length parts) 4)
+                    (list (cons 'kind 'exit)
+                          (cons 'pid (parse-u32 (list-ref parts 2)))
+                          (cons 'exit-code (parse-i32 (list-ref parts 3))))))
+              ((string=? kind "CONNECT")
+               (and (>= (length parts) 6)
+                    (list (cons 'kind 'connect)
+                          (cons 'pid (parse-u32 (list-ref parts 2)))
+                          (cons 'process-name (list-ref parts 4)))))
+              ((string=? kind "LISTEN")
+               (and (>= (length parts) 6)
+                    (list (cons 'kind 'listen)
+                          (cons 'pid (parse-u32 (list-ref parts 2)))
+                          (cons 'process-name (list-ref parts 4)))))
+              ((string=? kind "OPEN")
+               (and (>= (length parts) 7)
+                    (list (cons 'kind 'open)
+                          (cons 'pid (parse-u32 (list-ref parts 2)))
+                          (cons 'name (list-ref parts 4))
+                          (cons 'path (list-ref parts 5))
+                          (cons 'sensitive? (sensitive-path? (list-ref parts 5))))))
+              ((string=? kind "WRITE")
+               (and (>= (length parts) 7)
+                    (list (cons 'kind 'write)
+                          (cons 'pid (parse-u32 (list-ref parts 2)))
+                          (cons 'name (list-ref parts 4))
+                          (cons 'fd (list-ref parts 5))
+                          (cons 'bytes (list-ref parts 6)))))
+              (else #f)))))))