Port secmon platform::linux /proc parsers to (jsecmon proc-linux)
ober
aefc42008a0fb0b5a3c00c1a494f7acb89564ac9
--- 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) --- 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 | new file mode 100644 --- /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))) new file mode 100644 --- /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)))))))))