Port parse_security_environ into (jsecmon proc-linux)

ober

c903f1203f7a5215c5f71750e40e3b512014bd5b

diff --git a/README.md b/README.md
index 9543f22..c5d1b57 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ make container-check # container/jail escape mount classifier (host bind, docker
 make dns-servers-check # resolv.conf nameserver parse + public-resolver union
 make sensitive-path-check # DTrace sensitive-path classifier (passwd/ssh/cron/...)
 make dtrace-parse-check # DTrace SECMON|TYPE|... line parser (exec/exit/connect/...)
-make proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net hex IP
+make proc-linux-check # Linux /proc parsers: stat ppid+comm, uid, TCP state, net hex IP, environ scan
 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
@@ -128,7 +128,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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::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. Also `parse_security_environ` (from `ebpf/loader.rs`, but functionally a `/proc/[pid]/environ` parser): splits the NUL-separated blob, drops empty and non-UTF-8 entries (faithful to `std::str::from_utf8(..).ok()`, reproduced exception-free by a lossy-decode→re-encode→compare round trip), and keeps any var whose name `starts_with` one of 19 security-relevant prefixes **or** whose `to_uppercase()` does — so a lower-cased `path=`/`ld_…` still matches the upper-cased prefixes, while `http_proxy`/`https_proxy` match only literally-lower vars. `make proc-linux-check` reproduces secmon's five linux.rs tests + ipv6/parse-addr + a comm-with-paren corner + 10 environ-scan cases (invalid-UTF-8 drop, uppercase-arm, trailing NUL). (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`. 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`. `make freebsd-parse-check` reproduces secmon's three freebsd.rs tests + ipv6/wildcard/negatives + the ps-line cases + the sockstat/netstat rows traced from source. (The `kldstat`/`sockstat`/`netstat`/`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. |
diff --git a/examples/proc_linux_check.ss b/examples/proc_linux_check.ss
index 182ed8e..1fe9dd0 100644
--- a/examples/proc_linux_check.ss
+++ b/examples/proc_linux_check.ss
@@ -65,6 +65,36 @@
        "0000:0000:0000:0000:0000:0000:0100:0000")
 (check "parse-addr no colon -> #f" (parse-addr "0100007F" "tcp") #f)
 
+;; ── parse-security-environ: /proc/[pid]/environ NUL-separated bytes ───────────
+(displayln "parse-security-environ:")
+(def NUL (string #\nul))
+(def (envblob . ss) (string->utf8 (string-join ss NUL)))   ;; valid-UTF-8 blob
+(check "keeps PATH=/LD_/USER= prefixes"
+       (parse-security-environ (envblob "PATH=/usr/bin" "LD_PRELOAD=evil.so" "USER=root"))
+       '("PATH=/usr/bin" "LD_PRELOAD=evil.so" "USER=root"))
+(check "lower-cased var matched via uppercase arm"
+       (parse-security-environ (envblob "path=/x" "ld_preload=y" "TERM=xterm"))
+       '("path=/x" "ld_preload=y"))
+(check "non-interesting var dropped"
+       (parse-security-environ (envblob "TERM=xterm" "EDITOR=vi")) '())
+(check "empty entries between NULs dropped"
+       (parse-security-environ (envblob "PATH=/x" "" "USER=y"))
+       '("PATH=/x" "USER=y"))
+(check "trailing NUL -> trailing empty dropped"
+       (parse-security-environ (string->utf8 (str "SHELL=/bin/sh" NUL)))
+       '("SHELL=/bin/sh"))
+(check "invalid-UTF-8 entry dropped"
+       (parse-security-environ
+        (u8-list->bytevector '(80 65 84 72 61 120 0 255 254 0 85 83 69 82 61 121)))
+       '("PATH=x" "USER=y"))
+(check "mixed-case matches uppercase prefix HTTP_PROXY"
+       (parse-security-environ (envblob "Http_Proxy=z")) '("Http_Proxy=z"))
+(check "literal lowercase http_proxy kept"
+       (parse-security-environ (envblob "http_proxy=q")) '("http_proxy=q"))
+(check "SSH_ prefix kept"
+       (parse-security-environ (envblob "SSH_AUTH_SOCK=/tmp/s")) '("SSH_AUTH_SOCK=/tmp/s"))
+(check "empty input -> ()" (parse-security-environ (u8-list->bytevector '())) '())
+
 (newline)
 (if (= fails 0)
     (displayln "OK: proc-linux matches secmon's linux.rs behaviour.")
diff --git a/jsecmon/proc-linux.ss b/jsecmon/proc-linux.ss
index 81d6541..7cb304f 100644
--- a/jsecmon/proc-linux.ss
+++ b/jsecmon/proc-linux.ss
@@ -10,6 +10,7 @@
 ;;;   parse-ipv4   : little-endian hex -> dotted quad          | #f
 ;;;   parse-ipv6   : 32-hex string     -> colon-grouped        | #f
 ;;;   parse-addr   : "HEXADDR:HEXPORT" -> (addr . port)        | #f
+;;;   parse-security-environ : /proc/[pid]/environ bytes -> security vars list
 ;;;
 ;;; Pure text/number parsing, like the DNS/SELinux/DTrace parsers, so untyped.
 ;;;
@@ -28,7 +29,8 @@
 ;;; examples/proc_linux_check.ss.
 
 (library (jsecmon proc-linux)
-  (export parse-stat parse-uid hex-to-state parse-ipv4 parse-ipv6 parse-addr)
+  (export parse-stat parse-uid hex-to-state parse-ipv4 parse-ipv6 parse-addr
+          parse-security-environ)
   (import (except (chezscheme)
                   make-hash-table hash-table?
                   sort sort!
@@ -121,4 +123,58 @@
                   (let ((addr (if (string-contains protocol "6")
                                   (parse-ipv6 (list-ref parts 0))
                                   (parse-ipv4 (list-ref parts 0)))))
-                    (and addr (cons addr port)))))))))
+                    (and addr (cons addr port))))))))
+
+  ;; ── /proc/[pid]/environ security-relevant var scan ──────────────────────────
+  ;; From secmon src/ebpf/loader.rs's parse_security_environ; it lives in the
+  ;; ebpf loader but is functionally a /proc/[pid]/environ parser, so it sits
+  ;; with the other /proc parsers here.
+  (def *environ-prefixes*
+    '("LD_" "PATH=" "HOME=" "USER=" "SHELL=" "PWD=" "SSH_" "DISPLAY="
+      "http_proxy" "https_proxy" "HTTP_PROXY" "HTTPS_PROXY" "SUDO_"
+      "PYTHONPATH" "PERL5LIB" "RUBYLIB" "NODE_PATH" "JAVA_HOME" "CLASSPATH"))
+
+  ;; std::str::from_utf8(bytes).ok(): the decoded string when the bytes are
+  ;; well-formed UTF-8, else #f. Round-trip check (lossy decode, re-encode,
+  ;; compare) — a mismatch means an ill-formed sequence was replaced, i.e. the
+  ;; input was not valid UTF-8, matching Rust's strict from_utf8. (Done without
+  ;; exceptions: a literal #f in a (try … (catch (e) #f)) handler mis-returns #t
+  ;; in this Jerboa build, so the round trip avoids try entirely.)
+  (def (utf8-strict bv)
+    (let ((s (utf8->string bv)))
+      (if (bytevector=? (string->utf8 s) bv) s #f)))
+
+  ;; s.starts_with(prefix) OR s.to_uppercase().starts_with(prefix), any prefix.
+  ;; The uppercase arm means a lower-cased "path=…" / "ld_…" still matches the
+  ;; upper-cased prefixes, while the lower-case prefixes (http_proxy/https_proxy)
+  ;; only match a literally lower-cased var.
+  (def (environ-interesting? s)
+    (let ((su (string-upcase s)))
+      (let loop ((ps *environ-prefixes*))
+        (cond ((null? ps) #f)
+              ((or (string-prefix? (car ps) s) (string-prefix? (car ps) su)) #t)
+              (else (loop (cdr ps)))))))
+
+  ;; split a bytevector on NUL into a list of bytevectors (Rust slice::split):
+  ;; "a\0b" -> ("a" "b"), "a\0" -> ("a" ""), "" -> (""), so a trailing NUL and an
+  ;; empty input both yield a final empty piece (dropped later as empty).
+  (def (split-nul bv)
+    (let ((n (bytevector-length bv)))
+      (let loop ((i 0) (cur '()) (acc '()))
+        (cond ((= i n)
+               (reverse (cons (u8-list->bytevector (reverse cur)) acc)))
+              ((= (bytevector-u8-ref bv i) 0)
+               (loop (+ i 1) '() (cons (u8-list->bytevector (reverse cur)) acc)))
+              (else
+               (loop (+ i 1) (cons (bytevector-u8-ref bv i) cur) acc))))))
+
+  ;; /proc/[pid]/environ bytes -> list of security-relevant "VAR=val" strings in
+  ;; input order; entries that are empty or not valid UTF-8 are dropped.
+  (def (parse-security-environ content)
+    (let loop ((ps (split-nul content)) (acc '()))
+      (if (null? ps)
+          (reverse acc)
+          (let ((s (utf8-strict (car ps))))
+            (if (and s (not (string-empty? s)) (environ-interesting? s))
+                (loop (cdr ps) (cons s acc))
+                (loop (cdr ps) acc)))))))