monitor: port secmon's file-integrity monitor (files.rs)
ober
dac7b9d22748ecaf2c720336546c6e4ac76162df
--- 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) new file mode 100644 --- /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))) new file mode 100644 --- /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")))