Port event_json + local_store classification tables to untyped Jerboa

ober

a78d3e018334a49440a2ec262298052b8e8ab7e8

diff --git a/Makefile b/Makefile
index 1536503..b43afd6 100644
--- 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)
diff --git a/README.md b/README.md
index b1f339b..76a9ce9 100644
--- 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           |
diff --git a/examples/event_meta_check.ss b/examples/event_meta_check.ss
new file mode 100644
index 0000000..2c6c77a
--- /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)))
diff --git a/jsecmon/event-meta.ss b/jsecmon/event-meta.ss
new file mode 100644
index 0000000..44c94b0
--- /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))))