Port secmon SELinuxMonitor line parser to (jsecmon selinux)

Jaime Fournier <jaimef@linbsd.org>

7622e760918d401dc00c58c0a8ffbdd7b8554c03

diff --git a/Makefile b/Makefile
index 15418d3..7d3daaa 100644
--- 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 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 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)"
@@ -131,6 +131,12 @@ netconn-check:
 kernmod-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/kernmod_check.ss
 
+# SELinux audit-log parser (secmon src/monitor/selinux.rs): AVC denied/granted
+# via secmon's regex through Jerboa pregexp, plus boolean/policy/role events and
+# the extract_field helper. Pure text parsing, no native lib.
+selinux-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/selinux_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
@@ -147,6 +153,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/suspicious_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/netconn_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/kernmod_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/selinux_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index dc9e59d..c122f2c 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ make dns-sniffer-check # DNS wire-format parser (QNAME/compression/answers) + de
 make suspicious-check # SuspiciousPatterns: shell/tool-from-service, revshell + miner
 make netconn-check   # connection classifier: bad-port, high-port-mult-1000, web→external
 make kernmod-check   # kernel-module classifier: rootkit substring, short name, no vowels
+make selinux-check   # SELinux audit-log parser: AVC + boolean/policy/role events
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -98,5 +99,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor::events::SuspiciousPatterns` (process-spawn classifier) | `jsecmon/suspicious.ss` | ✅ **untyped layer** — `check_suspicious(process, parent)`: shell-from-service, attack-tool-from-service (name exact-match or exe suffix), reverse-shell command-line patterns, and crypto-miner name/cmdline patterns, in secmon's order, returning the same reason string. Pure string classification like triage. Pins two corners the Rust depends on: a missing parent short-circuits to "clean" before any check, and `str::contains` is a *literal* substring test (so `python -c.*socket` is literal, not a regex). `make suspicious-check` reproduces secmon's two events.rs tests + the other three signals + both corners. |
 | `monitor::network::NetworkMonitor` (connection classifier) | `jsecmon/netconn.ss` | ✅ **untyped layer** — `check_suspicious(port, addr, process)`: known reverse-shell/C2/l33t port, ephemeral port (49152..65535) that is a round multiple of 1000, and a web-server process (nginx/apache/httpd/php-fpm) connecting to a non-private address, in secmon's order with the same reason string. Pure metadata classification. secmon hides the web-server names with `obfstr!` (same scheme as `typed/obfuscate.ss`); they decode to these plaintext literals at runtime. Pins the faithfulness quirk that the "private" prefix set is literal `{127. 10. 192.168. 172.}`, so `172.` matches all of 172.x, not just RFC1918 172.16/12. `make netconn-check` reproduces secmon's two network.rs tests + the full bad-port list + the high-port and web-server rules with private-address negatives. |
 | `monitor::kernel::KernelModuleMonitor` (kernel-module classifier) | `jsecmon/kernmod.ss` | ✅ **untyped layer** — `is_suspicious_module(name)`: lower-cased name contains a known-rootkit substring (diamorphine/reptile/hide/rootkit/keylog/…), or a 1-2 char name not on the legitimate-short allow-list (ip dm sd sr nf if), or a >4 char name with no vowel, in secmon's order. Pure string classification like the other classifiers. obfstr!-hidden name lists decode to these plaintext literals. Pins the faithfulness corner that only the substring test lower-cases the name — the short-name and vowel tests use the original case, and the vowel set is both-case `aeiouAEIOU`. `make kernmod-check` reproduces secmon's two kernel.rs tests + each signal exercised independently + the case corners. |
+| `monitor::selinux::SELinuxMonitor` (audit-log parser) | `jsecmon/selinux.ss` | ✅ **untyped layer** — the line-parsing core: `parse_audit_line` dispatches on the `type=` tag (AVC → `parse_avc_event`, MAC_CONFIG_CHANGE → boolean change, MAC_POLICY_LOAD → policy load, USER_ROLE_CHANGE → role change) into a `selinux-event` record mirroring `SELinuxEventInfo`, plus the `extract_field` helper. A text-format parser yielding a structured record, like the DNS parser, so untyped. secmon's AVC regex is reused verbatim through Jerboa's `(std pregexp)` `pregexp-match` (capture order 1=decision 2=permission 3=pid 4=comm 5=scontext 6=tcontext 7=tclass — verified identical). Pins `extract_field`'s quoting/empty/missing-quote corners and the `val=` default-empty. `make selinux-check` reproduces secmon's two selinux.rs tests + the dispatcher + all four event kinds. (The I/O — tailing the audit log, mode polling — is the deferred monitor loop.) |
 | `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           |
diff --git a/examples/selinux_check.ss b/examples/selinux_check.ss
new file mode 100644
index 0000000..8a29203
--- /dev/null
+++ b/examples/selinux_check.ss
@@ -0,0 +1,90 @@
+;;; Parity check for (jsecmon selinux) against secmon's selinux.rs tests
+;;; (test_extract_field, test_parse_avc_denied), plus the dispatcher and the
+;;; other four event kinds, and the extract-field faithfulness corners.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/selinux_check.ss
+
+(import (jerboa prelude)
+        (jsecmon selinux))
+
+(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)))))
+
+;; ── secmon test_extract_field ────────────────────────────────────────────────
+(displayln "secmon test_extract_field:")
+(def ef-line
+  (str "type=AVC msg=audit(123:456): avc:  denied  { read } for  pid=1234 "
+       "comm=\"bash\" name=\"shadow\" scontext=user_u:user_r:user_t:s0 "
+       "tcontext=system_u:object_r:shadow_t:s0 tclass=file"))
+(check "pid="    (extract-field ef-line "pid=")    "1234")
+(check "comm="   (extract-field ef-line "comm=")   "bash")
+(check "name="   (extract-field ef-line "name=")   "shadow")
+(check "tclass=" (extract-field ef-line "tclass=") "file")
+
+;; ── secmon test_parse_avc_denied ─────────────────────────────────────────────
+(displayln "secmon test_parse_avc_denied:")
+(def avc-line
+  (str "type=AVC msg=audit(1234567890.123:456): avc:  denied  { read } for  "
+       "pid=1234 comm=\"cat\" name=\"shadow\" dev=\"sda1\" ino=12345 "
+       "scontext=user_u:user_r:user_t:s0 tcontext=system_u:object_r:etc_t:s0 "
+       "tclass=file permissive=0"))
+(def ev (parse-avc-event avc-line))
+(check "event parsed"   (selinux-event? ev) #t)
+(check "  event-type"   (selinux-event-event-type ev) 'avc-denied)
+(check "  pid"          (selinux-event-pid ev) 1234)
+(check "  process-name" (selinux-event-process-name ev) "cat")
+(check "  permission"   (selinux-event-permission ev) "read")
+(check "  target-class" (selinux-event-target-class ev) "file")
+(check "  path (name=)" (selinux-event-path ev) "shadow")
+(check "  message=line" (selinux-event-message ev) avc-line)
+
+;; ── dispatcher routes by type= tag ───────────────────────────────────────────
+(displayln "parse-audit-line dispatch:")
+(check "AVC routes to avc"
+       (selinux-event-event-type (parse-audit-line avc-line)) 'avc-denied)
+(check "granted decision"
+       (selinux-event-event-type
+        (parse-avc-event
+         (str "type=AVC msg=audit(1:2): avc:  granted  { read } for  pid=1 "
+              "comm=\"cat\" scontext=u:r:t:s0 tcontext=u:o:t:s0 tclass=file")))
+       'avc-granted)
+(check "unknown type -> #f"
+       (parse-audit-line "type=SYSCALL msg=audit(1:2): nothing here") #f)
+
+;; ── boolean change (MAC_CONFIG_CHANGE) ───────────────────────────────────────
+(displayln "config / policy / role:")
+(def cfg-line
+  (str "type=MAC_CONFIG_CHANGE msg=audit(1:2): bool=httpd_can_network_connect "
+       "val=1 pid=99 comm=\"setsebool\" subj=sysadm_u:sysadm_r:sysadm_t:s0"))
+(def cfg (parse-audit-line cfg-line))
+(check "config event-type" (selinux-event-event-type cfg) 'boolean-change)
+(check "config pid"        (selinux-event-pid cfg) 99)
+(check "config comm"       (selinux-event-process-name cfg) "setsebool")
+(check "config message"    (selinux-event-message cfg)
+       "SELinux boolean 'httpd_can_network_connect' changed to '1'")
+
+(def pol (parse-audit-line "type=MAC_POLICY_LOAD msg=audit(1:2): pid=5 comm=\"load_policy\""))
+(check "policy event-type" (selinux-event-event-type pol) 'policy-load)
+(check "policy message"    (selinux-event-message pol) "SELinux policy loaded")
+
+(def role (parse-audit-line "type=USER_ROLE_CHANGE msg=audit(1:2): pid=7 default-context=staff_u:staff_r:staff_t:s0"))
+(check "role event-type"   (selinux-event-event-type role) 'role-change)
+(check "role target-context" (selinux-event-target-context role) "staff_u:staff_r:staff_t:s0")
+(check "role message"      (selinux-event-message role) "SELinux role change")
+
+;; ── extract-field faithfulness corners ───────────────────────────────────────
+(displayln "extract-field corners:")
+(check "missing field -> #f"   (extract-field "pid=1 comm=\"x\"" "uid=") #f)
+(check "val= absent defaults to empty in message"
+       (selinux-event-message
+        (parse-audit-line "type=MAC_CONFIG_CHANGE msg=audit(1:2): bool=secure_mode"))
+       "SELinux boolean 'secure_mode' changed to ''")
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: selinux matches secmon's selinux.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/selinux.ss b/jsecmon/selinux.ss
new file mode 100644
index 0000000..fa1d9b9
--- /dev/null
+++ b/jsecmon/selinux.ss
@@ -0,0 +1,149 @@
+#!chezscheme
+;;; jsecmon SELinux audit-log parser (secmon monitor::selinux), untyped.
+;;;
+;;; Port of the line-parsing core of secmon's src/monitor/selinux.rs: turn a
+;;; line from the kernel audit log into a structured SELinux event, dispatching
+;;; on the `type=` tag in secmon's order:
+;;;   type=AVC + "avc:"     -> AVC denied/granted   (parse-avc-event, regex)
+;;;   type=MAC_CONFIG_CHANGE -> boolean change       (parse-config-change)
+;;;   type=MAC_POLICY_LOAD   -> policy load           (parse-policy-load)
+;;;   type=USER_ROLE_CHANGE  -> role change           (parse-role-change)
+;;; anything else -> #f.
+;;;
+;;; This is a wire-/text-format parser yielding a structured record, exactly
+;;; like the DNS parser in (jsecmon dns-sniffer), so it lives in the untyped
+;;; layer. The AVC pattern is secmon's regex verbatim, run through Jerboa's
+;;; pregexp (capture order: 1=denied/granted 2=permission 3=pid 4=comm
+;;; 5=scontext 6=tcontext 7=tclass) — verified to capture identically.
+;;;
+;;; Faithfulness notes (mirroring extract_field / parse_* exactly):
+;;;   * extract-field finds the FIRST occurrence of "field"; a quoted value is
+;;;     returned without quotes, an unquoted value runs to the next space; an
+;;;     empty unquoted value is #f, a missing closing quote is #f.
+;;;   * pid/uid use string->number (secmon's .parse().ok()); a non-numeric
+;;;     value yields #f.
+;;;   * config-change's val= defaults to "" when absent (unwrap_or_default).
+;;;
+;;; Verified against secmon's selinux.rs tests in examples/selinux_check.ss.
+
+(library (jsecmon selinux)
+  (export make-selinux-event selinux-event?
+          selinux-event-event-type selinux-event-pid selinux-event-uid
+          selinux-event-process-name selinux-event-source-context
+          selinux-event-target-context selinux-event-target-class
+          selinux-event-permission selinux-event-path selinux-event-message
+          extract-field parse-audit-line parse-avc-event
+          parse-config-change parse-policy-load parse-role-change)
+  (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?)
+          (std pregexp))
+
+  ;; Mirrors secmon's SELinuxEventInfo. event-type is a symbol:
+  ;; 'avc-denied 'avc-granted 'boolean-change 'policy-load 'role-change.
+  (defstruct selinux-event
+    (event-type pid uid process-name source-context target-context
+     target-class permission path message))
+
+  ;; extract_field(line, field) -> string | #f.
+  (def (extract-field line field)
+    (let ((idx (string-contains line field)))
+      (and idx
+           (let* ((start (+ idx (string-length field)))
+                  (rest (substring line start (string-length line))))
+             (if (string-prefix? "\"" rest)
+                 ;; quoted: content up to the next quote (none -> #f)
+                 (let* ((sub (substring rest 1 (string-length rest)))
+                        (q (string-contains sub "\"")))
+                   (and q (substring rest 1 (+ q 1))))
+                 ;; unquoted: up to next space, or end; empty -> #f
+                 (let* ((sp (string-contains rest " "))
+                        (value (substring rest 0 (or sp (string-length rest)))))
+                   (if (string-empty? value) #f value)))))))
+
+  ;; extract a field, parse to an integer, #f if absent or non-numeric.
+  (def (extract-int line field)
+    (let ((s (extract-field line field)))
+      (and s (string->number s))))
+
+  ;; comm= is quoted in these records, so extract-field already returns it
+  ;; unquoted — secmon's extra .trim_matches('"') is then a no-op.
+
+  (def avc-pattern
+    (string-append
+     "avc:\\s+(denied|granted)\\s+\\{\\s*(\\w+)\\s*\\}"
+     ".*?pid=(\\d+).*?comm=\"([^\"]+)\""
+     ".*?scontext=(\\S+)\\s+tcontext=(\\S+)\\s+tclass=(\\w+)"))
+
+  (def (parse-avc-event line)
+    (let ((m (pregexp-match avc-pattern line)))
+      (and m
+           (let ((decision (list-ref m 1))
+                 (permission (list-ref m 2))
+                 (pid (string->number (list-ref m 3)))
+                 (comm (list-ref m 4))
+                 (scontext (list-ref m 5))
+                 (tcontext (list-ref m 6))
+                 (tclass (list-ref m 7)))
+             (make-selinux-event
+              (if (string=? decision "denied") 'avc-denied 'avc-granted)
+              pid
+              (extract-int line "uid=")
+              comm
+              scontext
+              tcontext
+              tclass
+              permission
+              (or (extract-field line "name=") (extract-field line "path="))
+              line)))))
+
+  (def (parse-config-change line)
+    (let ((bool-name (extract-field line "bool=")))
+      (and bool-name
+           (let ((new-val (or (extract-field line "val=") "")))
+             (make-selinux-event
+              'boolean-change
+              (extract-int line "pid=")
+              (extract-int line "uid=")
+              (extract-field line "comm=")
+              (extract-field line "subj=")
+              #f #f #f #f
+              (str "SELinux boolean '" bool-name "' changed to '" new-val "'"))))))
+
+  (def (parse-policy-load line)
+    (make-selinux-event
+     'policy-load
+     (extract-int line "pid=")
+     (extract-int line "uid=")
+     (extract-field line "comm=")
+     (extract-field line "subj=")
+     #f #f #f #f
+     "SELinux policy loaded"))
+
+  (def (parse-role-change line)
+    (make-selinux-event
+     'role-change
+     (extract-int line "pid=")
+     (extract-int line "uid=")
+     (extract-field line "comm=")
+     (extract-field line "subj=")
+     (extract-field line "default-context=")
+     #f #f #f
+     "SELinux role change"))
+
+  ;; Dispatch on the audit `type=` tag, in secmon's order.
+  (def (parse-audit-line line)
+    (cond
+      ((and (string-contains line "type=AVC") (string-contains line "avc:"))
+       (parse-avc-event line))
+      ((string-contains line "type=MAC_CONFIG_CHANGE") (parse-config-change line))
+      ((string-contains line "type=MAC_POLICY_LOAD")   (parse-policy-load line))
+      ((string-contains line "type=USER_ROLE_CHANGE")  (parse-role-change line))
+      (else #f))))