file-change: port FileIntegrityMonitor::is_suspicious_change (untyped)
ober
2ebc4c0b50320c529a6bf38de5553d01784e6ddb
--- 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 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 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)" @@ -202,6 +202,13 @@ event-danger-check: persistence-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/persistence_check.ss +# File-change suspicion classifier (secmon monitor/files.rs +# is_suspicious_change): setuid/setgid added, platform critical files, +# authorized_keys/cron substrings, new files in platform sensitive dirs. Pure — +# stat/hashing is the deferred I/O — parameterized over a platform symbol. +file-change-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/file_change_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 @@ -228,6 +235,8 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_meta_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_danger_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/persistence_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/file_change_check.ss clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ make event-meta-check # event-type -> display severity + coarse store-priority u 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 file-change-check # is_suspicious_change: setuid/setgid added, critical files, sensitive dirs make checks # every Jerboa-side check in one shot ``` @@ -120,5 +121,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `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/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::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/file_change_check.ss @@ -0,0 +1,76 @@ +;;; Parity check for (jsecmon file-change) against secmon monitor/files.rs +;;; (FileIntegrityMonitor::is_suspicious_change). 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/file_change_check.ss + +(import (jerboa prelude) + (jsecmon file-change)) + +(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))))) + +;; helper: a non-flagged path with given modes/type so mode tests isolate +(def (susp path old new ct plat) (is-suspicious-change path old new ct plat)) + +;; ── setuid / setgid bits ───────────────────────────────────────────────────── +(displayln "mode bits:") +(check "setuid added" (susp "/tmp/x" #o644 #o4755 'modified 'linux) "Setuid bit added to file") +(check "setgid added" (susp "/tmp/x" #o644 #o2755 'modified 'linux) "Setgid bit added to file") +(check "setuid first when both" (susp "/tmp/x" #o644 #o6755 'modified 'linux) "Setuid bit added to file") +(check "setuid already present -> #f" (susp "/tmp/x" #o4755 #o4755 'modified 'linux) #f) +(check "modes #f -> skip" (susp "/tmp/x" #f #f 'modified 'linux) #f) +(check "only one mode known -> skip" (susp "/tmp/x" #o644 #f 'modified 'linux) #f) + +;; ── critical files (exact, platform-aware) ─────────────────────────────────── +(displayln "critical files:") +(check "linux shadow" (susp "/etc/shadow" #f #f 'modified 'linux) + "Critical system file modified: /etc/shadow") +(check "linux ld.so.preload" (susp "/etc/ld.so.preload" #f #f 'modified 'linux) + "Critical system file modified: /etc/ld.so.preload") +(check "freebsd master.passwd" (susp "/etc/master.passwd" #f #f 'modified 'freebsd) + "Critical system file modified: /etc/master.passwd") +;; master.passwd is NOT critical on linux -> falls through to #f +(check "master.passwd not critical on linux" (susp "/etc/master.passwd" #f #f 'modified 'linux) #f) +;; ld.so.preload is NOT critical on freebsd +(check "ld.so.preload not critical on freebsd" (susp "/etc/ld.so.preload" #f #f 'modified 'freebsd) #f) +(check "other platform sudoers" (susp "/etc/sudoers" #f #f 'modified 'macos) + "Critical system file modified: /etc/sudoers") +;; critical wins over created-in-sensitive-dir (step 3 before step 6) +(check "critical wins over created" (susp "/etc/sudoers" #f #f 'created 'linux) + "Critical system file modified: /etc/sudoers") + +;; ── authorized_keys / cron substrings ──────────────────────────────────────── +(displayln "authorized_keys / cron:") +(check "authorized_keys" (susp "/home/u/.ssh/authorized_keys" #f #f 'modified 'linux) + "SSH authorized_keys modified") +(check "cron path" (susp "/etc/cron.daily/evil" #f #f 'modified 'linux) + "Cron configuration modified") +;; KEY ordering: created /etc/cron.d/x matches "cron" BEFORE the sensitive-dir step +(check "created cron.d -> cron message (not sensitive-dir)" + (susp "/etc/cron.d/evil" #f #f 'created 'linux) "Cron configuration modified") + +;; ── sensitive dirs (created only, prefix, platform-aware) ──────────────────── +(displayln "sensitive dirs:") +(check "created sudoers.d" (susp "/etc/sudoers.d/evil" #f #f 'created 'linux) + "New file created in sensitive directory: /etc/sudoers.d") +(check "created systemd" (susp "/etc/systemd/system/evil.service" #f #f 'created 'linux) + "New file created in sensitive directory: /etc/systemd/system") +;; same path but Modified (not created) -> #f +(check "modified sudoers.d -> #f" (susp "/etc/sudoers.d/evil" #f #f 'modified 'linux) #f) +(check "freebsd periodic" (susp "/etc/periodic/daily/evil" #f #f 'created 'freebsd) + "New file created in sensitive directory: /etc/periodic/daily") +;; other platform has no sensitive dirs +(check "other platform no sensitive dirs" + (susp "/etc/sudoers.d/evil" #f #f 'created 'macos) #f) +;; nothing matches +(check "boring file -> #f" (susp "/tmp/notes.txt" #o644 #o644 'modified 'linux) #f) + +(newline) +(if (= fails 0) + (displayln "OK: file-change matches secmon's files.rs is_suspicious_change.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/file-change.ss @@ -0,0 +1,85 @@ +#!chezscheme +;;; jsecmon file-change suspicion classifier (secmon monitor/files.rs), untyped. +;;; +;;; Port of FileIntegrityMonitor::is_suspicious_change with the stat/hashing I/O +;;; stripped — the deciding fields are passed in directly: +;;; is-suspicious-change : (path old-mode new-mode change-type platform) +;;; -> reason string | #f +;;; old-mode/new-mode are octal permission integers or #f (Rust Option<u32>); +;;; change-type is a symbol (created|modified|...); platform is linux|freebsd|... +;;; (Rust's cfg!(target_os), so the critical-files / sensitive-dirs sets vary). +;;; +;;; Ordered first-match (faithful to the Rust): +;;; 1. setuid bit ADDED (new has 04000, old did not), both modes known +;;; 2. setgid bit ADDED (new has 02000, old did not), both modes known +;;; 3. path is EXACTLY a platform critical file +;;; 4. path CONTAINS "authorized_keys" +;;; 5. path CONTAINS "cron" +;;; 6. change-type is created AND path STARTS-WITH a platform sensitive dir +;;; else #f +;;; +;;; Faithfulness corners: +;;; * the mode block is entered only when BOTH old and new modes are known +;;; (Rust `if let (Some, Some)`); setuid is checked before setgid. +;;; * critical files match exactly (==); authorized_keys/cron are substring; +;;; sensitive dirs are a prefix and only for `created`. +;;; * step 5 ("cron" substring) precedes step 6, so a CREATED /etc/cron.d/x +;;; reports "Cron configuration modified", never the sensitive-dir message. +;;; +;;; secmon has no #[test] for this, so examples/file_change_check.ss derives +;;; every expectation from the Rust source and IS the spec for this port. + +(library (jsecmon file-change) + (export is-suspicious-change critical-files sensitive-dirs) + (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?)) + + ;; critical_files, per cfg!(target_os). + (def (critical-files platform) + (cond + ((eq? platform 'linux) + '("/etc/passwd" "/etc/shadow" "/etc/sudoers" "/etc/ssh/sshd_config" + "/etc/ld.so.preload")) + ((eq? platform 'freebsd) + '("/etc/passwd" "/etc/master.passwd" "/etc/sudoers" "/usr/local/etc/sudoers" + "/etc/ssh/sshd_config" "/etc/rc.conf" "/etc/login.conf")) + (else '("/etc/passwd" "/etc/sudoers")))) + + ;; sensitive_dirs (new-file watch), per cfg!(target_os). + (def (sensitive-dirs platform) + (cond + ((eq? platform 'linux) + '("/etc/cron.d" "/etc/sudoers.d" "/etc/profile.d" "/etc/systemd/system")) + ((eq? platform 'freebsd) + '("/var/cron/tabs" "/etc/periodic/daily" "/etc/periodic/weekly" + "/usr/local/etc/rc.d" "/etc/rc.d")) + (else '()))) + + (def (mode-bit-added old new bit) + (and (integer? old) (integer? new) + (not (= (bitwise-and new bit) 0)) + (= (bitwise-and old bit) 0))) + + ;; first-match as an or/and chain (not cond): a clause containing `for/or` + ;; before an `else` trips the MCP expander's pre-scan, though real Chez is + ;; fine. Each (and TEST RESULT) mirrors a cond clause — equivalent. + (def (is-suspicious-change path old-mode new-mode change-type platform) + (or + (and (mode-bit-added old-mode new-mode #o4000) "Setuid bit added to file") + (and (mode-bit-added old-mode new-mode #o2000) "Setgid bit added to file") + (and (member path (critical-files platform)) + (str "Critical system file modified: " path)) + (and (string-contains path "authorized_keys") "SSH authorized_keys modified") + (and (string-contains path "cron") "Cron configuration modified") + (and (eq? change-type 'created) + (for/or ((d (sensitive-dirs platform))) + (and (string-prefix? d path) + (str "New file created in sensitive directory: " d)))))))