Port secmon dtrace::consumer EventParser line parsing to (jsecmon dtrace-parse)
ober
050f57e5b39334c6a13d1b4697bbf55ded5813d7
--- 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 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 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)" @@ -155,6 +155,12 @@ dns-servers-check: sensitive-path-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/sensitive_path_check.ss +# DTrace output-line parser (secmon src/dtrace/consumer.rs EventParser): split +# SECMON|TYPE|... lines into per-type structured records (EXEC/EXIT/CONNECT/ +# LISTEN/OPEN/WRITE), composing sensitive-path for the OPEN gate. Pure, no lib. +dtrace-parse-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/dtrace_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 @@ -175,6 +181,7 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/container_check.ss $(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 clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ make selinux-check # SELinux audit-log parser: AVC + boolean/policy/role event make container-check # container/jail escape mount classifier (host bind, docker sock) 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 checks # every Jerboa-side check in one shot ``` @@ -106,5 +107,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `monitor::container::ContainerEscapeMonitor` (mount classifier) | `jsecmon/container.ss` | ✅ **untyped layer** — `is_suspicious_mount(mount)`: a mount that starts with `/host` or `/mnt/host`, is exactly `/`, or contains `/var/run/docker` / `/run/docker` / `devd.pipe` (FreeBSD jail), flagging a container/jail escape. Pure string classification like the other monitor classifiers, so untyped; obfstr!-hidden patterns decode to these plaintext literals. `make container-check` reproduces secmon's `test_suspicious_mount_detection` + each escape signal + negatives. (Isolation detection and mount/path/cap polling are provider-driven I/O — the deferred monitor loop.) | | `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.) | | `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/dtrace_parse_check.ss @@ -0,0 +1,86 @@ +;;; Parity check for (jsecmon dtrace-parse) against secmon's consumer.rs tests +;;; (test_parse_exec_line, test_parse_exit_line), plus the other four event +;;; formats and the no-event corners. +;;; +;;; scheme --libdirs "$JERBOA/lib:." --script examples/dtrace_parse_check.ss + +(import (jerboa prelude) + (jsecmon dtrace-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))))) +(def (field ev k) (cond ((assq k ev) => cdr) (else 'MISSING))) + +;; ── secmon test_parse_exec_line (the |-split layout) ───────────────────────── +(displayln "secmon test_parse_exec_line:") +(def exec-line "SECMON|EXEC|1234|1000|0|bash|bash -c echo hello") +(def parts (dtrace-fields exec-line)) +(check "parts[1]=EXEC" (list-ref parts 1) "EXEC") +(check "parts[2]=1234" (list-ref parts 2) "1234") +(check "parts[3]=1000" (list-ref parts 3) "1000") +(check "parts[4]=0" (list-ref parts 4) "0") +(check "parts[5]=bash" (list-ref parts 5) "bash") +;; and the parsed record +(def exec (parse-dtrace-line exec-line)) +(check "exec kind" (field exec 'kind) 'exec) +(check "exec pid" (field exec 'pid) 1234) +(check "exec ppid" (field exec 'ppid) 1000) +(check "exec uid" (field exec 'uid) 0) +(check "exec name" (field exec 'name) "bash") +(check "exec cmdline" (field exec 'cmdline) '("bash" "-c" "echo" "hello")) + +;; ── secmon test_parse_exit_line ────────────────────────────────────────────── +(displayln "secmon test_parse_exit_line:") +(def exit-line "SECMON|EXIT|1234|0") +(def eparts (dtrace-fields exit-line)) +(check "parts[1]=EXIT" (list-ref eparts 1) "EXIT") +(check "parts[2]=1234" (list-ref eparts 2) "1234") +(check "parts[3]=0" (list-ref eparts 3) "0") +(def ev-exit (parse-dtrace-line exit-line)) +(check "exit kind" (field ev-exit 'kind) 'exit) +(check "exit pid" (field ev-exit 'pid) 1234) +(check "exit exit-code" (field ev-exit 'exit-code) 0) +(check "exit negative code" + (field (parse-dtrace-line "SECMON|EXIT|9|-1") 'exit-code) -1) + +;; ── connect / listen / open / write ────────────────────────────────────────── +(displayln "connect / listen / open / write:") +(def conn (parse-dtrace-line "SECMON|CONNECT|42|1000|curl|7")) +(check "connect kind" (field conn 'kind) 'connect) +(check "connect pid" (field conn 'pid) 42) +(check "connect name" (field conn 'process-name) "curl") +(def lis (parse-dtrace-line "SECMON|LISTEN|99|0|nginx|3")) +(check "listen kind" (field lis 'kind) 'listen) +(check "listen name" (field lis 'process-name) "nginx") +(def op (parse-dtrace-line "SECMON|OPEN|5|0|cat|/etc/shadow|3")) +(check "open kind" (field op 'kind) 'open) +(check "open path" (field op 'path) "/etc/shadow") +(check "open sensitive?" (field op 'sensitive?) #t) +(check "open non-sensitive" + (field (parse-dtrace-line "SECMON|OPEN|5|0|cat|/tmp/foo|3") 'sensitive?) #f) +(def wr (parse-dtrace-line "SECMON|WRITE|7|0|tee|4|512")) +(check "write kind" (field wr 'kind) 'write) +(check "write fd" (field wr 'fd) "4") +(check "write bytes" (field wr 'bytes) "512") + +;; ── no-event corners ───────────────────────────────────────────────────────── +(displayln "no-event corners:") +(check "single field -> #f" (parse-dtrace-line "SECMON") #f) +(check "unknown type -> #f" (parse-dtrace-line "SECMON|REBOOT|1|2") #f) +(check "EXEC too few -> #f" (parse-dtrace-line "SECMON|EXEC|1|2|3|bash") #f) +(check "EXIT too few -> #f" (parse-dtrace-line "SECMON|EXIT|1") #f) +;; non-numeric pid -> unwrap_or(0) +(check "non-numeric pid -> 0" + (field (parse-dtrace-line "SECMON|EXIT|notapid|0") 'pid) 0) +;; negative pid is rejected by u32 parse -> 0 +(check "negative pid -> 0 (u32)" + (field (parse-dtrace-line "SECMON|EXIT|-5|0") 'pid) 0) + +(newline) +(if (= fails 0) + (displayln "OK: dtrace-parse matches secmon's consumer.rs behaviour.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/dtrace-parse.ss @@ -0,0 +1,114 @@ +#!chezscheme +;;; jsecmon DTrace output-line parser (secmon dtrace::consumer), untyped. +;;; +;;; Port of the pure parsing core of `EventParser` from secmon's +;;; src/dtrace/consumer.rs: turn one pipe-delimited DTrace line into a +;;; structured record. secmon's wire format is +;;; SECMON|<TYPE>|<fields...> +;;; split on '|', with parts[1] the event type, dispatched in secmon's order: +;;; EXEC (>=7): SECMON|EXEC|pid|ppid|uid|execname|args +;;; EXIT (>=4): SECMON|EXIT|pid|exit_code +;;; CONNECT (>=6): SECMON|CONNECT|pid|uid|execname|fd +;;; LISTEN (>=6): SECMON|LISTEN|pid|uid|execname|fd +;;; OPEN (>=7): SECMON|OPEN|pid|uid|execname|path|fd +;;; WRITE (>=7): SECMON|WRITE|pid|uid|execname|fd|bytes +;;; A line with <2 fields, an unknown type, or too few fields for its type +;;; yields no record (#f) — exactly secmon's "return Ok(())" no-ops. +;;; +;;; A text parser yielding a structured record, like the DNS and SELinux +;;; parsers, so untyped. Returns an alist (the per-type fields are disjoint, so +;;; an alist is cleaner than one wide struct). Numeric fields use Rust's +;;; .parse().unwrap_or(0): a non-numeric / out-of-domain value becomes 0 (u32 +;;; rejects negatives; the exit code is i32 and may be negative). The EXEC +;;; cmdline is args split on whitespace, matching split_whitespace(). +;;; +;;; OPEN carries a `sensitive?` flag computed by (jsecmon sensitive-path) — the +;;; same gate handle_open applies. (In secmon, handle_open / handle_write emit +;;; no SecurityEvent, only a log line; the parser still surfaces their fields, +;;; with OPEN's sensitivity decision exposed for the caller.) +;;; +;;; Verified against secmon's consumer.rs tests (test_parse_exec_line, +;;; test_parse_exit_line) in examples/dtrace_parse_check.ss. + +(library (jsecmon dtrace-parse) + (export dtrace-fields parse-dtrace-line) + (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?) + (jsecmon sensitive-path)) + + ;; SECMON|TYPE|... split on '|', exactly secmon's line.split('|'). + (def (dtrace-fields line) (string-split line #\|)) + + ;; Rust parts[n].parse::<u32>().unwrap_or(0): non-negative integer or 0. + (def (parse-u32 s) + (let ((n (string->number s))) + (if (and n (integer? n) (>= n 0)) n 0))) + + ;; parts[n].parse::<i32>().unwrap_or(0): any integer or 0. + (def (parse-i32 s) + (let ((n (string->number s))) + (if (and n (integer? n)) n 0))) + + ;; args.split_whitespace(): non-empty whitespace-delimited tokens. + (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)))) + + ;; one DTrace line -> alist record, or #f if the line yields no event. + (def (parse-dtrace-line line) + (let ((parts (dtrace-fields line))) + (if (< (length parts) 2) + #f + (let ((kind (list-ref parts 1))) + (cond + ((string=? kind "EXEC") + (and (>= (length parts) 7) + (list (cons 'kind 'exec) + (cons 'pid (parse-u32 (list-ref parts 2))) + (cons 'ppid (parse-u32 (list-ref parts 3))) + (cons 'uid (parse-u32 (list-ref parts 4))) + (cons 'name (list-ref parts 5)) + (cons 'args (list-ref parts 6)) + (cons 'cmdline (ws-tokens (list-ref parts 6)))))) + ((string=? kind "EXIT") + (and (>= (length parts) 4) + (list (cons 'kind 'exit) + (cons 'pid (parse-u32 (list-ref parts 2))) + (cons 'exit-code (parse-i32 (list-ref parts 3)))))) + ((string=? kind "CONNECT") + (and (>= (length parts) 6) + (list (cons 'kind 'connect) + (cons 'pid (parse-u32 (list-ref parts 2))) + (cons 'process-name (list-ref parts 4))))) + ((string=? kind "LISTEN") + (and (>= (length parts) 6) + (list (cons 'kind 'listen) + (cons 'pid (parse-u32 (list-ref parts 2))) + (cons 'process-name (list-ref parts 4))))) + ((string=? kind "OPEN") + (and (>= (length parts) 7) + (list (cons 'kind 'open) + (cons 'pid (parse-u32 (list-ref parts 2))) + (cons 'name (list-ref parts 4)) + (cons 'path (list-ref parts 5)) + (cons 'sensitive? (sensitive-path? (list-ref parts 5)))))) + ((string=? kind "WRITE") + (and (>= (length parts) 7) + (list (cons 'kind 'write) + (cons 'pid (parse-u32 (list-ref parts 2))) + (cons 'name (list-ref parts 4)) + (cons 'fd (list-ref parts 5)) + (cons 'bytes (list-ref parts 6))))) + (else #f)))))))