jsecmon: add analyze-cli --flag scanners (parse-flag-value/has-flag/is-json-format)
ober
f82a78b3ac0c5fc720104a7f31733d72e84191bf
--- 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 | --- 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.") --- 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)))))))