Port secmon ContainerEscapeMonitor::is_suspicious_mount to (jsecmon container)

Jaime Fournier <jaimef@linbsd.org>

36bd223bbd62b36a8e0f42fd429c29b5cfeb4eb8

diff --git a/Makefile b/Makefile
index 7d3daaa..aaa55af 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 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 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)"
@@ -137,6 +137,12 @@ kernmod-check:
 selinux-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/selinux_check.ss
 
+# Container/jail escape mount classifier (secmon
+# ContainerEscapeMonitor::is_suspicious_mount): /host bind, root mount, Docker
+# socket, FreeBSD devd.pipe. Pure string classification, no native lib.
+container-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/container_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
@@ -154,6 +160,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/netconn_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/kernmod_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/selinux_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/container_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index c122f2c..b7502fa 100644
--- a/README.md
+++ b/README.md
@@ -37,6 +37,7 @@ make suspicious-check # SuspiciousPatterns: shell/tool-from-service, revshell + 
 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 selinux-check   # SELinux audit-log parser: AVC + boolean/policy/role events
+make container-check # container/jail escape mount classifier (host bind, docker sock)
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -100,5 +101,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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::selinux::SELinuxMonitor` (audit-log parser) | `jsecmon/selinux.ss` | ✅ **untyped layer** — the line-parsing core: `parse_audit_line` dispatches on the `type=` tag (AVC → `parse_avc_event`, MAC_CONFIG_CHANGE → boolean change, MAC_POLICY_LOAD → policy load, USER_ROLE_CHANGE → role change) into a `selinux-event` record mirroring `SELinuxEventInfo`, plus the `extract_field` helper. A text-format parser yielding a structured record, like the DNS parser, so untyped. secmon's AVC regex is reused verbatim through Jerboa's `(std pregexp)` `pregexp-match` (capture order 1=decision 2=permission 3=pid 4=comm 5=scontext 6=tcontext 7=tclass — verified identical). Pins `extract_field`'s quoting/empty/missing-quote corners and the `val=` default-empty. `make selinux-check` reproduces secmon's two selinux.rs tests + the dispatcher + all four event kinds. (The I/O — tailing the audit log, mode polling — is the deferred monitor loop.) |
+| `monitor::container::ContainerEscapeMonitor` (mount classifier) | `jsecmon/container.ss` | ✅ **untyped layer** — `is_suspicious_mount(mount)`: a mount that starts with `/host` or `/mnt/host`, is exactly `/`, or contains `/var/run/docker` / `/run/docker` / `devd.pipe` (FreeBSD jail), flagging a container/jail escape. Pure string classification like the other monitor classifiers, so untyped; obfstr!-hidden patterns decode to these plaintext literals. `make container-check` reproduces secmon's `test_suspicious_mount_detection` + each escape signal + negatives. (Isolation detection and mount/path/cap polling are provider-driven I/O — the deferred monitor loop.) |
 | `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/container_check.ss b/examples/container_check.ss
new file mode 100644
index 0000000..521e68b
--- /dev/null
+++ b/examples/container_check.ss
@@ -0,0 +1,46 @@
+;;; Parity check for (jsecmon container) against secmon's container.rs test
+;;; test_suspicious_mount_detection, plus each escape signal independently and a
+;;; few negatives. (test_isolation_detection only exercises a mock provider
+;;; field, not portable logic.)
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/container_check.ss
+
+(import (jerboa prelude)
+        (jsecmon container))
+
+(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)))))
+(def (fires? r) (and r #t))
+
+;; ── secmon test_suspicious_mount_detection ───────────────────────────────────
+(displayln "secmon test_suspicious_mount_detection:")
+(check "/host fires"            (fires? (suspicious-mount? "/host")) #t)
+(check "/mnt/host/etc fires"    (fires? (suspicious-mount? "/mnt/host/etc")) #t)
+(check "/var/run/docker.sock fires" (fires? (suspicious-mount? "/var/run/docker.sock")) #t)
+(check "/app clean"             (suspicious-mount? "/app") #f)
+
+;; ── each signal independently ────────────────────────────────────────────────
+(displayln "individual signals:")
+(check "/host prefix"        (fires? (suspicious-mount? "/host/proc")) #t)
+(check "/mnt/host prefix"    (fires? (suspicious-mount? "/mnt/host")) #t)
+(check "exact / root"        (fires? (suspicious-mount? "/")) #t)
+(check "/run/docker.sock"    (fires? (suspicious-mount? "/run/docker.sock")) #t)
+(check "docker mid-path"     (fires? (suspicious-mount? "/foo/var/run/docker/x")) #t)
+(check "FreeBSD devd.pipe"   (fires? (suspicious-mount? "/var/run/devd.pipe")) #t)
+
+;; ── negatives ────────────────────────────────────────────────────────────────
+(displayln "negatives:")
+(check "/data clean"         (suspicious-mount? "/data") #f)
+(check "/home/user clean"    (suspicious-mount? "/home/user") #f)
+(check "/hostel (not /host prefix-of-path) still prefix-matches"
+       (fires? (suspicious-mount? "/hostel")) #t) ;; starts_with "/host" is literal
+(check "/etc/hosts clean"    (suspicious-mount? "/etc/hosts") #f)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: container matches secmon's container.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/container.ss b/jsecmon/container.ss
new file mode 100644
index 0000000..af9f27c
--- /dev/null
+++ b/jsecmon/container.ss
@@ -0,0 +1,43 @@
+#!chezscheme
+;;; jsecmon container-escape mount classifier (secmon monitor::container), untyped.
+;;;
+;;; Port of `ContainerEscapeMonitor::is_suspicious_mount` from secmon's
+;;; src/monitor/container.rs: flag a mount path that would let a container or
+;;; jail reach the host. A mount is suspicious if it
+;;;   * starts with /host or /mnt/host         (host filesystem bind-mounted in)
+;;;   * is exactly "/"                          (whole root mounted)
+;;;   * contains /var/run/docker or /run/docker (the Docker socket — escape)
+;;;   * contains devd.pipe                      (FreeBSD jail devd socket)
+;;;
+;;; Pure string classification returning a boolean, like the other monitor
+;;; classifiers (kernmod / suspicious / netconn), so untyped. secmon hides the
+;;; patterns with obfstr!; they decode to these plaintext literals at runtime.
+;;;
+;;; The rest of ContainerEscapeMonitor (isolation detection, mount/path/cap
+;;; polling) is provider-driven I/O — the deferred monitor loop. The one pure,
+;;; test-backed kernel is this mount predicate.
+;;;
+;;; Verified against secmon's container.rs test_suspicious_mount_detection in
+;;; examples/container_check.ss.
+
+(library (jsecmon container)
+  (export suspicious-mount?)
+  (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?))
+
+  ;; mount: string -> #t if the mount path looks like a container/jail escape.
+  (def (suspicious-mount? mount)
+    (or (string-prefix? "/host" mount)
+        (string-prefix? "/mnt/host" mount)
+        (string=? mount "/")
+        (and (string-contains mount "/var/run/docker") #t)
+        (and (string-contains mount "/run/docker") #t)
+        (and (string-contains mount "devd.pipe") #t))))