file-change: port FileIntegrityMonitor::is_suspicious_change (untyped)

ober

2ebc4c0b50320c529a6bf38de5553d01784e6ddb

diff --git a/Makefile b/Makefile
index 444aede..01694d4 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 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)
diff --git a/README.md b/README.md
index 6a904d2..e971631 100644
--- 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           |
diff --git a/examples/file_change_check.ss b/examples/file_change_check.ss
new file mode 100644
index 0000000..906b29b
--- /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)))
diff --git a/jsecmon/file-change.ss b/jsecmon/file-change.ss
new file mode 100644
index 0000000..4b293f8
--- /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)))))))