Port secmon read_dns_servers core to (jsecmon dns-servers)

ober

136776e481b49f8136b2972524a6958e7df6197e

diff --git a/Makefile b/Makefile
index aaa55af..dd2bd08 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 selinux-check container-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 container-check dns-servers-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)"
@@ -143,6 +143,12 @@ selinux-check:
 container-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/container_check.ss
 
+# DNS-server set builder (secmon src/monitor/dns.rs read_dns_servers): parse
+# resolv.conf nameserver lines and union with the public-resolver set. Pure
+# parsing (the file read is the deferred I/O wrapper), no native lib.
+dns-servers-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_servers_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
@@ -161,6 +167,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/kernmod_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/selinux_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/container_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/dns_servers_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/README.md b/README.md
index b7502fa..002c971 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,7 @@ make netconn-check   # connection classifier: bad-port, high-port-mult-1000, web
 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 container-check # container/jail escape mount classifier (host bind, docker sock)
+make dns-servers-check # resolv.conf nameserver parse + public-resolver union
 make checks          # every Jerboa-side check in one shot
 ```
 
@@ -102,5 +103,6 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `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::container::ContainerEscapeMonitor` (mount classifier) | `jsecmon/container.ss` | ✅ **untyped layer** — `is_suspicious_mount(mount)`: a mount that starts with `/host` or `/mnt/host`, is exactly `/`, or contains `/var/run/docker` / `/run/docker` / `devd.pipe` (FreeBSD jail), flagging a container/jail escape. Pure string classification like the other monitor classifiers, so untyped; obfstr!-hidden patterns decode to these plaintext literals. `make container-check` reproduces secmon's `test_suspicious_mount_detection` + each escape signal + negatives. (Isolation detection and mount/path/cap polling are provider-driven I/O — the deferred monitor loop.) |
+| `monitor::dns::read_dns_servers` (resolver-set builder) | `jsecmon/dns-servers.ss` | ✅ **untyped layer** — `parse_dns_servers(content)`: collect each `nameserver <ip>` entry from resolv.conf text (the 2nd whitespace field of a trimmed line starting with `nameserver`) and union with the fixed public-resolver set (Google/Cloudflare/Quad9/OpenDNS). Pure parsing, so untyped; the `/etc/resolv.conf` read is the deferred I/O wrapper (split off like the selinux log tail). Folds tabs/CR to spaces to match Rust's `split_whitespace`. `make dns-servers-check` reproduces secmon's `test_read_dns_servers` (publics always present) + the nameserver parsing with multi-space/tab/indented lines and dedup. |
 | `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/dns_servers_check.ss b/examples/dns_servers_check.ss
new file mode 100644
index 0000000..caa24d4
--- /dev/null
+++ b/examples/dns_servers_check.ss
@@ -0,0 +1,56 @@
+;;; Parity check for (jsecmon dns-servers) against secmon's dns.rs test
+;;; test_read_dns_servers (the public servers are always present), plus the
+;;; resolv.conf nameserver parsing the file-read wraps.
+;;;
+;;;   scheme --libdirs "$JERBOA/lib:." --script examples/dns_servers_check.ss
+
+(import (jerboa prelude)
+        (jsecmon dns-servers))
+
+(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)))))
+(def (has? lst x) (and (member x lst) #t))
+
+;; ── secmon test_read_dns_servers (public servers always present) ─────────────
+(displayln "secmon test_read_dns_servers:")
+(def empty-conf (parse-dns-servers ""))
+(check "8.8.8.8 present" (has? empty-conf "8.8.8.8") #t)
+(check "1.1.1.1 present" (has? empty-conf "1.1.1.1") #t)
+(check "all 7 public servers" (length empty-conf) 7)
+
+;; ── resolv.conf nameserver parsing ───────────────────────────────────────────
+(displayln "resolv.conf parsing:")
+(def conf
+  (str "# managed by resolvconf\n"
+       "nameserver 192.168.1.1\n"
+       "nameserver    10.0.0.53\n"   ;; multiple spaces -> 2nd field still found
+       "search lan\n"
+       "options edns0\n"))
+(def servers (parse-dns-servers conf))
+(check "192.168.1.1 parsed"  (has? servers "192.168.1.1") #t)
+(check "10.0.0.53 (multi-space) parsed" (has? servers "10.0.0.53") #t)
+(check "public still unioned in" (has? servers "9.9.9.9") #t)
+(check "search line not taken as server" (has? servers "lan") #f)
+(check "comment not taken" (has? servers "#") #f)
+
+;; tab-separated nameserver line
+(check "tab-separated nameserver"
+       (has? (parse-dns-servers "nameserver\t172.16.0.1\n") "172.16.0.1") #t)
+;; leading-whitespace nameserver line (trimmed before the prefix test)
+(check "indented nameserver"
+       (has? (parse-dns-servers "   nameserver 203.0.113.9\n") "203.0.113.9") #t)
+;; a bare "nameserver" with no IP contributes nothing (needs 2 fields)
+(check "bare nameserver adds nothing but publics"
+       (length (parse-dns-servers "nameserver\n")) 7)
+;; duplicate of a public server doesn't double-count
+(check "dup public not double-counted"
+       (length (parse-dns-servers "nameserver 8.8.8.8\n")) 7)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: dns-servers matches secmon's dns.rs behaviour.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/dns-servers.ss b/jsecmon/dns-servers.ss
new file mode 100644
index 0000000..293aaf4
--- /dev/null
+++ b/jsecmon/dns-servers.ss
@@ -0,0 +1,59 @@
+#!chezscheme
+;;; jsecmon DNS-server set builder (secmon monitor::dns), untyped.
+;;;
+;;; Port of the pure core of `read_dns_servers` from secmon's
+;;; src/monitor/dns.rs: given the text of /etc/resolv.conf, collect every
+;;; `nameserver <ip>` entry (the IP is the second whitespace field of a line
+;;; that starts with "nameserver", after trimming) and union it with the fixed
+;;; set of well-known public resolvers. The result is the set of addresses the
+;;; DNS monitor treats as legitimate resolvers.
+;;;
+;;; secmon's function also does the file read; that I/O is the deferred monitor
+;;; wrapper. The parsing + the public-server union is the pure, test-backed part
+;;; and lives here in the untyped layer (like the selinux line parser, the file
+;;; read is split off). secmon hides the resolv.conf path with obfstr!; the
+;;; public-server literals are in the clear in the Rust too.
+;;;
+;;; Verified against secmon's dns.rs test_read_dns_servers in
+;;; examples/dns_servers_check.ss.
+
+(library (jsecmon dns-servers)
+  (export parse-dns-servers *public-dns-servers*)
+  (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?))
+
+  ;; Always-trusted public resolvers, exactly as secmon seeds the set.
+  (def *public-dns-servers*
+    '("8.8.8.8" "8.8.4.4"          ;; Google
+      "1.1.1.1" "1.0.0.1"          ;; Cloudflare
+      "9.9.9.9"                    ;; Quad9
+      "208.67.222.222" "208.67.220.220"))  ;; OpenDNS
+
+  ;; Whitespace tokens of a line: tabs/CR folded to spaces (mirroring Rust's
+  ;; split_whitespace, which splits on any whitespace run and skips empties).
+  (def (ws-tokens s)
+    (let ((normalized
+           (string-map (lambda (c)
+                         (if (or (char=? c #\tab) (char=? c #\return)) #\space c))
+                       s)))
+      (filter (lambda (x) (not (string-empty? x)))
+              (string-split normalized #\space))))
+
+  ;; resolv.conf content (string) -> list of server IP strings (deduped).
+  (def (parse-dns-servers content)
+    (let ((from-conf
+           (for/fold ((acc '())) ((line (in-list (string-split content #\newline))))
+             (let* ((trimmed (string-trim line))
+                    (toks (ws-tokens trimmed)))
+               (if (and (string-prefix? "nameserver" trimmed) (>= (length toks) 2))
+                   (cons (list-ref toks 1) acc)
+                   acc)))))
+      (unique (append (reverse from-conf) *public-dns-servers*)))))