Port secmon per-host risk scoring to Typed Jerboa
Jaime Fournier <jaimef@linbsd.org>
433d9e1a4be8084237e38ab73bba3a7daa5b4db3
--- 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) | new file mode 100644 --- /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); +} new file mode 100644 --- /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))))