Port secmon SELinuxMonitor line parser to (jsecmon selinux)
Jaime Fournier <jaimef@linbsd.org>
7622e760918d401dc00c58c0a8ffbdd7b8554c03
--- 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) --- 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 | new file mode 100644 --- /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))) new file mode 100644 --- /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))))