monitor: port secmon's file-integrity monitor (files.rs)

ober

dac7b9d22748ecaf2c720336546c6e4ac76162df

diff --git a/Makefile b/Makefile
index 96d53a6..16b49f9 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 entity-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 collector-cli-check event-summary-check ioc-check frame-check correlate-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-check dga-check calendar-check monitor-process-check monitor-network-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check entity-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 collector-cli-check event-summary-check ioc-check frame-check correlate-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-check dga-check calendar-check monitor-process-check monitor-network-check monitor-files-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)"
@@ -357,6 +357,12 @@ monitor-process-check:
 monitor-network-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_network_check.ss
 
+# File-integrity monitor (secmon files.rs): the ORDERED detect-change diff and
+# the re-stat/new-file scan, pure over an injected provider; verdict via the
+# verified (jsecmon file-change). No dylib needed.
+monitor-files-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_files_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
@@ -407,6 +413,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/crypto_ecies_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_process_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_network_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_files_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/examples/monitor_files_check.ss b/examples/monitor_files_check.ss
new file mode 100644
index 0000000..e1ea6ac
--- /dev/null
+++ b/examples/monitor_files_check.ss
@@ -0,0 +1,96 @@
+;;; Behaviour check for the file-integrity monitor (secmon files.rs).
+;;;
+;;; secmon's files.rs has no provider trait and no #[test]; we add the same seam
+;;; the other monitors use, so `detect-change` (the ORDERED state diff) and
+;;; `scan-files` (re-stat + new-file discovery) are testable off a fixture
+;;; filesystem. The suspicion verdict is the verified (jsecmon file-change).
+;;; Every expectation is derived from the Rust source.
+;;;
+;;; Run from the repo root with the repo on the libdir path:
+;;;   scheme --libdirs $JERBOA/lib --libdirs . --script examples/monitor_files_check.ss
+
+(import (jerboa prelude)
+        (jsecmon monitor-files))
+
+(def fails 0)
+(def (check label got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") label " => " got
+               (if ok "" (str "  (want " want ")")))))
+(def (types evs) (map (lambda (e) (hash-get e "type")) evs))
+(def (has? s sub) (and (string-contains s sub) #t))
+
+;; --- detect-change: each ORDERED branch, derived from detect_change ----------
+(displayln "detect-change priority branches:")
+(def base (make-file-state "h1" #o644 0 0 100 #t))
+(def gone (make-file-state #f 0 0 0 0 #f))
+(check "deleted"   (file-change-change-type (detect-change "/f" base gone)) 'deleted)
+(check "created"   (file-change-change-type (detect-change "/f" gone base)) 'created)
+(check "perm"      (file-change-change-type
+                    (detect-change "/f" base (make-file-state "h1" #o755 0 0 100 #t)))
+       'permission-changed)
+(def owner-chg (detect-change "/f" base (make-file-state "h1" #o644 1000 0 100 #t)))
+(check "owner"     (file-change-change-type owner-chg) 'owner-changed)
+;; secmon quirk: owner-changed reports old_mode = new_mode = new.mode
+(check "owner mode quirk"
+       (list (file-change-old-mode owner-chg) (file-change-new-mode owner-chg))
+       (list #o644 #o644))
+(check "modified (hash)" (file-change-change-type
+                          (detect-change "/f" base (make-file-state "h2" #o644 0 0 100 #t)))
+       'modified)
+(check "modified (mtime)" (file-change-change-type
+                           (detect-change "/f" base (make-file-state "h1" #o644 0 0 200 #t)))
+       'modified)
+(check "no change -> #f" (detect-change "/f" base base) #f)
+
+;; --- scan-files orchestration over a fixture provider ------------------------
+(def *fs* (make-hash-table))             ;; path -> file-state (the "filesystem")
+(def *dirs* (make-hash-table))           ;; dir  -> list of entry paths
+(def (fs-get p) (or (hash-get *fs* p) (make-file-state #f 0 0 0 0 #f)))
+(def (fs-dir p) (hash-get *dirs* p))     ;; #f unless registered as a dir
+(def provider (make-file-provider fs-get fs-dir "fhost"))
+(def (put! p st) (hash-put! *fs* p st))
+
+(put! "/etc/passwd" (make-file-state "p1" #o644 0 0 100 #t))
+(put! "/etc/hosts"  (make-file-state "h1" #o644 0 0 100 #t))
+(put! "/usr/bin/su" (make-file-state "s1" #o4755 0 0 100 #t))  ;; already setuid
+(put! "/bin/ping"   (make-file-state "b1" #o755 0 0 100 #t))
+(hash-put! *dirs* "/etc/cron.d" '())     ;; a watched dir, empty at baseline
+
+(def st (make-file-monitor provider 'linux))
+(baseline-files st provider
+                '("/etc/passwd" "/etc/hosts" "/usr/bin/su" "/bin/ping" "/etc/cron.d"))
+
+(displayln "baseline then unchanged scan is silent:")
+(check "no events" (scan-files st provider 1000) '())
+
+(displayln "mixed change scan classifies per path:")
+(put! "/etc/passwd" (make-file-state "p2" #o644 0 0 100 #t))   ;; content -> critical
+(put! "/etc/hosts"  (make-file-state "h1" #o644 0 0 200 #t))   ;; mtime -> benign modify
+(put! "/usr/bin/su" (make-file-state "s1" #o755 0 0 100 #t))   ;; setuid REMOVED -> benign perm
+(put! "/bin/ping"   (make-file-state "b1" #o4755 0 0 100 #t))  ;; setuid ADDED -> suspicious
+(def ev2 (scan-files st provider 1001))
+(check "four events, path order"
+       (types ev2)
+       '("suspicious_file_change" "file_changed" "file_changed" "suspicious_file_change"))
+(check "passwd reason"  (has? (hash-get (car ev2) "reason") "Critical") #t)
+(check "ping reason"    (has? (hash-get (list-ref ev2 3) "reason") "Setuid") #t)
+
+(displayln "new file appearing in a watched dir is a Created event:")
+(put! "/etc/cron.d/evil" (make-file-state "e1" #o644 0 0 300 #t))
+(hash-put! *dirs* "/etc/cron.d" '("/etc/cron.d/evil"))
+(def ev3 (scan-files st provider 1002))
+(check "one event" (length ev3) 1)
+(check "suspicious (cron)" (hash-get (car ev3) "type") "suspicious_file_change")
+;; secmon orders the "cron" substring check before the sensitive-dir check
+(check "cron reason" (has? (hash-get (car ev3) "reason") "Cron") #t)
+(check "created type" (file-change-change-type (hash-get (car ev3) "change")) 'created)
+
+(displayln "re-scan with no further change is idempotent:")
+(check "silent" (scan-files st provider 1003) '())
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: file monitor diffs state by priority, discovers new files, and classifies.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/monitor-files.ss b/jsecmon/monitor-files.ss
new file mode 100644
index 0000000..f297087
--- /dev/null
+++ b/jsecmon/monitor-files.ss
@@ -0,0 +1,230 @@
+#!chezscheme
+;;; jsecmon file-integrity monitor (secmon src/monitor/files.rs), untyped.
+;;;
+;;; secmon's FileIntegrityMonitor baselines a set of critical paths, then on
+;;; each tick re-stats them, diffs against the baseline, classifies the change,
+;;; and emits FileChanged / SuspiciousFileChange. Unlike process/network it has
+;;; no provider trait (it calls std::fs directly, and has no #[test]); we
+;;; introduce the same seam the other monitors use so the diff logic is testable
+;;; off a real filesystem:
+;;;
+;;;   `detect-change` (old-state + new-state -> change | #f) and `scan-files`
+;;;   (diff every tracked path + discover new files in watched dirs) are pure
+;;;   over an injected `file-provider` (get-file-state / list-dir). The
+;;;   suspicious-change verdict is the already-verified (jsecmon file-change)
+;;;   is-suspicious-change — not reimplemented. `make-linux-file-provider` is
+;;;   the thin std::fs shell.
+;;;
+;;; Faithfulness points the Rust pins (detect_change priority is ORDERED):
+;;;   deleted > created > permission-changed > owner-changed > modified.
+;;;   * owner-changed reports old_mode = new_mode = new.mode (secmon quirk).
+;;;   * modified fires on hash OR mtime change.
+;;;   * a directory in the configured set is recursed at baseline and re-listed
+;;;     each scan; a path that appears there later is a Created event.
+;;;   * event categories/severities per event_json.rs: file_changed/info,
+;;;     suspicious_file_change/high.
+;;;
+;;; NOTE on the live provider: full fidelity needs a stat(2)+sha256 shell
+;;; (mode/uid/gid bits and content hash); pure Chez gives us exists + mtime, so
+;;; the live path detects create/delete/modify (via mtime) but not perm/owner/
+;;; hash changes until that FFI lands. The DETECTION logic is provider-agnostic
+;;; and fully exercised by the fixture check.
+
+(library (jsecmon monitor-files)
+  (export make-file-state file-state?
+          file-state-hash file-state-mode file-state-uid file-state-gid
+          file-state-mtime file-state-exists
+          make-file-change file-change?
+          file-change-path file-change-change-type
+          file-change-old-hash file-change-new-hash
+          file-change-old-mode file-change-new-mode
+          file-change-uid file-change-gid
+          make-file-provider file-provider-hostname
+          make-file-mon-state file-mon-state-paths file-mon-state-states
+          make-file-monitor baseline-files detect-change scan-files
+          make-linux-file-provider linux-monitored-paths)
+  (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?)
+          (jsecmon file-change))
+
+  ;; secmon FileState / FileChangeInfo.
+  (defstruct file-state (hash mode uid gid mtime exists))
+  (defstruct file-change
+    (path change-type old-hash new-hash old-mode new-mode uid gid))
+
+  ;; injected seam:
+  ;;   get-file-state : path -> file-state  (exists=#f if absent; never errors)
+  ;;   list-dir       : path -> (list path ...) | #f   (#f if not a dir)
+  ;;   hostname       : string
+  (defstruct file-provider (get-file-state list-dir hostname))
+
+  ;; tracked baseline: an ordered path list, a path->file-state map, the watched
+  ;; dirs (for new-file discovery), and the platform for the suspicion verdict.
+  (defstruct file-mon-state (hostname paths states dirs platform))
+
+  (def (make-file-monitor provider platform)
+    (make-file-mon-state (file-provider-hostname provider)
+                         '() (make-hash-table) '() platform))
+
+  (def (track-path! state p)
+    (file-mon-state-paths-set! state (append (file-mon-state-paths state) (list p))))
+  (def (track-dir! state d)
+    (unless (member d (file-mon-state-dirs state))
+      (file-mon-state-dirs-set! state (append (file-mon-state-dirs state) (list d)))))
+
+  (def (baseline-one! state provider p)
+    (let ((states (file-mon-state-states state)))
+      (unless (hash-key? states p)
+        (hash-put! states p ((file-provider-get-file-state provider) p))
+        (track-path! state p))))
+
+  ;; record the initial state of each configured path; a configured directory is
+  ;; remembered and its current entries baselined (secmon baseline_path).
+  (def (baseline-files state provider configured)
+    (for-each
+     (lambda (p)
+       (let ((entries ((file-provider-list-dir provider) p)))
+         (if entries
+             (begin (track-dir! state p)
+                    (for-each (lambda (e) (baseline-one! state provider e)) entries))
+             (baseline-one! state provider p))))
+     configured))
+
+  ;; --- the pure change diff (secmon detect_change), ORDERED ------------------
+
+  (def (detect-change path old new)
+    (let ((oe (file-state-exists old)) (ne (file-state-exists new)))
+      (cond
+        ((and oe (not ne))
+         (make-file-change path 'deleted (file-state-hash old) #f
+                           (file-state-mode old) #f
+                           (file-state-uid old) (file-state-gid old)))
+        ((and (not oe) ne)
+         (make-file-change path 'created #f (file-state-hash new)
+                           #f (file-state-mode new)
+                           (file-state-uid new) (file-state-gid new)))
+        ((not (= (file-state-mode old) (file-state-mode new)))
+         (make-file-change path 'permission-changed
+                           (file-state-hash old) (file-state-hash new)
+                           (file-state-mode old) (file-state-mode new)
+                           (file-state-uid new) (file-state-gid new)))
+        ((or (not (= (file-state-uid old) (file-state-uid new)))
+             (not (= (file-state-gid old) (file-state-gid new))))
+         ;; secmon quirk: both modes reported as new.mode here
+         (make-file-change path 'owner-changed
+                           (file-state-hash old) (file-state-hash new)
+                           (file-state-mode new) (file-state-mode new)
+                           (file-state-uid new) (file-state-gid new)))
+        ((or (not (equal? (file-state-hash old) (file-state-hash new)))
+             (not (= (file-state-mtime old) (file-state-mtime new))))
+         (make-file-change path 'modified
+                           (file-state-hash old) (file-state-hash new)
+                           (file-state-mode old) (file-state-mode new)
+                           (file-state-uid new) (file-state-gid new)))
+        (else #f))))
+
+  ;; --- events ----------------------------------------------------------------
+
+  (def (file-event host now type severity change reason)
+    (let ((h (make-hash-table)))
+      (hash-put! h "host" host)
+      (hash-put! h "ts" now)
+      (hash-put! h "type" type)
+      (hash-put! h "severity" severity)
+      (hash-put! h "path" (file-change-path change))
+      (hash-put! h "change" change)
+      (when reason (hash-put! h "reason" reason))
+      h))
+
+  (def (classify-change host now change platform)
+    (let ((reason (is-suspicious-change
+                   (file-change-path change)
+                   (file-change-old-mode change)
+                   (file-change-new-mode change)
+                   (file-change-change-type change)
+                   platform)))
+      (if reason
+          (file-event host now "suspicious_file_change" "high" change reason)
+          (file-event host now "file_changed" "info" change #f))))
+
+  ;; --- the scan (secmon check_changes) ---------------------------------------
+
+  (def (scan-files state provider now)
+    (let ((host (file-mon-state-hostname state))
+          (states (file-mon-state-states state))
+          (plat (file-mon-state-platform state))
+          (events '()))
+      ;; 1. re-stat every tracked path, diff, classify
+      (for-each
+       (lambda (path)
+         (let ((old (hash-get states path)))
+           (when old
+             (let ((new ((file-provider-get-file-state provider) path)))
+               (let ((change (detect-change path old new)))
+                 (when change
+                   (set! events (cons (classify-change host now change plat) events)))
+                 (hash-put! states path new))))))
+       (file-mon-state-paths state))
+      ;; 2. discover new files appearing in the watched dirs (Created)
+      (for-each
+       (lambda (dir)
+         (let ((entries ((file-provider-list-dir provider) dir)))
+           (when entries
+             (for-each
+              (lambda (path)
+                (unless (hash-key? states path)
+                  (let ((st ((file-provider-get-file-state provider) path)))
+                    (hash-put! states path st)
+                    (track-path! state path)
+                    (let ((change (make-file-change
+                                   path 'created #f (file-state-hash st)
+                                   #f (file-state-mode st)
+                                   (file-state-uid st) (file-state-gid st))))
+                      (set! events (cons (classify-change host now change plat) events))))))
+              entries))))
+       (file-mon-state-dirs state))
+      (reverse events)))
+
+  ;; --- the live std::fs provider (thin shell; see NOTE in the header) --------
+
+  (def (live-get-file-state path)
+    (if (not (file-exists? path))
+        (make-file-state #f 0 0 0 0 #f)
+        (let ((mtime (guard (e (#t 0))
+                       (let ((t (file-modification-time path)))
+                         (if (time? t) (time-second t) 0)))))
+          (make-file-state #f 0 0 0 mtime #t))))  ;; mode/uid/gid/hash: stat+sha FFI TODO
+
+  (def (live-list-dir path)
+    (and (file-directory? path)
+         (guard (e (#t '()))
+           (map (lambda (e) (path-join path e)) (directory-list path)))))
+
+  (def (live-hostname)
+    (or (getenv "HOSTNAME")
+        (guard (e (#t "unknown"))
+          (let ((h (read-file-string "/proc/sys/kernel/hostname")))
+            (if h (string-trim h) "unknown")))))
+
+  (def (make-linux-file-provider)
+    (make-file-provider live-get-file-state live-list-dir (live-hostname)))
+
+  ;; secmon's Linux PLATFORM_PATHS (the obfstr! literals at runtime).
+  (def (linux-monitored-paths)
+    '("/etc/passwd" "/etc/shadow" "/etc/group" "/etc/sudoers" "/etc/sudoers.d"
+      "/etc/ssh/sshd_config" "/root/.ssh/authorized_keys" "/etc/pam.d"
+      "/etc/crontab" "/etc/cron.d" "/etc/cron.daily" "/etc/cron.hourly"
+      "/var/spool/cron" "/etc/rc.local" "/etc/init.d" "/etc/systemd/system"
+      "/etc/ld.so.preload" "/etc/ld.so.conf" "/etc/ld.so.conf.d"
+      "/etc/hosts" "/etc/resolv.conf" "/etc/profile" "/etc/bash.bashrc"
+      "/etc/environment" "/etc/modules" "/etc/modprobe.d"
+      "/usr/bin/sudo" "/usr/bin/su" "/usr/bin/passwd" "/usr/bin/chsh"
+      "/usr/bin/newgrp" "/bin/ping")))