event-danger: port NamespaceEventInfo::ns_types (CLONE_NEW* decoder)

Jaime Fournier <jaimef@linbsd.org>

170fdca8951fdd9d3cdc208ced421c094cc9ee48

diff --git a/README.md b/README.md
index ba66cb9..072b5ae 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ make proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net
 make freebsd-parse-check # FreeBSD kldstat/ps/address + sockstat & netstat connection lines
 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 event-danger-check # mount is_dangerous + capability dangerous_caps + namespace ns_types
 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
@@ -141,7 +141,7 @@ 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`. Plus `parse_ps_line` (the `ps -axo pid,ppid,uid,comm,args` fallback parser: ≥5 ws fields, `pid`/`ppid`/`uid` as u32 via `.parse().ok()?` so a non-u32 field rejects the whole line, `comm` is field[3], `args` is field[4..] re-joined with single spaces). Pure text/number parsing, so untyped. Also `parse_sockstat_line` (cols `USER COMMAND PID FD PROTO LOCAL FOREIGN`, ≥7, `pid` as u32-or-reject, protocol lower-cased, a `FOREIGN` of exactly `*:*` short-circuits to `("0.0.0.0" . 0)` **without** `parse_address`, `state` = LISTEN when remote is `0.0.0.0`/`::`/port 0 else ESTABLISHED) and `parse_netstat_line` (cols `Proto Recv-Q Send-Q LOCAL FOREIGN [state]`, ≥5, here `*:*` **does** go through `parse_address`, `state` = `parts[5]` or `UNKNOWN`, no pid/name) — both producing a connection alist mirroring `ConnectionInfo`. Plus `parse_freebsd_status` (the procfs `/proc/[pid]/status` columns: ≥13 ws fields → `(name ppid uid)`, name is `parts[0]`, ppid is `parts[2]`, uid is `parts[12]` = ruid, both u32-or-reject, trailing group columns ignored). `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives + the ps-line cases + the sockstat/netstat rows + the status columns traced from source. (The `kldstat`/`sockstat`/`netstat`/`ps`/`status` command/file reads 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/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. Plus `NamespaceEventInfo::ns_types` (`ns-types ns-flags` → the namespace names whose `CLONE_NEW*` mask is set, in Rust push order mnt/uts/ipc/user/pid/net/cgroup/time — masks 0x20000/0x04000000…0x40000000/0x80/0x100, not bit indices). 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. |
diff --git a/examples/event_danger_check.ss b/examples/event_danger_check.ss
index 1fa90eb..1742daf 100644
--- a/examples/event_danger_check.ss
+++ b/examples/event_danger_check.ss
@@ -68,6 +68,26 @@
          "CAP_NET_RAW" "CAP_SYS_RAWIO" "CAP_SYS_BOOT" "CAP_SYS_NICE"
          "CAP_SYS_TIME" "CAP_PERFMON" "CAP_BPF"))
 
+;; ── ns-types: CLONE_NEW* flag masks -> namespace names (push order) ──────────
+(displayln "ns-types:")
+(check "no flags -> empty" (ns-types 0) '())
+(check "mnt only (0x20000)"  (ns-types #x00020000) '("mnt"))
+(check "net only (0x40000000)" (ns-types #x40000000) '("net"))
+(check "cgroup only (0x80)"  (ns-types #x00000080) '("cgroup"))
+(check "time only (0x100)"   (ns-types #x00000100) '("time"))
+(check "user|pid in push order"
+       (ns-types (bitwise-ior #x10000000 #x20000000)) '("user" "pid"))
+;; CLONE_NEWNS|CLONE_NEWNET set together still emits mnt before net
+(check "mnt|net push order"
+       (ns-types (bitwise-ior #x00020000 #x40000000)) '("mnt" "net"))
+(check "all eight namespaces"
+       (ns-types (fold-left bitwise-ior 0
+                   '(#x00020000 #x04000000 #x08000000 #x10000000
+                     #x20000000 #x40000000 #x00000080 #x00000100)))
+       '("mnt" "uts" "ipc" "user" "pid" "net" "cgroup" "time"))
+;; a bit outside the eight masks (0x200) contributes nothing
+(check "unrelated bit 0x200 -> empty" (ns-types #x00000200) '())
+
 (newline)
 (if (= fails 0)
     (displayln "OK: event-danger matches secmon's monitor/events.rs predicates.")
diff --git a/jsecmon/event-danger.ss b/jsecmon/event-danger.ss
index 92d46c2..583b2c0 100644
--- a/jsecmon/event-danger.ss
+++ b/jsecmon/event-danger.ss
@@ -7,6 +7,7 @@
 ;;; (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)
+;;;   ns-types            : ns-flags-u32 -> (ns-name ...)            (ns_types)
 ;;;
 ;;; Faithfulness notes:
 ;;;   * is_dangerous tests each DANGEROUS_SOURCES path with
@@ -20,12 +21,16 @@
 ;;;   * 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.
+;;;   * ns_types tests `ns_flags & CLONE_NEW* != 0` against the eight namespace
+;;;     masks (mnt 0x20000, uts/ipc/user/pid/net the high 0x04000000..0x40000000
+;;;     bits, cgroup 0x80, time 0x100) and returns the names in Rust push order
+;;;     (mnt, uts, ipc, user, pid, net, cgroup, time). Masks, not bit indices.
 ;;;
 ;;; 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)
+  (export mount-danger-reason dangerous-caps ns-types)
   (import (except (chezscheme)
                   make-hash-table hash-table?
                   sort sort!
@@ -73,4 +78,17 @@
     (filter-map
      (lambda (pair)
        (and (bitwise-bit-set? cap-effective (car pair)) (cdr pair)))
-     *dangerous-cap-bits*)))
+     *dangerous-cap-bits*))
+
+  ;; (CLONE_NEW* mask . name) in the Rust push order — list preserves it.
+  (def *ns-flag-types*
+    '((#x00020000 . "mnt") (#x04000000 . "uts") (#x08000000 . "ipc")
+      (#x10000000 . "user") (#x20000000 . "pid") (#x40000000 . "net")
+      (#x00000080 . "cgroup") (#x00000100 . "time")))
+
+  ;; NamespaceEventInfo::ns_types: names whose CLONE_NEW* mask is set in ns_flags.
+  (def (ns-types ns-flags)
+    (filter-map
+     (lambda (pair)
+       (and (not (zero? (bitwise-and ns-flags (car pair)))) (cdr pair)))
+     *ns-flag-types*)))