monitor: port secmon's process-monitor scan loop (process.rs)

ober

c6a9ec6095774c5c80fbf1856de211e0d714dfe9

diff --git a/Makefile b/Makefile
index 4809402..5327f0e 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 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)
diff --git a/examples/monitor_process_check.ss b/examples/monitor_process_check.ss
new file mode 100644
index 0000000..2ba968b
--- /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)))
diff --git a/jsecmon/monitor-process.ss b/jsecmon/monitor-process.ss
new file mode 100644
index 0000000..2223a6b
--- /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))))