Port secmon per-host risk scoring to Typed Jerboa

Jaime Fournier <jaimef@linbsd.org>

433d9e1a4be8084237e38ab73bba3a7daa5b4db3

diff --git a/README.md b/README.md
index 05f84e3..8007dbf 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,8 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `dga::score_domain`      | `typed/dga.ss`     | ✅ full: lowercase + dot-trim + benign-suffix + label split + score; vectors pass (diagnostic `reasons` list pending) |
 | `&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) |
-| `sigma`, `triage`, `analytics` | —             | ⏳ pure logic, queued           |
+| `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 |
 | `psk::constant_time_eq`  | `typed/psk.ss`     | ✅ ported, vectors pass         |
 | `psk::from_hex` (hex codec) | `typed/psk.ss`  | ✅ hex-encode ported; decode + length check queued |
 | `psk` HKDF/SHA256/AES-GCM | —                 | ⏳ FFI-delegated to vetted crates (not reimplemented) |
diff --git a/tests/analytics_vectors.rs b/tests/analytics_vectors.rs
new file mode 100644
index 0000000..cd9712c
--- /dev/null
+++ b/tests/analytics_vectors.rs
@@ -0,0 +1,61 @@
+//! Vectors for the per-host risk scoring kernel, checked against secmon's
+//! analytics::compute_host_risks weighting (its inner `compute_score`) and the
+//! arithmetic its `risk_prefers_chains_over_noise` test relies on.
+
+use jerboa_typed_generated::jsecmon_analytics::host_risk_score;
+
+// argument order: critical, high, medium, distinct_rules, chains,
+// suspicious_cmdline, dga, rootkit_or_tamper, persistence
+fn score(c: u64, h: u64, m: u64, dr: u64, ch: u64, sc: u64, dga: u64, rt: u64, p: u64) -> u64 {
+    host_risk_score(c, h, m, dr, ch, sc, dga, rt, p)
+}
+
+#[test]
+fn quiet_host_scores_zero() {
+    // secmon's host-a: 100 info events, no detections → every accumulator 0.
+    assert_eq!(score(0, 0, 0, 0, 0, 0, 0, 0, 0), 0);
+}
+
+#[test]
+fn one_kill_chain_dominates_noise() {
+    // secmon's host-b: 1 high event + 1 priv_escalation_chain detection.
+    // high*3 + distinct_rules*6 + chains*25 = 3 + 6 + 25 = 34, and that must
+    // beat the quiet host (the point of risk_prefers_chains_over_noise).
+    assert_eq!(score(0, 1, 0, 1, 1, 0, 0, 0, 0), 34);
+    assert!(score(0, 1, 0, 1, 1, 0, 0, 0, 0) > score(0, 0, 0, 0, 0, 0, 0, 0, 0));
+}
+
+#[test]
+fn medium_events_are_halved_with_integer_division() {
+    // medium/2 truncates: 5 medium events → 2, not 2.5.
+    assert_eq!(score(0, 0, 5, 0, 0, 0, 0, 0, 0), 2);
+    assert_eq!(score(0, 0, 1, 0, 0, 0, 0, 0, 0), 0);
+}
+
+#[test]
+fn each_weight_matches_secmon() {
+    assert_eq!(score(1, 0, 0, 0, 0, 0, 0, 0, 0), 8); // critical*8
+    assert_eq!(score(0, 1, 0, 0, 0, 0, 0, 0, 0), 3); // high*3
+    assert_eq!(score(0, 0, 0, 1, 0, 0, 0, 0, 0), 6); // distinct_rules*6
+    assert_eq!(score(0, 0, 0, 0, 1, 0, 0, 0, 0), 25); // chains*25
+    assert_eq!(score(0, 0, 0, 0, 0, 1, 0, 0, 0), 8); // suspicious_cmdline*8
+    assert_eq!(score(0, 0, 0, 0, 0, 0, 1, 0, 0), 10); // dga*10
+    assert_eq!(score(0, 0, 0, 0, 0, 0, 0, 1, 0), 15); // rootkit_or_tamper*15
+    assert_eq!(score(0, 0, 0, 0, 0, 0, 0, 0, 1), 8); // persistence*8
+}
+
+#[test]
+fn multi_signal_sum_is_exact_below_the_cap() {
+    // 8 + 3 + (4/2=2) + 6 + 8 + 8 = 35, un-clamped.
+    assert_eq!(score(1, 1, 4, 1, 0, 1, 0, 0, 1), 35);
+}
+
+#[test]
+fn score_saturates_at_100() {
+    // 20 critical → 160, clamped to 100.
+    assert_eq!(score(20, 0, 0, 0, 0, 0, 0, 0, 0), 100);
+    // exactly four chains land on the cap (4*25 = 100).
+    assert_eq!(score(0, 0, 0, 0, 4, 0, 0, 0, 0), 100);
+    // a pile of everything stays pinned at 100, never overflows past it.
+    assert_eq!(score(50, 50, 50, 50, 50, 50, 50, 50, 50), 100);
+}
diff --git a/typed/analytics.ss b/typed/analytics.ss
new file mode 100644
index 0000000..19354a4
--- /dev/null
+++ b/typed/analytics.ss
@@ -0,0 +1,34 @@
+;;; jsecmon — per-host risk scoring kernel.
+;;;
+;;; Port of the tuned scoring math at the heart of secmon's
+;;; analytics::compute_host_risks (its inner `compute_score`). The surrounding
+;;; aggregation — grouping events/detections by host into accumulators with a
+;;; BTreeMap, then sorting and truncating to top-N — is data-shuffling that
+;;; belongs to the untyped orchestration layer. What's genuinely a numeric
+;;; kernel, and what secmon hand-tuned, is turning one host's accumulated
+;;; counts into a single 0..100 rank. That weighted, clamped sum is this
+;;; function.
+
+(typed-library (jsecmon analytics)
+  (export host-risk-score)
+
+  ;; Weighted composite risk for one host, clamped to 0..100. Mirrors
+  ;; secmon's compute_score exactly: critical*8 + high*3 + medium/2 (integer
+  ;; division) + distinct-rules*6 + chains*25 + suspicious-cmdline*8 + dga*10
+  ;; + rootkit-or-tamper*15 + persistence*8. secmon also clamps below at 0,
+  ;; but every input is a non-negative count and every weight is positive, so
+  ;; the sum is never negative — only the upper clamp can bind.
+  (def (host-risk-score (critical : Nat) (high : Nat) (medium : Nat)
+                        (distinct-rules : Nat) (chains : Nat)
+                        (suspicious-cmdline : Nat) (dga : Nat)
+                        (rootkit-or-tamper : Nat) (persistence : Nat)) : Nat
+    (let ((s (+ (* critical 8)
+                (* high 3)
+                (/ medium 2)
+                (* distinct-rules 6)
+                (* chains 25)
+                (* suspicious-cmdline 8)
+                (* dga 10)
+                (* rootkit-or-tamper 15)
+                (* persistence 8))))
+      (if (> s 100) 100 s))))