Port secmon NetworkMonitor::check_suspicious to (jsecmon netconn)
Jaime Fournier <jaimef@linbsd.org>
aa0ff21cfed95c00995554292c7d17aeee4e9e19
diff --git a/Makefile b/Makefile
index c93defb..20a5ad8 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 threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check threats-check geoip-check sigma-check yaml-rules-check buffer-check dns-sniffer-check suspicious-check netconn-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)"
@@ -120,6 +120,11 @@ dns-sniffer-check:
suspicious-check:
$(SCHEME) --libdirs $(LIBDIRS) --script examples/suspicious_check.ss
+# Connection classifier (secmon NetworkMonitor::check_suspicious): bad-port
+# list, high-port-multiple-of-1000, web-server-to-external. Pure, no native lib.
+netconn-check:
+ $(SCHEME) --libdirs $(LIBDIRS) --script examples/netconn_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
@@ -134,6 +139,7 @@ checks: kernels-check
$(SCHEME) --libdirs $(LIBDIRS) --script examples/buffer_check.ss
$(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_sniffer_check.ss
$(SCHEME) --libdirs $(LIBDIRS) --script examples/suspicious_check.ss
+ $(SCHEME) --libdirs $(LIBDIRS) --script examples/netconn_check.ss
clean:
rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 9876814..7559b95 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ make yaml-rules-check # user YAML detection rules (threshold/distinct/sequence/m
make buffer-check # the agent's encrypted event ring buffer (FIFO + priority eviction)
make dns-sniffer-check # DNS wire-format parser (QNAME/compression/answers) + dedup state
make suspicious-check # SuspiciousPatterns: shell/tool-from-service, revshell + miner
+make netconn-check # connection classifier: bad-port, high-port-mult-1000, web→external
make checks # every Jerboa-side check in one shot
```
@@ -94,5 +95,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
| `storage` impossible_travel | `jsecmon/threats.ss` | ✅ **untyped layer** — geoip-gated (reads `SECMON_GEOIP_CSV`): pair a user's consecutive successful `auth_event`s, fire `high` when the two source IPs resolve to different countries within `SECMON_TRAVEL_GAP_MIN` (default 30). Private IPs are dropped before pairing. `make geoip-check` proves the fire + the gap/same-country/private/cross-user negatives. |
| `buffer::ring` (StoredEvent ring buffer) | `jsecmon/buffer.ss` | ✅ **untyped layer** — port of secmon's `src/buffer/ring.rs`: the agent's bounded in-memory event ring. FIFO list + monotonic seq numbering, priority eviction (`event_severity_u8` table, drop lowest-severity oldest-first, oldest-critical last), seq/time-range polling, FIFO delivery-ack (`clear_before`), and the little-endian header codec (`seq u64 ∥ ts i64 ∥ sev u8 ∥ payload`). Pure mechanics, so untyped — the one security step, ECIES payload encryption, is FFI-deferred: the caller hands `buffer-store!` opaque ciphertext bytes. `make buffer-check` reproduces secmon's three ring tests (store/seq, priority eviction, FIFO-oldest) + codec round-trip. |
| `monitor::events::SuspiciousPatterns` (process-spawn classifier) | `jsecmon/suspicious.ss` | ✅ **untyped layer** — `check_suspicious(process, parent)`: shell-from-service, attack-tool-from-service (name exact-match or exe suffix), reverse-shell command-line patterns, and crypto-miner name/cmdline patterns, in secmon's order, returning the same reason string. Pure string classification like triage. Pins two corners the Rust depends on: a missing parent short-circuits to "clean" before any check, and `str::contains` is a *literal* substring test (so `python -c.*socket` is literal, not a regex). `make suspicious-check` reproduces secmon's two events.rs tests + the other three signals + both corners. |
+| `monitor::network::NetworkMonitor` (connection classifier) | `jsecmon/netconn.ss` | ✅ **untyped layer** — `check_suspicious(port, addr, process)`: known reverse-shell/C2/l33t port, ephemeral port (49152..65535) that is a round multiple of 1000, and a web-server process (nginx/apache/httpd/php-fpm) connecting to a non-private address, in secmon's order with the same reason string. Pure metadata classification. secmon hides the web-server names with `obfstr!` (same scheme as `typed/obfuscate.ss`); they decode to these plaintext literals at runtime. Pins the faithfulness quirk that the "private" prefix set is literal `{127. 10. 192.168. 172.}`, so `172.` matches all of 172.x, not just RFC1918 172.16/12. `make netconn-check` reproduces secmon's two network.rs tests + the full bad-port list + the high-port and web-server rules with private-address negatives. |
| `monitor::dns_sniffer` (DNS wire parser + dedup) | `jsecmon/dns-sniffer.ss` | ✅ **untyped layer** — the platform-independent half of secmon's `src/monitor/dns_sniffer.rs`: the DNS wire-format parser (QNAME decoding with compression-pointer chasing capped at 128 steps, QTYPE→string, question + A/AAAA answer-RR extraction) and the 5s dedup / 30s cleanup state machine. Every bounds check is preserved — a truncated/malformed/looping packet yields `#f`, never a bad read. Pure byte parsing → untyped, like geoip. The AF_PACKET raw-socket capture + `/proc` PID lookup stay for the monitor I/O driver. `make dns-sniffer-check` reproduces secmon's parser + dedup tests (+ AAAA, qtype table, pointer-loop/qdcount guards). |
| monitors / server / ebpf / dtrace | — | ⏳ I/O+async+FFI, last |
diff --git a/examples/netconn_check.ss b/examples/netconn_check.ss
new file mode 100644
index 0000000..c3b6828
--- /dev/null
+++ b/examples/netconn_check.ss
@@ -0,0 +1,61 @@
+;;; Parity check for (jsecmon netconn) against secmon's network.rs tests
+;;; (test_suspicious_port_detection, test_normal_connection_not_suspicious),
+;;; plus the high-port-multiple-of-1000 rule and the web-server-to-external rule
+;;; with its private-address negatives.
+;;;
+;;; scheme --libdirs "$JERBOA/lib:." --script examples/netconn_check.ss
+
+(import (jerboa prelude)
+ (jsecmon netconn))
+
+(def fails 0)
+(def (check name got want)
+ (let ((ok (equal? got want)))
+ (unless ok (set! fails (+ fails 1)))
+ (displayln (if ok " ok " " FAIL ") name
+ (if ok "" (str " got " got " want " want)))))
+(def (fires? r) (and r #t))
+
+;; ── secmon's two unit tests ──────────────────────────────────────────────────
+(displayln "secmon network.rs vectors:")
+;; remote port 4444 -> suspicious, reason mentions 4444
+(let ((r (connection-suspicious? 4444 "10.0.0.1" #f)))
+ (check "port 4444 fires" (fires? r) #t)
+ (check " reason names 4444" (and (string-contains r "4444") #t) #t))
+;; port 443 to a normal external IP, no process -> clean
+(check "443 normal is clean" (connection-suspicious? 443 "93.184.216.34" #f) #f)
+
+;; ── the full suspicious-port list ────────────────────────────────────────────
+(displayln "suspicious-port list:")
+(for-each (lambda (p)
+ (check (str " port " p " fires")
+ (connection-suspicious? p "8.8.8.8" #f)
+ (str "Connection to suspicious port " p)))
+ '(5555 6666 7777 8888 9999 1337 31337 4443 8443))
+
+;; ── high ephemeral port, multiple of 1000 ────────────────────────────────────
+(displayln "high-port multiple of 1000:")
+(check "50000 fires" (connection-suspicious? 50000 "8.8.8.8" #f)
+ "Connection to high port multiple of 1000")
+(check "65000 fires" (connection-suspicious? 65000 "8.8.8.8" #f)
+ "Connection to high port multiple of 1000")
+(check "49152 (not mult) clean" (connection-suspicious? 49152 "8.8.8.8" #f) #f)
+(check "40000 (below range) clean" (connection-suspicious? 40000 "8.8.8.8" #f) #f)
+
+;; ── web server connecting externally ─────────────────────────────────────────
+(displayln "web-server-to-external:")
+(check "nginx -> external fires"
+ (connection-suspicious? 443 "8.8.8.8" "nginx")
+ "Web server nginx connecting to external IP")
+(check "php-fpm -> external fires"
+ (connection-suspicious? 443 "203.0.113.5" "php-fpm: pool www")
+ "Web server php-fpm: pool www connecting to external IP")
+(check "nginx -> 10.x internal clean" (connection-suspicious? 443 "10.0.0.5" "nginx") #f)
+(check "nginx -> 192.168 internal clean"(connection-suspicious? 443 "192.168.1.9" "nginx") #f)
+(check "nginx -> 127. loopback clean" (connection-suspicious? 443 "127.0.0.1" "nginx") #f)
+(check "non-web -> external clean" (connection-suspicious? 443 "8.8.8.8" "sshd") #f)
+
+(newline)
+(if (= fails 0)
+ (displayln "OK: netconn matches secmon's network.rs behaviour.")
+ (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/netconn.ss b/jsecmon/netconn.ss
new file mode 100644
index 0000000..666b7bb
--- /dev/null
+++ b/jsecmon/netconn.ss
@@ -0,0 +1,62 @@
+#!chezscheme
+;;; jsecmon connection classifier (secmon monitor::network), untyped.
+;;;
+;;; Port of `NetworkMonitor::check_suspicious` from secmon's
+;;; src/monitor/network.rs: flag an outbound connection and say why, by three
+;;; signals in secmon's order:
+;;; 1. the remote port is a known reverse-shell / C2 / l33t port
+;;; 2. the remote port is an ephemeral port (49152..65535) that is a round
+;;; multiple of 1000 (a crude C2 tell)
+;;; 3. a web-server process (nginx/apache/httpd/php-fpm) is connecting to a
+;;; non-private remote address
+;;;
+;;; Pure classification over connection metadata, like the process classifier
+;;; in (jsecmon suspicious), so untyped. secmon hides the web-server names with
+;;; obfstr! (the same scheme ported to typed/obfuscate.ss) — at runtime they
+;;; decode to these plaintext literals, which is what the contains-test uses.
+;;;
+;;; Faithfulness note: secmon's "private address" test is the literal prefix set
+;;; {127. 10. 192.168. 172.} — so "172." matches all of 172.x, not just the
+;;; RFC1918 172.16/12. Ported verbatim, quirk included.
+;;;
+;;; Verified against secmon's network.rs tests in examples/netconn_check.ss.
+
+(library (jsecmon netconn)
+ (export connection-suspicious?)
+ (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?))
+
+ (def *suspicious-ports*
+ '(4444 5555 6666 7777 8888 9999 ;; common reverse-shell ports
+ 1337 31337 ;; l33t ports
+ 4443 8443)) ;; alt-HTTPS, sometimes C2
+ (def *web-servers* '("nginx" "apache" "httpd" "php-fpm"))
+ (def *private-prefixes* '("127." "10." "192.168." "172."))
+
+ (def (external-addr? addr)
+ (and (not (any (lambda (p) (string-prefix? p addr)) *private-prefixes*))
+ (not (string=? addr "0.0.0.0"))
+ (not (string=? addr "::"))))
+
+ ;; remote-port: integer; remote-addr: string; process-name: string or #f.
+ ;; -> reason string | #f.
+ (def (connection-suspicious? remote-port remote-addr process-name)
+ (cond
+ ((member remote-port *suspicious-ports*)
+ (str "Connection to suspicious port " remote-port))
+ ((and (>= remote-port 49152) (<= remote-port 65535)
+ (= (modulo remote-port 1000) 0))
+ "Connection to high port multiple of 1000")
+ ((and process-name
+ (any (lambda (s) (string-contains process-name s)) *web-servers*)
+ (external-addr? remote-addr))
+ (str "Web server " process-name " connecting to external IP"))
+ (else #f))))