Port platform::freebsd parsers to untyped Jerboa

ober

fbdadb455d42b7d577fbaa0692f280b0083c7fd8

diff --git a/Makefile b/Makefile
index 5a1ffb2..1536503 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 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 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)"
@@ -167,6 +167,13 @@ dtrace-parse-check:
 proc-linux-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/proc_linux_check.ss
 
+# FreeBSD line parsers (secmon src/platform/freebsd.rs): parse_kldstat_line
+# (kld module rows) and parse_address (sockstat/netstat host:port, with the
+# "*" wildcard and ipv6 bracket forms). Ports are DECIMAL here, unlike Linux's
+# hex /proc/net. Pure text/number parsing — file/command reads deferred — no lib.
+freebsd-parse-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/freebsd_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
@@ -189,6 +196,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/sensitive_path_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_parse_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/proc_linux_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/freebsd_parse_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 02a7118..b1f339b 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,7 @@ 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 proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net hex IP
+make freebsd-parse-check # FreeBSD kldstat row + sockstat addr:port (decimal, wildcard, v6)
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -110,5 +111,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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` (/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.) |
 | `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/freebsd_parse_check.ss b/examples/freebsd_parse_check.ss
new file mode 100644
index 0000000..0bea4e9
--- /dev/null
+++ b/examples/freebsd_parse_check.ss
@@ -0,0 +1,57 @@
+;;; Parity check for (jsecmon freebsd-parse) against secmon's freebsd.rs tests
+;;; (test_parse_kldstat_line, test_parse_address_ipv4, test_parse_address_wildcard),
+;;; plus the ipv6 bracket form and the too-few-fields / no-colon negatives.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/freebsd_parse_check.ss
+
+(import (jerboa prelude)
+        (jsecmon freebsd-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)))))
+
+;; ── secmon test_parse_kldstat_line ───────────────────────────────────────────
+(displayln "secmon test_parse_kldstat_line:")
+(def k1 (parse-kldstat-line " 1    9 0xffffffff80200000 1c76b80  kernel"))
+(check "kldstat is some"  (and k1 #t) #t)
+(check "kldstat name"     (cdr (assq 'name k1)) "kernel")
+(check "kldstat size hex" (cdr (assq 'size k1)) #x1c76b80)  ;; 29846400
+(check "kldstat action"   (cdr (assq 'action k1)) 'loaded)
+(check "kldstat pid #f"   (cdr (assq 'loaded-by-pid k1)) #f)
+;; a 0x-prefixed size column parses too
+(def k2 (parse-kldstat-line " 2    1 0xffffffff82000000 0x12345  if_em.ko"))
+(check "0x size"          (cdr (assq 'size k2)) #x12345)
+(check "module .ko name"  (cdr (assq 'name k2)) "if_em.ko")
+;; fewer than 5 whitespace fields -> #f
+(check "too few fields"   (parse-kldstat-line "1 2 3 4") #f)
+;; a non-hex size column -> size #f, row still parses (matches .ok())
+(def k3 (parse-kldstat-line " 3 1 0xffff 0xZZZZ zfs.ko"))
+(check "bad size -> #f"   (cdr (assq 'size k3)) #f)
+(check "bad size name ok" (cdr (assq 'name k3)) "zfs.ko")
+
+;; ── secmon test_parse_address_ipv4 ───────────────────────────────────────────
+(displayln "secmon test_parse_address_ipv4:")
+(check "ipv4 host:port" (parse-address "192.168.1.1:8080") (cons "192.168.1.1" 8080))
+
+;; ── secmon test_parse_address_wildcard ───────────────────────────────────────
+(displayln "secmon test_parse_address_wildcard:")
+(check "*:22 -> 0.0.0.0:22" (parse-address "*:22") (cons "0.0.0.0" 22))
+;; a "*" port becomes 0 (0 is truthy, so the row is kept)
+(check "addr:* -> port 0"   (parse-address "10.0.0.5:*") (cons "10.0.0.5" 0))
+
+;; ── ipv6 bracket form + negatives ────────────────────────────────────────────
+(displayln "ipv6 / negatives:")
+(check "ipv6 [a]:port"  (parse-address "[::1]:443") (cons "::1" 443))
+(check "ipv6 full"      (parse-address "[fe80::1]:53") (cons "fe80::1" 53))
+(check "no colon -> #f" (parse-address "192.168.1.1") #f)
+(check "bad port -> #f" (parse-address "10.0.0.1:notaport") #f)
+(check "port >65535 #f" (parse-address "10.0.0.1:70000") #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: freebsd-parse matches secmon's freebsd.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/freebsd-parse.ss b/jsecmon/freebsd-parse.ss
new file mode 100644
index 0000000..3457797
--- /dev/null
+++ b/jsecmon/freebsd-parse.ss
@@ -0,0 +1,94 @@
+#!chezscheme
+;;; jsecmon FreeBSD line parsers (secmon platform::freebsd), untyped.
+;;;
+;;; Port of the pure parsing helpers of secmon's src/platform/freebsd.rs, with
+;;; the command/file I/O stripped off:
+;;;   parse-kldstat-line : a `kldstat` row     -> module record | #f
+;;;   parse-address      : "addr:port"/"[v6]:port" -> (addr . port) | #f
+;;;
+;;; Pure text/number parsing, like the Linux /proc parsers, so untyped.
+;;;
+;;; Faithfulness notes:
+;;;   * kldstat: split on whitespace, need >=5 fields; name is parts[4], size is
+;;;     parts[3] parsed as hex (with or without a leading "0x"), size is None on
+;;;     a non-hex value (.ok()), action is always Loaded, loaded_by_pid None.
+;;;   * parse-address ports are DECIMAL u16 (.parse(), base 10) — UNLIKE the
+;;;     Linux /proc-net parser, whose ports are hex. A "*" address becomes
+;;;     0.0.0.0 and a "*" port becomes 0; otherwise a bad port is #f.
+;;;   * ipv6 form "[addr]:port" splits at the FIRST ']'; ipv4 form splits at the
+;;;     LAST ':' (rfind), so a bare ipv6 without brackets would mis-split — same
+;;;     as secmon, which relies on the bracket form for v6.
+;;;
+;;; Verified against secmon's freebsd.rs tests (test_parse_kldstat_line,
+;;; test_parse_address_ipv4, test_parse_address_wildcard) in
+;;; examples/freebsd_parse_check.ss.
+
+(library (jsecmon freebsd-parse)
+  (export parse-kldstat-line parse-address)
+  (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 (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))))
+
+  (def (rindex-char s ch)
+    (let loop ((i (- (string-length s) 1)))
+      (cond ((< i 0) #f)
+            ((char=? (string-ref s i) ch) i)
+            (else (loop (- i 1))))))
+
+  ;; u64::from_str_radix(s, 16).ok(): a non-negative integer in [0, 2^64), or #f.
+  (def (parse-hex-opt s)
+    (let ((n (string->number s 16)))
+      (if (and n (integer? n) (>= n 0) (< n (expt 2 64))) n #f)))
+
+  ;; s.parse::<u16>().ok(): a decimal integer in [0, 65535], or #f.
+  (def (parse-u16-opt s)
+    (let ((n (string->number s)))
+      (if (and n (integer? n) (>= n 0) (<= n 65535)) n #f)))
+
+  ;; a kldstat row -> alist ((name . s) (action . loaded) (size . n|#f)
+  ;; (loaded-by-pid . #f)) | #f.
+  (def (parse-kldstat-line line)
+    (let ((parts (ws-tokens line)))
+      (and (>= (length parts) 5)
+           (let* ((name (list-ref parts 4))
+                  (size-str (list-ref parts 3))
+                  (size (parse-hex-opt
+                         (if (string-prefix? "0x" size-str)
+                             (substring size-str 2 (string-length size-str))
+                             size-str))))
+             (list (cons 'name name)
+                   (cons 'action 'loaded)
+                   (cons 'size size)
+                   (cons 'loaded-by-pid #f))))))
+
+  ;; "addr:port" or "[ipv6]:port" -> (addr . port) | #f.
+  (def (parse-address addr-str)
+    (if (string-prefix? "[" addr-str)
+        (let ((eb (string-contains addr-str "]")))
+          (and eb
+               (let* ((addr (substring addr-str 1 eb))
+                      (port-str (substring addr-str (+ eb 2) (string-length addr-str)))
+                      (port (parse-u16-opt port-str)))
+                 (and port (cons addr port)))))
+        (let ((lc (rindex-char addr-str #\:)))
+          (and lc
+               (let* ((raw-addr (substring addr-str 0 lc))
+                      (port-str (substring addr-str (+ lc 1) (string-length addr-str)))
+                      (addr (if (string=? raw-addr "*") "0.0.0.0" raw-addr))
+                      (port (if (string=? port-str "*") 0 (parse-u16-opt port-str))))
+                 (and port (cons addr port))))))))