Port secmon KernelModuleMonitor::is_suspicious_module to (jsecmon kernmod)

Jaime Fournier

3ae851af6183cc8332da42820b6e477091ed8c61

diff --git a/Makefile b/Makefile
index 20a5ad8..15418d3 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 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 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)"
@@ -125,6 +125,12 @@ suspicious-check:
 netconn-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/netconn_check.ss
 
+# Kernel-module classifier (secmon KernelModuleMonitor::is_suspicious_module):
+# known-rootkit substring, short-name allow-list, long-and-vowelless. Pure
+# string classification, no native lib.
+kernmod-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/kernmod_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
@@ -140,6 +146,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_sniffer_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/suspicious_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/netconn_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/kernmod_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index 7559b95..dc9e59d 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ make buffer-check    # the agent's encrypted event ring buffer (FIFO + priority 
 make dns-sniffer-check # DNS wire-format parser (QNAME/compression/answers) + dedup state
 make suspicious-check # SuspiciousPatterns: shell/tool-from-service, revshell + miner
 make netconn-check   # connection classifier: bad-port, high-port-mult-1000, web→external
+make kernmod-check   # kernel-module classifier: rootkit substring, short name, no vowels
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -96,5 +97,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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::network::NetworkMonitor` (connection classifier) | `jsecmon/netconn.ss` | ✅ **untyped layer** — `check_suspicious(port, addr, process)`: known reverse-shell/C2/l33t port, ephemeral port (49152..65535) that is a round multiple of 1000, and a web-server process (nginx/apache/httpd/php-fpm) connecting to a non-private address, in secmon's order with the same reason string. Pure metadata classification. secmon hides the web-server names with `obfstr!` (same scheme as `typed/obfuscate.ss`); they decode to these plaintext literals at runtime. Pins the faithfulness quirk that the "private" prefix set is literal `{127. 10. 192.168. 172.}`, so `172.` matches all of 172.x, not just RFC1918 172.16/12. `make netconn-check` reproduces secmon's two network.rs tests + the full bad-port list + the high-port and web-server rules with private-address negatives. |
+| `monitor::kernel::KernelModuleMonitor` (kernel-module classifier) | `jsecmon/kernmod.ss` | ✅ **untyped layer** — `is_suspicious_module(name)`: lower-cased name contains a known-rootkit substring (diamorphine/reptile/hide/rootkit/keylog/…), or a 1-2 char name not on the legitimate-short allow-list (ip dm sd sr nf if), or a >4 char name with no vowel, in secmon's order. Pure string classification like the other classifiers. obfstr!-hidden name lists decode to these plaintext literals. Pins the faithfulness corner that only the substring test lower-cases the name — the short-name and vowel tests use the original case, and the vowel set is both-case `aeiouAEIOU`. `make kernmod-check` reproduces secmon's two kernel.rs tests + each signal exercised independently + the case 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/kernmod_check.ss b/examples/kernmod_check.ss
new file mode 100644
index 0000000..accbb30
--- /dev/null
+++ b/examples/kernmod_check.ss
@@ -0,0 +1,55 @@
+;;; Parity check for (jsecmon kernmod) against secmon's kernel.rs tests
+;;; (test_suspicious_module_detection, test_legitimate_modules), plus the three
+;;; signals exercised independently and the faithfulness corners.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/kernmod_check.ss
+
+(import (jerboa prelude)
+        (jsecmon kernmod))
+
+(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 kernel.rs vectors:")
+(check "diamorphine suspicious"     (suspicious-module? "diamorphine") #t)
+(check "hidden_rootkit suspicious"  (suspicious-module? "hidden_rootkit") #t)
+(check "aa suspicious (too short)"  (suspicious-module? "aa") #t)
+(check "xbcdfg suspicious (no vowel)" (suspicious-module? "xbcdfg") #t)
+(check "ext4 legitimate"            (suspicious-module? "ext4") #f)
+(check "nvidia legitimate"          (suspicious-module? "nvidia") #f)
+(check "ip legitimate short"        (suspicious-module? "ip") #f)
+(check "dm legitimate short"        (suspicious-module? "dm") #f)
+
+;; ── signal 1: known-malicious substring, case-insensitive ────────────────────
+(displayln "substring signal:")
+(check "reptile by exact name"      (suspicious-module? "reptile") #t)
+(check "Diamorphine mixed case"     (suspicious-module? "Diamorphine") #t)
+(check "my_keylogger contains keylog" (suspicious-module? "my_keylogger") #t)
+(check "STEALTH upper contains stealth" (suspicious-module? "STEALTH") #t)
+(check "backdoor_v2"                (suspicious-module? "backdoor_v2") #t)
+
+;; ── signal 2: short-name allow-list ──────────────────────────────────────────
+(displayln "short-name signal:")
+(for-each (lambda (m) (check (str "  " m " allowed") (suspicious-module? m) #f))
+          '("ip" "dm" "sd" "sr" "nf" "if"))
+(check "x (1 char, not allowed)"    (suspicious-module? "x") #t)
+(check "zz (2 char, not allowed)"   (suspicious-module? "zz") #t)
+
+;; ── signal 3: long & vowelless (uses ORIGINAL case, both-case vowel set) ──────
+(displayln "vowel signal:")
+(check "thinkpad has vowels -> clean" (suspicious-module? "thinkpad") #f)
+(check "xkcd (len 4, not > 4) clean"  (suspicious-module? "xkcd") #f)
+(check "xkcdz (len 5, no vowel)"      (suspicious-module? "xkcdz") #t)
+;; the vowel set includes upper-case, so an upper-case vowel keeps it clean
+(check "BRTHX wait that's vowelless" (suspicious-module? "BRTHX") #t)
+(check "BRATH upper-A is a vowel -> clean" (suspicious-module? "BRATH") #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: kernmod matches secmon's kernel.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/kernmod.ss b/jsecmon/kernmod.ss
new file mode 100644
index 0000000..5ff175c
--- /dev/null
+++ b/jsecmon/kernmod.ss
@@ -0,0 +1,60 @@
+#!chezscheme
+;;; jsecmon kernel-module classifier (secmon monitor::kernel), untyped.
+;;;
+;;; Port of `KernelModuleMonitor::is_suspicious_module` from secmon's
+;;; src/monitor/kernel.rs: decide whether a loaded kernel module's name looks
+;;; like a rootkit / evasion module, by three signals in secmon's order:
+;;;   1. the lower-cased name contains any known-malicious substring
+;;;      (diamorphine, reptile, hide, rootkit, keylog, ...)
+;;;   2. the name is 1-2 chars and not on the legitimate-short allow-list
+;;;      (ip dm sd sr nf if) — rootkits often use 1-2 char names
+;;;   3. the name is longer than 4 chars and contains no vowel (random-looking)
+;;;
+;;; Pure string classification returning a boolean, exactly like the process and
+;;; connection classifiers in (jsecmon suspicious) / (jsecmon netconn), so
+;;; untyped. secmon hides both name lists with obfstr! (the same scheme ported to
+;;; typed/obfuscate.ss); at runtime they decode to these plaintext literals.
+;;;
+;;; Faithfulness notes:
+;;;   * Only the contains-test lower-cases the name; the short-name exception
+;;;     and the vowel test use the ORIGINAL name (secmon's `name` vs
+;;;     `name_lower`). Module names are ASCII so byte-len == char-len.
+;;;   * The vowel set is "aeiouAEIOU" — both cases, since the vowel test runs on
+;;;     the un-lowercased name.
+;;;
+;;; Verified against secmon's kernel.rs tests in examples/kernmod_check.ss.
+
+(library (jsecmon kernmod)
+  (export suspicious-module?)
+  (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 *suspicious-modules*
+    '("diamorphine" "reptile" "adore" "knark" "suckit"   ;; known Linux rootkits
+      "azazel" "jynx" "vlany" "bdvl"
+      "hide" "hidden" "rootkit" "backdoor" "keylog" "stealth"))  ;; generic tells
+  (def *legitimate-short* '("ip" "dm" "sd" "sr" "nf" "if"))
+  (def *vowels* '(#\a #\e #\i #\o #\u #\A #\E #\I #\O #\U))
+
+  (def (has-vowel? name)
+    (for/or ((c (in-string name))) (and (memv c *vowels*) #t)))
+
+  ;; name: string -> #t if the module name looks rootkit/evasion-like, else #f.
+  (def (suspicious-module? name)
+    (let ((name-lower (string-downcase name)))
+      (cond
+        ;; 1. known-malicious substring (on the lower-cased name)
+        ((any (lambda (s) (string-contains name-lower s)) *suspicious-modules*) #t)
+        ;; 2. very short name not on the allow-list (original name)
+        ((and (<= (string-length name) 2) (not (member name *legitimate-short*))) #t)
+        ;; 3. long name with no vowel — random-looking (original name)
+        ((and (> (string-length name) 4) (not (has-vowel? name))) #t)
+        (else #f)))))