Add daemon security checks and DNS fuzzing
ober
fa006a3262c8b9ae6d047f198af2d0af766fefca
--- 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 --- 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"] }, --- 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 new file mode 100644 --- /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. new file mode 100644 --- /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" new file mode 100644 --- /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 new file mode 100644 --- /dev/null +++ b/fuzz/corpus/cdb_record/ns-record @@ -0,0 +1 @@ +\x00\x02\x00\x00\x00\x0e\x10\x02ns\x07example\x03com\x00 new file mode 100644 --- /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 new file mode 100644 --- /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 new file mode 100644 --- /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); +}); new file mode 100644 --- /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); +}); new file mode 100644 --- /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); +}); Binary files a/lib/jerboa-dns/sandbox/cdb_parser.wasm and b/lib/jerboa-dns/sandbox/cdb_parser.wasm differ Binary files a/lib/jerboa-dns/sandbox/dns_parser.wasm and b/lib/jerboa-dns/sandbox/dns_parser.wasm differ new file mode 100755 --- /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" --- 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" --- 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() --- 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" --- 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()