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")))))))