Port detection-rule ATT&CK catalog to untyped Jerboa

ober

f6141aa139d261e5d369c2b218e8f46929a4258a

diff --git a/Makefile b/Makefile
index 1dd766d..380e3fb 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme
 BUILD  ?= build/rust
 TYPED  := $(wildcard typed/*.ss)
 
-.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check revshell-check cron-check logtamper-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check revshell-check cron-check logtamper-check detection-rules-check checks clean
 # Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon
 # (a second --libdirs would replace, not append, the jerboa one).
 LIBDIRS := "$(JERBOA)/lib:$(CURDIR)"
@@ -270,6 +270,14 @@ cron-check:
 logtamper-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/logtamper_check.ss
 
+# Built-in detection-rule ATT&CK catalog (secmon src/storage/mod.rs): the
+# DETECTION_RULES table (name/description/severity/attack), rule_attack lookup,
+# rule-name listing, and anomaly_rule_attack (the statistical rules' broad-tactic
+# map). Pure metadata — the SQL detectors that fire these live in (jsecmon
+# threats). const has no #[test]; check asserts every row + both lookups.
+detection-rules-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/detection_rules_check.ss
+
 # Everything that runs through the Jerboa side of the bridge, one shot.
 checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_check.ss
@@ -307,6 +315,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/cron_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/logtamper_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/detection_rules_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index bba0f4b..39b204b 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ make ioc-check       # storage IOC: detect_ioc_type + is_ipv4 + parse_ioc_text
 make revshell-check  # revshell: is_shell/is_c2_port/is_legitimate + classify_connection
 make cron-check      # cron: per-platform CRON/PERIODIC path tables + systemd/periodic route
 make logtamper-check # logtamper: system-log/history tables + classify-tamper (trunc/mtime)
+make detection-rules-check # DETECTION_RULES ATT&CK catalog: rule_attack + anomaly_rule_attack
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -141,4 +142,5 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor/revshell` reverse-shell classifiers (`classify_connection` + helpers) | `jsecmon/revshell.ss` | ✅ **untyped layer** — the pure deciders of the reverse-shell monitor (connection/PID enumeration, event emission, and the dedup set stay in the monitor loop). `is_shell` / `is_revshell_tool` are **exact** lower-cased name membership; `is_c2_port` tests the 17-port C2 set; `is_legitimate_service` fires only on 443/8080/8443 and matches a **substring** of the name. `extract_addr_from_cmdline` finds `/dev/tcp/`, splits the remainder on `/`, and on ≥2 pieces returns `(addr . u16-port)` (first whitespace token of piece 1, junk/out-of-range→0), else `("unknown" . 0)`. `classify_connection` runs checks 1–4 first-match: shell→`shell-outbound`, C2-port-and-not-legit→`known-c2-port`, revshell-tool→`shell-outbound`, any `REVSHELL_PATTERNS` substring→`suspicious-redirect`, else `#f`. revshell.rs has no #[test], so `make revshell-check` (47 cases) **is** the spec. |
 | `monitor/cron` scheduled-task path tables + classifier | `jsecmon/cron.ss` | ✅ **untyped layer** — the pure pieces of the cron / systemd-timer / periodic monitor (the baseline walk + change detection stay in the loop). secmon keys `CRON_PATHS` / `PERIODIC_PATHS` off `#[cfg(target_os)]`, so `cron-paths` / `periodic-paths` are functions of a platform symbol (`'linux` / `'freebsd` / `'other`) reproducing the three cfg arms verbatim. `is-systemd-or-periodic-path` is the routing predicate `baseline_all` uses to decide whether a `PERIODIC_PATHS` entry is a systemd unit dir vs another cron-like dir — a plain **substring** test for `"systemd"` OR `"periodic"`. cron.rs has no #[test], so `make cron-check` asserts the full tables + the classifier and **is** the spec. |
 | `monitor/logtamper` log-tamper decision core | `jsecmon/logtamper.ss` | ✅ **untyped layer** — the pure pieces of the log-tampering monitor (the `fs::metadata` polling + size/mtime tracking map stay in the loop). Exposes the `SYSTEM_LOGS` / `HISTORY_FILES` constant tables and `TRUNCATION_THRESHOLD` (1000). `is-history-file` is any-`HISTORY_FILES`-**substring**. `classify-tamper old-size new-size old-mtime new-mtime path` reproduces `check_tampering`'s Ok-arm in push order: size dropped by **>** threshold → `history-cleared` (if a history file) else `truncated`; mtime went backwards and `> 0` → `timestamp-modified` (both can fire for one file). The `deleted` case is the `fs::metadata` Err arm (deferred I/O). logtamper.rs has no #[test], so `make logtamper-check` **is** the spec. |
+| `storage` detection-rule ATT&CK catalog (`DETECTION_RULES` / `rule_attack` / `anomaly_rule_attack`) | `jsecmon/detection-rules.ss` | ✅ **untyped layer** — the static `DETECTION_RULES` table (13 rows: name / description / severity / MITRE techniques) and its pure lookups, used to annotate findings with ATT&CK IDs and to list/validate rule names (the SQL detectors that fire these live in `(jsecmon threats)`). `rule-attack` finds the row by exact name and returns its `attack` list, else `()` (unwrap_or_default); `rule-names` / `rule-known?` mirror the listing/validation paths. `anomaly-rule-attack` is the separate match for the `detect_anomalies` **statistical** rules (not in the table): `kill_chain` → recon/lateral/exfil tactics, `off_hours` → Defense Evasion, others (frequency_spike, severity_cluster) → `()`. const has no #[test], so `make detection-rules-check` asserts every row + both lookups and **is** the spec. |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/detection_rules_check.ss b/examples/detection_rules_check.ss
new file mode 100644
index 0000000..6e8fa2f
--- /dev/null
+++ b/examples/detection_rules_check.ss
@@ -0,0 +1,72 @@
+;;; Parity check for (jsecmon detection-rules) against secmon src/storage/mod.rs.
+;;; DETECTION_RULES is a const with no dedicated #[test], so every expectation
+;;; here derives from the Rust source: the 13-row table, rule_attack, and
+;;; anomaly_rule_attack.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/detection_rules_check.ss
+
+(import (jerboa prelude)
+        (jsecmon detection-rules))
+
+(def fails 0)
+(def (check name got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") name
+               (if ok "" (str "   got " got " want " want)))))
+
+;; ── rule-names: all 13, in DETECTION_RULES order ─────────────────────────────
+(displayln "rule-names:")
+(check "13 rules in order"
+       (rule-names)
+       '("brute_force" "credential_stuffing" "dns_tunnel" "suspicious_cron"
+         "recon_port_scan" "data_exfil" "priv_escalation_chain"
+         "persistence_after_access" "log_cover" "lateral_after_shell"
+         "suspicious_cmdline" "dga_domain" "impossible_travel"))
+(check "count is 13" (length (rule-names)) 13)
+
+;; ── rule-known? ──────────────────────────────────────────────────────────────
+(displayln "rule-known?:")
+(check "brute_force known"   (rule-known? "brute_force") #t)
+(check "impossible_travel known" (rule-known? "impossible_travel") #t)
+(check "bogus unknown"       (rule-known? "no_such_rule") #f)
+;; anomaly (statistical) rules are NOT in DETECTION_RULES
+(check "kill_chain not a built-in rule" (rule-known? "kill_chain") #f)
+
+;; ── rule-attack: ATT&CK techniques per built-in rule ─────────────────────────
+(displayln "rule-attack:")
+(check "brute_force"      (rule-attack "brute_force") '("T1110" "T1110.001"))
+(check "credential_stuffing"
+       (rule-attack "credential_stuffing") '("T1110" "T1110.004"))
+(check "dns_tunnel"       (rule-attack "dns_tunnel") '("T1071.004" "T1572"))
+(check "suspicious_cron"  (rule-attack "suspicious_cron") '("T1053" "T1053.003"))
+(check "recon_port_scan"  (rule-attack "recon_port_scan") '("T1046"))
+(check "data_exfil"       (rule-attack "data_exfil") '("T1041" "T1071"))
+(check "priv_escalation_chain"
+       (rule-attack "priv_escalation_chain") '("T1078" "T1068" "T1548"))
+(check "persistence_after_access"
+       (rule-attack "persistence_after_access") '("T1505.003" "T1547" "T1543"))
+(check "log_cover"
+       (rule-attack "log_cover") '("T1070" "T1070.002" "T1070.003"))
+(check "lateral_after_shell"
+       (rule-attack "lateral_after_shell") '("T1021" "T1570"))
+(check "suspicious_cmdline"
+       (rule-attack "suspicious_cmdline") '("T1059" "T1059.004" "T1105" "T1027"))
+(check "dga_domain"       (rule-attack "dga_domain") '("T1568" "T1568.002"))
+(check "impossible_travel" (rule-attack "impossible_travel") '("T1078"))
+;; unknown rule -> empty (unwrap_or_default)
+(check "unknown rule -> ()" (rule-attack "no_such_rule") '())
+
+;; ── anomaly-rule-attack: the statistical rules' broad-tactic mapping ─────────
+(displayln "anomaly-rule-attack:")
+(check "kill_chain" (anomaly-rule-attack "kill_chain") '("TA0001" "TA0008" "TA0010"))
+(check "off_hours"  (anomaly-rule-attack "off_hours") '("TA0005"))
+;; technique-agnostic statistical rules -> empty
+(check "frequency_spike -> ()" (anomaly-rule-attack "frequency_spike") '())
+(check "severity_cluster -> ()" (anomaly-rule-attack "severity_cluster") '())
+(check "anything else -> ()"   (anomaly-rule-attack "brute_force") '())
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: detection-rules matches secmon's storage/mod.rs ATT&CK catalog.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/detection-rules.ss b/jsecmon/detection-rules.ss
new file mode 100644
index 0000000..a7af1ae
--- /dev/null
+++ b/jsecmon/detection-rules.ss
@@ -0,0 +1,90 @@
+#!chezscheme
+;;; jsecmon built-in detection-rule catalog (secmon src/storage/mod.rs), untyped.
+;;;
+;;; The static DETECTION_RULES table and its pure lookups, used to annotate
+;;; threat findings with MITRE ATT&CK techniques and to list/validate rule names.
+;;; The SQL detectors that actually fire these rules live in (jsecmon threats);
+;;; this module is just the metadata catalog:
+;;;   detection-rules     : ((name description severity (attack-id …)) …)
+;;;   rule-names          : -> (name …)            (DETECTION_RULES order)
+;;;   rule-known?         : name -> #t | #f        (any r.name == name)
+;;;   rule-attack         : name -> (attack-id …)  (built-in rule, else ())
+;;;   anomaly-rule-attack : name -> (attack-id …)  (statistical rules)
+;;;
+;;; rule-attack reproduces rule_attack: find the DETECTION_RULES entry by exact
+;;; name and return its `attack` slice, else the empty list (unwrap_or_default).
+;;; anomaly-rule-attack reproduces the separate match for the detect_anomalies
+;;; statistical rules (NOT in DETECTION_RULES): kill_chain spans the recon →
+;;; lateral → exfil tactics, off_hours is Defense Evasion, and frequency_spike /
+;;; severity_cluster are technique-agnostic (empty).
+;;;
+;;; The table is a const in Rust with no dedicated #[test], so
+;;; examples/detection_rules_check.ss asserts every row + both lookups and IS the
+;;; spec for this port.
+
+(library (jsecmon detection-rules)
+  (export detection-rules rule-names rule-known? rule-attack anomaly-rule-attack)
+  (import (except (chezscheme)
+                  make-hash-table hash-table?
+                  sort sort!
+                  printf fprintf
+                  path-extension path-absolute?
+                  with-input-from-string with-output-to-string
+                  iota 1+ 1-
+                  partition
+                  make-date make-time)
+          (except (jerboa prelude) meta atom?))
+
+  ;; (name description severity (attack-id …)), in DETECTION_RULES order.
+  (def detection-rules
+    (list
+      (list "brute_force" "5+ auth failures for same user in 10min" "high"
+            '("T1110" "T1110.001"))
+      (list "credential_stuffing"
+            "5+ distinct usernames failing from same host in 10min" "high"
+            '("T1110" "T1110.004"))
+      (list "dns_tunnel" "50+ DNS queries from same process in 5min" "high"
+            '("T1071.004" "T1572"))
+      (list "suspicious_cron" "Cron/scheduled task change by non-root user"
+            "medium" '("T1053" "T1053.003"))
+      (list "recon_port_scan"
+            "Same process connecting to 10+ distinct ports in 5min" "medium"
+            '("T1046"))
+      (list "data_exfil" "Same process making 20+ outbound connections in 5min"
+            "high" '("T1041" "T1071"))
+      (list "priv_escalation_chain"
+            "Auth success followed by privilege escalation within 5min"
+            "critical" '("T1078" "T1068" "T1548"))
+      (list "persistence_after_access"
+            "Reverse shell/webshell followed by persistence within 1h"
+            "critical" '("T1505.003" "T1547" "T1543"))
+      (list "log_cover" "Critical event followed by log tampering within 1h"
+            "critical" '("T1070" "T1070.002" "T1070.003"))
+      (list "lateral_after_shell"
+            "Reverse shell followed by lateral movement within 1h" "critical"
+            '("T1021" "T1570"))
+      (list "suspicious_cmdline"
+            "Process started with LOLBin / reverse-shell command-line patterns"
+            "high" '("T1059" "T1059.004" "T1105" "T1027"))
+      (list "dga_domain"
+            "DNS query for an algorithmically generated (high-entropy) domain"
+            "high" '("T1568" "T1568.002"))
+      (list "impossible_travel"
+            "Same user authenticated from two distant countries faster than is physically possible"
+            "high" '("T1078"))))
+
+  (def (rule-names) (map car detection-rules))
+
+  (def (rule-known? name)
+    (if (for/or ((r detection-rules)) (string=? (car r) name)) #t #f))
+
+  (def (rule-attack name)
+    (or (for/or ((r detection-rules))
+          (and (string=? (car r) name) (list-ref r 3)))
+        '()))
+
+  (def (anomaly-rule-attack name)
+    (cond
+      ((string=? name "kill_chain") '("TA0001" "TA0008" "TA0010"))
+      ((string=? name "off_hours") '("TA0005"))
+      (#t '()))))