Port triage benign-event classifiers to Typed Jerboa

Jaime Fournier <jaimef@linbsd.org>

1ab7c885facee48e7389e4beca69ceaecfdfa548

diff --git a/README.md b/README.md
index 286e3ec..3b3c84a 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,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) |
-| `sigma`, `triage`         | —                  | ⏳ sigma=YAML import (I/O), triage=storage-coupled rules — untyped layer |
+| `triage` classifiers      | `typed/triage.ss`  | ✅ pure predicates (transient-unit?, phantom-rootkit-race?); vectors pass (EventRow/JSON glue + remaining rules stay untyped) |
+| `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) |
 | `psk` HKDF/SHA256/AES-GCM | —                 | ⏳ FFI-delegated to vetted crates (not reimplemented) |
diff --git a/tests/triage_vectors.rs b/tests/triage_vectors.rs
new file mode 100644
index 0000000..5ad47b0
--- /dev/null
+++ b/tests/triage_vectors.rs
@@ -0,0 +1,45 @@
+//! Vectors for the triage classification kernels, checked against the exact
+//! predicates secmon's src/triage.rs rules apply (is_transient_unit and
+//! rule_phantom_sshd_rootkit's benign-race test). These are case-sensitive —
+//! the inputs are kernel/daemon detail strings, not user text.
+
+use jerboa_typed_generated::jsecmon_triage::{phantom_rootkit_race_p, transient_unit_p};
+
+#[test]
+fn transient_unit_matches_parenthesized_comm() {
+    // systemd transient units present a parenthesized comm.
+    assert!(transient_unit_p("(sd-pam)".to_string()));
+    assert!(transient_unit_p("(direxec)".to_string()));
+    assert!(transient_unit_p("(x)".to_string())); // len 3, just over the guard
+    // the bare "()" is len 2 and must NOT qualify (the > 2 guard)
+    assert!(!transient_unit_p("()".to_string()));
+    // ordinary process names and half-parenthesized names are not transient
+    assert!(!transient_unit_p("sshd".to_string()));
+    assert!(!transient_unit_p("(unterminated".to_string()));
+    assert!(!transient_unit_p("trailing)".to_string()));
+    assert!(!transient_unit_p("".to_string()));
+}
+
+#[test]
+fn phantom_rootkit_race_matches_benign_sshd_and_phantom() {
+    let pfx = "ROOTKIT: Hidden process detected!";
+    // sshd accept worker: comm=sshd with [accepted] or [priv]
+    assert!(phantom_rootkit_race_p(format!("{pfx} pid=1234 comm=sshd [accepted]")));
+    assert!(phantom_rootkit_race_p(format!("{pfx} pid=1234 comm=sshd [priv]")));
+    // the lost-read variant: "no info available", no comm at all
+    assert!(phantom_rootkit_race_p(format!("{pfx} pid=9999 no info available")));
+}
+
+#[test]
+fn phantom_rootkit_race_rejects_real_rootkits() {
+    let pfx = "ROOTKIT: Hidden process detected!";
+    // a hidden process that is NOT a short-lived sshd worker is a real finding
+    assert!(!phantom_rootkit_race_p(format!("{pfx} pid=1234 comm=bash [accepted]")));
+    // sshd but neither accept nor privsep marker — not the known race
+    assert!(!phantom_rootkit_race_p(format!("{pfx} pid=1234 comm=sshd running")));
+    // the benign markers without the ROOTKIT prefix don't get a free pass
+    assert!(!phantom_rootkit_race_p(
+        "some other alert comm=sshd [accepted]".to_string()
+    ));
+    assert!(!phantom_rootkit_race_p("".to_string()));
+}
diff --git a/typed/triage.ss b/typed/triage.ss
new file mode 100644
index 0000000..db58ceb
--- /dev/null
+++ b/typed/triage.ss
@@ -0,0 +1,37 @@
+;;; jsecmon — triage classification kernels.
+;;;
+;;; secmon's triage rules (src/triage.rs) recognize known-benign events so an
+;;; analyst isn't buried under false positives. Each rule is glue —
+;;; EventRow/JSON field extraction returning an Option<TriageVerdict> — wrapped
+;;; around a small pure predicate that decides "is this string the benign
+;;; shape?". The glue stays in the untyped layer; the predicates worth writing
+;;; once in a checked language live here. They are case-sensitive (the inputs
+;;; are kernel/daemon detail strings, not user text), so they run on the raw
+;;; UTF-8 bytes without lowercasing.
+
+(typed-library (jsecmon triage)
+  (export transient-unit? phantom-rootkit-race?)
+  (import (jsecmon strbytes))
+
+  ;; is_transient_unit: a systemd transient unit's comm is parenthesized, e.g.
+  ;; "(sd-pam)", "(direxec)". Require both parens AND length > 2 so the bare
+  ;; "()" doesn't qualify (matching the Rust guard exactly).
+  (def (transient-unit? (name : String)) : Bool
+    (let ((bs (string->utf8 name)))
+      (and (> (bytevector-length bs) 2)
+           (and (bytes-prefix? bs (string->utf8 "("))
+                (bytes-suffix? bs (string->utf8 ")"))))))
+
+  ;; rule_phantom_sshd_rootkit's classifier: the rootkit detector races a
+  ;; short-lived sshd accept/privsep worker (or loses its comm/cmdline read)
+  ;; and reports a "hidden" process that was simply mid-exit. A benign race is
+  ;; a "ROOTKIT: Hidden process detected!" report that is either an sshd accept
+  ;; worker (comm=sshd with [accepted] or [priv]) or a "no info available"
+  ;; phantom. A real rootkit hides processes consistently, not for one sample.
+  (def (phantom-rootkit-race? (details : String)) : Bool
+    (let ((bs (string->utf8 details)))
+      (and (bytes-prefix? bs (string->utf8 "ROOTKIT: Hidden process detected!"))
+           (or (and (bytes-contains? bs (string->utf8 "comm=sshd"))
+                    (or (bytes-contains? bs (string->utf8 "[accepted]"))
+                        (bytes-contains? bs (string->utf8 "[priv]"))))
+               (bytes-contains? bs (string->utf8 "no info available")))))))