analyze-cli: port parse_duration_ms + AlertSink::parse (untyped)
Jaime Fournier <jaimef@linbsd.org>
0181195c1cdcfee84e43dbf8780c28ee8ef98bcf
diff --git a/Makefile b/Makefile
index a4923eb..f3f8d36 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ SCHEME ?= $(JERBOA)/.chez/bin/scheme
BUILD ?= build/rust
TYPED := $(wildcard typed/*.ss)
-.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-check kernmod-check selinux-check container-check dns-servers-check sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check checks clean
# Combined libdir path so sibling libraries `(jsecmon ...)` resolve to ./jsecmon
# (a second --libdirs would replace, not append, the jerboa one).
LIBDIRS := "$(JERBOA)/lib:$(CURDIR)"
@@ -223,6 +223,12 @@ webshell-check:
platform-mounts-check:
$(SCHEME) --libdirs $(LIBDIRS) --script examples/platform_mounts_check.ss
+# analyze CLI parse helpers (secmon src/bin/analyze.rs): parse_duration_ms
+# (10m/2h/1d -> ms, with the exact error strings) and AlertSink::parse
+# (stdout/file:/webhook:/syslog[:tag]). Pure Result-returning string parsers.
+analyze-cli-check:
+ $(SCHEME) --libdirs $(LIBDIRS) --script examples/analyze_cli_check.ss
+
# Everything that runs through the Jerboa side of the bridge, one shot.
checks: kernels-check
$(SCHEME) --libdirs $(LIBDIRS) --script examples/triage_check.ss
@@ -253,6 +259,7 @@ checks: kernels-check
$(SCHEME) --libdirs $(LIBDIRS) --script examples/file_change_check.ss
$(SCHEME) --libdirs $(LIBDIRS) --script examples/webshell_check.ss
$(SCHEME) --libdirs $(LIBDIRS) --script examples/platform_mounts_check.ss
+ $(SCHEME) --libdirs $(LIBDIRS) --script examples/analyze_cli_check.ss
clean:
rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 5d1e1f7..b7427d2 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +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 checks # every Jerboa-side check in one shot
```
@@ -127,4 +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.) |
| monitors / server / ebpf / dtrace | — | ⏳ I/O+async+FFI, last |
diff --git a/examples/analyze_cli_check.ss b/examples/analyze_cli_check.ss
new file mode 100644
index 0000000..ff5ec7b
--- /dev/null
+++ b/examples/analyze_cli_check.ss
@@ -0,0 +1,67 @@
+;;; 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.
+;;;
+;;; scheme --libdirs "$JERBOA/lib:." --script examples/analyze_cli_check.ss
+
+(import (jerboa prelude)
+ (jsecmon analyze-cli))
+
+(def fails 0)
+(def (check name got want)
+ (let ((ok (equal? got want)))
+ (unless ok (set! fails (+ fails 1)))
+ (displayln (if ok " ok " " FAIL ") name
+ (if ok "" (str " got " got " want " want)))))
+
+;; extract an (err msg) payload as a plain value (no built-in accessor).
+(def (err-msg r) (let ((m 'none)) (map-err (lambda (x) (set! m x) x) r) m))
+
+;; ── parse-duration-ms: ok values ─────────────────────────────────────────────
+(displayln "parse-duration-ms ok:")
+(check "bare number -> seconds" (unwrap (parse-duration-ms "30")) 30000)
+(check "explicit s" (unwrap (parse-duration-ms "5s")) 5000)
+(check "minutes" (unwrap (parse-duration-ms "10m")) 600000)
+(check "hours" (unwrap (parse-duration-ms "2h")) 7200000)
+(check "days" (unwrap (parse-duration-ms "1d")) 86400000)
+(check "trimmed" (unwrap (parse-duration-ms " 15m ")) 900000)
+(check "zero" (unwrap (parse-duration-ms "0")) 0)
+
+;; ── parse-duration-ms: err values (exact messages) ──────────────────────────
+(displayln "parse-duration-ms err:")
+(check "empty is err" (err? (parse-duration-ms "")) #t)
+(check "empty message" (err-msg (parse-duration-ms "")) "empty duration")
+(check "whitespace -> empty" (err-msg (parse-duration-ms " ")) "empty duration")
+(check "unknown unit" (err-msg (parse-duration-ms "10x"))
+ "unknown duration unit: 'x' (expected: s/m/h/d)")
+;; trailing digits after the unit make the unit non-empty and unknown
+(check "10m5 -> unknown unit" (err-msg (parse-duration-ms "10m5"))
+ "unknown duration unit: 'm5' (expected: s/m/h/d)")
+;; starts with non-digit -> empty number-part -> invalid duration
+(check "non-digit start invalid" (err-msg (parse-duration-ms "abc"))
+ "invalid duration: abc")
+
+;; ── parse-alert-sink: ok values ─────────────────────────────────────────────
+(displayln "parse-alert-sink ok:")
+(check "stdout" (unwrap (parse-alert-sink "stdout")) '(stdout))
+(check "file" (unwrap (parse-alert-sink "file:/var/log/sec.jsonl"))
+ '(file "/var/log/sec.jsonl"))
+(check "webhook" (unwrap (parse-alert-sink "webhook:https://h/p"))
+ '(webhook "https://h/p"))
+(check "syslog bare -> secmon tag" (unwrap (parse-alert-sink "syslog"))
+ '(syslog "secmon"))
+(check "syslog:tag" (unwrap (parse-alert-sink "syslog:myapp"))
+ '(syslog "myapp"))
+;; strip_prefix remainder may be empty
+(check "file: empty path" (unwrap (parse-alert-sink "file:")) '(file ""))
+
+;; ── parse-alert-sink: err ────────────────────────────────────────────────────
+(displayln "parse-alert-sink err:")
+(check "unknown sink is err" (err? (parse-alert-sink "carrier")) #t)
+(check "unknown sink message" (err-msg (parse-alert-sink "carrier"))
+ "unknown alert sink: 'carrier' (expected: stdout, file:/path, webhook:url, syslog[:tag])")
+
+(newline)
+(if (= fails 0)
+ (displayln "OK: analyze-cli matches secmon's analyze.rs parse helpers.")
+ (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/analyze-cli.ss b/jsecmon/analyze-cli.ss
new file mode 100644
index 0000000..f61cc5d
--- /dev/null
+++ b/jsecmon/analyze-cli.ss
@@ -0,0 +1,87 @@
+#!chezscheme
+;;; jsecmon analyze CLI parse helpers (secmon src/bin/analyze.rs), untyped.
+;;;
+;;; The pure argument-parsing helpers of the `analyze` binary, lifted out of the
+;;; 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-duration-ms (analyze.rs parse_duration_ms):
+;;; * trim; empty -> Err "empty duration".
+;;; * split at the first non-ASCII-digit char (or end): leading digits are the
+;;; number, the rest is the unit. A non-numeric number-part (incl. empty,
+;;; when the string starts with a non-digit) -> Err "invalid duration: {s}".
+;;; * unit "" or "s" -> *1000, "m" -> *60000, "h" -> *3600000, "d" -> *86400000;
+;;; anything else (e.g. "m5", "x") -> Err "unknown duration unit: '{u}' ...".
+;;; The number must fit i64 (Rust parse::<i64>); an over-range value is an
+;;; "invalid duration" (digits-only, so it is never negative).
+;;;
+;;; parse-alert-sink (analyze.rs AlertSink::parse) — first-match, in order:
+;;; "stdout" -> (stdout); "file:PATH" -> (file PATH); "webhook:URL" ->
+;;; (webhook URL); "syslog" -> (syslog "secmon"); "syslog:TAG" -> (syslog TAG);
+;;; else Err. PATH/URL/TAG are the strip_prefix remainder verbatim (may be
+;;; empty, e.g. "file:" -> (file "")). The sink is a tagged list here.
+;;;
+;;; secmon has no #[test] for these, so examples/analyze_cli_check.ss derives
+;;; every expectation from the Rust source and IS the spec for this port.
+
+(library (jsecmon analyze-cli)
+ (export parse-duration-ms parse-alert-sink)
+ (import (except (chezscheme)
+ make-hash-table hash-table?
+ sort sort!
+ printf fprintf
+ path-extension path-absolute?
+ with-input-from-string with-output-to-string
+ iota 1+ 1-
+ partition
+ make-date make-time)
+ (except (jerboa prelude) meta atom?))
+
+ (def (ascii-digit? c) (and (char>=? c #\0) (char<=? c #\9)))
+
+ ;; index of the first non-ASCII-digit char, or the length (Rust
+ ;; `s.find(|c| !c.is_ascii_digit()).unwrap_or(s.len())`).
+ (def (first-non-digit s)
+ (let ((n (string-length s)))
+ (let loop ((i 0))
+ (if (and (< i n) (ascii-digit? (string-ref s i))) (loop (+ i 1)) i))))
+
+ (def (duration-mult unit)
+ (cond
+ ((or (string=? unit "") (string=? unit "s")) 1000)
+ ((string=? unit "m") 60000)
+ ((string=? unit "h") 3600000)
+ ((string=? unit "d") 86400000)
+ (#t #f))) ;; #t catch-all (not `else`) — dodges the MCP expander quirk
+
+ (def (parse-duration-ms s0)
+ (let ((s (string-trim s0)))
+ (if (string-empty? s)
+ (err "empty duration")
+ (let* ((cut (first-non-digit s))
+ (num-part (substring s 0 cut))
+ (unit (substring s cut (string-length s)))
+ (n (string->number num-part)))
+ (if (and n (integer? n) (< n (expt 2 63)))
+ (let ((mult (duration-mult unit)))
+ (if mult
+ (ok (* n mult))
+ (err (str "unknown duration unit: '" unit
+ "' (expected: s/m/h/d)"))))
+ (err (str "invalid duration: " s)))))))
+
+ (def (parse-alert-sink spec)
+ (cond
+ ((string=? spec "stdout") (ok (list 'stdout)))
+ ((string-prefix? "file:" spec)
+ (ok (list 'file (substring spec 5 (string-length spec)))))
+ ((string-prefix? "webhook:" spec)
+ (ok (list 'webhook (substring spec 8 (string-length spec)))))
+ ((string=? spec "syslog") (ok (list 'syslog "secmon")))
+ ((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])"))))))