Port detection-rule ATT&CK catalog to untyped Jerboa
ober
f6141aa139d261e5d369c2b218e8f46929a4258a
--- 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) --- 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 | new file mode 100644 --- /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))) new file mode 100644 --- /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 '()))))