Add daemon security checks and DNS fuzzing

ober

fa006a3262c8b9ae6d047f198af2d0af766fefca

diff --git a/.gitignore b/.gitignore
index ce155e8..b0865f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,8 @@ bench/results/**/*.pid
 # Rust wasm build artifacts (the lib/jerboa-dns/sandbox/*.wasm outputs
 # are committed; the intermediate target/ dir is not)
 wasm/target/
+fuzz/target/
+fuzz/artifacts/
 
 # jerbuild build artifacts
 /jdns
diff --git a/.jerboa/security.json b/.jerboa/security.json
index 6e2e916..45af92b 100644
--- a/.jerboa/security.json
+++ b/.jerboa/security.json
@@ -3,9 +3,9 @@
   "repo": "jerboa-dns",
   "extends": ["jerboa:daemon", "jerboa:ffi", "jerboa:network-service", "jerboa:parser"],
   "paths": {
-    "production": ["*.ss", "*.sls", "lib/**/*.ss", "lib/**/*.sls", "src/**/*.{ss,sls,c,h}", "static/**/*.{ss,sls,c,h}", "bin/**", "Makefile"],
-    "tests": ["test/**", "tests/**", "**/*-test.ss", "bench/**"],
-    "generated": ["build/**", "dist/**", "target/**", "bench/results/**", "*.so", "*.wpo"],
+    "production": ["*.ss", "*.sls", "lib/**/*.ss", "lib/**/*.sls", "src/**/*.{ss,sls,c,h}", "static/**/*.{ss,sls,c,h}", "bin/**", "wasm/**/*.rs", "Makefile"],
+    "tests": ["test/**", "tests/**", "**/*-test.ss", "bench/**", "fuzz/**"],
+    "generated": ["build/**", "dist/**", "target/**", "wasm/target/**", "fuzz/target/**", "fuzz/artifacts/**", "bench/results/**", "*.so", "*.wpo"],
     "vendor": ["vendor/**", "third_party/**"],
     "docs": ["README.md", "docs/**", "*.md", "AGENTS.md"]
   },
diff --git a/Makefile b/Makefile
index ce26847..3a2cb26 100644
--- a/Makefile
+++ b/Makefile
@@ -12,11 +12,12 @@ NATIVE_A   := $(NATIVE_DIR)/libjerboa_native.a
 LIBDIRS    := --libdirs lib:$(JH)/lib
 JEXEC      := $(JERBUILD) exec $(LIBDIRS)
 BIN        := jdns
+CARGO_AUDIT ?= $(shell command -v cargo-audit 2>/dev/null || printf '%s/.cargo/bin/cargo-audit' "$$HOME")
 
 WASM_DIR     = wasm
 WASM_OUTPUTS = lib/jerboa-dns/sandbox/dns_parser.wasm lib/jerboa-dns/sandbox/cdb_parser.wasm
 
-.PHONY: all build binary wasm wasm-clean test clean
+.PHONY: all build binary wasm wasm-clean test security audit fuzz-check verify clean
 
 all: binary
 
@@ -51,6 +52,28 @@ test: binary
 	done
 	@echo "All tests passed."
 
+security:
+	scripts/daemon-security-check.sh
+
+audit:
+	@if ! [ -x "$(CARGO_AUDIT)" ]; then \
+		echo "cargo-audit is required. Install with: cargo install cargo-audit --locked"; \
+		exit 1; \
+	fi
+	(cd $(WASM_DIR) && "$(CARGO_AUDIT)" audit)
+
+fuzz-check:
+	@if rustup toolchain list 2>/dev/null | grep -q '^nightly' && \
+	    { command -v cargo-fuzz >/dev/null 2>&1 || [ -x "$$HOME/.cargo/bin/cargo-fuzz" ]; }; then \
+		NIGHTLY_BIN=$$(dirname "$$(rustup which cargo --toolchain nightly)"); \
+		PATH="$$NIGHTLY_BIN:$$HOME/.cargo/bin:$$PATH" cargo fuzz build --fuzz-dir fuzz; \
+	else \
+		echo "cargo-fuzz with nightly Rust unavailable; falling back to cargo check"; \
+		cargo check --manifest-path fuzz/Cargo.toml --bins; \
+	fi
+
+verify: security test fuzz-check audit
+
 clean:
 	rm -f $(BIN)
 	find lib \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..9991390
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,30 @@
+# Security Policy
+
+`jerboa-dns` is an authoritative DNS daemon that listens on UDP and TCP. Treat
+it as experimental until the release checklist in `~/Release-plan.md` is
+complete for this repository.
+
+## Supported Status
+
+No public production-support commitment exists yet. Security-sensitive releases
+must be cut from a clean checkout after:
+
+- `make security`
+- `make test`
+- `make fuzz-check`
+- `make audit`
+- `make verify`
+
+## Hardening Expectations
+
+- DNS packet parsing and CDB record walking must stay covered by fuzz harnesses.
+- Filesystem sandbox fallback must fail closed unless explicitly enabled for
+  local development.
+- Privilege drop, supplementary group clearing, timeouts, and packet-size caps
+  are release gates.
+- Do not publish operational zone data, deployment paths, hostnames, or keys.
+
+## Reporting
+
+Before public release, report issues privately to the repository owner. After a
+public release, add a dedicated advisory contact and disclosure window here.
diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock
new file mode 100644
index 0000000..6f12096
--- /dev/null
+++ b/fuzz/Cargo.lock
@@ -0,0 +1,115 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "jerboa-cdb-parser"
+version = "0.1.0"
+
+[[package]]
+name = "jerboa-dns-fuzz"
+version = "0.0.0"
+dependencies = [
+ "jerboa-cdb-parser",
+ "jerboa-dns-parser",
+ "libfuzzer-sys",
+]
+
+[[package]]
+name = "jerboa-dns-parser"
+version = "0.1.0"
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom",
+ "libc",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libfuzzer-sys"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
+dependencies = [
+ "arbitrary",
+ "cc",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml
new file mode 100644
index 0000000..c085787
--- /dev/null
+++ b/fuzz/Cargo.toml
@@ -0,0 +1,36 @@
+[package]
+name = "jerboa-dns-fuzz"
+version = "0.0.0"
+edition = "2021"
+publish = false
+
+[package.metadata]
+cargo-fuzz = true
+
+[workspace]
+
+[dependencies]
+jerboa-cdb-parser = { path = "../wasm/cdb", features = ["fuzzing"] }
+jerboa-dns-parser = { path = "../wasm/dns", features = ["fuzzing"] }
+libfuzzer-sys = "0.4"
+
+[[bin]]
+name = "dns_query"
+path = "fuzz_targets/dns_query.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "cdb_file"
+path = "fuzz_targets/cdb_file.rs"
+test = false
+doc = false
+bench = false
+
+[[bin]]
+name = "cdb_record"
+path = "fuzz_targets/cdb_record.rs"
+test = false
+doc = false
+bench = false
diff --git a/fuzz/corpus/cdb_record/ns-record b/fuzz/corpus/cdb_record/ns-record
new file mode 100644
index 0000000..0de9c58
--- /dev/null
+++ b/fuzz/corpus/cdb_record/ns-record
@@ -0,0 +1 @@
+\x00\x02\x00\x00\x00\x0e\x10\x02ns\x07example\x03com\x00
diff --git a/fuzz/corpus/dns_query/basic-a-query b/fuzz/corpus/dns_query/basic-a-query
new file mode 100644
index 0000000..371a5ca
--- /dev/null
+++ b/fuzz/corpus/dns_query/basic-a-query
@@ -0,0 +1 @@
+\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01
diff --git a/fuzz/corpus/dns_query/compression-loop b/fuzz/corpus/dns_query/compression-loop
new file mode 100644
index 0000000..2cc31bc
--- /dev/null
+++ b/fuzz/corpus/dns_query/compression-loop
@@ -0,0 +1 @@
+\x12\x34\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\xc0\x0c\x00\x01\x00\x01
diff --git a/fuzz/fuzz_targets/cdb_file.rs b/fuzz/fuzz_targets/cdb_file.rs
new file mode 100644
index 0000000..eaa8726
--- /dev/null
+++ b/fuzz/fuzz_targets/cdb_file.rs
@@ -0,0 +1,7 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+    jerboa_cdb_parser::fuzz_cdb_input(data);
+});
diff --git a/fuzz/fuzz_targets/cdb_record.rs b/fuzz/fuzz_targets/cdb_record.rs
new file mode 100644
index 0000000..ef60570
--- /dev/null
+++ b/fuzz/fuzz_targets/cdb_record.rs
@@ -0,0 +1,7 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+    jerboa_cdb_parser::fuzz_cdb_record_value(data);
+});
diff --git a/fuzz/fuzz_targets/dns_query.rs b/fuzz/fuzz_targets/dns_query.rs
new file mode 100644
index 0000000..024973f
--- /dev/null
+++ b/fuzz/fuzz_targets/dns_query.rs
@@ -0,0 +1,7 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+    jerboa_dns_parser::fuzz_parse_query_input(data);
+});
diff --git a/lib/jerboa-dns/sandbox/cdb_parser.wasm b/lib/jerboa-dns/sandbox/cdb_parser.wasm
index 2bbb4d4..70b9050 100755
Binary files a/lib/jerboa-dns/sandbox/cdb_parser.wasm and b/lib/jerboa-dns/sandbox/cdb_parser.wasm differ
diff --git a/lib/jerboa-dns/sandbox/dns_parser.wasm b/lib/jerboa-dns/sandbox/dns_parser.wasm
index 89f236a..d07c0bd 100755
Binary files a/lib/jerboa-dns/sandbox/dns_parser.wasm and b/lib/jerboa-dns/sandbox/dns_parser.wasm differ
diff --git a/scripts/daemon-security-check.sh b/scripts/daemon-security-check.sh
new file mode 100755
index 0000000..15eb379
--- /dev/null
+++ b/scripts/daemon-security-check.sh
@@ -0,0 +1,84 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+cd "$ROOT"
+
+fail=0
+
+say() {
+  printf '[daemon-security] %s\n' "$*"
+}
+
+warn() {
+  printf '[daemon-security] WARN: %s\n' "$*" >&2
+}
+
+require_file() {
+  if [ ! -f "$1" ]; then
+    printf '[daemon-security] missing required file: %s\n' "$1" >&2
+    fail=1
+  fi
+}
+
+rg_excludes=(
+  --hidden
+  --glob '!.git/**'
+  --glob '!target/**'
+  --glob '!**/target/**'
+  --glob '!fuzz/artifacts/**'
+  --glob '!bench/results/**'
+  --glob '!*.lock'
+)
+
+say "checking daemon release/security metadata"
+require_file ".jerboa/security.json"
+require_file "SECURITY.md"
+
+if command -v rg >/dev/null 2>&1; then
+  say "running high-confidence secret scan"
+  if rg -n -S "${rg_excludes[@]}" \
+      -e 'BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY' \
+      -e 'OPENAI_API_KEY[[:space:]]*=' \
+      -e 'GITHUB_TOKEN[[:space:]]*=' \
+      -e 'AWS_SECRET_ACCESS_KEY[[:space:]]*=' \
+      -e 'api[_-]?key[[:space:]]*[:=][[:space:]]*"?[A-Za-z0-9_./+=:-]{24,}' \
+      -e 'password[[:space:]]*[:=][[:space:]]*"[^"[:space:]]{12,}"' \
+      .; then
+    printf '[daemon-security] high-confidence secret material found\n' >&2
+    fail=1
+  fi
+else
+  warn "ripgrep not found; skipping secret scan"
+fi
+
+if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+  say "checking for tracked build artifacts"
+  tracked=$(git ls-files \
+    'target/**' '*/target/**' 'fuzz/artifacts/**' '*.so' '*.wpo' '*.dylib' '*.rlib' 2>/dev/null || true)
+  if [ -n "$tracked" ]; then
+    printf '%s\n' "$tracked" >&2
+    printf '[daemon-security] tracked generated artifacts found\n' >&2
+    fail=1
+  fi
+fi
+
+if command -v gitsafe >/dev/null 2>&1; then
+  say "running gitsafe working-tree scan"
+  if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+    if ! git ls-files -z -c -o --exclude-standard | xargs -0 gitsafe scan --severity high; then
+      fail=1
+    fi
+  elif ! find . -type f -not -path './.git/*' -print0 | xargs -0 gitsafe scan --severity high; then
+    fail=1
+  fi
+elif command -v gitleaks >/dev/null 2>&1; then
+  say "running gitleaks working-tree scan"
+  if ! gitleaks detect --no-git --redact --source "$ROOT"; then
+    fail=1
+  fi
+else
+  warn "gitsafe not found; install ~/mine/jerboa-gitsafe for release-grade secret scanning"
+fi
+
+exit "$fail"
diff --git a/wasm/cdb/Cargo.toml b/wasm/cdb/Cargo.toml
index 6f4719f..76435ea 100644
--- a/wasm/cdb/Cargo.toml
+++ b/wasm/cdb/Cargo.toml
@@ -4,6 +4,10 @@ version = "0.1.0"
 edition = "2021"
 publish = false
 
+[features]
+default = []
+fuzzing = []
+
 [lib]
-crate-type = ["cdylib"]
+crate-type = ["cdylib", "rlib"]
 path = "src/lib.rs"
diff --git a/wasm/cdb/src/lib.rs b/wasm/cdb/src/lib.rs
index 6c68cff..aa24c2c 100644
--- a/wasm/cdb/src/lib.rs
+++ b/wasm/cdb/src/lib.rs
@@ -24,7 +24,7 @@
 //   name        ≤ 255 bytes  (RFC 1035)
 //   per query   ≤ 1 second of wasmi fuel (host-side cap)
 
-#![no_std]
+#![cfg_attr(not(feature = "fuzzing"), no_std)]
 
 use core::sync::atomic::{AtomicUsize, Ordering};
 
@@ -276,9 +276,7 @@ fn do_query(data: &[u8], key: &[u8], output: &mut [u8]) -> Result<usize, i32> {
             if rec_body_end > data.len() {
                 return Err(ERR_CDB_MALFORMED);
             }
-            if rec_keylen == key.len()
-                && &data[entry_pos + 8..entry_pos + 8 + rec_keylen] == key
-            {
+            if rec_keylen == key.len() && &data[entry_pos + 8..entry_pos + 8 + rec_keylen] == key {
                 if found >= MAX_RECORDS {
                     return Err(ERR_TOO_MANY_RECORDS);
                 }
@@ -339,6 +337,29 @@ fn validate_value(val: &[u8]) -> Result<(), i32> {
     Ok(())
 }
 
+#[cfg(feature = "fuzzing")]
+pub fn fuzz_cdb_input(input: &[u8]) {
+    if input.len() < 2 {
+        let _ = validate_cdb_header(input);
+        return;
+    }
+
+    let key_len = core::cmp::min(core::cmp::min(input[0] as usize, MAX_KEY), input.len() - 1);
+    let key = &input[1..1 + key_len];
+    let data = &input[1 + key_len..];
+    let _ = validate_cdb_header(data);
+
+    if validate_cdb_header(data) && !key.is_empty() {
+        let mut output = [0u8; 4096];
+        let _ = do_query(data, key, &mut output);
+    }
+}
+
+#[cfg(feature = "fuzzing")]
+pub fn fuzz_cdb_record_value(input: &[u8]) {
+    let _ = validate_value(input);
+}
+
 // Walk a wire-format DNS name starting at `start`. Returns the number
 // of bytes consumed (including the terminating zero). No compression
 // pointers are allowed inside CDB-stored names.
@@ -370,9 +391,7 @@ fn walk_name(buf: &[u8], start: usize) -> Result<usize, i32> {
         if next > buf.len() {
             return Err(ERR_NAME_MALFORMED);
         }
-        total = total
-            .checked_add(1 + label_len)
-            .ok_or(ERR_NAME_MALFORMED)?;
+        total = total.checked_add(1 + label_len).ok_or(ERR_NAME_MALFORMED)?;
         if total > MAX_NAME {
             return Err(ERR_NAME_MALFORMED);
         }
@@ -454,6 +473,7 @@ impl<'a> Writer<'a> {
     }
 }
 
+#[cfg(all(not(feature = "fuzzing"), target_arch = "wasm32"))]
 #[panic_handler]
 fn panic(_info: &core::panic::PanicInfo) -> ! {
     core::arch::wasm32::unreachable()
diff --git a/wasm/dns/Cargo.toml b/wasm/dns/Cargo.toml
index a2aec29..dd1c1ff 100644
--- a/wasm/dns/Cargo.toml
+++ b/wasm/dns/Cargo.toml
@@ -4,6 +4,10 @@ version = "0.1.0"
 edition = "2021"
 publish = false
 
+[features]
+default = []
+fuzzing = []
+
 [lib]
-crate-type = ["cdylib"]
+crate-type = ["cdylib", "rlib"]
 path = "src/lib.rs"
diff --git a/wasm/dns/src/lib.rs b/wasm/dns/src/lib.rs
index 1b92238..fd15f84 100644
--- a/wasm/dns/src/lib.rs
+++ b/wasm/dns/src/lib.rs
@@ -17,7 +17,7 @@
 //   name   ≤ 255   (RFC 1035)
 //   jumps  ≤ 100   (compression-pointer loop guard)
 
-#![no_std]
+#![cfg_attr(not(feature = "fuzzing"), no_std)]
 
 use core::sync::atomic::{AtomicUsize, Ordering};
 
@@ -133,6 +133,12 @@ fn do_parse(input: &[u8], output: &mut [u8]) -> Result<usize, i32> {
     Ok(w.pos)
 }
 
+#[cfg(feature = "fuzzing")]
+pub fn fuzz_parse_query_input(input: &[u8]) {
+    let mut output = [0u8; MAX_NAME + 16];
+    let _ = do_parse(input, &mut output);
+}
+
 fn be16(buf: &[u8], off: usize) -> u16 {
     ((buf[off] as u16) << 8) | (buf[off + 1] as u16)
 }
@@ -260,7 +266,8 @@ impl<'a> Writer<'a> {
     }
 }
 
-// no_std panic handler — abort by trapping.
+// no_std panic handler: abort by trapping inside the WASM sandbox.
+#[cfg(all(not(feature = "fuzzing"), target_arch = "wasm32"))]
 #[panic_handler]
 fn panic(_info: &core::panic::PanicInfo) -> ! {
     core::arch::wasm32::unreachable()