Port anomaly_rule_attack tactic tagger into (jsecmon analytics)
ober
d36d916e7ad5420512355d69af23da7c559283d4
--- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `storage::detect_frequency_spikes` (spike core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure per-(host,event-type) hourly-spike test: given `(host event-type hour count)` rows (the `hourly_counts` GROUP BY aggregate is deferred I/O), sum count and tally hours per key, then emit any row whose key average `> 0` and whose `count` strictly exceeds `3×` that average. Returns `((host …) (event-type …) (hour …) (count …) (average …) (ratio …))` in input row order for the caller to wrap (`parse_hour_to_ms` is calendar-deferred); `average`/`ratio` are f64 like Rust's `total/hours` and `count/avg`. `make analytics-check` adds 3×-spike, exact-3×-excluded (strict `>`), flat, avg-0-guard, key-independence, two-group-order, and empty cases. | | `storage::detect_kill_chain` (chain core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure multi-phase kill-chain detector: given `(host ts-ms event-type)` rows pre-sorted by host then ts (SQL fetch deferred), slide from each i over the same-host run with `ts ≤ ts_i + window-ms`, map each type to an ATT&CK-ish phase via `event-type->attack-phase` (also exported; unmapped types skipped), and when the **distinct** phases reach `min-phases` (3) emit a chain then skip past it (Rust `i = j`), else advance one. Emits `((host …) (window-start …) (window-end …) (phases …) (event-types …))`; Rust collects phases from an unordered `HashSet`, so `phases` is canonicalized to first-seen order (treat as a set) while `event-types` keeps phase-mapped types in order. `make analytics-check` adds the classifier table, three-phases, two-distinct-only, unmapped-skip, host-boundary, window-edge, past-edge, two-chains-after-skip, and empty cases. | | `storage::detect_off_hours` (predicate) | `jsecmon/analytics.ss` | ✅ **untyped layer** — `off-hours?`, the decision rule factored out of the SQL `WHERE`: a critical/high event is off-hours on a weekend or outside 08:00–18:00 UTC (`weekday` = strftime `%w` 0=Sun…6=Sat, `hour` = `%H` 0–23). The timestamp→(weekday,hour) decomposition is calendar-deferred. `make analytics-check` adds weekend, midday, and the 08:00/17:00/18:00 boundaries. | +| `storage::anomaly_rule_attack` (tactic tagger) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure rule-name→ATT&CK-tactic table `detect_anomalies` stamps onto each anomaly: `kill_chain`→TA0001/TA0008/TA0010, `off_hours`→TA0005, every other rule→none. `make analytics-check` covers both tagged rules plus untagged/unknown. | | `storage::detect_lolbin_cmdline` + `detect_dga_domain` | `jsecmon/detect.ss` | ✅ **untyped layer** — the kernel-driven detection rules: score every process_start cmdline (lolbin) / dns_query (dga) into anomalies above threshold. `make detect-check` runs the full events→detect→analytics pipeline; all three scoring kernels fire. Per-pattern lolbin label lists now available via `(jsecmon lolbin)`'s `match-bits`-backed breakdown; label-level DGA dedup still pending (needs a DGA kernel that returns its match breakdown). | | `triage` classifiers | `typed/triage.ss` | ✅ pure predicates (transient-unit?, phantom-rootkit-race?); vectors pass | | `triage` engine (rules + dispatch) | `jsecmon/triage.ss` | ✅ **untyped layer** — all 18 false-positive rules + first-match engine, in secmon's exact RULES order, dispatch in ordinary Jerboa delegating byte/string classification to the typed kernels; 40 triage vectors pass (`make triage-check`), incl. the security-relevant negatives (non-sshd reading host keys, systemd impersonated from /tmp, unknown daemon reading passwd). | --- a/examples/analytics_check.ss +++ b/examples/analytics_check.ss @@ -278,6 +278,13 @@ (check "Wed 18:00 is off-hours" (off-hours? 3 18) #t) (check "Mon 23:00 is off-hours" (off-hours? 1 23) #t) +;; ── anomaly-rule-attack (storage anomaly_rule_attack tactic tagger) ─────────── +(displayln "anomaly-rule-attack:") +(check "kill_chain tactics" (anomaly-rule-attack "kill_chain") '("TA0001" "TA0008" "TA0010")) +(check "off_hours tactic" (anomaly-rule-attack "off_hours") '("TA0005")) +(check "frequency_spike -> none" (anomaly-rule-attack "frequency_spike") '()) +(check "unknown rule -> none" (anomaly-rule-attack "whatever") '()) + (newline) (if (= fails 0) (displayln "OK: untyped analytics matches secmon's vectors.") --- a/jsecmon/analytics.ss +++ b/jsecmon/analytics.ss @@ -22,7 +22,7 @@ host-risk-first-seen-ms host-risk-last-seen-ms group-incidents detect-sequence-pairs detect-severity-clusters detect-frequency-spikes detect-kill-chains event-type->attack-phase - off-hours? + off-hours? anomaly-rule-attack incident? incident-rule incident-host incident-key incident-severity incident-attack incident-first-ms incident-last-ms incident-occurrences incident-sample) @@ -308,6 +308,15 @@ (def (off-hours? weekday hour) (or (= weekday 0) (= weekday 6) (< hour 8) (>= hour 18))) + ;; secmon storage::anomaly_rule_attack — the ATT&CK tactic IDs detect_anomalies + ;; stamps onto each anomaly by rule name. Only the multi-stage rules carry + ;; tactics: kill_chain spans Initial Access / Lateral Movement / Exfiltration, + ;; off_hours is Defense Evasion; every other rule maps to none. + (def (anomaly-rule-attack name) + (cond ((string=? name "kill_chain") '("TA0001" "TA0008" "TA0010")) + ((string=? name "off_hours") '("TA0005")) + (else '()))) + (def (group-incidents detections) (let ((groups (make-hash-table))) (for-each