freebsd-parse: add parse-ps-line (ps -axo fallback parser)
ober
ed0137b5af7f6bcbdea20f6f0c67cb4a867b985e
--- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage). | `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.) | +| `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. `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives + the ps-line cases. (The `kldstat`/`sockstat`/`ps` 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. | --- a/examples/freebsd_parse_check.ss +++ b/examples/freebsd_parse_check.ss @@ -1,6 +1,7 @@ ;;; Parity check for (jsecmon freebsd-parse) against secmon's freebsd.rs tests ;;; (test_parse_kldstat_line, test_parse_address_ipv4, test_parse_address_wildcard), -;;; plus the ipv6 bracket form and the too-few-fields / no-colon negatives. +;;; plus the ipv6 bracket form and the too-few-fields / no-colon negatives, and +;;; parse-ps-line (no secmon #[test]; derived from the ps-fallback source). ;;; ;;; scheme --libdirs "$JERBOA/lib:." --script examples/freebsd_parse_check.ss @@ -51,6 +52,32 @@ (check "bad port -> #f" (parse-address "10.0.0.1:notaport") #f) (check "port >65535 #f" (parse-address "10.0.0.1:70000") #f) +;; ── parse-ps-line (ps -axo pid,ppid,uid,comm,args) ─────────────────────────── +;; secmon has no #[test] here; derived from the Rust source. +(displayln "parse-ps-line:") +(check "basic row" + (parse-ps-line "1234 1 0 sshd /usr/sbin/sshd -D") + '(1234 1 0 "sshd" "/usr/sbin/sshd -D")) +;; args is field[4..] joined with single spaces (run-spacing collapsed) +(check "multi-arg args joined" + (parse-ps-line "42 7 1001 bash -c echo hi") + '(42 7 1001 "bash" "-c echo hi")) +;; exactly 5 fields -> args is the single 5th field +(check "exactly 5 fields" + (parse-ps-line "5 4 3 comm justargs") + '(5 4 3 "comm" "justargs")) +;; fewer than 5 whitespace fields -> #f +(check "too few fields -> #f" (parse-ps-line "1 2 3 comm") #f) +;; a non-numeric pid/ppid/uid rejects the whole line (.parse::<u32>().ok()?) +(check "non-numeric pid -> #f" (parse-ps-line "x 1 0 comm args") #f) +(check "non-numeric uid -> #f" (parse-ps-line "1 2 z comm args") #f) +(check "negative uid -> #f" (parse-ps-line "1 2 -1 comm args") #f) +(check "pid 2^32 -> #f" (parse-ps-line "4294967296 1 0 comm args") #f) +;; pid 0 is valid (0 is truthy here) +(check "pid 0 ok" + (parse-ps-line "0 0 0 kernel sched") + '(0 0 0 "kernel" "sched")) + (newline) (if (= fails 0) (displayln "OK: freebsd-parse matches secmon's freebsd.rs behaviour.") --- a/jsecmon/freebsd-parse.ss +++ b/jsecmon/freebsd-parse.ss @@ -5,6 +5,8 @@ ;;; the command/file I/O stripped off: ;;; parse-kldstat-line : a `kldstat` row -> module record | #f ;;; parse-address : "addr:port"/"[v6]:port" -> (addr . port) | #f +;;; parse-ps-line : a `ps -axo pid,ppid,uid,comm,args` row +;;; -> (pid ppid uid comm args) | #f ;;; ;;; Pure text/number parsing, like the Linux /proc parsers, so untyped. ;;; @@ -24,7 +26,7 @@ ;;; examples/freebsd_parse_check.ss. (library (jsecmon freebsd-parse) - (export parse-kldstat-line parse-address) + (export parse-kldstat-line parse-address parse-ps-line) (import (except (chezscheme) make-hash-table hash-table? sort sort! @@ -60,6 +62,27 @@ (let ((n (string->number s))) (if (and n (integer? n) (>= n 0) (<= n 65535)) n #f))) + ;; s.parse::<u32>().ok(): a decimal integer in [0, 2^32), or #f. + (def (parse-u32-opt s) + (let ((n (string->number s))) + (if (and n (integer? n) (>= n 0) (< n (expt 2 32))) n #f))) + + ;; a `ps -axo pid,ppid,uid,comm,args` row -> (pid ppid uid comm args) | #f. + ;; Needs >=5 whitespace fields; pid/ppid/uid are u32 (Rust `.parse().ok()?`, + ;; so any non-u32 field rejects the whole line); comm is field[3]; args is + ;; field[4..] re-joined with single spaces (the original run-spacing is lost, + ;; matching split_whitespace + join(" ")). + (def (parse-ps-line line) + (let ((parts (ws-tokens line))) + (and (>= (length parts) 5) + (let ((pid (parse-u32-opt (list-ref parts 0))) + (ppid (parse-u32-opt (list-ref parts 1))) + (uid (parse-u32-opt (list-ref parts 2)))) + (and pid ppid uid + (list pid ppid uid + (list-ref parts 3) + (string-join (list-tail parts 4) " "))))))) + ;; a kldstat row -> alist ((name . s) (action . loaded) (size . n|#f) ;; (loaded-by-pid . #f)) | #f. (def (parse-kldstat-line line)