Port anomaly_rule_attack tactic tagger into (jsecmon analytics)

ober

d36d916e7ad5420512355d69af23da7c559283d4

diff --git a/README.md b/README.md
index 85e070f..0e80ecb 100644
--- 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). |
diff --git a/examples/analytics_check.ss b/examples/analytics_check.ss
index 4c6be34..c62258b 100644
--- 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.")
diff --git a/jsecmon/analytics.ss b/jsecmon/analytics.ss
index adedd4a..9ac09e5 100644
--- 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