jsecmon: add analyze-cli parse-alert-sinks (collect every --alert-to)
ober
e516e496e35c2268c77a1f36c75c00ca9d5595be
--- a/README.md +++ b/README.md @@ -129,6 +129,6 @@ 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), 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.) | +| `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) and `parse_alert_sinks` (collect every `--alert-to <spec>`, parsing each and short-circuiting on the first bad spec like Rust's `?`; a trailing `--alert-to` with no value is skipped, and no flags → the empty list — the watch-time default-to-stdout lives in `cmd_watch`), 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.) | | `bin/collector` (CLI/hosts parse helpers) | `jsecmon/collector-cli.ss` | ✅ **untyped layer** — the pure argument/hosts parsing of the `collector` binary, with the async polling + ECIES/PSK key loading + SQLite I/O deferred: `parse_after_seq` (first `--after` value as u64, `unwrap_or(0)` so junk/negative/≥2⁶⁴ → 0), `parse_format` (→ `'json`/`'human`/`'quiet`; a per-index scan where an unknown `--format` value does **not** consume the value — differs from analyze's `is_json_format` — and the no-flag default is `quiet` when a `--db` is present else `human`), `parse_db_path`, `normalize_host` (append `:31337` unless the host already contains **any** `:`, so bare IPv6 is left as-is, faithfully), `collect_positional_hosts` (skip the four value flags **and** their values, drop other `--` args, normalize the rest), and `parse_hosts_file`'s pure contents→hosts core (trim, drop blanks/`#` comments, normalize). secmon has no `#[test]` here so `make collector-cli-check` asserts against the Rust source. | | monitors / server / ebpf / dtrace | — | ⏳ I/O+async+FFI, last | --- a/examples/analyze_cli_check.ss +++ b/examples/analyze_cli_check.ss @@ -62,6 +62,27 @@ (check "unknown sink message" (err-msg (parse-alert-sink "carrier")) "unknown alert sink: 'carrier' (expected: stdout, file:/path, webhook:url, syslog[:tag])") +;; ── parse-alert-sinks: collect every --alert-to, short-circuit on bad spec ─── +(displayln "parse-alert-sinks:") +(check "single sink" (unwrap (parse-alert-sinks '("--alert-to" "stdout"))) + '((stdout))) +(check "two sinks in order" + (unwrap (parse-alert-sinks '("--alert-to" "stdout" "--alert-to" "file:/v/a.log"))) + '((stdout) (file "/v/a.log"))) +(check "interleaved with other args" + (unwrap (parse-alert-sinks '("watch" "--alert-to" "syslog:app" "--db" "e.db"))) + '((syslog "app"))) +(check "no flags -> empty list" (unwrap (parse-alert-sinks '("watch" "--db" "x"))) '()) +;; a bad spec short-circuits with that spec's err message +(check "bad spec is err" + (err? (parse-alert-sinks '("--alert-to" "stdout" "--alert-to" "bogus"))) #t) +(check "bad spec message" + (err-msg (parse-alert-sinks '("--alert-to" "bogus"))) + "unknown alert sink: 'bogus' (expected: stdout, file:/path, webhook:url, syslog[:tag])") +;; a trailing --alert-to (no value) is skipped, earlier sinks kept +(check "trailing --alert-to skipped" + (unwrap (parse-alert-sinks '("--alert-to" "stdout" "--alert-to"))) '((stdout))) + ;; ── parse-flag-value: first match yields its successor, else #f ────────────── (displayln "parse-flag-value:") (check "flag value" (parse-flag-value '("--top" "20") "--top") "20") --- a/jsecmon/analyze-cli.ss +++ b/jsecmon/analyze-cli.ss @@ -5,6 +5,7 @@ ;;; 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) +;;; parse-alert-sinks : args -> (ok (sink…)) | (err msg) (all --alert-to) ;;; parse-flag-value : args flag -> next-arg string | #f ;;; has-flag : args flag -> #t | #f ;;; is-json-format : args -> #t | #f @@ -32,7 +33,7 @@ ;;; 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-alert-sinks parse-flag-value has-flag is-json-format) (import (except (chezscheme) make-hash-table hash-table? @@ -91,6 +92,23 @@ (#t (err (str "unknown alert sink: '" spec "' (expected: stdout, file:/path, webhook:url, syslog[:tag])"))))) + ;; Collect every `--alert-to <spec>` into (ok (sinks…)) | (err msg). Each spec + ;; is parse-alert-sink'd and the FIRST err short-circuits (Rust's `?`). An + ;; `--alert-to` with a value advances by two; a trailing `--alert-to` with no + ;; value is simply skipped (Rust `i += 1`). Order is preserved; no flags -> + ;; (ok ()). The watch-time "empty -> [Stdout]" default lives in cmd_watch, not + ;; here, so this faithfully returns the empty list. + (def (parse-alert-sinks args) + (let loop ((rest args) (acc '())) + (cond + ((null? rest) (ok (reverse acc))) + ((string=? (car rest) "--alert-to") + (if (pair? (cdr rest)) + (let ((r (parse-alert-sink (cadr rest)))) + (if (err? r) r (loop (cddr rest) (cons (unwrap r) acc)))) + (loop (cdr rest) acc))) + (#t (loop (cdr rest) acc))))) + ;; 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);