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

ober

8f7f54c01fb08b062fe83359b90b5a11e34ec111

diff --git a/Makefile b/Makefile
index 5327f0e..96d53a6 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 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 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)"
@@ -351,6 +351,12 @@ crypto-ecies-check: rust
 monitor-process-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_process_check.ss
 
+# Network monitor scan loop (secmon network.rs): pure de-dup/classify/emit over
+# an injected provider, driven with fixture connections + listeners. Classifier
+# is the verified (jsecmon netconn). No dylib needed.
+monitor-network-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_network_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
@@ -400,6 +406,7 @@ checks: kernels-check
 	$(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
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_network_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/examples/monitor_network_check.ss b/examples/monitor_network_check.ss
new file mode 100644
index 0000000..4ec6f7d
--- /dev/null
+++ b/examples/monitor_network_check.ss
@@ -0,0 +1,87 @@
+;;; Behaviour check for the network monitor scan loop (secmon network.rs).
+;;;
+;;; Mirrors secmon's MockNetworkProvider tests: `scan-connections` is pure over
+;;; an injected `net-provider`, so we drive it with fixture connections and
+;;; listeners and assert the de-dup / classify / emit behaviour here (the live
+;;; /proc/net provider is the untestable Linux shell). The suspicious-connection
+;;; reasons come from the already-verified (jsecmon netconn).
+;;;
+;;; Run from the repo root with the repo on the libdir path:
+;;;   scheme --libdirs $JERBOA/lib --libdirs . --script examples/monitor_network_check.ss
+
+(import (jerboa prelude)
+        (jsecmon monitor-network))
+
+(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 (sevs  evs) (map (lambda (e) (hash-get e "severity")) evs))
+(def (has?  s sub) (and (string-contains s sub) #t))
+
+;; --- a mutable fixture provider (the MockNetworkProvider analogue) -----------
+(def *conns* '())                        ;; established connections, per phase
+(def *listeners* '())                    ;; listening ports, per phase
+(def provider (make-net-provider (lambda () *conns*) (lambda () *listeners*) "testhost"))
+(def st (make-network-monitor provider))
+
+;; fixtures: two listeners; a benign conn; a reverse-shell-port conn; a web
+;; server reaching an external IP.
+(def l-ssh  (make-conn-info "tcp" "0.0.0.0"     22   "0.0.0.0" 0 "LISTEN" #f "sshd"))
+(def l-pg   (make-conn-info "tcp" "127.0.0.1"   5432 "0.0.0.0" 0 "LISTEN" #f "postgres"))
+(def c-ok   (make-conn-info "tcp" "192.168.1.10" 50000 "93.184.216.34" 443   "ESTABLISHED" #f "curl"))
+(def c-4444 (make-conn-info "tcp" "192.168.1.10" 50001 "10.0.0.5"      4444  "ESTABLISHED" #f #f))
+(def c-web  (make-conn-info "tcp" "192.168.1.10" 80    "8.8.8.8"       12345 "ESTABLISHED" #f "nginx"))
+
+(displayln "A: first scan — listeners then connections, classified:")
+(set! *listeners* (list l-ssh l-pg))
+(set! *conns*     (list c-ok c-4444 c-web))
+(def ev1 (scan-connections st provider 1000))
+(check "event order/types"
+       (types ev1)
+       '("listening_port" "listening_port" "network_connection"
+         "suspicious_connection" "suspicious_connection"))
+(check "severities"
+       (sevs ev1)
+       '("info" "info" "info" "high" "high"))
+(check "listener carries conn" (conn-info-process-name (hash-get (car ev1) "connection")) "sshd")
+(check "port 4444 reason"  (has? (hash-get (list-ref ev1 3) "reason") "4444") #t)
+(check "web-server reason" (has? (hash-get (list-ref ev1 4) "reason") "Web server") #t)
+
+(displayln "B: re-scan with identical state is idempotent:")
+(check "no events" (scan-connections st provider 1001) '())
+
+(displayln "C: a new listener and a new connection -> one event each:")
+(def l-http (make-conn-info "tcp" "0.0.0.0"     8080 "0.0.0.0" 0 "LISTEN" #f "caddy"))
+(def c-new  (make-conn-info "tcp" "192.168.1.10" 50002 "1.2.3.4" 8080 "ESTABLISHED" #f "app"))
+(set! *listeners* (list l-ssh l-pg l-http))
+(set! *conns*     (list c-ok c-4444 c-web c-new))
+(def ev3 (scan-connections st provider 1002))
+(check "one listener + one conn" (types ev3) '("listening_port" "network_connection"))
+
+(displayln "D: the de-dup key includes state — a state change is a new event:")
+(def c-ok-tw (make-conn-info "tcp" "192.168.1.10" 50000 "93.184.216.34" 443 "TIME_WAIT" #f "curl"))
+(set! *conns* (list c-ok c-4444 c-web c-new c-ok-tw))
+(def ev4 (scan-connections st provider 1003))
+(check "state change re-emits" (types ev4) '("network_connection"))
+
+(displayln "E: a provider Err skips just that half of the scan:")
+;; connections error, listeners all already known -> nothing
+(def err-conns (make-net-provider (lambda () #f) (lambda () *listeners*) "testhost"))
+(check "conn Err, no new listeners" (scan-connections st err-conns 1004) '())
+;; listeners error, but a brand-new connection -> just that connection
+(def c-fresh (make-conn-info "tcp" "192.168.1.10" 50003 "5.6.7.8" 9090 "ESTABLISHED" #f "app"))
+(set! *conns* (list c-ok c-4444 c-web c-new c-ok-tw c-fresh))
+(def err-listen (make-net-provider (lambda () *conns*) (lambda () #f) "testhost"))
+(def ev5 (scan-connections st err-listen 1005))
+(check "listener Err, one new conn" (types ev5) '("network_connection"))
+(check "it's the fresh one" (conn-info-remote-port (hash-get (car ev5) "connection")) 9090)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: network monitor de-dups by key, classifies, and survives provider errors.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/monitor-network.ss b/jsecmon/monitor-network.ss
new file mode 100644
index 0000000..048b78e
--- /dev/null
+++ b/jsecmon/monitor-network.ss
@@ -0,0 +1,177 @@
+#!chezscheme
+;;; jsecmon network monitor (secmon src/monitor/network.rs), untyped.
+;;;
+;;; Port of secmon's `NetworkMonitor<N: NetworkProvider>` scan loop — the same
+;;; provider-trait seam as the process monitor. The OS-touching part is a
+;;; `NetworkProvider` (list_connections / list_listeners) with a MockNetwork-
+;;; Provider for tests; the scan logic — diff the live connection/listener sets
+;;; against what we've already seen, classify each new connection, emit events —
+;;; is pure over that trait. We mirror it:
+;;;
+;;;   `scan-connections` is pure over an injected `net-provider`, fixture-
+;;;   testable on any host (examples/monitor_network_check.ss). `make-linux-
+;;;   network-provider` is the thin /proc/net shell — Linux-only — built from
+;;;   the verified (jsecmon proc-linux) parsers. The suspicious-connection
+;;;   classifier is the already-ported (jsecmon netconn) connection-suspicious?
+;;;   — not reimplemented here.
+;;;
+;;; Faithfulness points the Rust pins:
+;;;   * de-dup keys are exactly secmon's format! strings: listener
+;;;     "proto:laddr:lport"; connection "proto:laddr:lport:raddr:rport:state".
+;;;   * a provider Err on either list simply skips that half of the scan.
+;;;   * secmon never ages connections out (the cleanup is a no-op TODO), so the
+;;;     known sets only grow — no exit events. Ported as-is.
+;;;   * event categories/severities match event_json.rs: listening_port/info,
+;;;     network_connection/info, suspicious_connection/high.
+
+(library (jsecmon monitor-network)
+  (export make-conn-info conn-info?
+          conn-info-protocol conn-info-local-addr conn-info-local-port
+          conn-info-remote-addr conn-info-remote-port conn-info-state
+          conn-info-pid conn-info-process-name
+          make-net-provider net-provider-hostname
+          make-net-state net-state-hostname
+          net-state-known-connections net-state-known-listeners
+          make-network-monitor scan-connections
+          make-linux-network-provider linux-list-connections linux-list-listeners)
+  (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 netconn)
+          (jsecmon proc-linux))
+
+  ;; secmon ConnectionInfo (src/monitor/events.rs).
+  (defstruct conn-info
+    (protocol local-addr local-port remote-addr remote-port state pid process-name))
+
+  ;; the injected OS seam (secmon's NetworkProvider trait):
+  ;;   list-connections : () -> (list conn-info ...) | #f   (#f == Err)
+  ;;   list-listeners   : () -> (list conn-info ...) | #f
+  ;;   hostname         : string
+  (defstruct net-provider (list-connections list-listeners hostname))
+
+  ;; accumulated knowledge (secmon NetworkMonitor fields): two grow-only key sets.
+  (defstruct net-state (hostname known-connections known-listeners))
+
+  (def (make-network-monitor provider)
+    (make-net-state (net-provider-hostname provider)
+                    (make-hash-table) (make-hash-table)))
+
+  ;; secmon's two format! de-dup keys, verbatim.
+  (def (listen-key c)
+    (str (conn-info-protocol c) ":" (conn-info-local-addr c) ":" (conn-info-local-port c)))
+  (def (conn-key c)
+    (str (conn-info-protocol c) ":" (conn-info-local-addr c) ":" (conn-info-local-port c)
+         ":" (conn-info-remote-addr c) ":" (conn-info-remote-port c) ":" (conn-info-state c)))
+
+  ;; --- structured events -----------------------------------------------------
+
+  (def (conn-event host now type severity c 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 "connection" c)
+      (when reason (hash-put! h "reason" reason))
+      h))
+
+  (def (listening-port-event host now c)
+    (conn-event host now "listening_port" "info" c #f))
+  (def (new-connection-event host now c)
+    (conn-event host now "network_connection" "info" c #f))
+  (def (suspicious-connection-event host now c reason)
+    (conn-event host now "suspicious_connection" "high" c reason))
+
+  ;; --- the pure scan (secmon scan_connections) -------------------------------
+
+  (def (scan-connections state provider now)
+    (let ((host (net-state-hostname state))
+          (kl (net-state-known-listeners state))
+          (kc (net-state-known-connections state))
+          (events '()))
+      ;; listeners first (secmon order)
+      (let ((ls ((net-provider-list-listeners provider))))
+        (when ls
+          (for-each
+           (lambda (c)
+             (let ((k (listen-key c)))
+               (unless (hash-key? kl k)
+                 (hash-put! kl k #t)
+                 (set! events (cons (listening-port-event host now c) events)))))
+           ls)))
+      ;; then established connections, classified
+      (let ((cs ((net-provider-list-connections provider))))
+        (when cs
+          (for-each
+           (lambda (c)
+             (let ((k (conn-key c)))
+               (unless (hash-key? kc k)
+                 (hash-put! kc k #t)
+                 (let ((reason (connection-suspicious?
+                                (conn-info-remote-port c)
+                                (conn-info-remote-addr c)
+                                (conn-info-process-name c))))
+                   (set! events
+                         (cons (if reason
+                                   (suspicious-connection-event host now c reason)
+                                   (new-connection-event host now c))
+                               events))))))
+           cs)))
+      (reverse events)))
+
+  ;; --- the live Linux /proc/net provider (thin shell; Linux-only) ------------
+
+  (def (slurp path) (guard (e (#t #f)) (read-file-string path)))
+
+  ;; one /proc/net/{tcp,udp}[6] row -> conn-info, via the verified parsers.
+  ;; NOTE: pid/process-name are left #f — secmon resolves them by scanning every
+  ;; /proc/*/fd for the socket inode; that heavy walk is deferred. process-name
+  ;; only feeds netconn's web-server check, so the live path under-reports that
+  ;; one signal; the port-based signals are unaffected.
+  (def (parse-conn-line line protocol)
+    (let ((parts (filter (lambda (s) (not (string-empty? s)))
+                         (string-split line #\space))))
+      (and (>= (length parts) 10)
+           (let ((la (parse-addr (list-ref parts 1) protocol))
+                 (ra (parse-addr (list-ref parts 2) protocol))
+                 (st (hex-to-state (list-ref parts 3))))
+             (and la ra
+                  (make-conn-info protocol (car la) (cdr la) (car ra) (cdr ra)
+                                  st #f #f))))))
+
+  (def (parse-proc-net path protocol)
+    (let ((content (slurp path)))
+      (if (not content)
+          '()
+          (filter-map (lambda (line) (parse-conn-line line protocol))
+                      ;; skip the header row
+                      (let ((ls (string-split content #\newline)))
+                        (if (pair? ls) (cdr ls) '()))))))
+
+  (def *net-sources*
+    '(("/proc/net/tcp" . "tcp") ("/proc/net/tcp6" . "tcp6")
+      ("/proc/net/udp" . "udp") ("/proc/net/udp6" . "udp6")))
+
+  (def (all-proc-net)
+    (apply append (map (lambda (src) (parse-proc-net (car src) (cdr src))) *net-sources*)))
+
+  (def (linux-list-connections)
+    (filter (lambda (c) (string=? (conn-info-state c) "ESTABLISHED")) (all-proc-net)))
+  (def (linux-list-listeners)
+    (filter (lambda (c) (string=? (conn-info-state c) "LISTEN")) (all-proc-net)))
+
+  (def (linux-net-hostname)
+    (or (getenv "HOSTNAME")
+        (let ((h (slurp "/proc/sys/kernel/hostname")))
+          (if h (string-trim h) "unknown"))))
+
+  (def (make-linux-network-provider)
+    (make-net-provider linux-list-connections linux-list-listeners (linux-net-hostname))))