Port FreeBSD sockstat/netstat connection-line parsers

ober

cff8490de45121969d9dc003befe62925f9308a1

diff --git a/README.md b/README.md
index 6ca5721..9543f22 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +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 freebsd-parse-check # FreeBSD kldstat/ps/address + sockstat & netstat connection lines
 make event-meta-check # event-type -> display severity + coarse store-priority u8 tables
 make config-check    # AgentConfig defaults + from_env merge + platform db/key paths
 make event-danger-check # mount is_dangerous + capability dangerous_caps predicates
@@ -129,7 +129,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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`. Plus `parse_ps_line` (the `ps -axo pid,ppid,uid,comm,args` fallback parser: ≥5 ws fields, `pid`/`ppid`/`uid` as u32 via `.parse().ok()?` so a non-u32 field rejects the whole line, `comm` is field[3], `args` is field[4..] re-joined with single spaces). Pure text/number parsing, so untyped. `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives + the ps-line cases. (The `kldstat`/`sockstat`/`ps` command runs 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`. Plus `parse_ps_line` (the `ps -axo pid,ppid,uid,comm,args` fallback parser: ≥5 ws fields, `pid`/`ppid`/`uid` as u32 via `.parse().ok()?` so a non-u32 field rejects the whole line, `comm` is field[3], `args` is field[4..] re-joined with single spaces). Pure text/number parsing, so untyped. Also `parse_sockstat_line` (cols `USER COMMAND PID FD PROTO LOCAL FOREIGN`, ≥7, `pid` as u32-or-reject, protocol lower-cased, a `FOREIGN` of exactly `*:*` short-circuits to `("0.0.0.0" . 0)` **without** `parse_address`, `state` = LISTEN when remote is `0.0.0.0`/`::`/port 0 else ESTABLISHED) and `parse_netstat_line` (cols `Proto Recv-Q Send-Q LOCAL FOREIGN [state]`, ≥5, here `*:*` **does** go through `parse_address`, `state` = `parts[5]` or `UNKNOWN`, no pid/name) — both producing a connection alist mirroring `ConnectionInfo`. `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives + the ps-line cases + the sockstat/netstat rows traced from source. (The `kldstat`/`sockstat`/`netstat`/`ps` 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.) |
 | `config` | `jsecmon/config.ss` | ✅ **untyped layer** — `AgentConfig`'s pure parts: the defaults (`0.0.0.0:31337`, poll `100`ms, buffer `10000`), the `from_env` merge (overwrites `listen_addr` on any present value but only overwrites poll/buffer when the value parses as strict u64 — a bad value **keeps the default**, it is not zeroed), and `local_db_path`/`local_key_path` (env override, else `/opt/secmon/{events.db,local.key}` on linux+freebsd, else the `./secmon_*` cwd fallback). Parameterized over a `getenv` callback + a `platform` symbol so the env reads stay deferred I/O; the build.rs-embedded secrets (`get_public_key`/`get_psk`/`is_debug_mode`) belong to the build/FFI phase, not this layer. secmon has no tests here, so `make config-check` asserts the behaviour against the Rust source. |
 | `monitor/events` (danger predicates) | `jsecmon/event-danger.ss` | ✅ **untyped layer** — the payload predicates that drive a mount/capability event's severity, lifted off their structs: `MountEventInfo::is_dangerous` (`mount-danger-reason source target` → reason string, with the faithful corner that the `/` source entry's prefix is `//` so a plain `/foo` is **not** flagged, and dangerous *targets* match exact-only) and `CapabilityEventInfo::dangerous_caps` (`cap_effective` bits → cap names in the Rust push order, full u64 so bits 38/39 work). These compute the booleans `event-meta`'s mount/capability severity helpers consume. Pure, no native lib; `make event-danger-check` asserts against the Rust source. |
diff --git a/examples/freebsd_parse_check.ss b/examples/freebsd_parse_check.ss
index 32603e1..0e33ab1 100644
--- a/examples/freebsd_parse_check.ss
+++ b/examples/freebsd_parse_check.ss
@@ -78,6 +78,42 @@
        (parse-ps-line "0 0 0 kernel sched")
        '(0 0 0 "kernel" "sched"))
 
+;; expected connection alist builder (same field order as connection-info)
+(def (cinfo protocol la lp ra rp state pid pname)
+  (list (cons 'protocol protocol) (cons 'local-addr la) (cons 'local-port lp)
+        (cons 'remote-addr ra) (cons 'remote-port rp) (cons 'state state)
+        (cons 'pid pid) (cons 'process-name pname)))
+
+;; ── parse-sockstat-line: USER COMMAND PID FD PROTO LOCAL FOREIGN ──────────────
+(displayln "parse-sockstat-line:")
+(check "established"
+       (parse-sockstat-line "root sshd 1234 4 tcp4 192.168.1.10:22 10.0.0.5:54321")
+       (cinfo "tcp4" "192.168.1.10" 22 "10.0.0.5" 54321 "ESTABLISHED" 1234 "sshd"))
+(check "wildcard foreign *:* -> LISTEN, no parse-address"
+       (parse-sockstat-line "root nginx 555 6 tcp4 *:80 *:*")
+       (cinfo "tcp4" "0.0.0.0" 80 "0.0.0.0" 0 "LISTEN" 555 "nginx"))
+(check "ipv6 listener, protocol lower-cased"
+       (parse-sockstat-line "root sshd 600 3 TCP6 [::]:22 *:*")
+       (cinfo "tcp6" "::" 22 "0.0.0.0" 0 "LISTEN" 600 "sshd"))
+(check "remote '::' (port nonzero) -> LISTEN"
+       (parse-sockstat-line "root app 700 5 tcp6 [2001:db8::1]:443 [::]:5")
+       (cinfo "tcp6" "2001:db8::1" 443 "::" 5 "LISTEN" 700 "app"))
+(check "too few cols -> #f" (parse-sockstat-line "a b c") #f)
+(check "non-u32 pid -> #f"
+       (parse-sockstat-line "root sshd notapid 4 tcp4 1.2.3.4:22 5.6.7.8:9") #f)
+(check "bad local addr -> #f"
+       (parse-sockstat-line "root sshd 10 4 tcp4 noport 5.6.7.8:9") #f)
+
+;; ── parse-netstat-line: Proto Recv-Q Send-Q LOCAL FOREIGN [state] ─────────────
+(displayln "parse-netstat-line:")
+(check "established w/ state"
+       (parse-netstat-line "tcp4 0 0 192.168.1.10:22 10.0.0.5:54321 ESTABLISHED" "tcp")
+       (cinfo "tcp" "192.168.1.10" 22 "10.0.0.5" 54321 "ESTABLISHED" #f #f))
+(check "no state col -> UNKNOWN; here '*:*' DOES go through parse-address"
+       (parse-netstat-line "udp4 0 0 *:80 *:*" "udp")
+       (cinfo "udp" "0.0.0.0" 80 "0.0.0.0" 0 "UNKNOWN" #f #f))
+(check "too few cols -> #f" (parse-netstat-line "tcp 0 0 1.2.3.4:5" "tcp") #f)
+
 (newline)
 (if (= fails 0)
     (displayln "OK: freebsd-parse matches secmon's freebsd.rs behaviour.")
diff --git a/jsecmon/freebsd-parse.ss b/jsecmon/freebsd-parse.ss
index 15a7f0c..1ebe5b2 100644
--- a/jsecmon/freebsd-parse.ss
+++ b/jsecmon/freebsd-parse.ss
@@ -7,6 +7,8 @@
 ;;;   parse-address      : "addr:port"/"[v6]:port" -> (addr . port) | #f
 ;;;   parse-ps-line      : a `ps -axo pid,ppid,uid,comm,args` row
 ;;;                        -> (pid ppid uid comm args) | #f
+;;;   parse-sockstat-line : a `sockstat` row    -> connection alist | #f
+;;;   parse-netstat-line  : a `netstat -an` row -> connection alist | #f
 ;;;
 ;;; Pure text/number parsing, like the Linux /proc parsers, so untyped.
 ;;;
@@ -20,13 +22,26 @@
 ;;;   * 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.
+;;;   * parse-sockstat-line: cols USER COMMAND PID FD PROTO LOCAL FOREIGN, needs
+;;;     >=7; pid is u32 (`.parse().ok()?` rejects the whole row); protocol is
+;;;     lower-cased; a FOREIGN of exactly "*:*" becomes ("0.0.0.0" . 0) WITHOUT
+;;;     consulting parse-address; state is LISTEN when remote addr is "0.0.0.0"
+;;;     / "::" or remote port is 0, else ESTABLISHED.
+;;;   * parse-netstat-line: cols Proto Recv-Q Send-Q LOCAL FOREIGN [state], needs
+;;;     >=5; local=parts[3], foreign=parts[4] (so here "*:*" DOES go through
+;;;     parse-address -> ("0.0.0.0" . 0), unlike sockstat); state is parts[5] when
+;;;     present else "UNKNOWN"; protocol is the caller-supplied arg; pid/name #f.
+;;; A connection alist is ((protocol . s) (local-addr . s) (local-port . n)
+;;; (remote-addr . s) (remote-port . n) (state . s) (pid . n|#f)
+;;; (process-name . s|#f)), mirroring ConnectionInfo.
 ;;;
 ;;; 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.
+;;; test_parse_address_ipv4, test_parse_address_wildcard) plus sockstat/netstat
+;;; expectations traced from the source in examples/freebsd_parse_check.ss.
 
 (library (jsecmon freebsd-parse)
-  (export parse-kldstat-line parse-address parse-ps-line)
+  (export parse-kldstat-line parse-address parse-ps-line
+          parse-sockstat-line parse-netstat-line)
   (import (except (chezscheme)
                   make-hash-table hash-table?
                   sort sort!
@@ -114,4 +129,55 @@
                       (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))))))))
+                 (and port (cons addr port)))))))
+
+  (def (connection-info protocol local-addr local-port
+                        remote-addr remote-port state pid process-name)
+    (list (cons 'protocol protocol)
+          (cons 'local-addr local-addr)
+          (cons 'local-port local-port)
+          (cons 'remote-addr remote-addr)
+          (cons 'remote-port remote-port)
+          (cons 'state state)
+          (cons 'pid pid)
+          (cons 'process-name process-name)))
+
+  ;; a `sockstat` row -> connection alist | #f.
+  (def (parse-sockstat-line line)
+    (let ((parts (ws-tokens line)))
+      (and (>= (length parts) 7)
+           (let ((pid (parse-u32-opt (list-ref parts 2))))
+             (and pid
+                  (let* ((process-name (list-ref parts 1))
+                         (protocol (string-downcase (list-ref parts 4)))
+                         (local (parse-address (list-ref parts 5))))
+                    (and local
+                         (let* ((foreign-tok (list-ref parts 6))
+                                (remote (if (string=? foreign-tok "*:*")
+                                            (cons "0.0.0.0" 0)
+                                            (parse-address foreign-tok))))
+                           (and remote
+                                (let* ((remote-addr (car remote))
+                                       (remote-port (cdr remote))
+                                       (state (if (or (string=? remote-addr "0.0.0.0")
+                                                      (string=? remote-addr "::")
+                                                      (= remote-port 0))
+                                                  "LISTEN" "ESTABLISHED")))
+                                  (connection-info protocol (car local) (cdr local)
+                                                   remote-addr remote-port state
+                                                   pid process-name)))))))))))
+
+  ;; a `netstat -an` row -> connection alist | #f. protocol is supplied by the
+  ;; caller (secmon passes "tcp" / "udp").
+  (def (parse-netstat-line line protocol)
+    (let ((parts (ws-tokens line)))
+      (and (>= (length parts) 5)
+           (let ((local (parse-address (list-ref parts 3)))
+                 (remote (parse-address (list-ref parts 4))))
+             (and local remote
+                  (let ((state (if (> (length parts) 5)
+                                   (list-ref parts 5)
+                                   "UNKNOWN")))
+                    (connection-info protocol (car local) (cdr local)
+                                     (car remote) (cdr remote)
+                                     state #f #f))))))))