monitor: port secmon's network-monitor scan loop (network.rs)
ober
8f7f54c01fb08b062fe83359b90b5a11e34ec111
--- 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) new file mode 100644 --- /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))) new file mode 100644 --- /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))))