Port secmon platform::linux /proc parsers to (jsecmon proc-linux)

ober

aefc42008a0fb0b5a3c00c1a494f7acb89564ac9

diff --git a/Makefile b/Makefile
index 3d6aef9..5a1ffb2 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 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 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)"
@@ -161,6 +161,12 @@ sensitive-path-check:
 dtrace-parse-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_parse_check.ss
 
+# Linux /proc parsers (secmon src/platform/linux.rs): parse_stat (ppid+comm),
+# parse_uid, hex_to_state (TCP), parse_ipv4/ipv6/addr (/proc/net hex). Pure
+# text/number parsing — the file reads are the deferred I/O — no native lib.
+proc-linux-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/proc_linux_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
@@ -182,6 +188,7 @@ checks: kernels-check
 	$(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
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/proc_linux_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 52fc25e..02a7118 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ make container-check # container/jail escape mount classifier (host bind, docker
 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 checks          # every Jerboa-side check in one shot
 ```
 
@@ -108,5 +109,6 @@ 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` (/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.) |
 | `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/proc_linux_check.ss b/examples/proc_linux_check.ss
new file mode 100644
index 0000000..182ed8e
--- /dev/null
+++ b/examples/proc_linux_check.ss
@@ -0,0 +1,71 @@
+;;; Parity check for (jsecmon proc-linux) against secmon's linux.rs tests
+;;; (test_parse_stat, test_parse_stat_with_spaces, test_parse_uid,
+;;; test_hex_to_state, test_parse_ipv4), plus ipv6/parse-addr and corners.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/proc_linux_check.ss
+
+(import (jerboa prelude)
+        (jsecmon proc-linux))
+
+(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_stat ───────────────────────────────────────────────────
+(displayln "secmon test_parse_stat:")
+(def s1 (parse-stat "1234 (bash) S 1000 1234 1234 0 -1 4194304"))
+(check "stat is some"   (and s1 #t) #t)
+(check "stat ppid"      (car s1) 1000)
+(check "stat comm"      (cdr s1) "bash")
+
+;; ── secmon test_parse_stat_with_spaces ───────────────────────────────────────
+(displayln "secmon test_parse_stat_with_spaces:")
+(def s2 (parse-stat "1234 (Web Content) S 1000 1234 1234 0"))
+(check "spaced ppid"    (car s2) 1000)
+(check "spaced comm"    (cdr s2) "Web Content")
+;; comm with a literal ')' inside -> rfind(')') takes the last one
+(def s3 (parse-stat "9 (a)b) S 7 1 1"))
+(check "paren-in-comm ppid" (car s3) 7)
+(check "paren-in-comm comm" (cdr s3) "a)b")
+
+;; ── secmon test_parse_uid ────────────────────────────────────────────────────
+(displayln "secmon test_parse_uid:")
+(check "uid"  (parse-uid "Name:\tbash\nUid:\t1000\t1000\t1000\t1000\nGid:\t1000") 1000)
+(check "no Uid line -> #f" (parse-uid "Name:\tbash\nGid:\t0") #f)
+
+;; ── secmon test_hex_to_state ─────────────────────────────────────────────────
+(displayln "secmon test_hex_to_state:")
+(check "01 ESTABLISHED" (hex-to-state "01") "ESTABLISHED")
+(check "0A LISTEN"      (hex-to-state "0A") "LISTEN")
+(check "FF UNKNOWN"     (hex-to-state "FF") "UNKNOWN")
+(check "06 TIME_WAIT"   (hex-to-state "06") "TIME_WAIT")
+
+;; ── secmon test_parse_ipv4 ───────────────────────────────────────────────────
+(displayln "secmon test_parse_ipv4:")
+(check "0100007F -> 127.0.0.1" (parse-ipv4 "0100007F") "127.0.0.1")
+;; 0.0.0.0 and a routable address
+(check "00000000 -> 0.0.0.0"   (parse-ipv4 "00000000") "0.0.0.0")
+(check "0101A8C0 -> 192.168.1.1" (parse-ipv4 "0101A8C0") "192.168.1.1")
+(check "bad hex -> #f"         (parse-ipv4 "ZZ") #f)
+
+;; ── ipv6 + parse-addr (not unit-tested in secmon, but documented format) ─────
+(displayln "ipv6 / parse-addr:")
+(check "ipv6 32-hex grouped"
+       (parse-ipv6 "00000000000000000000000001000000")
+       "0000:0000:0000:0000:0000:0000:0100:0000")
+(check "ipv6 wrong length -> #f" (parse-ipv6 "DEAD") #f)
+;; /proc/net/tcp local_address line: 127.0.0.1:80 (port 0050 = 80)
+(check "parse-addr v4" (parse-addr "0100007F:0050" "tcp") (cons "127.0.0.1" 80))
+(check "parse-addr port hex" (cdr (parse-addr "0100007F:1F90" "tcp")) 8080)
+(check "parse-addr v6 picks ipv6"
+       (car (parse-addr "00000000000000000000000001000000:0050" "tcp6"))
+       "0000:0000:0000:0000:0000:0000:0100:0000")
+(check "parse-addr no colon -> #f" (parse-addr "0100007F" "tcp") #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: proc-linux matches secmon's linux.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/proc-linux.ss b/jsecmon/proc-linux.ss
new file mode 100644
index 0000000..81d6541
--- /dev/null
+++ b/jsecmon/proc-linux.ss
@@ -0,0 +1,124 @@
+#!chezscheme
+;;; jsecmon Linux /proc + /proc/net parsers (secmon platform::linux), untyped.
+;;;
+;;; Port of the pure parsing helpers of secmon's src/platform/linux.rs — the
+;;; functions that turn /proc text into structured values, with the actual file
+;;; reads (the deferred I/O) stripped off:
+;;;   parse-stat   : /proc/[pid]/stat  -> (ppid . comm)        | #f
+;;;   parse-uid    : /proc/[pid]/status-> uid                  | #f
+;;;   hex-to-state : /proc/net/tcp st  -> TCP state string
+;;;   parse-ipv4   : little-endian hex -> dotted quad          | #f
+;;;   parse-ipv6   : 32-hex string     -> colon-grouped        | #f
+;;;   parse-addr   : "HEXADDR:HEXPORT" -> (addr . port)        | #f
+;;;
+;;; Pure text/number parsing, like the DNS/SELinux/DTrace parsers, so untyped.
+;;;
+;;; Faithfulness notes:
+;;;   * parse-stat takes comm as the text between the FIRST '(' and the LAST
+;;;     ')' (so a comm with spaces or parens survives), then ppid is the 2nd
+;;;     whitespace field after ") " (state is 1st). secmon: find('(') / rfind(')').
+;;;   * parse-stat ppid and parse-uid use .parse::<u32>().ok() — failure is #f
+;;;     (None), NOT 0; both reject negatives (u32).
+;;;   * parse-ipv4 is little-endian: byte0 is the low octet. u32::from_str_radix
+;;;     rejects >0xFFFFFFFF, so a too-long hex string yields #f.
+;;;   * parse-addr's port is u16 (<=65535); protocol containing '6' picks ipv6.
+;;;
+;;; Verified against secmon's linux.rs tests (test_parse_stat[_with_spaces],
+;;; test_parse_uid, test_hex_to_state, test_parse_ipv4) in
+;;; examples/proc_linux_check.ss.
+
+(library (jsecmon proc-linux)
+  (export parse-stat parse-uid hex-to-state parse-ipv4 parse-ipv6 parse-addr)
+  (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?))
+
+  ;; .parse::<u32>().ok(): a non-negative integer, or #f on failure.
+  (def (parse-nat-opt s)
+    (let ((n (string->number s)))
+      (if (and n (integer? n) (>= n 0)) n #f)))
+
+  ;; split_whitespace(): non-empty whitespace-delimited tokens (tabs/CR folded).
+  (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))))
+
+  ;; index of the last occurrence of ch in s, or #f (str::rfind).
+  (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))))))
+
+  ;; /proc/[pid]/stat: "pid (comm) state ppid ..." -> (ppid . comm) | #f.
+  (def (parse-stat content)
+    (let ((start (string-contains content "("))
+          (end (rindex-char content #\))))
+      (and start end
+           (let* ((comm (substring content (+ start 1) end))
+                  (after-start (+ end 2))
+                  (after-comm (if (<= after-start (string-length content))
+                                  (substring content after-start (string-length content))
+                                  ""))
+                  (toks (ws-tokens after-comm)))
+             (and (>= (length toks) 2)
+                  (let ((ppid (parse-nat-opt (list-ref toks 1))))
+                    (and ppid (cons ppid comm))))))))
+
+  ;; /proc/[pid]/status: first "Uid:" line, 2nd field -> uid | #f.
+  (def (parse-uid content)
+    (let loop ((lines (string-split content #\newline)))
+      (cond
+        ((null? lines) #f)
+        ((string-prefix? "Uid:" (car lines))
+         (let ((toks (ws-tokens (car lines))))
+           (and (>= (length toks) 2) (parse-nat-opt (list-ref toks 1)))))
+        (else (loop (cdr lines))))))
+
+  (def *tcp-states*
+    '(("01" . "ESTABLISHED") ("02" . "SYN_SENT") ("03" . "SYN_RECV")
+      ("04" . "FIN_WAIT1") ("05" . "FIN_WAIT2") ("06" . "TIME_WAIT")
+      ("07" . "CLOSE") ("08" . "CLOSE_WAIT") ("09" . "LAST_ACK")
+      ("0A" . "LISTEN") ("0B" . "CLOSING")))
+
+  ;; /proc/net/{tcp,udp} state hex -> name, default "UNKNOWN".
+  (def (hex-to-state hex)
+    (cond ((assoc hex *tcp-states*) => cdr) (else "UNKNOWN")))
+
+  ;; little-endian hex (e.g. "0100007F") -> "127.0.0.1" | #f.
+  (def (parse-ipv4 hex)
+    (let ((bytes (string->number hex 16)))
+      (and bytes (integer? bytes) (>= bytes 0) (<= bytes #xFFFFFFFF)
+           (str (bitwise-and bytes 255) "."
+                (bitwise-and (ash bytes -8) 255) "."
+                (bitwise-and (ash bytes -16) 255) "."
+                (bitwise-and (ash bytes -24) 255)))))
+
+  ;; 32-hex string -> eight colon-separated 4-hex groups | #f.
+  (def (parse-ipv6 hex)
+    (and (= (string-length hex) 32)
+         (string-join
+          (map (lambda (i) (substring hex (* i 4) (+ (* i 4) 4))) (iota 8))
+          ":")))
+
+  ;; "HEXADDR:HEXPORT" + protocol -> (addr . port) | #f.
+  (def (parse-addr addr-str protocol)
+    (let ((parts (string-split addr-str #\:)))
+      (and (= (length parts) 2)
+           (let ((port (string->number (list-ref parts 1) 16)))
+             (and port (integer? port) (>= port 0) (<= port 65535)
+                  (let ((addr (if (string-contains protocol "6")
+                                  (parse-ipv6 (list-ref parts 0))
+                                  (parse-ipv4 (list-ref parts 0)))))
+                    (and addr (cons addr port)))))))))