monitor: port secmon's DNS monitor polling fallback (dns.rs)

ober

98e620a1c017aa6003553f681ab39381adc76b3f

diff --git a/Makefile b/Makefile
index 16b49f9..b2ed486 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 entity-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 sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check frame-check correlate-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-check dga-check calendar-check monitor-process-check monitor-network-check monitor-files-check checks clean
+.PHONY: rust test ffi-demo kernels-check triage-check triage-store-check analytics-check detect-check storage-check entity-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 sensitive-path-check dtrace-parse-check proc-linux-check freebsd-parse-check event-meta-check config-check event-danger-check persistence-check file-change-check webshell-check platform-mounts-check analyze-cli-check collector-cli-check event-summary-check ioc-check frame-check correlate-check revshell-check cron-check logtamper-check detection-rules-check ipaddr-check auth-check lolbin-check dga-check calendar-check monitor-process-check monitor-network-check monitor-files-check monitor-dns-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)"
@@ -363,6 +363,11 @@ monitor-network-check:
 monitor-files-check:
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_files_check.ss
 
+# DNS monitor connection-polling fallback (secmon dns.rs): port-53 filter, 5 s
+# dedup window, 30 s cleanup — pure over the shared net-provider seam. No dylib.
+monitor-dns-check:
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_dns_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
@@ -414,6 +419,7 @@ checks: kernels-check
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_process_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_network_check.ss
 	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_files_check.ss
+	$(SCHEME) --libdirs $(LIBDIRS) --script examples/monitor_dns_check.ss
 
 clean:
 	rm -rf $(BUILD)
diff --git a/examples/monitor_dns_check.ss b/examples/monitor_dns_check.ss
new file mode 100644
index 0000000..771a243
--- /dev/null
+++ b/examples/monitor_dns_check.ss
@@ -0,0 +1,70 @@
+;;; Behaviour check for the DNS monitor's connection-polling fallback (dns.rs).
+;;;
+;;; `scan-dns-connections` is pure over an injected net-provider (shared with
+;;; the network monitor) and `now`, so we drive it with fixture connections and
+;;; assert the port-53 filter, the 5 s dedup window, the key composition, and
+;;; the 30 s cleanup — all derived from secmon's scan_dns_connections.
+;;;
+;;; Run from the repo root with the repo on the libdir path:
+;;;   scheme --libdirs $JERBOA/lib --libdirs . --script examples/monitor_dns_check.ss
+
+(import (jerboa prelude)
+        (jsecmon monitor-network)        ;; make-conn-info / make-net-provider
+        (jsecmon monitor-dns))
+
+(def fails 0)
+(def (check label got want)
+  (let ((ok (equal? got want)))
+    (unless ok (set! fails (+ fails 1)))
+    (displayln (if ok "  ok   " "  FAIL ") label " => " got
+               (if ok "" (str "  (want " want ")")))))
+(def (field k evs) (map (lambda (e) (hash-get e k)) evs))
+(def (recent-count st) (length (hash-keys (dns-mon-state-recent st))))
+
+;; fixtures: a UDP query to 8.8.8.8:53, a TCP query to 1.1.1.1:53, and a plain
+;; HTTPS connection that must be ignored.
+(def q-udp (make-conn-info "udp" "10.0.0.2" 40000 "8.8.8.8" 53  "ESTABLISHED" 1234 "curl"))
+(def q-tcp (make-conn-info "tcp" "10.0.0.2" 40002 "1.1.1.1" 53  "ESTABLISHED" 1234 "curl"))
+(def c-web (make-conn-info "tcp" "10.0.0.2" 40001 "93.184.216.34" 443 "ESTABLISHED" 1234 "curl"))
+
+(def *conns* (list q-udp c-web q-tcp))
+(def netp (make-net-provider (lambda () *conns*) (lambda () '()) "dhost"))
+(def st (make-dns-monitor '("8.8.8.8" "1.1.1.1") "dhost"))
+
+(displayln "A: only port-53 connections, typed by protocol:")
+(def ev1 (scan-dns-connections st netp 1000))
+(check "two dns_query"   (field "type" ev1) '("dns_query" "dns_query"))
+(check "UDP then TCP"    (field "query_type" ev1) '("UDP" "TCP"))
+(check "server addrs"    (field "server_addr" ev1) '("8.8.8.8" "1.1.1.1"))
+(check "query_name unknown" (hash-get (car ev1) "query_name") "<unknown>")
+(check "web conn ignored" (length ev1) 2)
+
+(displayln "B: a repeat within the 5 s window is deduped:")
+(check "silent at +1s" (scan-dns-connections st netp 2000) '())
+
+(displayln "C: past the 5 s window it re-fires:")
+(check "re-fires at +6s" (field "type" (scan-dns-connections st netp 7000))
+       '("dns_query" "dns_query"))
+
+(displayln "D: the dedup key includes local_port — a new socket is distinct:")
+(def q-udp2 (make-conn-info "udp" "10.0.0.2" 49999 "8.8.8.8" 53 "ESTABLISHED" 1234 "curl"))
+(set! *conns* (list q-udp c-web q-tcp q-udp2))
+(check "new local_port emits once" (length (scan-dns-connections st netp 7100)) 1)
+
+(displayln "E: a provider error makes the scan a no-op:")
+(def errp (make-net-provider (lambda () #f) (lambda () '()) "dhost"))
+(check "no events" (scan-dns-connections st errp 8000) '())
+
+(displayln "F: cleanup ages out dedup entries older than 30 s:")
+(def before (recent-count st))
+(cleanup-dns-queries st 8000)            ;; newest entries ~7100, diff < 30 s -> kept
+(check "young entries kept" (recent-count st) before)
+(cleanup-dns-queries st 40000)           ;; now all entries are > 30 s old -> dropped
+(check "old entries dropped" (recent-count st) 0)
+;; with the dedup map cleared, the same connections fire again
+(check "fires after cleanup" (length (scan-dns-connections st netp 41000)) 3)
+
+(newline)
+(if (= fails 0)
+    (displayln "OK: dns fallback filters port 53, dedups within 5 s, and ages out at 30 s.")
+    (begin (displayln fails " FAILURES") (exit 1)))
diff --git a/jsecmon/monitor-dns.ss b/jsecmon/monitor-dns.ss
new file mode 100644
index 0000000..aefc6ca
--- /dev/null
+++ b/jsecmon/monitor-dns.ss
@@ -0,0 +1,114 @@
+#!chezscheme
+;;; jsecmon DNS monitor — connection-polling fallback (secmon monitor/dns.rs).
+;;;
+;;; secmon's DnsMonitor has two strategies: an AF_PACKET sniffer (Linux+root,
+;;; in dns_sniffer.rs — its parser is already ported, examples/dns_sniffer_
+;;; check.ss) and a connection-polling fallback that watches /proc/net for
+;;; connections to port 53. This ports the fallback's scan loop: it reuses the
+;;; NetworkProvider seam (so it shares (jsecmon monitor-network)'s conn-info /
+;;; net-provider) and the trusted-resolver set from (jsecmon dns-servers).
+;;;
+;;;   `scan-dns-connections` is pure over an injected net-provider + `now`
+;;;   (epoch ms), so the port-53 filter, the dedup window, and the event build
+;;;   are fixture-testable; `cleanup-dns-queries` ages the dedup map.
+;;;
+;;; Faithfulness points the Rust pins:
+;;;   * only remote_port == 53 connections are considered (others `continue`).
+;;;   * dedup key = "pid:remote_addr:protocol:local_port" (pid defaults to 0);
+;;;     a key seen < 5000 ms ago is skipped.
+;;;   * query_type = "UDP" if protocol contains "udp" else "TCP"; query_name is
+;;;     literally "<unknown>" (the fallback can't see the domain). Category/
+;;;     severity per event_json.rs: dns_query / info.
+;;;   * cleanup drops dedup entries older than 30000 ms.
+;;;   * secmon computes is_dns = servers ∋ addr || port == 53; since we already
+;;;     filtered to port 53 that is always true, so the resolver set does not
+;;;     gate the fallback — kept verbatim for fidelity.
+
+(library (jsecmon monitor-dns)
+  (export make-dns-monitor dns-mon-state-hostname dns-mon-state-servers
+          dns-mon-state-recent
+          scan-dns-connections cleanup-dns-queries
+          linux-dns-servers linux-dns-hostname)
+  (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?)
+          (jsecmon monitor-network)
+          (jsecmon dns-servers))
+
+  ;; servers : list of trusted resolver addrs; recent : key -> last-seen ms.
+  (defstruct dns-mon-state (hostname servers recent))
+
+  (def (make-dns-monitor servers hostname)
+    (make-dns-mon-state hostname servers (make-hash-table)))
+
+  (def (query-type-of c)
+    (if (string-contains (conn-info-protocol c) "udp") "UDP" "TCP"))
+
+  (def (dns-query-event host now c)
+    (let ((h (make-hash-table)))
+      (hash-put! h "host" host)
+      (hash-put! h "ts" now)
+      (hash-put! h "type" "dns_query")
+      (hash-put! h "severity" "info")
+      (hash-put! h "query_name" "<unknown>")
+      (hash-put! h "query_type" (query-type-of c))
+      (hash-put! h "server_addr" (conn-info-remote-addr c))
+      (hash-put! h "pid" (conn-info-pid c))
+      (hash-put! h "pname" (conn-info-process-name c))
+      (hash-put! h "connection" c)
+      h))
+
+  ;; secmon scan_dns_connections (fallback): emit a deduped DnsQuery for each
+  ;; live connection to a remote port 53. Pure over the net-provider and `now`.
+  (def (scan-dns-connections state net-provider now)
+    (let ((conns ((net-provider-list-connections net-provider))))
+      (if (not conns)
+          '()                          ;; Err(list_connections) -> no-op
+          (let ((recent  (dns-mon-state-recent state))
+                (servers (dns-mon-state-servers state))
+                (host    (dns-mon-state-hostname state))
+                (events '()))
+            (for-each
+             (lambda (c)
+               (when (= (conn-info-remote-port c) 53)
+                 ;; is_dns = servers ∋ addr || port==53 — always true here (see header)
+                 (let ((is-dns (or (member (conn-info-remote-addr c) servers)
+                                   (= (conn-info-remote-port c) 53))))
+                   (when is-dns
+                     (let ((key (str (or (conn-info-pid c) 0) ":"
+                                     (conn-info-remote-addr c) ":"
+                                     (conn-info-protocol c) ":"
+                                     (conn-info-local-port c))))
+                       (let ((last (hash-get recent key)))
+                         (unless (and last (< (- now last) 5000))
+                           (hash-put! recent key now)
+                           (set! events (cons (dns-query-event host now c) events)))))))))
+             conns)
+            (reverse events)))))
+
+  ;; secmon cleanup_old_queries: drop dedup entries older than 30 s.
+  (def (cleanup-dns-queries state now)
+    (let ((recent (dns-mon-state-recent state)))
+      (for-each (lambda (k)
+                  (when (>= (- now (hash-get recent k)) 30000)
+                    (hash-remove! recent k)))
+                (hash-keys recent))))
+
+  ;; --- the thin /etc/resolv.conf shell (Linux) -------------------------------
+  (def (slurp path) (guard (e (#t #f)) (read-file-string path)))
+
+  (def (linux-dns-servers)
+    (let ((content (slurp "/etc/resolv.conf")))
+      (if content (parse-dns-servers content) *public-dns-servers*)))
+
+  (def (linux-dns-hostname)
+    (or (let ((h (slurp "/etc/hostname"))) (and h (string-trim h)))
+        (let ((h (slurp "/proc/sys/kernel/hostname"))) (and h (string-trim h)))
+        "unknown")))
diff --git a/jsecmon/monitor-network.ss b/jsecmon/monitor-network.ss
index 048b78e..54d188b 100644
--- a/jsecmon/monitor-network.ss
+++ b/jsecmon/monitor-network.ss
@@ -30,6 +30,7 @@
           conn-info-remote-addr conn-info-remote-port conn-info-state
           conn-info-pid conn-info-process-name
           make-net-provider net-provider-hostname
+          net-provider-list-connections net-provider-list-listeners
           make-net-state net-state-hostname
           net-state-known-connections net-state-known-listeners
           make-network-monitor scan-connections