monitor: port secmon's process-monitor scan loop (process.rs)
ober
c6a9ec6095774c5c80fbf1856de211e0d714dfe9
--- 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 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 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)" @@ -345,6 +345,12 @@ crypto-ecies-check: rust cd $(BUILD) && cargo build --release $(SCHEME) --libdirs $(LIBDIRS) --script examples/crypto_ecies_check.ss +# Process monitor scan loop (secmon process.rs): pure diff/classify/emit over an +# injected provider, driven here with fixture processes (the MockProcessProvider +# analogue). No dylib needed — this is untyped logic, fixture-testable anywhere. +monitor-process-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_process_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 @@ -393,6 +399,7 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/calendar_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/crypto_psk_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/crypto_ecies_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_process_check.ss clean: rm -rf $(BUILD) new file mode 100644 --- /dev/null +++ b/examples/monitor_process_check.ss @@ -0,0 +1,101 @@ +;;; Behaviour check for the process monitor scan loop (secmon process.rs). +;;; +;;; secmon tests ProcessMonitor through a MockProcessProvider; we mirror that +;;; exactly — `scan-processes` is pure over an injected `mon-provider`, so we +;;; drive it with fixture processes and assert the diff/classify/emit behaviour +;;; on this macOS host (the live /proc provider is the untestable Linux shell). +;;; +;;; Run from the repo root with the repo on the libdir path: +;;; scheme --libdirs $JERBOA/lib --libdirs . --script examples/monitor_process_check.ss + +(import (jerboa prelude) + (jsecmon monitor-process)) + +(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 (pids evs) (map (lambda (e) (hash-get e "pid")) evs)) + +;; --- a mutable fixture provider (the MockProcessProvider analogue) ----------- +(def *pids* '()) ;; the live pid set, mutated per phase +(def *procs* (make-hash-table)) ;; pid -> proc-info +(def *hidden* '()) ;; pids whose get_process "fails" (#f) +(def (reg! p info) (hash-put! *procs* p info)) +(def (fix-get p) (and (not (memv p *hidden*)) (hash-get *procs* p))) +(def provider (make-mon-provider (lambda () *pids*) fix-get "testhost")) + +(reg! 1 (make-proc-info 1 0 0 "/sbin/init" "systemd" '("/sbin/init") #f '())) +(reg! 100 (make-proc-info 100 1 1000 "/usr/bin/gitea" "gitea" '("/usr/bin/gitea" "web") #f '())) +(reg! 200 (make-proc-info 200 1 0 "/usr/sbin/nginx" "nginx" '("nginx" "-g") #f '())) +;; a shell whose parent (gitea) is a network service -> suspicious (signal 1) +(reg! 300 (make-proc-info 300 100 1000 "/bin/bash" "bash" '("/bin/bash") #f '())) +(reg! 400 (make-proc-info 400 1 1000 "/usr/bin/htop" "htop" '("htop") #f '())) + +(def st (make-monitor provider)) + +(displayln "A: first scan — all pids new, all benign:") +(set! *pids* '(1 100 200)) +(def ev1 (scan-processes st provider 1000)) +(check "three process_start" (types ev1) '("process_start" "process_start" "process_start")) +(check "ascending pids" (pids ev1) '(1 100 200)) +(check "event carries proc" (proc-info-name (hash-get (car ev1) "process")) "systemd") +(check "known = current" (mon-state-known st) '(1 100 200)) + +(displayln "agent_start event shape:") +(def as (agent-start-event "testhost" "0.1.0" 999)) +(check "type" (hash-get as "type") "agent_start") +(check "host" (hash-get as "host") "testhost") +(check "version" (hash-get as "version") "0.1.0") + +(displayln "B: a shell spawned by a service -> suspicious_exec:") +(set! *pids* '(1 100 200 300)) +(def ev2 (scan-processes st provider 1001)) +(check "one event" (length ev2) 1) +(check "suspicious_exec" (hash-get (car ev2) "type") "suspicious_exec") +(check "high severity" (hash-get (car ev2) "severity") "high") +(check "pname bash" (hash-get (car ev2) "pname") "bash") +(check "reason present" (string? (hash-get (car ev2) "reason")) #t) +(check "parent is gitea" (proc-info-name (hash-get (car ev2) "parent")) "gitea") +(check "known grows" (mon-state-known st) '(1 100 200 300)) + +(displayln "C: a pid disappears -> process_exit:") +(set! *pids* '(1 200 300)) +(def ev3 (scan-processes st provider 1002)) +(check "one process_exit" (types ev3) '("process_exit")) +(check "exit pid 100" (hash-get (car ev3) "pid") 100) +(check "exit_code #f" (hash-get (car ev3) "exit_code") #f) +(check "known drops 100" (mon-state-known st) '(1 200 300)) + +(displayln "D: re-scan with no change is idempotent:") +(def ev4 (scan-processes st provider 1003)) +(check "no events" ev4 '()) +(check "known stable" (mon-state-known st) '(1 200 300)) + +(displayln "E: a pid whose get_process fails is not known, and is retried:") +(set! *hidden* '(400)) +(set! *pids* '(1 200 300 400)) +(def ev5 (scan-processes st provider 1004)) +(check "no event for failed read" ev5 '()) +(check "400 not added to known" (memv 400 (mon-state-known st)) #f) +(set! *hidden* '()) ;; the read now succeeds +(def ev6 (scan-processes st provider 1005)) +(check "retried -> process_start" (types ev6) '("process_start")) +(check "retried pid 400" (hash-get (car ev6) "pid") 400) +(check "400 now known" (and (memv 400 (mon-state-known st)) #t) #t) + +(displayln "F: list_pids error -> whole scan no-ops, state unchanged:") +(def known-before (mon-state-known st)) +(def errprov (make-mon-provider (lambda () #f) fix-get "testhost")) +(def ev7 (scan-processes st errprov 1006)) +(check "error -> no events" ev7 '()) +(check "state unchanged" (mon-state-known st) known-before) + +(newline) +(if (= fails 0) + (displayln "OK: process monitor diffs pids, classifies spawns, and tracks exits.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/monitor-process.ss @@ -0,0 +1,197 @@ +#!chezscheme +;;; jsecmon process monitor (secmon src/monitor/process.rs), untyped. +;;; +;;; Port of secmon's `ProcessMonitor<P: ProcessProvider>` scan loop. secmon +;;; splits this exactly the way we do: the OS-touching part is a `ProcessProvider` +;;; trait (list_pids / get_process / get_hostname) with a `MockProcessProvider` +;;; for tests, and the *scan logic* — diffing the live pid set against what we +;;; already know, classifying each new spawn, emitting start/exit/suspicious +;;; events — is pure over that trait. We mirror that seam precisely: +;;; +;;; `scan-processes` is pure over an injected `mon-provider`, so the diff/ +;;; classify/emit logic is fixture-testable on any host (examples/ +;;; monitor_process_check.ss). `make-linux-provider` is the thin /proc shell +;;; — Linux-only, can't run on this macOS host — built from the already- +;;; verified `(jsecmon proc-linux)` parsers. +;;; +;;; Faithfulness points the Rust pins: +;;; * A pid whose get_process fails is NOT added to known_pids — it is retried +;;; on the next scan (secmon `continue`s before the insert). +;;; * A new pid's parent is looked up in the cache; a missing parent makes +;;; check_suspicious short-circuit to "not suspicious" (see suspicious.ss). +;;; * list_pids error → the whole scan no-ops, state unchanged (Result Err). +;;; * cache is pruned only past 1000 entries, keeping pids still in known. +;;; +;;; Events are STRUCTURED row hashes (the routing scalars host/ts/type/severity +;;; /pid/pname plus the raw proc-info objects), not yet JSON. That mirrors +;;; secmon's split where process.rs emits EventType structs and event_json.rs +;;; serializes them — the JSON `data` body is the next increment's concern. + +(library (jsecmon monitor-process) + (export make-proc-info proc-info? + proc-info-pid proc-info-ppid proc-info-uid proc-info-exe + proc-info-name proc-info-cmdline proc-info-cwd proc-info-environ + make-mon-provider mon-provider-hostname + make-mon-state mon-state-hostname mon-state-known mon-state-cache + make-monitor scan-processes agent-start-event + make-linux-provider linux-list-pids linux-get-process linux-hostname) + (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 suspicious) + (jsecmon proc-linux)) + + ;; secmon ProcessInfo (src/monitor/events.rs). parent, when present, is itself + ;; a proc-info pulled from the cache. + (defstruct proc-info (pid ppid uid exe name cmdline cwd environ)) + + ;; the injected OS seam (secmon's ProcessProvider trait): + ;; list-pids : () -> (list pid ...) | #f (#f == enumeration error) + ;; get-process : pid -> proc-info | #f + ;; hostname : string + (defstruct mon-provider (list-pids get-process hostname)) + + ;; accumulated knowledge across scans (secmon's ProcessMonitor fields): + ;; known : list of pids seen and successfully read (set semantics via memv) + ;; cache : hash pid -> proc-info, for parent lookup and prune + (defstruct mon-state (hostname known cache)) + + (def (make-monitor provider) + (make-mon-state (mon-provider-hostname provider) '() (make-hash-table))) + + ;; --- structured events ----------------------------------------------------- + + (def (base-event host now type severity) + (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) + h)) + + ;; agent_start: secmon run() emits this once, before the first scan. + (def (agent-start-event host version now) + (let ((h (base-event host now "agent_start" "info"))) + (hash-put! h "version" version) + h)) + + (def (spawn-event host now type severity proc parent reason) + (let ((h (base-event host now type severity))) + (hash-put! h "pid" (proc-info-pid proc)) + (hash-put! h "pname" (proc-info-name proc)) + (hash-put! h "process" proc) + (hash-put! h "parent" parent) ;; proc-info | #f + (when reason (hash-put! h "reason" reason)) + h)) + + (def (process-exit-event host now pid) + (let ((h (base-event host now "process_exit" "info"))) + (hash-put! h "pid" pid) + (hash-put! h "exit_code" #f) ;; secmon: exit_code None + h)) + + ;; --- the pure scan (secmon scan_processes) --------------------------------- + + (def (scan-processes state provider now) + (let ((pids ((mon-provider-list-pids provider)))) + (if (not pids) + '() ;; Err(list_pids) -> no-op, state kept + (let* ((host (mon-state-hostname state)) + (cache (mon-state-cache state)) + (get (mon-provider-get-process provider)) + (known (mon-state-known state)) + ;; sort for deterministic event ordering (secmon iterates a + ;; HashSet, so order is unspecified; ascending is a safe pin). + (current (list-sort < pids)) + (new-pids (filter (lambda (p) (not (memv p known))) current)) + (exited (filter (lambda (p) (not (memv p current))) known)) + (survivors (filter (lambda (p) (memv p current)) known)) + (events '()) + (added '())) + ;; new pids: read, classify, cache, emit start/suspicious + (for-each + (lambda (pid) + (let ((proc (get pid))) + (when proc ;; failed read -> NOT added to known + (let* ((parent (hash-get cache (proc-info-ppid proc))) + (reason (check-suspicious + (proc-info-name proc) + (proc-info-exe proc) + (proc-info-cmdline proc) + (and parent (proc-info-name parent))))) + (hash-put! cache pid proc) + (set! added (cons pid added)) + (set! events + (cons (if reason + (spawn-event host now "suspicious_exec" "high" + proc parent reason) + (spawn-event host now "process_start" "info" + proc parent #f)) + events)))))) + new-pids) + ;; exited pids: drop from cache, emit exit + (for-each + (lambda (pid) + (hash-remove! cache pid) + (set! events (cons (process-exit-event host now pid) events))) + exited) + ;; known := survivors + successfully-read new pids + (let ((known-after (append survivors (reverse added)))) + (mon-state-known-set! state known-after) + ;; prune the cache only past 1000 (secmon), keeping known pids + (when (> (length (hash-keys cache)) 1000) + (for-each (lambda (k) + (unless (memv k known-after) (hash-remove! cache k))) + (hash-keys cache)))) + (reverse events))))) + + ;; --- the live Linux /proc provider (thin shell; Linux-only, untested here) - + + (def (slurp path) ;; read a /proc file, #f if unreadable + (guard (e (#t #f)) (read-file-string path))) + + (def (split-nul s) ;; NUL-separated /proc fields + (filter (lambda (x) (not (string-empty? x))) (string-split s #\nul))) + + (def (linux-list-pids) + (guard (e (#t #f)) + (filter-map (lambda (name) + (let ((n (string->number name))) + (and n (integer? n) (> n 0) n))) + (directory-list "/proc")))) + + ;; assemble a proc-info from /proc/[pid]/{stat,status,cmdline,environ} via the + ;; verified proc-linux parsers. NOTE: exe is approximated from cmdline[0] and + ;; cwd left #f — both need a readlink secmon does on /proc/[pid]/{exe,cwd}; + ;; neither feeds check-suspicious (which uses name/exe/cmdline/parent), so this + ;; is faithful for detection. A later increment can add the readlinks. + (def (linux-get-process pid) + (let ((stat (slurp (str "/proc/" pid "/stat")))) + (and stat + (let ((ps (parse-stat stat))) + (and ps + (let* ((ppid (car ps)) + (comm (cdr ps)) + (status (slurp (str "/proc/" pid "/status"))) + (uid (or (and status (parse-uid status)) 0)) + (cmdline (let ((c (slurp (str "/proc/" pid "/cmdline")))) + (if c (split-nul c) '()))) + (environ (let ((e (slurp (str "/proc/" pid "/environ")))) + (if e (split-nul e) '()))) + (exe (if (pair? cmdline) (car cmdline) comm))) + (make-proc-info pid ppid uid exe comm cmdline #f environ))))))) + + (def (linux-hostname) + (or (getenv "HOSTNAME") + (let ((h (slurp "/proc/sys/kernel/hostname"))) + (if h (string-trim h) "unknown")))) + + (def (make-linux-provider) + (make-mon-provider linux-list-pids linux-get-process (linux-hostname))))