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])"))))))