jsecmon: add analyze-cli --flag scanners (parse-flag-value/has-flag/is-json-format)

ober

f82a78b3ac0c5fc720104a7f31733d72e84191bf

diff --git a/README.md b/README.md
index b7427d2..809519e 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ make persistence-check # classify_path (-> persistence type) + suspicious-conten
 make file-change-check # is_suspicious_change: setuid/setgid added, critical files, sensitive dirs
 make webshell-check  # web-server-spawned suspicious child: name/cmdline classifier + reason
 make platform-mounts-check # is_dangerous_path (per-platform exact set) + get_mounts line parsers
-make analyze-cli-check # analyze bin: parse_duration_ms + AlertSink::parse (Result + exact errors)
+make analyze-cli-check # analyze bin: parse_duration_ms + AlertSink::parse + --flag scanners
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -128,5 +128,5 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor/files` (`FileIntegrityMonitor::is_suspicious_change`) | `jsecmon/file-change.ss` | ✅ **untyped layer** — the deciding logic with stat/hashing stripped (modes + change-type + platform passed in): ordered first-match — setuid then setgid bit *added* (both modes known), exact platform critical file, `authorized_keys`/`cron` substrings, then a platform sensitive dir on `created` only. Pins the order corner that the `cron` substring precedes the sensitive-dir step, so a created `/etc/cron.d/x` reports "Cron configuration modified", never the sensitive-dir message; the critical-files/sensitive-dirs sets switch on `cfg!(target_os)` (linux/freebsd/other). Pure — the `stat`/SHA-256 baseline is the deferred I/O — no native lib; secmon has no `#[test]` here so `make file-change-check` asserts against the Rust source. |
 | `monitor/webshell` (`WebshellMonitor` classifiers) | `jsecmon/webshell.ss` | ✅ **untyped layer** — the three pure deciders with the `/proc` scan + parent/child PID walk + event emission stripped: `is_web_server` (lower-cased name **substring** vs the server list, so `php-fpm` matches `php`), `is_suspicious_child` (process name by **exact** lower-cased equality — `bashx` is clean — OR the joined+lowercased cmdline **substring**-matched against the pattern list), and `get_detection_reason` (scans only the cmdline patterns, **in list order**, first match → `Suspicious command pattern: {pat}`, else the default `Web server spawned suspicious process: {name}` with the **original-case** name). obfstr!-hidden lists decode to these plaintext literals. Pins the corner that the reason is chosen by pattern-list order, not cmdline-token order, and that a name-only hit yields the default reason. Pure — the PID walk is the deferred monitor loop — no native lib; secmon has no `#[test]` here so `make webshell-check` asserts against the Rust source. |
 | `monitor::dns_sniffer` (DNS wire parser + dedup) | `jsecmon/dns-sniffer.ss` | ✅ **untyped layer** — the platform-independent half of secmon's `src/monitor/dns_sniffer.rs`: the DNS wire-format parser (QNAME decoding with compression-pointer chasing capped at 128 steps, QTYPE→string, question + A/AAAA answer-RR extraction) and the 5s dedup / 30s cleanup state machine. Every bounds check is preserved — a truncated/malformed/looping packet yields `#f`, never a bad read. Pure byte parsing → untyped, like geoip. The AF_PACKET raw-socket capture + `/proc` PID lookup stay for the monitor I/O driver. `make dns-sniffer-check` reproduces secmon's parser + dedup tests (+ AAAA, qtype table, pointer-loop/qdcount guards). |
-| `bin/analyze` (CLI parse helpers) | `jsecmon/analyze-cli.ss` | ✅ **untyped layer** — the pure argument parsers of the `analyze` binary, returning the prelude Result (ok/err) to mirror Rust's `Result<_, String>` **including the exact error text**: `parse_duration_ms` (`10m`/`2h`/`1d`/bare-seconds → ms; splits leading ASCII digits from the unit; empty → `empty duration`, bad number/leading-non-digit → `invalid duration: {s}`, bad unit like `m5` → `unknown duration unit: …`; the number must fit i64) and `AlertSink::parse` (`stdout` / `file:PATH` / `webhook:URL` / `syslog` / `syslog:TAG`, first-match in order, remainder taken verbatim so `file:` → empty path). Pure string→Result; the sink dispatch (stdout/file append/curl webhook/`logger` syslog) is the deferred I/O. secmon has no `#[test]` here so `make analyze-cli-check` asserts against the Rust source. (`format_ts`/`format_ts_iso` are chrono-calendar-coupled display helpers — deferred with the other calendar I/O.) |
+| `bin/analyze` (CLI parse helpers) | `jsecmon/analyze-cli.ss` | ✅ **untyped layer** — the pure argument parsers of the `analyze` binary, returning the prelude Result (ok/err) to mirror Rust's `Result<_, String>` **including the exact error text**: `parse_duration_ms` (`10m`/`2h`/`1d`/bare-seconds → ms; splits leading ASCII digits from the unit; empty → `empty duration`, bad number/leading-non-digit → `invalid duration: {s}`, bad unit like `m5` → `unknown duration unit: …`; the number must fit i64) and `AlertSink::parse` (`stdout` / `file:PATH` / `webhook:URL` / `syslog` / `syslog:TAG`, first-match in order, remainder taken verbatim so `file:` → empty path), plus the generic `--flag` scanners shared across the CLI (`parse_flag_value` → the arg after the **first** `flag`, or `#f` even when the flag is last; `has_flag` → membership; `is_json_format` → the first `--format` that has a value decides, a trailing `--format` is skipped). Pure string→Result/bool; the sink dispatch (stdout/file append/curl webhook/`logger` syslog) and query dispatch are the deferred I/O. secmon has no `#[test]` here so `make analyze-cli-check` asserts against the Rust source. (`format_ts`/`format_ts_iso` are chrono-calendar-coupled display helpers — deferred with the other calendar I/O.) |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/analyze_cli_check.ss b/examples/analyze_cli_check.ss
index ff5ec7b..cc7202b 100644
--- a/examples/analyze_cli_check.ss
+++ b/examples/analyze_cli_check.ss
@@ -1,6 +1,7 @@
 ;;; Parity check for (jsecmon analyze-cli) against secmon src/bin/analyze.rs
-;;; (parse_duration_ms + AlertSink::parse). secmon has no #[test] here, so this
-;;; derives expectations from the Rust source and IS the spec for the port.
+;;; (parse_duration_ms, AlertSink::parse, and the --flag scanners). secmon has
+;;; no #[test] here, so this derives expectations from the Rust source and IS
+;;; the spec for the port.
 ;;;
 ;;;   scheme --libdirs "$JERBOA/lib:." --script examples/analyze_cli_check.ss
 
@@ -61,6 +62,31 @@
 (check "unknown sink message" (err-msg (parse-alert-sink "carrier"))
        "unknown alert sink: 'carrier' (expected: stdout, file:/path, webhook:url, syslog[:tag])")
 
+;; ── parse-flag-value: first match yields its successor, else #f ──────────────
+(displayln "parse-flag-value:")
+(check "flag value"        (parse-flag-value '("--top" "20") "--top") "20")
+(check "flag mid-list"     (parse-flag-value '("--json" "--top" "20") "--top") "20")
+(check "flag missing -> #f" (parse-flag-value '("--json") "--top") #f)
+;; flag present but last (no successor) -> #f, and does NOT keep scanning
+(check "trailing flag -> #f" (parse-flag-value '("--json" "--top") "--top") #f)
+;; first match wins: the value after the FIRST occurrence
+(check "first match wins"  (parse-flag-value '("--top" "5" "--top" "9") "--top") "5")
+
+;; ── has-flag: membership ──────────────────────────────────────────────────────
+(displayln "has-flag:")
+(check "has-flag present"  (has-flag '("--json" "--no-triage") "--no-triage") #t)
+(check "has-flag absent"   (has-flag '("--json") "--no-triage") #f)
+
+;; ── is-json-format: first --format WITH a value decides ──────────────────────
+(displayln "is-json-format:")
+(check "json format"       (is-json-format '("--format" "json")) #t)
+(check "format not json"   (is-json-format '("--format" "table")) #f)
+(check "no format flag"    (is-json-format '("--top" "20")) #f)
+;; a trailing "--format" (no successor) is skipped; scanning continues
+(check "trailing --format -> #f" (is-json-format '("a" "--format")) #f)
+;; first --format with a value decides, even if a later one says json
+(check "first format wins" (is-json-format '("--format" "table" "--format" "json")) #f)
+
 (newline)
 (if (= fails 0)
     (displayln "OK: analyze-cli matches secmon's analyze.rs parse helpers.")
diff --git a/jsecmon/analyze-cli.ss b/jsecmon/analyze-cli.ss
index f61cc5d..ebc8932 100644
--- a/jsecmon/analyze-cli.ss
+++ b/jsecmon/analyze-cli.ss
@@ -5,8 +5,12 @@
 ;;; CLI plumbing (the dispatch/query/HTTP/syslog side effects stay deferred):
 ;;;   parse-duration-ms : "10m" etc. -> (ok ms) | (err msg)
 ;;;   parse-alert-sink  : "file:/p" etc. -> (ok sink) | (err msg)
-;;; Both return the prelude's Result (ok/err), mirroring Rust's
-;;; `Result<_, String>` exactly — including the error message text.
+;;;   parse-flag-value  : args flag -> next-arg string | #f
+;;;   has-flag          : args flag -> #t | #f
+;;;   is-json-format    : args -> #t | #f
+;;; The two parsers return the prelude's Result (ok/err), mirroring Rust's
+;;; `Result<_, String>` exactly — including the error message text. The three
+;;; flag scanners are the generic --flag readers shared across the CLI.
 ;;;
 ;;; parse-duration-ms (analyze.rs parse_duration_ms):
 ;;;   * trim; empty -> Err "empty duration".
@@ -28,7 +32,8 @@
 ;;; every expectation from the Rust source and IS the spec for this port.
 
 (library (jsecmon analyze-cli)
-  (export parse-duration-ms parse-alert-sink)
+  (export parse-duration-ms parse-alert-sink
+          parse-flag-value has-flag is-json-format)
   (import (except (chezscheme)
                   make-hash-table hash-table?
                   sort sort!
@@ -84,4 +89,29 @@
       ((string-prefix? "syslog:" spec)
        (ok (list 'syslog (substring spec 7 (string-length spec)))))
       (#t (err (str "unknown alert sink: '" spec
-                    "' (expected: stdout, file:/path, webhook:url, syslog[:tag])"))))))
+                    "' (expected: stdout, file:/path, webhook:url, syslog[:tag])")))))
+
+  ;; Generic flag scanners over an args list (strings). Faithful corners:
+  ;;   * parse-flag-value returns on the FIRST `arg == flag`, yielding the next
+  ;;     arg or #f (Rust `args.get(i+1).cloned()` — #f even when flag is last);
+  ;;     it does NOT keep scanning past the first match.
+  ;;   * is-json-format finds the first "--format" that HAS a following value
+  ;;     and returns whether it is "json"; a trailing "--format" (no successor)
+  ;;     is skipped and scanning continues (Rust's if-let-then-fallthrough).
+  (def (parse-flag-value args flag)
+    (let loop ((rest args))
+      (cond
+        ((null? rest) #f)
+        ((string=? (car rest) flag) (if (pair? (cdr rest)) (cadr rest) #f))
+        (#t (loop (cdr rest))))))
+
+  (def (has-flag args flag)
+    (if (member flag args) #t #f))
+
+  (def (is-json-format args)
+    (let loop ((rest args))
+      (cond
+        ((null? rest) #f)
+        ((string=? (car rest) "--format")
+         (if (pair? (cdr rest)) (string=? (cadr rest) "json") (loop (cdr rest))))
+        (#t (loop (cdr rest)))))))