Port parse_ip_udp_dns into (jsecmon dns-sniffer)

ober

3fe5f4f20280f477acdd920ba2be940c2cc3295a

diff --git a/README.md b/README.md
index c5d1b57..f547b75 100644
--- a/README.md
+++ b/README.md
@@ -136,7 +136,7 @@ then crypto orchestration, then I/O / async / FFI (monitors, server, storage).
 | `monitor/persistence` (helpers) | `jsecmon/persistence.ss` | ✅ **untyped layer** — `classify_path` (path → `PersistenceType` symbol via an ordered first-match substring chain; `systemd` before `cron`, `.timer` vs service, and the shell-profile arm == the default) and `extract_suspicious_content` (first line matching `SUSPICIOUS_PATTERNS`, returned in original case, truncated to 200 chars + `...`). Faithfully preserves secmon's dead-pattern bug: the line is lowercased before `contains`, so the uppercase patterns `NOPASSWD`/`ALL=(ALL)` can never match. Pure — the directory walk + baseline hashing are the deferred I/O — no native lib; `make persistence-check` asserts against the Rust source. |
 | `monitor/files` (`FileIntegrityMonitor::is_suspicious_change`) | `jsecmon/file-change.ss` | ✅ **untyped layer** — the deciding logic with stat/hashing stripped (modes + change-type + platform passed in): ordered first-match — setuid then setgid bit *added* (both modes known), exact platform critical file, `authorized_keys`/`cron` substrings, then a platform sensitive dir on `created` only. Pins the order corner that the `cron` substring precedes the sensitive-dir step, so a created `/etc/cron.d/x` reports "Cron configuration modified", never the sensitive-dir message; the critical-files/sensitive-dirs sets switch on `cfg!(target_os)` (linux/freebsd/other). Pure — the `stat`/SHA-256 baseline is the deferred I/O — no native lib; secmon has no `#[test]` here so `make file-change-check` asserts against the Rust source. |
 | `monitor/webshell` (`WebshellMonitor` classifiers) | `jsecmon/webshell.ss` | ✅ **untyped layer** — the three pure deciders with the `/proc` scan + parent/child PID walk + event emission stripped: `is_web_server` (lower-cased name **substring** vs the server list, so `php-fpm` matches `php`), `is_suspicious_child` (process name by **exact** lower-cased equality — `bashx` is clean — OR the joined+lowercased cmdline **substring**-matched against the pattern list), and `get_detection_reason` (scans only the cmdline patterns, **in list order**, first match → `Suspicious command pattern: {pat}`, else the default `Web server spawned suspicious process: {name}` with the **original-case** name). obfstr!-hidden lists decode to these plaintext literals. Pins the corner that the reason is chosen by pattern-list order, not cmdline-token order, and that a name-only hit yields the default reason. Pure — the PID walk is the deferred monitor loop — no native lib; secmon has no `#[test]` here so `make webshell-check` asserts against the Rust source. |
-| `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). |
+| `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), the `parse_ip_udp_dns` IPv4+UDP header peel (version/IHL/protocol checks, port-53 server/response classification → DNS payload), 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. Only 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, and the IP/UDP peel with version/protocol/port negatives). |
 | `bin/analyze` (CLI parse helpers) | `jsecmon/analyze-cli.ss` | ✅ **untyped layer** — the pure argument parsers of the `analyze` binary, returning the prelude Result (ok/err) to mirror Rust's `Result<_, String>` **including the exact error text**: `parse_duration_ms` (`10m`/`2h`/`1d`/bare-seconds → ms; splits leading ASCII digits from the unit; empty → `empty duration`, bad number/leading-non-digit → `invalid duration: {s}`, bad unit like `m5` → `unknown duration unit: …`; the number must fit i64) and `AlertSink::parse` (`stdout` / `file:PATH` / `webhook:URL` / `syslog` / `syslog:TAG`, first-match in order, remainder taken verbatim so `file:` → empty path) and `parse_alert_sinks` (collect every `--alert-to <spec>`, parsing each and short-circuiting on the first bad spec like Rust's `?`; a trailing `--alert-to` with no value is skipped, and no flags → the empty list — the watch-time default-to-stdout lives in `cmd_watch`), plus the generic `--flag` scanners shared across the CLI (`parse_flag_value` → the arg after the **first** `flag`, or `#f` even when the flag is last; `has_flag` → membership; `is_json_format` → the first `--format` that has a value decides, a trailing `--format` is skipped). Pure string→Result/bool; the sink dispatch (stdout/file append/curl webhook/`logger` syslog) and query dispatch are the deferred I/O. secmon has no `#[test]` here so `make analyze-cli-check` asserts against the Rust source. (`format_ts`/`format_ts_iso` are chrono-calendar-coupled display helpers — deferred with the other calendar I/O.) |
 | `bin/collector` (CLI/hosts parse helpers) | `jsecmon/collector-cli.ss` | ✅ **untyped layer** — the pure argument/hosts parsing of the `collector` binary, with the async polling + ECIES/PSK key loading + SQLite I/O deferred: `parse_after_seq` (first `--after` value as u64, `unwrap_or(0)` so junk/negative/≥2⁶⁴ → 0), `parse_format` (→ `'json`/`'human`/`'quiet`; a per-index scan where an unknown `--format` value does **not** consume the value — differs from analyze's `is_json_format` — and the no-flag default is `quiet` when a `--db` is present else `human`), `parse_db_path`, `normalize_host` (append `:31337` unless the host already contains **any** `:`, so bare IPv6 is left as-is, faithfully), `collect_positional_hosts` (skip the four value flags **and** their values, drop other `--` args, normalize the rest), and `parse_hosts_file`'s pure contents→hosts core (trim, drop blanks/`#` comments, normalize). secmon has no `#[test]` here so `make collector-cli-check` asserts against the Rust source. |
 | `storage` event readers (`extract_pid` / `extract_process_name` / `build_summary`) | `jsecmon/event-summary.ss` | ✅ **untyped layer** — the pure readers that turn an event's flat JSON `data` (a hash table, as `string->json-object` yields) back into a pid / process name / one-line summary, with the SQLite query + serde plumbing left to storage. Each field is read through a **typed** getter so only a JSON value of the right type counts (`as_u64`/`as_i64`/`as_str`/`as_bool`); `extract_pid` walks pid→source_pid→spawned_pid→web_server_pid and truncates the first hit to **u32** (Rust `v as u32`, so ≥2³² wraps, and pid 0 is a real hit); `extract_process_name` walks process_name→name→exe→source_process→spawned_process. `build_summary` reproduces every per-type format with the exact `unwrap_or` defaults (`"?"`/`0`), the `process_exit` exit-code *option* (Some(0) still prints `(0)`), the nested `selinux_event` perm/class/path-vs-message branches with the 80-char message cap, and the catch-all that scans values **in sorted key order** (serde's default BTreeMap) for the first string longer than 3 chars (capped at 80) else the event type. `make event-summary-check` (43 cases) reproduces secmon's `test_extract_helpers` + `test_build_summary` and adds every per-type / typed-getter corner derived from the source. |
diff --git a/examples/dns_sniffer_check.ss b/examples/dns_sniffer_check.ss
index 58d999a..50d0eed 100644
--- a/examples/dns_sniffer_check.ss
+++ b/examples/dns_sniffer_check.ss
@@ -101,6 +101,38 @@
 (check "qtype 12 -> PTR"   (dns-type-str 12) "PTR")
 (check "qtype 999 -> OTHER"(dns-type-str 999) "OTHER")
 
+;; ── parse-ip-udp-dns (IPv4+UDP header peel, then the DNS payload) ─────────────
+;; 20-byte IPv4 header (0x45 = version 4, IHL 5) + protocol 17 (UDP) + 8-byte UDP
+;; header, then the DNS payload. server/local/is-response are decided by port 53.
+(def (ip-udp-wrap src-ip dst-ip src-port dst-port dns-bv)
+  (bytes (append (list #x45 #x00 #x00 #x00  #x00 #x00 #x00 #x00  #x40 17 #x00 #x00)
+                 src-ip dst-ip
+                 (u16-bytes src-port) (u16-bytes dst-port) (list 0 0 0 0)  ;; UDP hdr
+                 (map (lambda (i) (bytevector-u8-ref dns-bv i))
+                      (iota (bytevector-length dns-bv))))))
+
+(displayln "parse-ip-udp-dns (IP+UDP peel):")
+(let ((r (parse-ip-udp-dns
+          (ip-udp-wrap '(10 0 0 1) '(8 8 8 8) 12345 53 (build-query "example.com" 1)))))
+  (check "  query (dst:53)"  (captured-dns-query-name r) "example.com")
+  (check "  server = dst-ip" (captured-dns-server-addr r) "8.8.8.8")
+  (check "  local = src-port"(captured-dns-local-port r) 12345)
+  (check "  not response"    (captured-dns-is-response r) #f))
+(let ((r (parse-ip-udp-dns
+          (ip-udp-wrap '(8 8 8 8) '(10 0 0 1) 53 12345 (build-response "example.com" '(93 184 216 34))))))
+  (check "  response (src:53)" (captured-dns-is-response r) #t)
+  (check "  server = src-ip"   (captured-dns-server-addr r) "8.8.8.8")
+  (check "  A addr"            (captured-dns-response-addrs r) '("93.184.216.34")))
+(check "too short (<40) -> #f" (parse-ip-udp-dns (make-bytevector 39 0)) #f)
+(check "not IPv4 -> #f"
+       (let ((p (ip-udp-wrap '(1 2 3 4) '(5 6 7 8) 1000 53 (build-query "x.com" 1))))
+         (bytevector-u8-set! p 0 #x65) (parse-ip-udp-dns p)) #f)   ;; version 6
+(check "not UDP -> #f"
+       (let ((p (ip-udp-wrap '(1 2 3 4) '(5 6 7 8) 1000 53 (build-query "x.com" 1))))
+         (bytevector-u8-set! p 9 6) (parse-ip-udp-dns p)) #f)      ;; protocol TCP
+(check "neither port 53 -> #f"
+       (parse-ip-udp-dns (ip-udp-wrap '(1 2 3 4) '(5 6 7 8) 1000 2000 (build-query "x.com" 1))) #f)
+
 ;; ── test_sniffer_state_dedup ─────────────────────────────────────────────────
 (displayln "dedup state (5s window):")
 (let ((st  (make-dns-sniffer "test-host"))
diff --git a/jsecmon/dns-sniffer.ss b/jsecmon/dns-sniffer.ss
index c1ebe26..ce3e5e9 100644
--- a/jsecmon/dns-sniffer.ss
+++ b/jsecmon/dns-sniffer.ss
@@ -21,7 +21,7 @@
   (export make-captured-dns captured-dns?
           captured-dns-query-name captured-dns-query-type captured-dns-server-addr
           captured-dns-local-port captured-dns-response-addrs captured-dns-is-response
-          parse-dns-name dns-type-str parse-dns-payload
+          parse-dns-name dns-type-str parse-dns-payload parse-ip-udp-dns
           make-dns-sniffer dns-sniffer-state? dns-sniffer-process dns-sniffer-cleanup)
   (import (except (chezscheme)
                   make-hash-table hash-table?
@@ -141,6 +141,44 @@
                                                server-addr local-port
                                                addrs is-response))))))))))
 
+  ;; Peel an IPv4 + UDP header off a captured packet and hand the DNS payload to
+  ;; parse-dns-payload -> captured-dns | #f. secmon's linux_sniffer::parse_ip_udp_dns;
+  ;; the AF_PACKET SOCK_DGRAM read strips the link header, so byte 0 is the IP
+  ;; header. Pure byte work (every Rust bounds check kept), so it lives with the
+  ;; rest of the wire parser even though its sole caller is the Linux socket loop.
+  ;; The server is whichever side is port 53; local-port is the other side, and a
+  ;; src-port of 53 marks a response. Rejects: <40 bytes, IP version != 4, a bad
+  ;; IHL, protocol != 17 (UDP), neither port 53, or an empty DNS payload.
+  (def (parse-ip-udp-dns data)
+    (let ((n (bytevector-length data)))
+      (if (< n 40)
+          #f
+          (let ((version (bitwise-and
+                          (bitwise-arithmetic-shift-right (bytevector-u8-ref data 0) 4) #xf))
+                (ihl (* (bitwise-and (bytevector-u8-ref data 0) #xf) 4)))
+            (if (or (not (= version 4)) (< ihl 20) (> ihl n)
+                    (not (= (bytevector-u8-ref data 9) 17)))   ;; 17 = UDP
+                #f
+                (let ((src-ip (ipv4-str data 12))
+                      (dst-ip (ipv4-str data 16))
+                      (u ihl))                                  ;; UDP header starts at IHL
+                  (if (> (+ u 8) n)
+                      #f
+                      (let ((src-port (u16-be data u))
+                            (dst-port (u16-be data (+ u 2))))
+                        (let ((dispatch
+                               (cond ((= dst-port 53) (list dst-ip src-port #f))
+                                     ((= src-port 53) (list src-ip dst-port #t))
+                                     (else #f))))
+                          (if (not dispatch)
+                              #f
+                              (let ((dns-start (+ u 8)))
+                                (if (>= dns-start n)
+                                    #f
+                                    (parse-dns-payload
+                                     (bv-slice data dns-start (- n dns-start))
+                                     (car dispatch) (cadr dispatch) (caddr dispatch))))))))))))))
+
   ;; ── dedup + correlation state (DnsSnifferState) ────────────────────────────
   ;; recent: dedup-key "name:type" -> (cons timestamp-ms response-addrs).
   (defstruct dns-sniffer-state (hostname recent))