Add untyped (jsecmon triage) false-positive engine

Jaime Fournier <jaimef@linbsd.org>

edb7bbb5cbc85e86f1ee63da0c8be1b7f8a05d29

diff --git a/Makefile b/Makefile
index 2b88337..c729b88 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,10 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme
 BUILD  ?= build/rust
 TYPED  := $(wildcard typed/*.ss)
 
-.PHONY: rust test ffi-demo kernels-check clean
+.PHONY: rust test ffi-demo kernels-check triage-check 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)"
 
 # Generate the Rust crate from the Typed Jerboa kernels, then drop in the
 # hand-written verification tests (the generator only writes src/ + Cargo.toml).
@@ -35,7 +38,14 @@ ffi-demo: rust
 # jsecmon/kernels.ss; combine into one --libdirs (a second one would replace it).
 kernels-check: rust
 	cd $(BUILD) && cargo build --release
-	$(SCHEME) --libdirs "$(JERBOA)/lib:$(CURDIR)" --script examples/kernels_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/kernels_check.ss
+
+# Verify the untyped orchestration layer: the (jsecmon triage) false-positive
+# engine, which dispatches in ordinary Jerboa and delegates byte/string
+# classification to the typed kernels. Checked against secmon's triage vectors.
+triage-check: rust
+	cd $(BUILD) && cargo build --release
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 56471a0..8d76e2e 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@ make rust            # typed/*.ss → build/rust (a cargo crate)
 make test            # regenerate, then cargo test against secmon's vectors
 make ffi-demo        # build the cdylib + drive two kernels from a Jerboa script
 make kernels-check   # exercise the (jsecmon kernels) library against the vectors
+make triage-check    # verify the untyped (jsecmon triage) engine vs secmon vectors
 ```
 
 ### The C ABI bridge
@@ -62,7 +63,8 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `&str` ops (lowercase/ends_with/starts_with/contains/split/whole-word) | `typed/strbytes.ss` | ✅ Bytes toolkit, vectors pass — shared by dga/lolbin/sigma |
 | `lolbin::score` + `severity` | `typed/lolbin.ss` | ✅ full 25-pattern table + severity buckets; vectors pass (JSON-cmdline parse stays in untyped wrapper; diagnostic match list with caller) |
 | `analytics::compute_host_risks` | `typed/analytics.ss` | ✅ risk-score kernel (clamped weighted sum); vectors pass (host grouping/sort/top-N stays untyped) |
-| `triage` classifiers      | `typed/triage.ss`  | ✅ pure predicates (transient-unit?, phantom-rootkit-race?); vectors pass (EventRow/JSON glue + remaining rules stay untyped) |
+| `triage` classifiers      | `typed/triage.ss`  | ✅ pure predicates (transient-unit?, phantom-rootkit-race?); vectors pass |
+| `triage` engine (rules + dispatch) | `jsecmon/triage.ss` | ✅ **untyped layer** — 6 false-positive rules + first-match engine, dispatch in ordinary Jerboa delegating byte/string classification to the typed kernels; 11 secmon triage vectors pass (`make triage-check`). Remaining rules are mechanical follow-on. |
 | `sigma`                   | —                  | ⏳ YAML import — I/O, untyped layer |
 | `psk::constant_time_eq`  | `typed/psk.ss`     | ✅ ported, vectors pass         |
 | `psk::from_hex` (hex codec) | `typed/psk.ss`  | ✅ hex encode + decode + 32-byte precondition; vectors pass (decode∘encode identity over all 256 byte values) |
diff --git a/examples/triage_check.ss b/examples/triage_check.ss
new file mode 100644
index 0000000..b776900
--- /dev/null
+++ b/examples/triage_check.ss
@@ -0,0 +1,74 @@
+;;; Parity check for the untyped (jsecmon triage) engine.
+;;;
+;;; Reproduces secmon's own triage unit tests (src/triage.rs #[test] mod): same
+;;; event vectors, same expected (label, category) — plus two phantom-rootkit
+;;; cases that exercise the Typed-Jerboa kernel delegation. Events are built
+;;; through the real JSON path (string->json-object), as triage_row does.
+;;;
+;;; Run from the repo root with the dylib built and the repo on the libdir path
+;;; (triage imports (jsecmon kernels), which loads the cdylib on import):
+;;;   (cd build/rust && cargo build --release)
+;;;   scheme --libdirs $JERBOA/lib --libdirs . --script examples/triage_check.ss
+
+(import (jerboa prelude)
+        (jsecmon triage))
+
+(def fails 0)
+
+(def (ev type pname json)
+  (make-event type pname (string->json-object json)))
+
+;; Expect a verdict with the given category + label.
+(def (want label cat type pname json)
+  (let* ((v (triage-event (ev type pname json)))
+         (ok (and v (eq? (verdict-category v) cat)
+                  (string=? (verdict-label v) label))))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ")
+               label "  <= " type "/" pname
+               (if ok "" (str "   got " (and v (verdict-label v)))))))
+
+;; Expect NO verdict (a real threat, or anything triage must not suppress).
+(def (none type pname json)
+  (let* ((v (triage-event (ev type pname json)))
+         (ok (not v)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ")
+               "(none)  <= " type "/" pname
+               (if ok "" (str "   got " (verdict-label v))))))
+
+(displayln "triage parity (secmon src/triage.rs vectors):")
+
+(want "sshd-privsep" 'benign
+      "privilege_change" "sshd" "{\"old_id\":109,\"new_id\":0,\"id_type\":\"uid\"}")
+(none "privilege_change" "evil_binary" "{\"old_id\":1000,\"new_id\":0,\"id_type\":\"uid\"}")
+
+(want "systemd-private-mount" 'benign
+      "mount_event" "(mandb)" "{\"source\":\"/dev/shm\"}")
+(want "loop-device-remount" 'benign
+      "mount_event" "mount" "{\"source\":\"/dev/loop3\"}")
+
+(want "snapd-https" 'benign
+      "reverse_shell" "snapd" "{\"remote_port\":443,\"remote_addr\":\"185.125.188.58\"}")
+(none "reverse_shell" "bash" "{\"remote_port\":4444,\"remote_addr\":\"1.2.3.4\"}")
+
+(want "proc-net-placeholder-socket" 'benign
+      "network_connection" "polkitd"
+      "{\"local_addr\":\"0.0.0.0\",\"local_port\":0,\"remote_addr\":\"10.0.0.5\",\"remote_port\":41266,\"state\":\"accepted\"}")
+(want "proc-net-placeholder-socket" 'benign
+      "network_connection" "sshd"
+      "{\"local_addr\":\"0.0.0.0\",\"local_port\":0,\"remote_addr\":\"0.0.0.0\",\"remote_port\":22,\"state\":\"connecting\"}")
+(none "network_connection" "curl"
+      "{\"local_addr\":\"10.0.0.10\",\"local_port\":54321,\"remote_addr\":\"1.2.3.4\",\"remote_port\":443,\"state\":\"established\"}")
+
+;; phantom-rootkit-race delegates its details classification to the typed kernel.
+(want "phantom-rootkit-race" 'benign
+      "container_event" "?"
+      "{\"container_event_type\":\"NamespaceEscape\",\"details\":\"ROOTKIT: Hidden process detected! comm=sshd [accepted]\"}")
+(none "container_event" "?"
+      "{\"container_event_type\":\"NamespaceEscape\",\"details\":\"ROOTKIT: Hidden process detected! comm=evil pid=31337\"}")
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: untyped triage engine matches secmon's vectors.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/triage.ss b/jsecmon/triage.ss
new file mode 100644
index 0000000..7237d68
--- /dev/null
+++ b/jsecmon/triage.ss
@@ -0,0 +1,146 @@
+#!chezscheme
+;;; jsecmon triage — the false-positive suppression engine, in ordinary Jerboa.
+;;;
+;;; secmon's triage layer classifies detector hits that are actually normal
+;;; system behavior (sshd privsep, snapd HTTPS, systemd private mounts, /proc
+;;; walker races, …) so the analyst reads each explanation once and never sees
+;;; the noise again. It is a list of rules tried in order; the first match wins.
+;;;
+;;; This is the natural untyped/typed split: the *dispatch* (event-type gate,
+;;; process-name match, JSON field reads, rule ordering) is ordinary Jerboa,
+;;; while the gnarly byte/string classification (is-this-a-transient-unit-name,
+;;; is-this-a-rootkit-detector-race) is delegated to the vetted Typed-Jerboa
+;;; kernels over the C ABI via `(jsecmon kernels)`.
+;;;
+;;; Verified against secmon's own triage test vectors (examples/triage_check.ss).
+
+(library (jsecmon triage)
+  (export make-event event? event-type event-process-name event-data
+          make-verdict verdict? verdict-category verdict-label
+          verdict-headline verdict-explanation
+          triage-rules triage-event)
+  (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?)
+          ;; Only the two classifiers we delegate — `only` avoids clashing with
+          ;; any like-named byte helpers the prelude already exports.
+          (only (jsecmon kernels) transient-unit? phantom-rootkit-race?))
+
+  ;; An event as triage sees it: its type name, the process that caused it, and
+  ;; its parsed JSON `data` (a hash table from string->json-object, or #f).
+  (defstruct event (type process-name data))
+
+  ;; A rule's verdict. `category` is 'benign (a known false positive) or
+  ;; 'expected (sensitive-but-normal for this host). label is a kebab-case tag.
+  (defstruct verdict (category label headline explanation))
+
+  ;; ── JSON data access (mirror serde's get(...).and_then(as_*).unwrap_or) ────
+  (def (d-str data key)
+    (if data (let ((v (hash-get data key))) (if (string? v) v "")) ""))
+  (def (d-num data key default)
+    (if data (let ((v (hash-get data key))) (if (number? v) v default)) default))
+
+  (def (pname ev) (or (event-process-name ev) ""))
+
+  (def (sshd-proc? name)
+    (or (string=? name "sshd") (string=? name "sshd-session")
+        (string=? name "sshd-auth") (string=? name "sshd-pam")
+        (string=? name "sshd-rexec")))
+
+  (def (recognized-mount-source? s)
+    (or (string=? s "/") (string=? s "/dev/shm") (string=? s "/dev/pts")
+        (string=? s "/dev/mqueue") (string=? s "/dev/hugepages")
+        (string-prefix? "/proc/self/fd/" s)
+        (string-prefix? "/run/" s)
+        (string-prefix? "/tmp" s)
+        (string-prefix? "/var/tmp" s)))
+
+  ;; ── rules (each: event -> verdict | #f) ────────────────────────────────────
+
+  ;; sshd's privilege-separated child re-acquires root to authenticate — fires
+  ;; on EVERY incoming SSH connection and is normal OpenSSH design.
+  (def (rule-sshd-privsep ev)
+    (and (string=? (event-type ev) "privilege_change")
+         (sshd-proc? (pname ev))
+         (= (d-num (event-data ev) "new_id" -1) 0)
+         (make-verdict 'benign "sshd-privsep"
+           "sshd privilege separation re-acquiring root to authenticate"
+           "OpenSSH uses privilege separation: an unprivileged child handles the network protocol, then setuids back to root to call PAM and open the user's session. This fires twice (uid + gid) on every incoming SSH connection. It is part of sshd's design and is not a privilege escalation attack.")))
+
+  ;; systemd transient units (cron jobs, logrotate, mandb) bind-mount pseudo-fs
+  ;; into a fresh namespace for PrivateTmp=/ProtectSystem=; the mount IS the
+  ;; sandbox, not an escape.
+  (def (rule-systemd-private-mount ev)
+    (and (string=? (event-type ev) "mount_event")
+         (transient-unit? (pname ev))               ;; typed kernel
+         (recognized-mount-source? (d-str (event-data ev) "source"))
+         (make-verdict 'benign "systemd-private-mount"
+           "systemd transient unit setting up a private mount namespace"
+           "Names in parentheses like (mandb), (logrotate), (cron) are systemd transient units. When configured with PrivateTmp=, ProtectSystem=, ProtectHome= or similar, systemd creates a fresh mount namespace and bind-mounts the listed pseudo-filesystems into it before exec'ing the job. The bind mount IS the sandbox, not an escape from it.")))
+
+  ;; `mount` remounting /dev/loopN — almost always snap/squashfs auto-refresh.
+  (def (rule-loop-device-remount ev)
+    (and (string=? (event-type ev) "mount_event")
+         (string=? (pname ev) "mount")
+         (string-prefix? "/dev/loop" (d-str (event-data ev) "source"))
+         (make-verdict 'benign "loop-device-remount"
+           "Loop device (re)mount, typically snap or squashfs"
+           "/dev/loopN is a loopback block device, used by snap, AppImage, and ISO mounts to expose a file as a filesystem. The most common cause of recurring 5-minute /dev/loopN mounts is snapd refreshing its squashfs-backed packages. Treat as noise unless the loop device appears unexpectedly.")))
+
+  ;; snapd talking to Canonical's store over 443 gets misflagged as a reverse
+  ;; shell because 443 is wrongly in the C2-port list.
+  (def (rule-snapd-https ev)
+    (and (string=? (event-type ev) "reverse_shell")
+         (string=? (pname ev) "snapd")
+         (= (d-num (event-data ev) "remote_port" 0) 443)
+         (make-verdict 'benign "snapd-https"
+           "snapd HTTPS to Canonical's snap store (port 443)"
+           "snapd runs as root and routinely connects to api.snapcraft.io on port 443 to check for and download package updates. This is being flagged because port 443 is in the KnownC2Port list, which is a bug. The remote address (typically 185.125.188.x) is Canonical's IP range.")))
+
+  ;; The rootkit detector races a short-lived sshd accept worker between its
+  ;; readdir(/proc) and kill(pid,0) scans. The details-string classification is
+  ;; the typed kernel's job; this rule just gates on the event shape.
+  (def (rule-phantom-rootkit ev)
+    (and (string=? (event-type ev) "container_event")
+         (string=? (d-str (event-data ev) "container_event_type") "NamespaceEscape")
+         (phantom-rootkit-race? (d-str (event-data ev) "details"))   ;; typed kernel
+         (make-verdict 'benign "phantom-rootkit-race"
+           "Rootkit detector race against a short-lived sshd accept worker"
+           "The rootkit detector finds 'hidden' processes by comparing readdir(/proc) against a kill(pid,0) probe. If a process exits between the two scans it looks 'visible to kill, hidden from /proc'. sshd's accept/privsep workers live for milliseconds, so on a busy host this race fires a few times a day. The 'no info available' variant is the same race where the comm/cmdline read also lost. A real rootkit hides processes consistently, not for a single sample.")))
+
+  ;; The /proc network walker maps socket inodes to PIDs racily; sockets caught
+  ;; mid-transition show the impossible (local_addr=0.0.0.0, local_port=0) and
+  ;; get misattributed to whatever long-lived process is scanned first.
+  (def (rule-proc-net-placeholder ev)
+    (and (string=? (event-type ev) "network_connection")
+         (let ((a (d-str (event-data ev) "local_addr")))
+           (or (string=? a "0.0.0.0") (string=? a "::") (string=? a "::0")))
+         (= (d-num (event-data ev) "local_port" -1) 0)
+         (make-verdict 'benign "proc-net-placeholder-socket"
+           "/proc network monitor reporting a placeholder socket as a real connection"
+           "The Linux /proc network monitor walks /proc/net/{tcp,udp} and maps socket inodes back to PIDs by scanning every /proc/<pid>/fd/. Short-lived sockets in transitional states (just after accept(), or after bind() before listen()) appear with placeholder local_addr=0.0.0.0 and local_port=0 and get attached to whichever long-lived process is scanned first. That triple is impossible for a real connect(2), so any event matching it is a /proc-walker artifact.")))
+
+  ;; ── engine ─────────────────────────────────────────────────────────────────
+  ;; Order doesn't matter for these vectors (no overlap), but first-match-wins
+  ;; mirrors secmon's RULES loop exactly.
+  (def triage-rules
+    (list rule-sshd-privsep
+          rule-systemd-private-mount
+          rule-loop-device-remount
+          rule-snapd-https
+          rule-phantom-rootkit
+          rule-proc-net-placeholder))
+
+  ;; Run every rule against an event; return the first verdict, or #f.
+  (def (triage-event ev)
+    (let loop ((rs triage-rules))
+      (if (null? rs)
+          #f
+          (or ((car rs) ev) (loop (cdr rs)))))))