Port monitor/events mount+capability danger predicates to untyped Jerboa
ober
222852cca471b5725f073efc1acb2e75db573847
--- 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 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 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)" @@ -188,6 +188,13 @@ event-meta-check: config-check: $(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss +# Mount/capability danger predicates (secmon monitor/events.rs): MountEventInfo +# is_dangerous (sensitive bind-mount sources / mount-over targets) and +# CapabilityEventInfo dangerous_caps (cap_effective bit set -> cap names). These +# feed event-meta's mount/capability severity. Pure, no native lib. +event-danger-check: + $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_danger_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 @@ -213,6 +220,7 @@ checks: kernels-check $(SCHEME) --libdirs $(LIBDIRS) --script examples/freebsd_parse_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_meta_check.ss $(SCHEME) --libdirs $(LIBDIRS) --script examples/config_check.ss + $(SCHEME) --libdirs $(LIBDIRS) --script examples/event_danger_check.ss clean: rm -rf $(BUILD) --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ make proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net make freebsd-parse-check # FreeBSD kldstat row + sockstat addr:port (decimal, wildcard, v6) make event-meta-check # event-type -> display severity + coarse store-priority u8 tables make config-check # AgentConfig defaults + from_env merge + platform db/key paths +make event-danger-check # mount is_dangerous + capability dangerous_caps predicates make checks # every Jerboa-side check in one shot ``` @@ -116,5 +117,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `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.) | | `config` | `jsecmon/config.ss` | ✅ **untyped layer** — `AgentConfig`'s pure parts: the defaults (`0.0.0.0:31337`, poll `100`ms, buffer `10000`), the `from_env` merge (overwrites `listen_addr` on any present value but only overwrites poll/buffer when the value parses as strict u64 — a bad value **keeps the default**, it is not zeroed), and `local_db_path`/`local_key_path` (env override, else `/opt/secmon/{events.db,local.key}` on linux+freebsd, else the `./secmon_*` cwd fallback). Parameterized over a `getenv` callback + a `platform` symbol so the env reads stay deferred I/O; the build.rs-embedded secrets (`get_public_key`/`get_psk`/`is_debug_mode`) belong to the build/FFI phase, not this layer. secmon has no tests here, so `make config-check` asserts the behaviour against the Rust source. | +| `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::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/event_danger_check.ss @@ -0,0 +1,74 @@ +;;; Parity check for (jsecmon event-danger) against secmon monitor/events.rs +;;; (MountEventInfo::is_dangerous, CapabilityEventInfo::dangerous_caps). secmon +;;; has no #[test] for these, so this derives expectations from the Rust source +;;; and IS the spec for the port. +;;; +;;; scheme --libdirs "$JERBOA/lib:." --script examples/event_danger_check.ss + +(import (jerboa prelude) + (jsecmon event-danger)) + +(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))))) + +;; ── MountEventInfo::is_dangerous: dangerous sources ────────────────────────── +(displayln "mount danger (sources):") +(check "root exact" (mount-danger-reason "/" "/tmp/t") + "Bind mount of sensitive path: /") +(check "/etc exact" (mount-danger-reason "/etc" "/tmp/t") + "Bind mount of sensitive path: /etc") +(check "/etc subpath" (mount-danger-reason "/etc/foo" "/tmp/t") + "Bind mount of sensitive path: /etc/foo") +(check "/home subpath" (mount-danger-reason "/home/user" "/tmp/t") + "Bind mount of sensitive path: /home/user") +(check "/proc subpath" (mount-danger-reason "/proc/1/mem" "/tmp/t") + "Bind mount of sensitive path: /proc/1/mem") +(check "docker.sock" (mount-danger-reason "/var/run/docker.sock" "/tmp/t") + "Bind mount of sensitive path: /var/run/docker.sock") +;; KEY faithfulness: the "/" entry's prefix is "//", so "/foo" is NOT flagged +(check "/foo NOT dangerous (slash prefix is //)" (mount-danger-reason "/foo" "/tmp/t") #f) +(check "safe source+target -> #f" (mount-danger-reason "/tmp/x" "/tmp/y") #f) + +;; ── dangerous targets (EXACT match only) ───────────────────────────────────── +(displayln "mount danger (targets):") +(check "target passwd" (mount-danger-reason "/tmp/x" "/etc/passwd") + "Mount over sensitive path: /etc/passwd") +(check "target /bin" (mount-danger-reason "/tmp/x" "/bin") + "Mount over sensitive path: /bin") +(check "target subpath NOT matched (exact only)" + (mount-danger-reason "/tmp/x" "/usr/bin/python") #f) +;; a dangerous source wins over (is checked before) a dangerous target +(check "source checked before target" + (mount-danger-reason "/etc/shadow" "/etc/passwd") + "Bind mount of sensitive path: /etc/shadow") + +;; ── CapabilityEventInfo::dangerous_caps ────────────────────────────────────── +(displayln "dangerous caps:") +(check "none" (dangerous-caps 0) '()) +(check "non-dangerous bit 20 -> none" (dangerous-caps (ash 1 20)) '()) +(check "SYS_ADMIN" (dangerous-caps (ash 1 21)) '("CAP_SYS_ADMIN")) +(check "BPF (bit 39)" (dangerous-caps (ash 1 39)) '("CAP_BPF")) +(check "PERFMON (bit 38)" (dangerous-caps (ash 1 38)) '("CAP_PERFMON")) +;; output follows the table push order, NOT the bit order of the input +(check "21|19 -> ADMIN then PTRACE" + (dangerous-caps (bitwise-ior (ash 1 19) (ash 1 21))) + '("CAP_SYS_ADMIN" "CAP_SYS_PTRACE")) +(check "NET_ADMIN|NET_RAW" + (dangerous-caps (bitwise-ior (ash 1 12) (ash 1 13))) + '("CAP_NET_ADMIN" "CAP_NET_RAW")) +;; all 11 dangerous bits set -> full list in push order +(check "all dangerous bits" + (dangerous-caps (fold-left (lambda (acc b) (bitwise-ior acc (ash 1 b))) 0 + '(21 19 16 12 13 17 22 23 25 38 39))) + '("CAP_SYS_ADMIN" "CAP_SYS_PTRACE" "CAP_SYS_MODULE" "CAP_NET_ADMIN" + "CAP_NET_RAW" "CAP_SYS_RAWIO" "CAP_SYS_BOOT" "CAP_SYS_NICE" + "CAP_SYS_TIME" "CAP_PERFMON" "CAP_BPF")) + +(newline) +(if (= fails 0) + (displayln "OK: event-danger matches secmon's monitor/events.rs predicates.") + (begin (displayln fails " FAILURES") (exit 1))) new file mode 100644 --- /dev/null +++ b/jsecmon/event-danger.ss @@ -0,0 +1,76 @@ +#!chezscheme +;;; jsecmon mount/capability danger predicates (secmon monitor/events.rs), untyped. +;;; +;;; The pure payload predicates that decide a MountEvent's / CapabilityEvent's +;;; severity, lifted off their structs so they can be checked and so they can +;;; drive (jsecmon event-meta)'s mount-event-severity / capability-event-severity +;;; (which take exactly the boolean these compute): +;;; mount-danger-reason : (source target) -> reason string | #f (is_dangerous) +;;; dangerous-caps : cap-effective-u64 -> (cap-name ...) (dangerous_caps) +;;; +;;; Faithfulness notes: +;;; * is_dangerous tests each DANGEROUS_SOURCES path with +;;; `source == path || source.starts_with("{path}/")`. For the "/" entry the +;;; prefix is "//", so a plain absolute path like "/foo" is NOT flagged by +;;; "/" — only source exactly "/" is. The other prefixes ("/etc" -> "/etc/") +;;; match subpaths as expected. The reason string interpolates the *input* +;;; source/target, not the matched prefix, so source-list order can't change +;;; the output. Sources are checked before targets. +;;; * DANGEROUS_TARGETS match is EXACT (== only, no prefix). +;;; * dangerous_caps tests bits of cap_effective and returns the names in the +;;; Rust push order (SYS_ADMIN first ... BPF last); bits 38/39 exceed 32 so +;;; cap_effective is a full u64. +;;; +;;; secmon has no #[test] for these, so examples/event_danger_check.ss derives +;;; every expectation from the Rust source and IS the spec for this port. + +(library (jsecmon event-danger) + (export mount-danger-reason dangerous-caps) + (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 *dangerous-sources* + '("/" "/etc" "/etc/shadow" "/etc/passwd" "/etc/sudoers" + "/root" "/home" "/var/run/docker.sock" "/run/podman" + "/var/run/containerd" "/proc" "/sys" "/dev")) + + (def *dangerous-targets* + '("/etc/passwd" "/etc/shadow" "/etc/sudoers" + "/bin" "/sbin" "/usr/bin" "/usr/sbin")) + + ;; MountEventInfo::is_dangerous: a reason string, or #f when safe. + (def (mount-danger-reason source target) + (let loop-src ((paths *dangerous-sources*)) + (cond + ((null? paths) + (let loop-tgt ((tps *dangerous-targets*)) + (cond ((null? tps) #f) + ((string=? target (car tps)) + (str "Mount over sensitive path: " target)) + (else (loop-tgt (cdr tps)))))) + ((or (string=? source (car paths)) + (string-prefix? (str (car paths) "/") source)) + (str "Bind mount of sensitive path: " source)) + (else (loop-src (cdr paths)))))) + + ;; (cap-bit . name) in the Rust push order — the returned list preserves it. + (def *dangerous-cap-bits* + '((21 . "CAP_SYS_ADMIN") (19 . "CAP_SYS_PTRACE") (16 . "CAP_SYS_MODULE") + (12 . "CAP_NET_ADMIN") (13 . "CAP_NET_RAW") (17 . "CAP_SYS_RAWIO") + (22 . "CAP_SYS_BOOT") (23 . "CAP_SYS_NICE") (25 . "CAP_SYS_TIME") + (38 . "CAP_PERFMON") (39 . "CAP_BPF"))) + + ;; CapabilityEventInfo::dangerous_caps: names whose bit is set in cap_effective. + (def (dangerous-caps cap-effective) + (filter-map + (lambda (pair) + (and (bitwise-bit-set? cap-effective (car pair)) (cdr pair))) + *dangerous-cap-bits*)))