platform-mounts: port is_dangerous_path + get_mounts parsers (untyped)
ober
e272a246ab32d17f12417d99b8251aee4748a8f3
--- 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 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 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)" @@ -216,6 +216,13 @@ file-change-check: webshell-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/webshell_check.ss +# Platform mount helpers (secmon platform/{linux,freebsd}.rs): is_dangerous_path +# (exact membership in the per-platform set) + the get_mounts line parsers +# (Linux split_whitespace field[1]; FreeBSD " on " split, drop " (opts)"). Pure — +# the /proc read / `mount` exec is the deferred I/O. +platform-mounts-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/platform_mounts_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 @@ -245,6 +252,7 @@ checks: kernels-check $(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 + $(SCHEME) --libdirs $(LIBDIRS) --script examples/platform_mounts_check.ss clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ make event-danger-check # mount is_dangerous + capability dangerous_caps predica 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 platform-mounts-check # is_dangerous_path (per-platform exact set) + get_mounts line parsers make checks # every Jerboa-side check in one shot ``` @@ -116,6 +117,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `monitor::dns::read_dns_servers` (resolver-set builder) | `jsecmon/dns-servers.ss` | ✅ **untyped layer** — `parse_dns_servers(content)`: collect each `nameserver <ip>` entry from resolv.conf text (the 2nd whitespace field of a trimmed line starting with `nameserver`) and union with the fixed public-resolver set (Google/Cloudflare/Quad9/OpenDNS). Pure parsing, so untyped; the `/etc/resolv.conf` read is the deferred I/O wrapper (split off like the selinux log tail). Folds tabs/CR to spaces to match Rust's `split_whitespace`. `make dns-servers-check` reproduces secmon's `test_read_dns_servers` (publics always present) + the nameserver parsing with multi-space/tab/indented lines and dedup. | | `dtrace::scripts::is_sensitive_path` (sensitive-path classifier) | `jsecmon/sensitive-path.ss` | ✅ **untyped layer** — `is_sensitive_path(path)`: a sensitive prefix (passwd/shadow/sudoers/ssh dirs/cron/periodic/ld.so.preload/`/boot/`/…), with `/home/` special-cased to only `/.ssh/` subpaths, else `authorized_keys` anywhere, else `/cron` or `/periodic`. Pure string classification, so untyped; obfstr!-hidden literals decode to these plaintexts. Ported with secmon's loop-with-early-return so the **load-bearing corner** holds: a `/home/` path short-circuits before the `authorized_keys` check, so `/home/user/authorized_keys` (no `/.ssh/`) is **not** sensitive. `make sensitive-path-check` reproduces secmon's `test_sensitive_path_detection` + each signal + that corner. | | `dtrace::consumer::EventParser` (DTrace line parser) | `jsecmon/dtrace-parse.ss` | ✅ **untyped layer** — `parse_dtrace_line(line)`: split a `SECMON\|TYPE\|…` DTrace line on `\|` and dispatch on `parts[1]` into a per-type structured record (EXEC/EXIT/CONNECT/LISTEN/OPEN/WRITE) with each handler's exact field extraction; a <2-field / unknown-type / too-few-fields line yields no record (`#f`), matching secmon's `return Ok(())` no-ops. A text parser yielding a structured record, like the DNS/SELinux parsers, so untyped (alist, since the per-type fields are disjoint). Numeric fields use `.parse().unwrap_or(0)` (u32 rejects negatives → 0; exit code is i32), and the EXEC cmdline is `split_whitespace`. **Composes** `(jsecmon sensitive-path)` for the OPEN `sensitive?` gate. `make dtrace-parse-check` reproduces secmon's `test_parse_exec_line` / `test_parse_exit_line` + the other four formats + the no-event and `unwrap_or(0)` corners. (The stateful parts — process cache, suspicious-exec dispatch, channel send — are the deferred consumer loop.) | +| `platform::{linux,freebsd}` (`is_dangerous_path` + `get_mounts` parsers) | `jsecmon/platform-mounts.ss` | ✅ **untyped layer** — the pure halves of each `IsolationProvider`, reads stripped: `is_dangerous_path` is **exact** membership in the platform's dangerous-path set (Linux 12 entries incl. `/proc/kcore`, `/dev/mem`, the docker/crio/containerd sockets; FreeBSD 6 incl. `/dev/io`, `devd.pipe`; unknown platforms empty, per the `UnsupportedProvider` default), and `parse_mounts` reproduces each `get_mounts` line loop — Linux `split_whitespace` keeping field[1], FreeBSD `split(" on ")` keeping piece[1] minus a trailing ` (opts)`. Pins that membership is exact not prefix (`/host/foo` is clean), and that the FreeBSD split takes piece[1] of a multi-`" on "` split (not everything-after-first). obfstr!-hidden lists decode to these plaintexts. Pure — the `/proc/self/mounts` read / `mount` exec is the deferred I/O — no native lib; secmon has no `#[test]` here so `make platform-mounts-check` asserts against the Rust source. (The `container.rs` test-only mock `is_dangerous_path` is `#[cfg(test)]` scaffolding, not ported.) | | `platform::linux` (/proc parsers) | `jsecmon/proc-linux.ss` | ✅ **untyped layer** — the pure parsing helpers with the file reads stripped: `parse_stat` (comm between first `(` and **last** `)`, ppid the 2nd field after `") "`), `parse_uid` (first `Uid:` line, 2nd field), `hex_to_state` (TCP state table → `UNKNOWN`), `parse_ipv4` (little-endian hex → dotted quad), `parse_ipv6` (32-hex → 8 groups), `parse_addr` (`HEXADDR:HEXPORT`, ipv6 when protocol contains `6`). Pure text/number parsing, so untyped. `parse_stat`/`parse_uid` use `.parse::<u32>().ok()` so failure is `#f` (not 0) and negatives are rejected; `parse_ipv4` rejects >`0xFFFFFFFF`; ports are u16. `make proc-linux-check` reproduces secmon's five linux.rs tests + ipv6/parse-addr + a comm-with-paren corner. (The `/proc` reads and inode→pid scan are the deferred I/O.) | | `platform::freebsd` (parsers) | `jsecmon/freebsd-parse.ss` | ✅ **untyped layer** — the pure parsing helpers with the command/file reads stripped: `parse_kldstat_line` (≥5 whitespace fields, name is `parts[4]`, size is `parts[3]` as hex with optional `0x`, size `None` on non-hex via `.ok()`, action always `Loaded`) and `parse_address` (`addr:port` split at the **last** `:`, `[ipv6]:port` split at the first `]`, `*` address → `0.0.0.0`, `*` port → `0`). Ports here are **DECIMAL** u16 (`.parse()`), unlike Linux's hex `/proc/net`. Pure text/number parsing, so untyped. `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives. (The `kldstat`/`sockstat` command runs are the deferred I/O.) | | `event_json` + `local_store` (tables) | `jsecmon/event-meta.ss` | ✅ **untyped layer** — the pure classification tables lifted out of the payload-carrying `EventType` enum: `event_json.rs` `get_event_json_data`'s **display severity** (25 constant arms as a name→severity table, + the 7 payload-dependent arms as named helpers taking the deciding field — `auth`/`privilege_change`/`mount`/`capability`/`podman`/`selinux`/`lateral_movement`), and `local_store.rs` `event_severity_u8`'s **coarse store priority** 0..3, which is an *independent* scale (e.g. `privilege_escalation` is `critical` for display but `0` for the store). secmon has no `#[test]` here, so `make event-meta-check` asserts both full tables arm-for-arm against the Rust source. (The JSON payload bodies stay with the I/O layer that owns the event structs.) | new file mode 100644 --- /dev/null +++ b/examples/platform_mounts_check.ss @@ -0,0 +1,74 @@ +;;; Parity check for (jsecmon platform-mounts) against secmon +;;; platform/{linux,freebsd}.rs (is_dangerous_path + the get_mounts line loops). +;;; 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/platform_mounts_check.ss + +(import (jerboa prelude) + (jsecmon platform-mounts)) + +(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-dangerous-path (EXACT membership, platform-keyed) ───────────────────── +(displayln "is-dangerous-path linux:") +(check "linux /dev/mem" (is-dangerous-path "/dev/mem" 'linux) #t) +(check "linux /host" (is-dangerous-path "/host" 'linux) #t) +(check "linux core_pattern" (is-dangerous-path "/proc/sys/kernel/core_pattern" 'linux) #t) +(check "linux containerd sock" (is-dangerous-path "/run/containerd/containerd.sock" 'linux) #t) +(check "linux /dev/io (bsd-only) -> #f" (is-dangerous-path "/dev/io" 'linux) #f) +(check "linux /etc/passwd -> #f" (is-dangerous-path "/etc/passwd" 'linux) #f) +;; exact, not prefix: a child of /host is NOT itself dangerous +(check "linux /host/foo exact -> #f" (is-dangerous-path "/host/foo" 'linux) #f) + +(displayln "is-dangerous-path freebsd:") +(check "freebsd /dev/io" (is-dangerous-path "/dev/io" 'freebsd) #t) +(check "freebsd devd.pipe" (is-dangerous-path "/var/run/devd.pipe" 'freebsd) #t) +(check "freebsd /host" (is-dangerous-path "/host" 'freebsd) #t) +(check "freebsd /dev/mem (both)" (is-dangerous-path "/dev/mem" 'freebsd) #t) +(check "freebsd /proc/kcore (linux-only) -> #f" (is-dangerous-path "/proc/kcore" 'freebsd) #f) + +(displayln "is-dangerous-path other:") +(check "other platform empty set -> #f" (is-dangerous-path "/dev/mem" 'macos) #f) + +;; ── parse-mounts linux (/proc/self/mounts: field[1], split_whitespace) ─────── +(displayln "parse-mounts linux:") +(check "two mounts" + (parse-mounts "proc /proc proc rw,nosuid 0 0\nsysfs /sys sysfs rw 0 0" 'linux) + '("/proc" "/sys")) +(check "blank line skipped" + (parse-mounts "proc /proc proc\n\nsysfs /sys sysfs" 'linux) + '("/proc" "/sys")) +(check "too few fields skipped" + (parse-mounts "single\n" 'linux) '()) +(check "multiple spaces collapse" + (parse-mounts "dev /mnt ext4 rw" 'linux) '("/mnt")) +(check "tabs are whitespace" + (parse-mounts "dev\t/mnt\text4" 'linux) '("/mnt")) + +;; ── parse-mounts freebsd (`mount`: split " on ", piece[1], drop " (opts)") ─── +(displayln "parse-mounts freebsd:") +(check "two mounts with opts" + (parse-mounts "/dev/ada0p2 on / (ufs, local)\ntmpfs on /tmp (tmpfs, local)" 'freebsd) + '("/" "/tmp")) +(check "no opts -> whole tail" + (parse-mounts "devfs on /dev" 'freebsd) '("/dev")) +(check "no ' on ' skipped" + (parse-mounts "garbage line" 'freebsd) '()) +;; multi-" on ": take piece[1] (between 1st and 2nd), not everything-after-first +(check "multi on -> piece[1]" + (parse-mounts "a on /b on c (x)" 'freebsd) '("/b")) + +;; ── unknown platform: no mounts (UnsupportedProvider returns vec![]) ───────── +(check "other platform -> no mounts" + (parse-mounts "proc /proc proc\nx on /y" 'macos) '()) + +(newline) +(if (= fails 0) + (displayln "OK: platform-mounts matches secmon's linux/freebsd providers.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/platform-mounts.ss @@ -0,0 +1,112 @@ +#!chezscheme +;;; jsecmon platform mount helpers (secmon platform/{linux,freebsd}.rs), untyped. +;;; +;;; The pure parts of the per-platform IsolationProvider, with the file/command +;;; reads (the deferred I/O) stripped off: +;;; is-dangerous-path : path platform -> #t | #f +;;; parse-mounts : text platform -> list of mount points +;;; Two halves of one flow: read /proc/self/mounts (Linux) or `mount` output +;;; (FreeBSD) -> parse-mounts -> classify each with is-dangerous-path (and feed +;;; the same list to (jsecmon container) is-suspicious-mount). +;;; +;;; is-dangerous-path is EXACT membership in the platform's dangerous-path set +;;; (Rust `LIST.iter().any(|s| s == path)`); unknown platforms have an empty +;;; set (the UnsupportedProvider default returns false). obfstr!-hidden +;;; literals decode to these plaintexts. +;;; +;;; parse-mounts mirrors each get_mounts line loop exactly: +;;; * Linux: split_whitespace, keep field[1] (the mount point) when >=2 fields +;;; — blank lines yield 0 fields and are skipped. +;;; * FreeBSD: split on " on " (ALL occurrences, like Rust `split`), take +;;; piece[1] when >=2 pieces, then drop a trailing " (opts)" — field[1] is +;;; the piece between the 1st and 2nd " on ", not "everything after the +;;; first", so the split is faithful to Rust's multi-delimiter split. +;;; Rust `.lines()` is reproduced by splitting on \n and stripping a trailing +;;; \r (handles \r\n); the trailing empty after a final \n is harmless (it +;;; parses to nothing on both platforms). +;;; +;;; secmon has no #[test] for these, so examples/platform_mounts_check.ss +;;; derives every expectation from the Rust source and IS the spec. + +(library (jsecmon platform-mounts) + (export is-dangerous-path parse-mounts + *linux-dangerous-paths* *freebsd-dangerous-paths*) + (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?)) + + ;; LINUX_DANGEROUS_PATHS (decoded obfstr! literals), exact-matched. + (def *linux-dangerous-paths* + '("/proc/sys/kernel/core_pattern" "/proc/sysrq-trigger" "/proc/kcore" + "/proc/kmem" "/dev/mem" "/dev/kmem" "/host" "/.dockerenv" + "/var/run/docker.sock" "/run/docker.sock" + "/var/run/crio/crio.sock" "/run/containerd/containerd.sock")) + + ;; FREEBSD_DANGEROUS_PATHS (decoded), exact-matched. + (def *freebsd-dangerous-paths* + '("/dev/mem" "/dev/kmem" "/dev/io" "/host" + "/var/run/devd.pipe" "/var/run/devd.seqpacket.pipe")) + + ;; the per-platform dangerous-path set (empty for unknown platforms — the + ;; UnsupportedProvider default). if-chain rather than cond/else: the MCP + ;; expander's pre-scan mis-flags this two-way switch as a misplaced `else` + ;; (real Chez is fine), and an if-chain sidesteps it entirely. + (def (dangerous-paths-for platform) + (if (eq? platform 'linux) *linux-dangerous-paths* + (if (eq? platform 'freebsd) *freebsd-dangerous-paths* '()))) + + (def (is-dangerous-path path platform) + (if (member path (dangerous-paths-for platform)) #t #f)) + + (def (strip-cr line) + (if (string-suffix? "\r" line) + (substring line 0 (- (string-length line) 1)) + line)) + + (def (lines-of text) (map strip-cr (string-split text #\newline))) + + ;; split on EVERY occurrence of `sub` (Rust str::split semantics). + (def (split-on-substring s sub) + (let ((sublen (string-length sub))) + (let loop ((s s) (acc '())) + (let ((idx (string-contains s sub))) + (if idx + (loop (substring s (+ idx sublen) (string-length s)) + (cons (substring s 0 idx) acc)) + (reverse (cons s acc))))))) + + ;; split_whitespace: fold tab/CR to space, split, drop empties. + (def (split-ws line) + (let ((folded (string-map (lambda (c) + (if (or (char=? c #\tab) (char=? c #\return)) + #\space c)) + line))) + (filter (lambda (s) (not (string-empty? s))) (string-split folded #\space)))) + + ;; LinuxContainerProvider::get_mounts line loop: field[1] when >=2 fields. + (def (parse-mount-line-linux line) + (let ((parts (split-ws line))) + (and (>= (length parts) 2) (list-ref parts 1)))) + + ;; FreeBSDJailProvider::get_mounts line loop: piece[1] of " on " split, minus + ;; a trailing " (opts)". + (def (parse-mount-line-freebsd line) + (let ((parts (split-on-substring line " on "))) + (and (>= (length parts) 2) + (let* ((mount-part (list-ref parts 1)) + (pos (string-contains mount-part " ("))) + (if pos (substring mount-part 0 pos) mount-part))))) + + ;; pick the per-line parser (if-chain, same reason as above), then filter-map + ;; once. Unknown platforms parse to no mounts (UnsupportedProvider vec![]). + (def (parse-mounts text platform) + (let ((parser (if (eq? platform 'linux) parse-mount-line-linux + (if (eq? platform 'freebsd) parse-mount-line-freebsd #f)))) + (if parser (filter-map parser (lines-of text)) '()))))