Security hardening and release readiness
ober
0234e609681d75d49d27d4032a4033b7cb1a38b2
new file mode 100644 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + JERBOA_VERSION: v0.2.3 + JERBUILD: ${{ github.workspace }}/.jerboa/bin/jerbuild + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install system tools + run: | + set -eu + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential curl ca-certificates + + - name: Install Rust components + run: | + set -eu + rustup target add wasm32-unknown-unknown + + - name: Install cargo-audit + run: | + set -eu + cargo install cargo-audit --locked + + - name: Bootstrap Jerboa + run: | + set -eu + sh support/ensure-jerboa.sh "$JERBOA_VERSION" .jerboa/bin + "$JERBUILD" --jerboa-home + + - name: Verify + run: make verify new file mode 100644 --- /dev/null +++ b/.github/workflows/security-baseline.yml @@ -0,0 +1,35 @@ +name: Security Baseline + +on: + push: + branches: [main, master] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + baseline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Required release files + run: | + set -eu + test -f LICENSE + test -f SECURITY.md + test -f .gitignore + find . -maxdepth 1 -iname "README*" -type f | grep -q . + + - name: High-confidence secret scan + run: | + set -eu + pattern="(BEGIN (RSA|OPENSSH|EC|DSA|PRIVATE) KEY|ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-(ant-api03|proj|svcacct)-[A-Za-z0-9_-]{30,}|AKIA[0-9A-Z]{16})" + matches="$(git grep -n -I -E "$pattern" -- . ":!*.png" ":!*.jpg" ":!*.jpeg" ":!*.gif" ":!*.so" ":!*.dylib" ":!*.o" ":!*.a" ":!*.boot" ":!*.tar.gz" || true)" + if [ -n "$matches" ]; then + echo "$matches" + echo "High-confidence secret pattern found." + exit 1 + fi --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ fuzz/artifacts/ # jerbuild build artifacts /jdns support/ffi-symbols.gen + +# Release evidence generated by make release-evidence +/dist/ --- a/.jerboa/security.json +++ b/.jerboa/security.json @@ -16,7 +16,17 @@ "process": { "shellInterpolation": "deny" }, "network": { "requireTimeouts": true, "requireFailClosedSandbox": true, "maxReadBytes": 512 }, "eval": { "stringEval": "deny", "bareRead": "deny", "allowReadEval": false }, - "daemon": { "requirePrivilegeDrop": true, "requireSetgroups": true } + "daemon": { "requirePrivilegeDrop": true, "requireSetgroups": true }, + "supplyChain": { "sbom": "required", "reproducibilityReport": "required", "rustsec": "required" }, + "releaseEvidence": { + "productionSoak": "current-soak-recorded-or-target-proof-required", + "edgeRateLimit": "target-evidence-recorded-required-before-public-release", + "proofFiles": ["JDNS_TARGET_SOAK_PROOF_FILE", "JDNS_TARGET_RATE_LIMIT_PROOF_FILE"], + "failClosedProofFlags": ["JDNS_REQUIRE_TARGET_SOAK_PROOF", "JDNS_REQUIRE_TARGET_RATE_LIMIT_PROOF"], + "evidenceSanitizer": "scripts/sanitize-evidence.sh", + "artifactArchivePolicy": "hashes-only", + "forbiddenEvidenceMaterial": ["private paths", "SSH clone URLs", "full uname output", "operational hostnames", "private zone data", "keys", "tokens"] + } }, "suppressions": [] } new file mode 100644 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # jerbuild bundles Chez Scheme + the jerboa stdlib + the jerboa-native Rust # crate, so building jdns needs only jerbuild + a C compiler + cargo (with the # wasm32-unknown-unknown target for the sandbox payloads). No jerboa checkout. -JERBUILD ?= jerbuild +JERBUILD ?= $(shell if [ -x "$(CURDIR)/../jerboa/dist/jerbuild" ]; then printf '%s\n' "$(CURDIR)/../jerboa/dist/jerbuild"; elif command -v jerbuild >/dev/null 2>&1; then command -v jerbuild; else printf '%s\n' jerbuild; fi) JH := $(shell $(JERBUILD) --jerboa-home 2>/dev/null) ifeq ($(JH),) $(error jerbuild not found on PATH (or '$(JERBUILD) --jerboa-home' failed). Install jerbuild, or set JERBUILD=/path/to/jerbuild) @@ -13,11 +13,16 @@ 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") +DIST_DIR ?= dist/release-evidence +SBOM_DIR ?= dist/sbom +REPRO_DIR ?= dist/reproducibility +SOAK_DIR ?= dist/soak +FUZZ_EVIDENCE_DIR ?= dist/fuzz-evidence 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 security audit fuzz-check verify clean +.PHONY: all build binary wasm wasm-clean test security audit fuzz-check fuzz-evidence sbom reproducibility-report soak-evidence verify release-evidence clean all: binary @@ -63,6 +68,7 @@ audit: (cd $(WASM_DIR) && "$(CARGO_AUDIT)" audit) fuzz-check: + cargo run --manifest-path fuzz/Cargo.toml --bin corpus_smoke @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)"); \ @@ -72,8 +78,83 @@ fuzz-check: cargo check --manifest-path fuzz/Cargo.toml --bins; \ fi -verify: security test fuzz-check audit +fuzz-evidence: + FUZZ_EVIDENCE_DIR="$(FUZZ_EVIDENCE_DIR)" bash scripts/fuzz-evidence.sh + +sbom: binary + JERBUILD="$(JERBUILD)" SBOM_DIR="$(SBOM_DIR)" bash scripts/sbom.sh + +reproducibility-report: + JERBUILD="$(JERBUILD)" REPRO_DIR="$(REPRO_DIR)" bash scripts/reproducibility-report.sh + +soak-evidence: + SOAK_EVIDENCE_DIR="$(SOAK_DIR)" bash scripts/soak-evidence.sh + +verify: security test fuzz-check audit sbom reproducibility-report + +release-evidence: verify + rm -rf "$(DIST_DIR)" + mkdir -p "$(DIST_DIR)" + git rev-parse HEAD > "$(DIST_DIR)/git-commit.txt" + git status --short > "$(DIST_DIR)/git-status.txt" + { \ + echo "os=$$(uname -s 2>/dev/null || printf unknown)"; \ + echo "kernel_release=$$(uname -r 2>/dev/null || printf unknown)"; \ + echo "machine=$$(uname -m 2>/dev/null || printf unknown)"; \ + $(JERBUILD) --version 2>/dev/null | sed 's/^/jerbuild_version=/'; \ + if $(JERBUILD) --jerboa-home >/dev/null 2>&1; then echo "jerboa_home_status=present"; else echo "jerboa_home_status=missing"; fi; \ + } > "$(DIST_DIR)/build-environment.txt" + cargo metadata --manifest-path wasm/Cargo.toml --locked --format-version 1 > "$(DIST_DIR)/cargo-metadata-wasm.json" + cargo metadata --manifest-path fuzz/Cargo.toml --locked --format-version 1 > "$(DIST_DIR)/cargo-metadata-fuzz.json" + (cd wasm && "$(CARGO_AUDIT)" audit > "../$(DIST_DIR)/rustsec-wasm.txt") + find lib/jerboa-dns/sandbox -type f -name '*.wasm' -exec shasum -a 256 {} \; > "$(DIST_DIR)/wasm-sha256.txt" + rm -rf "$(DIST_DIR)/sbom" "$(DIST_DIR)/reproducibility" + cp -R "$(SBOM_DIR)" "$(DIST_DIR)/sbom" + cp -R "$(REPRO_DIR)" "$(DIST_DIR)/reproducibility" + cat "$(SBOM_DIR)/status.txt" > "$(DIST_DIR)/sbom.log" + cat "$(REPRO_DIR)/result.txt" > "$(DIST_DIR)/reproducibility.log" + grep -q '^artifact_archive_status=hashes-only$$' "$(REPRO_DIR)/result.txt" + grep -q '^scratch_build_logs_status=not-archived$$' "$(REPRO_DIR)/result.txt" + $(MAKE) --no-print-directory soak-evidence + rm -rf "$(DIST_DIR)/soak" + cp -R "$(SOAK_DIR)" "$(DIST_DIR)/soak" + cat "$(SOAK_DIR)/status.txt" > "$(DIST_DIR)/soak.log" + @if [ "$${JDNS_RUN_RELEASE_SOAK:-0}" = "1" ]; then \ + grep -q '^tcp_smoke_status=pass$$' "$(SOAK_DIR)/status.txt"; \ + fi + $(MAKE) --no-print-directory fuzz-evidence + rm -rf "$(DIST_DIR)/fuzz-evidence" + cp -R "$(FUZZ_EVIDENCE_DIR)" "$(DIST_DIR)/fuzz-evidence" + cat "$(FUZZ_EVIDENCE_DIR)/status.txt" > "$(DIST_DIR)/fuzz-evidence.log" + @if [ "$${JDNS_RUN_COVERAGE_FUZZ:-0}" = "1" ]; then \ + grep -q '^coverage_fuzz_status=local-smoke-recorded$$' "$(FUZZ_EVIDENCE_DIR)/status.txt"; \ + fi + { \ + printf 'abuse_policy_status=present\n'; \ + printf 'authoritative_only_status=documented\n'; \ + printf 'recursion_status=disabled\n'; \ + printf 'any_query_policy_status=minimized-refused\n'; \ + printf 'udp_payload_policy_status=capped-512-with-truncation\n'; \ + printf 'tcp_resource_policy_status=documented\n'; \ + printf 'edge_rate_limit_status=external-required-until-native-limit\n'; \ + printf 'source=docs/rate-limit-amplification.md\n'; \ + } > "$(DIST_DIR)/abuse-policy.txt" + grep -q '^abuse_policy_status=present$$' "$(DIST_DIR)/abuse-policy.txt" + grep -q '^any_query_policy_status=minimized-refused$$' "$(DIST_DIR)/abuse-policy.txt" + grep -q '^edge_rate_limit_status=external-required-until-native-limit$$' "$(DIST_DIR)/abuse-policy.txt" + sh scripts/sanitize-evidence.sh "$(DIST_DIR)" + sh scripts/sanitize-evidence.sh "$(SBOM_DIR)" + sh scripts/sanitize-evidence.sh "$(REPRO_DIR)" + sh scripts/sanitize-evidence.sh "$(SOAK_DIR)" + sh scripts/sanitize-evidence.sh "$(FUZZ_EVIDENCE_DIR)" clean: - rm -f $(BIN) + rm -f $(BIN) \ + jdns-macos-arm64 jdns-data-macos-arm64 \ + jdns-linux-amd64 jdns-data-linux-amd64 \ + jdns-linux-arm64 jdns-data-linux-arm64 \ + jdns-freebsd-amd64 jdns-data-freebsd-amd64 \ + jdns*-main.c jdns*-libs.boot \ + petite_boot.h scheme_boot.h app_boot.h program_boot.h \ + support/ffi-symbols.gen find lib \( -name '*.so' -o -name '*.wpo' \) -delete 2>/dev/null || true --- a/README.md +++ b/README.md @@ -10,6 +10,30 @@ make build make test ``` +Release evidence: + +```sh +make release-evidence +``` + +The release bundle records DNS soak/load status under +`dist/release-evidence/soak/`. By default this is a blocked, record-only status; +set `JDNS_RUN_RELEASE_SOAK=1` with production-duration `JDNS_SOAK_SECONDS` to +capture current UDP `dnsperf` evidence plus bounded DNS-over-TCP smoke. +Bounded local coverage-guided fuzz evidence is recorded with: + +```sh +JDNS_RUN_COVERAGE_FUZZ=1 JDNS_FUZZ_RUNS=2048 make fuzz-evidence +``` + +When `JDNS_RUN_COVERAGE_FUZZ=1` is set for `make release-evidence`, the release +bundle also records `coverage_fuzz_status=local-smoke-recorded` under +`dist/release-evidence/fuzz-evidence/` after `dns_query`, `cdb_record`, and +`cdb_file` complete. +Authoritative-only, amplification, and edge rate-limit policy is documented in +`docs/rate-limit-amplification.md` and copied into +`dist/release-evidence/abuse-policy.txt`. + Static release targets live behind the `static-*` Make targets, for example: ```sh @@ -27,6 +51,11 @@ is available before chroot. Embedded static builds can fall back to bounded nonblocking OS-thread tasks instead of failing at startup. TCP clients are capped at 128 concurrent sessions, and idle TCP reads/writes time out after 15 seconds. +The daemon is authoritative-only and does not recurse or forward queries. +Amplification-prone `ANY` queries are minimized with a small REFUSED response. +Production deployments must still provide edge rate limits until native +response-rate limiting exists. + Filesystem sandbox setup fails closed by default. For local development only, you can allow a weaker fallback with either: --- a/SECURITY.md +++ b/SECURITY.md @@ -14,15 +14,40 @@ must be cut from a clean checkout after: - `make fuzz-check` - `make audit` - `make verify` +- `make release-evidence` + +Release evidence must include current SBOM/toolchain provenance, RustSec/Cargo +metadata, and a repeated clean daemon build report covering the `jdns` binary, +generated libraries, FFI symbols, and WASM payloads. A reproducibility +`status=mismatch` report is a production blocker unless a release owner records +an explicit exception. Reproducibility evidence must archive hashes/status only, +not raw binaries, generated libraries, or scratch build logs. ## Hardening Expectations -- DNS packet parsing and CDB record walking must stay covered by fuzz harnesses. +- The daemon threat model is tracked in `docs/threat-model.md`. +- Rate-limit and amplification policy is tracked in + `docs/rate-limit-amplification.md`. +- DNS packet parsing and CDB record walking must stay covered by the fuzz + harnesses and corpus described in `docs/fuzzing.md`. +- The daemon must remain authoritative-only; recursion and forwarding are out + of scope. +- `ANY` queries must remain minimized instead of enumerating full zone records. - 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. +- Public production deployment still requires UDP/TCP soak/load evidence, + coverage-guided fuzzing evidence, SBOM/reproducibility evidence, target edge + rate-limit proof, and external review. +- Required target proof files must fail closed: `JDNS_TARGET_SOAK_PROOF_FILE` + must record `production_load_status=current-soak-recorded` and + `tcp_smoke_status=pass`, while `JDNS_TARGET_RATE_LIMIT_PROOF_FILE` must + record `edge_rate_limit_status=target-evidence-recorded`. +- Target proof and release evidence must not include private paths, SSH clone + references, full host fingerprints, operational hostnames, private zone data, + keys, or tokens. ## Reporting --- a/bench/run-server.sh +++ b/bench/run-server.sh @@ -56,7 +56,7 @@ echo "$PID" > "$LOG.pid" # Wait up to 10s for the listener to come up. for _ in $(seq 1 50); do - if grep -q "listening on" "$LOG" 2>/dev/null; then + if grep -Eq "listening on|evt=tcp_listening" "$LOG" 2>/dev/null; then echo "jdns up: pid=$PID port=$PORT mode=$MODE" exit 0 fi new file mode 100755 --- /dev/null +++ b/bench/run-tcp-smoke.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# Run bounded DNS-over-TCP query coverage against a local jdns instance. +# +# Usage: +# bench/run-tcp-smoke.sh <port> <queries-file> <case-count> <summary-out> + +set -euo pipefail + +PORT="${1:?port required}" +QUERY_FILE="${2:?queries file required}" +CASE_COUNT="${3:-128}" +SUMMARY_OUT="${4:?summary output required}" + +mkdir -p "$(dirname "$SUMMARY_OUT")" + +if ! command -v python3 >/dev/null 2>&1; then + { + echo "status=blocked-python3-missing" + echo "tcp_smoke_cases=0" + echo "tcp_smoke_completed=0" + echo "tcp_smoke_failures=0" + echo "detail=python3 is required for DNS-over-TCP smoke evidence" + } > "$SUMMARY_OUT" + exit 0 +fi + +python3 - "$PORT" "$QUERY_FILE" "$CASE_COUNT" > "$SUMMARY_OUT" <<'PY' +import math +import socket +import struct +import sys + +port = int(sys.argv[1]) +query_file = sys.argv[2] +case_count = int(sys.argv[3]) + +qtypes = { + "A": 1, + "NS": 2, + "CNAME": 5, + "SOA": 6, + "MX": 15, + "TXT": 16, + "AAAA": 28, + "ANY": 255, +} + + +def load_queries(path, limit): + queries = [] + with open(path, "r", encoding="utf-8") as handle: + for raw in handle: + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) < 2: + continue + queries.append((parts[0], parts[1].upper())) + if len(queries) >= limit: + break + return queries + + +def encode_name(name): + labels = name.rstrip(".").split(".") + out = bytearray() + total = 1 + for label in labels: + data = label.encode("ascii") + if len(data) == 0 or len(data) > 63: + raise ValueError("invalid label length for %r" % name) + total += 1 + len(data) + if total > 255: + raise ValueError("encoded name too long for %r" % name) + out.append(len(data)) + out.extend(data) + out.append(0) + return bytes(out) + + +def build_query(index, qname, qtype_name): + if qtype_name not in qtypes: + raise ValueError("unsupported qtype %r" % qtype_name) + qid = (0x6000 + index) & 0xFFFF + header = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) + question = encode_name(qname) + struct.pack("!HH", qtypes[qtype_name], 1) + return qid, header + question + + +def read_exact(sock, size): + data = bytearray() + while len(data) < size: + chunk = sock.recv(size - len(data)) + if not chunk: + raise EOFError("short TCP DNS response") + data.extend(chunk) + return bytes(data) + + +queries = load_queries(query_file, case_count) +completed = 0 +failures = [] +rcode_counts = {} +connections = 0 +max_per_connection = 0 + +if not queries: + failures.append("no queries loaded from %s" % query_file) +else: + per_connection = max(1, int(math.ceil(len(queries) / 16.0))) + max_per_connection = per_connection + for offset in range(0, len(queries), per_connection): + chunk = queries[offset:offset + per_connection] + try: + with socket.create_connection(("127.0.0.1", port), timeout=2.0) as sock: + sock.settimeout(2.0) + connections += 1 + for local_index, (qname, qtype_name) in enumerate(chunk): + index = offset + local_index + qid, payload = build_query(index, qname, qtype_name) + sock.sendall(struct.pack("!H", len(payload)) + payload) + response_len = struct.unpack("!H", read_exact(sock, 2))[0] + response = read_exact(sock, response_len) + if response_len < 12: + raise ValueError("response shorter than DNS header for %s %s" % (qname, qtype_name)) + rid, flags, qdcount, _ancount, _nscount, _arcount = struct.unpack("!HHHHHH", response[:12]) + if rid != qid: + raise ValueError("response id mismatch for %s %s" % (qname, qtype_name)) + if (flags & 0x8000) == 0: + raise ValueError("response QR bit missing for %s %s" % (qname, qtype_name)) + if qdcount != 1: + raise ValueError("response qdcount=%d for %s %s" % (qdcount, qname, qtype_name)) + rcode = flags & 0x000F + rcode_counts[rcode] = rcode_counts.get(rcode, 0) + 1 + if rcode not in (0, 3): + raise ValueError("unexpected rcode=%d for %s %s" % (rcode, qname, qtype_name)) + completed += 1 + except Exception as exc: + failures.append("case_%d=%s" % (offset, str(exc))) + if len(failures) >= 10: + break + +status = "pass" if queries and completed == len(queries) and not failures else "fail" + +print("repo=jerboa-dns") +print("status=%s" % status) +print("tcp_smoke_cases=%d" % len(queries)) +print("tcp_smoke_completed=%d" % completed) +print("tcp_smoke_failures=%d" % (len(queries) - completed + len(failures))) +print("tcp_smoke_connections=%d" % connections) +print("tcp_max_queries_per_connection=%d" % max_per_connection) +print("tcp_query_file=%s" % query_file) +print("tcp_rcode_noerror=%d" % rcode_counts.get(0, 0)) +print("tcp_rcode_nxdomain=%d" % rcode_counts.get(3, 0)) +for rcode in sorted(rcode_counts): + if rcode not in (0, 3): + print("tcp_rcode_%d=%d" % (rcode, rcode_counts[rcode])) +for failure in failures[:5]: + print("failure=%s" % failure) +PY --- a/bin/jdns-convert.ss +++ b/bin/jdns-convert.ss @@ -8,7 +8,15 @@ ;;; If output is omitted, writes to stdout. ;;; If input is omitted, reads from stdin. -(import (chezscheme)) ; jerboa-security: suppress direct-chezscheme-import-user-code +(import (scheme)) + +(define (call-with-replacing-output-file path proc) + (call-with-port + (open-file-output-port path + (file-options no-fail) + (buffer-mode block) + (native-transcoder)) + proc)) ;; ========== String Utilities ========== @@ -466,7 +474,6 @@ (let* ([entries (parse-tinydns-file input-path)] [groups (group-by-domain entries)]) (if output-path - (call-with-output-file output-path ; jerboa-security: suppress call-with-output-file-overwrite-fail - (lambda (port) (emit-zone port groups)) - 'replace) + (call-with-replacing-output-file output-path + (lambda (port) (emit-zone port groups))) (emit-zone (current-output-port) groups)))) --- a/bin/jdns-data.ss +++ b/bin/jdns-data.ss @@ -1,3 +1,3 @@ #!chezscheme -(import (chezscheme) (jerboa-dns main)) ; jerboa-security: suppress direct-chezscheme-import-user-code +(import (scheme) (jerboa-dns main)) (run-jdns-data! (cdr (command-line))) --- a/bin/jdns.ss +++ b/bin/jdns.ss @@ -1,3 +1,3 @@ #!chezscheme -(import (chezscheme) (jerboa-dns main)) ; jerboa-security: suppress direct-chezscheme-import-user-code +(import (scheme) (jerboa-dns main)) (run-jdns! (cdr (command-line))) --- a/build.ss +++ b/build.ss @@ -1,4 +1,4 @@ -(import (chezscheme)) ; jerboa-security: suppress direct-chezscheme-import-user-code +(import (scheme)) (compile-imported-libraries #t) (generate-wpo-files #t) (import new file mode 100644 --- /dev/null +++ b/docs/fuzzing.md @@ -0,0 +1,45 @@ +# jerboa-dns Fuzzing And Corpus + +`make fuzz-check` is the required local and CI gate for parser hardening. + +It always runs `fuzz/src/bin/corpus_smoke.rs` under stable Rust. That smoke +runner feeds every checked-in seed in: + +- `fuzz/corpus/dns_query` +- `fuzz/corpus/cdb_record` +- `fuzz/corpus/cdb_file` + +When nightly Rust and `cargo-fuzz` are available, `make fuzz-check` also builds +the libFuzzer targets in `fuzz/fuzz_targets`. + +The checked-in corpus is safe for publication and intentionally contains no +operational zone data. It covers normal A queries, DNS compression pointer +loops, truncated headers, reserved labels, forward pointers, maximum labels, +CDB record value validation, malformed CDB headers, and a minimal valid CDB +record walk. + +Bounded local coverage-guided fuzz evidence is recorded with: + +```sh +JDNS_RUN_COVERAGE_FUZZ=1 JDNS_FUZZ_RUNS=2048 make fuzz-evidence +``` + +The evidence runner copies checked-in seeds into an ignored work corpus under +`dist/fuzz-evidence/work-corpus/` before invoking cargo-fuzz, so generated +minimization inputs cannot mutate the safe-for-publication corpus under +`fuzz/corpus/`. + +- coverage_fuzz_evidence_status: documented +- coverage_fuzz_targets: dns_query-cdb_record-cdb_file +- coverage_fuzz_runs_minimum: 2048 + +Short local runs record `coverage_fuzz_status=local-smoke-recorded` and are +useful for release packet completeness. Production claims still need sustained +coverage-guided fuzzing evidence on a release host, plus UDP/TCP load/soak +evidence recorded before any production daemon claim. +`make release-evidence` now copies `make soak-evidence` output into +`dist/release-evidence/soak/`; unless `JDNS_RUN_RELEASE_SOAK=1` is set for a +current production-length run, that evidence intentionally records a blocked +soak status. When the opt-in soak path is run, the evidence also includes a +bounded DNS-over-TCP query/response smoke summary so TCP coverage is visible in +the release packet instead of relying only on UDP `dnsperf` output. new file mode 100644 --- /dev/null +++ b/docs/rate-limit-amplification.md @@ -0,0 +1,88 @@ +# Rate Limit And Amplification Policy + +`jerboa-dns` is authoritative-only. Its production safety depends on avoiding +open-recursive behavior, bounding response size and TCP state, and deploying an +edge rate-limit policy appropriate for the hosted zones. + +## Release Checklist Markers + +- authoritative_only_status: documented +- recursion_status: disabled +- any_query_policy_status: minimized-refused +- udp_payload_policy_status: capped-512-with-truncation +- tcp_resource_policy_status: documented +- edge_rate_limit_status: external-required-until-native-limit +- target_rate_limit_proof_status: required-before-public-release +- amplification_review_status: required-before-public-release + +These markers are checked by `make security` and copied into release evidence. +Changing one of them requires a matching implementation, tests, and refreshed +load evidence. + +## Authoritative-Only Behavior + +- The daemon must not perform recursion, forwarding, cache lookup, or outbound + DNS resolution. +- AXFR is disabled at the daemon boundary. +- Non-IN classes are refused without a response body. +- Unknown or malformed packets are dropped on the wire and logged. +- `ANY` queries are amplification-prone and are minimized at the daemon + boundary with a small REFUSED response instead of enumerating zone records. + +## Response Size Policy + +- UDP receives at most 512-byte DNS packets and emits responses capped to the + 512-byte DNS UDP limit. +- When a UDP response would exceed the configured transport maximum, the daemon + sets TC and truncates to the question section. +- TCP can carry larger protocol responses, but TCP sessions are bounded by + connection count, idle timeout, and read/write deadlines. +- EDNS larger UDP payload negotiation is not a production feature in this + release gate. Do not claim EDNS-sized UDP behavior without a separate abuse + review and soak run. + +## Zone Authoring Guidance + +- Avoid unnecessary `ANY`-amplifying record sets, large TXT records, and broad + wildcard records in public zones. +- Review SOA, NS, MX, SRV, TXT, and glue records for response size before + publication. +- Keep wildcard coverage narrow and document why it is needed. +- Treat private hostnames, internal addresses, and operational TXT records as + sensitive release-review items. + +## Edge Rate Limits + +The daemon does not yet include a native token-bucket or response-rate-limit +implementation. Production deployments must provide equivalent rate limiting at +the edge until that exists: + +- Per-source UDP query rate limits. +- Separate limits for malformed traffic and repeated NXDOMAIN/REFUSED traffic. +- TCP connection and connection-rate limits before the daemon. +- Packet-filter or load-balancer rules that drop spoof-prone traffic where the + deployment environment can identify it. +- Monitoring for response-size distribution, TC rate, refused `ANY` rate, and + dropped malformed query rate. + +Document the selected rate-limit layer, thresholds, and observed drops in the +release packet. Target proof attached with `JDNS_TARGET_RATE_LIMIT_PROOF_FILE` +must contain `edge_rate_limit_status=target-evidence-recorded` and is hashed +into `dist/soak/`; required proof fails closed with +`JDNS_REQUIRE_TARGET_RATE_LIMIT_PROOF=1`. Local short `dnsperf` smoke output is +not production abuse evidence. + +## Production Evidence + +A production release needs: + +- `dist/release-evidence/abuse-policy.txt` with the markers above. +- `dist/release-evidence/soak/status.txt` with + `production_load_status=current-soak-recorded` and `tcp_smoke_status=pass`. +- DNS soak logs that include both UDP and TCP coverage for the selected zone + mix. +- Edge rate-limit configuration or target-host firewall/load-balancer evidence. +- `target_rate_limit_proof_status=present` in `dist/release-evidence/soak/status.txt` + when edge rate-limit proof is attached instead of generated by the local run. +- External review of authoritative-only behavior, `ANY` minimization, response + sizing, and rate-limit thresholds. new file mode 100644 --- /dev/null +++ b/docs/release-evidence.md @@ -0,0 +1,118 @@ +# Release Evidence + +`jerboa-dns` stays private until a release candidate has current evidence for +the full daemon gate, dependency inventory, reproducibility, fuzzing, load +testing, and external review. + +Run from a clean checkout: + +```sh +make clean +make release-evidence JERBUILD=/path/to/jerbuild +``` + +The `release-evidence` target runs `make verify` first, then writes untracked +artifacts under `dist/release-evidence/`: + +- `git-commit.txt` and `git-status.txt`. +- `build-environment.txt` with host-neutral OS and Jerboa toolchain status. +- `sbom/` and `sbom.log` with toolchain identity, source/release-input hashes, + Cargo lockfile hashes, generated WASM hashes, native Rust input hashes, and + Git state for the local `jerboa` checkout when present. +- `reproducibility/` and `reproducibility.log` with two clean daemon builds, + SHA-256 manifests for `jdns`, generated libraries/FFI symbols, WASM payloads, + and bounded hash diffs when the manifests do not match. Raw binaries, + generated libraries, and scratch build logs are not archived. +- Cargo dependency metadata for the WASM parser workspace and fuzz workspace. +- RustSec audit output for the release WASM parser workspace. +- SHA-256 hashes for checked-in DNS/CDB parser WASM artifacts. +- `fuzz-evidence/` and `fuzz-evidence.log` with bounded cargo-fuzz evidence + status. By default this records `coverage_fuzz_status=blocked-not-run`; run + with `JDNS_RUN_COVERAGE_FUZZ=1` to capture current local coverage-fuzz smoke. +- tcp_soak_smoke_status: documented +- tcp_soak_smoke_cases_minimum: 128 +- tcp_soak_smoke_scope_status: tcp-query-response +- target_soak_proof_status: documented +- target_rate_limit_proof_status: documented +- `soak/` and `soak.log` with DNS load-test tool availability, historical + benchmark hashes, and current soak status. By default this records + `status=not-run` and `production_load_status=blocked-not-run`; run with + `JDNS_RUN_RELEASE_SOAK=1` to capture a current UDP `dnsperf` soak plus bounded + DNS-over-TCP query/response smoke. +- `abuse-policy.txt`, generated from `docs/rate-limit-amplification.md`, with + authoritative-only, recursion-disabled, minimized `ANY`, UDP payload, TCP + resource, and external edge-rate-limit policy markers. + +To record a current production-length soak, run: + +```sh +JDNS_RUN_RELEASE_SOAK=1 \ +JDNS_SOAK_MODE=sandbox \ +JDNS_SOAK_SECONDS=1800 \ +JDNS_SOAK_QPS=15000 \ +JDNS_TCP_SMOKE_CASES=128 \ +make release-evidence JERBUILD=/path/to/jerbuild +``` + +Shorter values are useful for harness smoke tests, but are recorded as +`status=short-smoke-not-production`, `load_status=local-smoke-recorded`, and +`production_load_status=blocked-not-run`; TCP smoke output is recorded as +`tcp_smoke_status=pass` only after the local DNS-over-TCP query/response helper +passes. Short runs do not satisfy the daemon release gate. +On macOS/local runs where chroot is unavailable, `JDNS_SOAK_ALLOW_SANDBOX_FALLBACK=1` +may be used only to verify the load-test harness; that evidence is not +production sandbox evidence. + +If production-length soak or edge rate-limit evidence is captured by another +release-host job, attach those proof files to the evidence bundle with: + +```sh +JDNS_TARGET_SOAK_PROOF_FILE=/path/to/soak-status.txt \ +JDNS_TARGET_RATE_LIMIT_PROOF_FILE=/path/to/rate-limit-proof.txt \ +JDNS_REQUIRE_TARGET_SOAK_PROOF=1 \ +JDNS_REQUIRE_TARGET_RATE_LIMIT_PROOF=1 \ +make soak-evidence +``` + +The soak proof must contain both `production_load_status=current-soak-recorded` +and `tcp_smoke_status=pass`. The rate-limit proof must contain +`edge_rate_limit_status=target-evidence-recorded`. Supplied proof files are +accepted only after marker, size, high-confidence secret, private-path, SSH +clone reference, and full-host-fingerprint checks pass; accepted files are +copied into `dist/soak/` and hashed. Missing, empty, marker-mismatched, +private-material, or secret-bearing required proof files fail closed with +`status=blocked-target-proof`. + +To also record bounded local cargo-fuzz evidence in the release packet, run: + +```sh +JDNS_RUN_COVERAGE_FUZZ=1 \ +JDNS_FUZZ_RUNS=2048 \ +make release-evidence JERBUILD=/path/to/jerbuild +``` + +This records `coverage_fuzz_status=local-smoke-recorded` only if all three +targets, `dns_query`, `cdb_record`, and `cdb_file`, complete. It is useful local +coverage evidence but does not replace sustained release-host fuzzing. + +For a release candidate, archive those files with the candidate binaries and +record the exact `JERBUILD` release used. The evidence sanitizer runs over +release, SBOM, reproducibility, soak, and fuzz evidence before the target +returns. Reproducibility is claimed only when +`dist/release-evidence/reproducibility/result.txt` records `status=match`, +`artifact_archive_status=hashes-only`, and +`scratch_build_logs_status=not-archived`, or a release owner records an +explicit exception. Soak/load evidence is claimed only +when `dist/release-evidence/soak/status.txt` records `status=current-soak-recorded` +for a production-length run, records `tcp_smoke_status=pass`, and the release +owner reviews the copied dnsperf, watchdog, and TCP smoke logs, or when a +required `target_soak_proof_status=present` proof file is reviewed and accepted. +Edge rate-limit evidence is claimed only when the release packet contains +reviewed target configuration/evidence and `target_rate_limit_proof_status=present`. + +The external review packet must include this file, `SECURITY.md`, +`docs/threat-model.md`, `docs/rate-limit-amplification.md`, `docs/fuzzing.md`, +the corpus under `fuzz/corpus/`, the latest UDP/TCP soak and load-test logs, +`dist/release-evidence/soak/`, `dist/release-evidence/fuzz-evidence/`,