Port platform::freebsd parsers to untyped Jerboa
ober
fbdadb455d42b7d577fbaa0692f280b0083c7fd8
--- 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) --- 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 | new file mode 100644 --- /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))) new file mode 100644 --- /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))))))))