Port event_json + local_store classification tables to untyped Jerboa
ober
a78d3e018334a49440a2ec262298052b8e8ab7e8
--- 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 freebsd-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 freebsd-parse-check event-meta-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)" @@ -174,6 +174,13 @@ proc-linux-check: freebsd-parse-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/freebsd_parse_check.ss +# Event-type metadata tables (secmon event_json.rs + local_store.rs): the +# display-severity string per event (constant arms + the 7 payload-dependent +# helpers) and the INDEPENDENT coarse local-store priority u8. Pure tables, no +# native lib. secmon has no tests here, so the check asserts the full tables. +event-meta-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_meta_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 @@ -197,6 +204,7 @@ checks: kernels-check $(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 + $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_meta_check.ss clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ 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 event-meta-check # event-type -> display severity + coarse store-priority u8 tables make checks # every Jerboa-side check in one shot ``` @@ -112,5 +113,6 @@ 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` (/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.) | +| `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.) | | `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/event_meta_check.ss @@ -0,0 +1,84 @@ +;;; Parity check for (jsecmon event-meta) against secmon's event_json.rs +;;; (get_event_json_data severity arms) and local_store.rs (event_severity_u8). +;;; secmon has no #[test] for these, so this asserts the full tables arm-for-arm +;;; against the Rust source and IS the spec for the port. +;;; +;;; scheme --libdirs "$JERBOA/lib:." --script examples/event_meta_check.ss + +(import (jerboa prelude) + (jsecmon event-meta)) + +(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))))) + +;; ── event_json.rs constant-severity arms ───────────────────────────────────── +(displayln "event_json static severity:") +(for-each + (lambda (pair) (check (car pair) (event-static-severity (car pair)) (cdr pair))) + '(("process_start" . "info") ("process_exit" . "info") + ("suspicious_exec" . "high") ("agent_start" . "info") ("heartbeat" . "info") + ("network_connection" . "info") ("listening_port" . "info") + ("suspicious_connection" . "high") ("file_changed" . "info") + ("suspicious_file_change" . "high") ("privilege_escalation" . "critical") + ("setuid_execution" . "medium") ("capability_change" . "medium") + ("kernel_module" . "high") ("scheduled_task_change" . "medium") + ("container_event" . "high") ("dns_query" . "info") ("file_open" . "info") + ("sensitive_file_access" . "high") ("ptrace_event" . "critical") + ("namespace_event" . "high") ("persistence_event" . "critical") + ("reverse_shell" . "critical") ("log_tampering" . "critical") + ("webshell" . "critical"))) +;; dynamic arms are NOT in the static table +(check "auth_event static -> #f" (event-static-severity "auth_event") #f) +(check "unknown -> #f" (event-static-severity "nope") #f) + +;; ── event_json.rs payload-dependent arms ───────────────────────────────────── +(displayln "event_json dynamic severity:") +(check "auth success" (auth-event-severity #t) "info") +(check "auth fail" (auth-event-severity #f) "medium") +(check "privchange root" (privilege-change-severity 0) "critical") +(check "privchange user" (privilege-change-severity 1000) "medium") +(check "mount dangerous" (mount-event-severity #t) "critical") +(check "mount safe" (mount-event-severity #f) "info") +(check "cap dangerous" (capability-event-severity #t) "high") +(check "cap none" (capability-event-severity #f) "info") +(check "podman priv" (podman-event-severity #t #f #f) "high") +(check "podman hostnet" (podman-event-severity #f #t #f) "high") +(check "podman hostpid" (podman-event-severity #f #f #t) "high") +(check "podman plain" (podman-event-severity #f #f #f) "info") +(check "selinux avc" (selinux-event-severity 'avc-denied) "high") +(check "selinux mode" (selinux-event-severity 'mode-change) "critical") +(check "selinux policy" (selinux-event-severity 'policy-load) "medium") +(check "selinux other" (selinux-event-severity 'config-change) "info") +(check "lateral internal" (lateral-movement-severity #t) "high") +(check "lateral external" (lateral-movement-severity #f) "medium") + +;; ── local_store.rs event_severity_u8 (independent coarse scale) ────────────── +(displayln "local_store severity u8:") +(for-each + (lambda (n) (check (str n " -> 3") (event-severity-u8 n) 3)) + '("reverse_shell" "log_tampering" "persistence_event")) +(for-each + (lambda (n) (check (str n " -> 2") (event-severity-u8 n) 2)) + '("suspicious_exec" "suspicious_connection" "suspicious_file_change" "webshell" + "lateral_movement" "kernel_module" "setuid_execution" "container_event" + "selinux_event")) +(for-each + (lambda (n) (check (str n " -> 1") (event-severity-u8 n) 1)) + '("auth_event" "scheduled_task_change" "capability_change" "capability_event")) +;; the catch-all `_ => 0`: high DISPLAY severity but 0 store priority +(check "privilege_escalation -> 0" (event-severity-u8 "privilege_escalation") 0) +(check "process_start -> 0" (event-severity-u8 "process_start") 0) +(check "unknown -> 0" (event-severity-u8 "nope") 0) + +;; ── enumeration ────────────────────────────────────────────────────────────── +(displayln "enumeration:") +(check "32 event names" (length *event-names*) 32) + +(newline) +(if (= fails 0) + (displayln "OK: event-meta matches secmon's event_json + local_store tables.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/event-meta.ss @@ -0,0 +1,132 @@ +#!chezscheme +;;; jsecmon event-type metadata (secmon event_json + local_store), untyped. +;;; +;;; The pure classification tables shared by secmon's event pipeline, lifted out +;;; of the payload-carrying EventType enum so they can be checked on their own: +;;; +;;; * event-static-severity : the display/alert severity string returned by +;;; src/event_json.rs `get_event_json_data` for the variants whose severity +;;; is a constant (info|medium|high|critical). The 7 variants whose severity +;;; depends on a payload field get a named helper instead (below), each +;;; taking that deciding field as a plain argument. +;;; * event-severity-u8 : the COARSE local-store priority (0..3) from +;;; src/local_store.rs `event_severity_u8`. This is an INDEPENDENT scale from +;;; the display string — e.g. privilege_escalation is "critical" for display +;;; but u8 0 for the store — so both are ported verbatim, not derived. +;;; +;;; Events are keyed by the snake_case name secmon emits as the first element of +;;; get_event_json_data's tuple (= the on-wire event_type), so the untyped I/O +;;; layer can classify an event from its name alone. +;;; +;;; No #[test] backs these in secmon (event_json/local_store are untested glue), +;;; so examples/event_meta_check.ss asserts the full tables against the Rust +;;; source arm-for-arm and IS the spec for this port. + +(library (jsecmon event-meta) + (export event-static-severity event-severity-u8 + auth-event-severity privilege-change-severity mount-event-severity + capability-event-severity podman-event-severity selinux-event-severity + lateral-movement-severity + *event-names*) + (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?)) + + ;; ── event_json.rs get_event_json_data: constant-severity arms ─────────────── + ;; (name . severity); the 7 payload-dependent arms are the helpers below and + ;; are deliberately absent here. + (def *static-severity* + '(("process_start" . "info") + ("process_exit" . "info") + ("suspicious_exec" . "high") + ("agent_start" . "info") + ("heartbeat" . "info") + ("network_connection" . "info") + ("listening_port" . "info") + ("suspicious_connection" . "high") + ("file_changed" . "info") + ("suspicious_file_change" . "high") + ("privilege_escalation" . "critical") + ("setuid_execution" . "medium") + ("capability_change" . "medium") + ("kernel_module" . "high") + ("scheduled_task_change" . "medium") + ("container_event" . "high") + ("dns_query" . "info") + ("file_open" . "info") + ("sensitive_file_access" . "high") + ("ptrace_event" . "critical") + ("namespace_event" . "high") + ("persistence_event" . "critical") + ("reverse_shell" . "critical") + ("log_tampering" . "critical") + ("webshell" . "critical"))) + + ;; The 7 payload-dependent arms, so the full set is enumerable. + (def *dynamic-names* + '("auth_event" "privilege_change" "mount_event" "capability_event" + "podman_event" "selinux_event" "lateral_movement")) + + ;; Every event name secmon can emit. + (def *event-names* + (append (map car *static-severity*) *dynamic-names*)) + + ;; Constant display severity for a name, or #f if the name is one of the + ;; payload-dependent arms (use the helper) or simply unknown. + (def (event-static-severity name) + (let ((p (assoc name *static-severity*))) (and p (cdr p)))) + + ;; ── event_json.rs: the payload-dependent severity arms ────────────────────── + ;; AuthEvent: info on success, else medium. + (def (auth-event-severity success?) + (if success? "info" "medium")) + + ;; PrivilegeChange: critical when the new uid/gid is 0 (root), else medium. + (def (privilege-change-severity new-id) + (if (= new-id 0) "critical" "medium")) + + ;; MountEvent: critical when MountInfo::is_dangerous() fired, else info. + (def (mount-event-severity dangerous?) + (if dangerous? "critical" "info")) + + ;; CapabilityEvent: high when dangerous_caps() is non-empty, else info. + (def (capability-event-severity has-dangerous-caps?) + (if has-dangerous-caps? "high" "info")) + + ;; PodmanEvent: high when privileged OR host-network OR host-pid, else info. + (def (podman-event-severity privileged? host-network? host-pid?) + (if (or privileged? host-network? host-pid?) "high" "info")) + + ;; SELinuxEvent: by SELinuxEventType — AvcDenied high, ModeChange critical, + ;; PolicyLoad medium, anything else info. selinux-type is a symbol. + (def (selinux-event-severity selinux-type) + (cond ((eq? selinux-type 'avc-denied) "high") + ((eq? selinux-type 'mode-change) "critical") + ((eq? selinux-type 'policy-load) "medium") + (else "info"))) + + ;; LateralMovementEvent: high on the internal network, else medium. + (def (lateral-movement-severity internal-network?) + (if internal-network? "high" "medium")) + + ;; ── local_store.rs event_severity_u8: coarse store priority 0..3 ──────────── + ;; Keyed by the same snake_case name; independent of the display string above. + (def *u8-3* '("reverse_shell" "log_tampering" "persistence_event")) + (def *u8-2* '("suspicious_exec" "suspicious_connection" "suspicious_file_change" + "webshell" "lateral_movement" "kernel_module" "setuid_execution" + "container_event" "selinux_event")) + (def *u8-1* '("auth_event" "scheduled_task_change" "capability_change" + "capability_event")) + + (def (event-severity-u8 name) + (cond ((member name *u8-3*) 3) + ((member name *u8-2*) 2) + ((member name *u8-1*) 1) + (else 0))))