Skip to content

Wide hardening pass before 0.1.0 release - #1

Merged
sylvinus merged 3 commits into
mainfrom
hardening
Aug 9, 2026
Merged

sylvinus merged 3 commits into
mainfrom
hardening

Conversation

@sylvinus

@sylvinus sylvinus commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • Added DNSSEC validation with secure, insecure, and bogus answer states.
    • Added configurable caching, query budgets, address filtering, tracing, and structured JSON output.
    • Expanded CLI options for DNSSEC, EDNS, TXT records, caching, validation, and private-address handling.
    • Added answer and RRset resolution interfaces with improved IDNA and record-type handling.
  • Security

    • Added protections against SSRF, cache poisoning, malformed responses, unsafe referrals, and resource exhaustion.
  • Documentation

    • Added comprehensive setup, contribution, security, licensing, and operational guidance.
  • Quality

    • Added CI checks, expanded automated testing, coverage validation, and release verification.

@sylvinus

sylvinus commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai please review

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

@sylvinus I will review the changes in #1.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sylvinus, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 25 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c0a6c15e-ccc7-46c7-ac44-ef3c65289efc

📥 Commits

Reviewing files that changed from the base of the PR and between f233d5c and a87fef5.

📒 Files selected for processing (9)
  • Makefile
  • scripts/release.sh
  • src/recursive_resolver/cache.py
  • src/recursive_resolver/cli.py
  • src/recursive_resolver/dnssec.py
  • src/recursive_resolver/singleflight.py
  • tests/test_cache.py
  • tests/test_cli.py
  • tests/test_singleflight.py
📝 Walkthrough

Walkthrough

This change establishes the first public release of an iterative DNSSEC resolver. It adds DNSSEC validation, hardened resolution, typed caching, security controls, CLI updates, comprehensive tests, CI, release automation, documentation, and contribution workflows.

Changes

Resolver release

Layer / File(s) Summary
Project contracts and contribution workflow
.github/ISSUE_TEMPLATE/*, .github/pull_request_template.md, CHANGELOG.md, CONTRIBUTING.md, README.md, SECURITY.md, THIRD-PARTY.md, LICENSE
Adds project documentation, issue forms, security guidance, licensing notices, and release documentation.
Build, CI, and release automation
.github/workflows/ci.yml, pyproject.toml, Dockerfile, Makefile, scripts/release.sh, scripts/check_authors.py
Adds matrix CI, package validation, authorship checks, all-extra installation, coverage enforcement, and Docker-based release automation.
Corpus and differential-testing tools
scripts/collect_domains_*.py, scripts/collect_domains_diverse.py, scripts/diff_harness.py, scripts/prepare_test_domains.py
Adds reproducible DNS corpus generation and concurrent comparison against dig and dnspython references.
Resolver safety, budgets, cache, and concurrency
src/recursive_resolver/addresses.py, budget.py, cache.py, exceptions.py, roots.py, singleflight.py
Adds address filtering, resource budgets, typed DNS caching, new resolver exceptions, root trust anchors, and concurrent request deduplication.
DNSSEC validation engine
src/recursive_resolver/dnssec.py
Adds trust-anchor validation, DNSKEY and DS chain checks, RRSIG verification, NSEC/NSEC3 proofs, wildcard validation, and bounded cryptographic processing.
Iterative resolution and transport
src/recursive_resolver/resolver.py
Replaces the earlier resolver flow with cache-aware, budgeted, DNSSEC-aware iterative resolution, strict referral handling, CNAME support, tracing, and transport fallback.
CLI, fixtures, and public API validation
src/recursive_resolver/__init__.py, __main__.py, cli.py, tests/conftest.py, tests/test_resolver.py, tests/test_cli.py, tests/test_csv.py
Exports the expanded API, guards module execution, adds DNSSEC-aware CLI output, and updates resolver and CSV tests.
DNSSEC, security, cache, and transport tests
tests/signed_zone.py, tests/test_dnssec*.py, tests/test_security.py, tests/test_cache.py, tests/test_internals.py, tests/test_singleflight.py, tests/test_transport.py, tests/test_integration.py
Adds signed-zone fixtures and tests for validation, security controls, caching, concurrency, transport behavior, integration behavior, and downgrade resistance.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant CLI
  participant RecursiveResolver
  participant DNSCache
  participant DNSSECValidator
  participant DNSAuthority
  Client->>CLI: submit query and resolver options
  CLI->>RecursiveResolver: resolve_answer or trace_answer
  RecursiveResolver->>DNSCache: check answer or delegation cache
  RecursiveResolver->>DNSAuthority: perform iterative DNS queries
  RecursiveResolver->>DNSSECValidator: validate RRset or denial proof
  DNSSECValidator-->>RecursiveResolver: validation state
  RecursiveResolver-->>CLI: Answer or resolver error
  CLI-->>Client: records, trace, and DNSSEC status
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the broad security, reliability, testing, and release-readiness changes in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (20)
scripts/collect_domains_diverse.py-218-220 (1)

218-220: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject negative --limit values.

A negative limit is truthy at Line 256. Python then removes rows from the end of the corpus. Reject negative values before slicing.

Proposed fix
     args = parser.parse_args()
+    if args.limit < 0:
+        parser.error("--limit must be zero or greater")
 
     rng = random.Random(args.seed)
@@
-    if args.limit:
+    if args.limit > 0:
         rows = rows[: args.limit]

Also applies to: 254-257

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/collect_domains_diverse.py` around lines 218 - 220, Validate the
parsed limit argument in the collection flow before the slicing logic around the
main corpus handling at lines 254–257, rejecting any negative --limit value
while continuing to treat 0 as unlimited. Ensure invalid input exits with a
clear argument error rather than allowing Python’s negative slicing behavior.
scripts/diff_harness.py-286-302 (1)

286-302: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate required execution inputs.

ThreadPoolExecutor rejects max_workers of 0 or negative values at runtime. An empty --reference-servers value leaves servers empty, so no reference is queried and run_one() hits the best is not None assertion. Reject these inputs during argument validation.

Proposed fix
     args = parser.parse_args()
 
+    if args.workers < 1:
+        parser.error("--workers must be at least 1")
+
@@
     servers = [s.strip() for s in args.reference_servers.split(",") if s.strip()]
+    if not servers:
+        parser.error("--reference-servers must contain at least one server")
+
     corpus = load_corpus(args.csv, args.sample, args.seed)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/diff_harness.py` around lines 286 - 302, Update the argument
validation near parser.parse_args() to reject non-positive args.workers and an
empty servers list derived from --reference-servers, reporting each as an
invalid input and returning 2 before execution begins. Preserve the existing
validation flow for reference, types, and configs.
README.md-111-114 (1)

111-114: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Limit the root-query warning to cache misses.

With cache_answers=True, an answer or negative-cache hit can finish resolution without a root query, even when delegation caching is disabled. Replace “every single resolution” with “every upstream cache miss” to describe the actual load.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 111 - 114, Update the warning text in the zone-cut
caching documentation to limit the root-query claim to upstream cache misses,
replacing “every single resolution” accordingly while preserving the existing
production warning and cache configuration context.
.github/ISSUE_TEMPLATE/bug_report.yml-30-31 (1)

30-31: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use an explicit reference resolver in the reproduction command.

The form requests dig +dnssec <domain> <type> through the system stub. CONTRIBUTING.md Lines 100-106 says that the system stub can synthesize a CNAME and that reference queries must use an explicit IP. Request dig +dnssec @<server> <domain> <type>, or require reporters to record the resolver address.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/ISSUE_TEMPLATE/bug_report.yml around lines 30 - 31, Update the
“Expected versus actual” description in the bug report template to require an
explicit reference resolver IP in the dig command, using the form `dig +dnssec
@<server> <domain> <type>` or requiring the reporter to record the resolver
address.
README.md-30-42 (1)

30-42: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep live DNS responses out of normative output examples.

Lines 35-41 and 167-178 hard-code A records and current CDN addresses. These values can change without a library release. Use placeholders or label the output as a dated snapshot.

Also applies to: 166-179

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 30 - 42, Update the README examples around
RecursiveResolver.resolve and resolve_answer, including the repeated example
near the later referenced section, so they do not present live DNS addresses as
normative output. Replace changing A-record values with stable placeholders or
explicitly label the output as a dated snapshot while preserving the API usage
and DNSSEC result demonstration.
README.md-50-52 (1)

50-52: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not claim that an RSA-2048 DKIM key is always split into two chunks.

DNS character strings can be split at arbitrary boundaries. A DKIM record can contain more than two chunks. The invariant is concatenation without a separator.

Suggested wording
-An RSA-2048 DKIM key is always split in two, so this is the difference between a key that verifies and one that never does.
+An RSA-2048 DKIM key requires multiple chunks, but publishers may split it into two or more chunks.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 50 - 52, Update the DKIM guidance in the README to
remove the claim that RSA-2048 keys are always split into two chunks. State that
DNS character strings may be split at arbitrary boundaries or into multiple
chunks, and that the correct invariant is concatenating all chunks without
separators.
README.md-93-94 (1)

93-94: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make DNSSEC requirements consistent with pyproject.toml.

pyproject.toml only depends on dnspython[dnssec,idna], which does not install cryptography, but README.md:93-94 says pip install recursive-resolver includes it and RecursiveResolver() enables DNSSEC by default. Either add cryptography>=42 to dependencies or change the documented default/quick-start to require the dnssec extra. Also update SECURITY.md so it does not imply cryptography comes from the dnssec extra when recursive-resolver can document it as a default runtime dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 93 - 94, Add cryptography>=42 to the dependencies in
pyproject.toml so the documented default RecursiveResolver() DNSSEC behavior
matches installation. Keep the README quick-start/default guidance consistent
with that dependency, and update SECURITY.md to state that cryptography is a
direct runtime dependency rather than implying it is provided by the dnspython
dnssec extra.
scripts/release.sh-87-88 (1)

87-88: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The pre-flight changelog check and the release-notes extraction use different patterns.

Line 87 accepts [${VERSION}] anywhere in CHANGELOG.md. Line 437 requires a heading that starts with ## [${VERSION}]. A version referenced only in a link definition at the bottom of the file passes pre-flight and then yields empty release notes after the irreversible PyPI upload. The grep pattern also treats . as a wildcard.

Match the heading form in pre-flight, and fail if the extracted notes are empty.

🛠️ Proposed change
-grep -q "\[${VERSION}\]" "${REPO_DIR}/CHANGELOG.md" \
-    || die "CHANGELOG.md has no entry for ${VERSION}"
+grep -qF "## [${VERSION}]" "${REPO_DIR}/CHANGELOG.md" \
+    || die "CHANGELOG.md has no '## [${VERSION}]' section"
 ' "${REPO_DIR}/CHANGELOG.md" > "${NOTES_FILE}"
+[[ -s "${NOTES_FILE}" ]] || die "extracted release notes for ${VERSION} are empty"
 printf '\n---\n\nInstall: `pip install %s==%s`\n' "${PKG_NAME}" "${VERSION}" >> "${NOTES_FILE}"

Also applies to: 436-440

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/release.sh` around lines 87 - 88, Update the pre-flight check near
the changelog validation to require a line beginning with the exact heading form
“## [${VERSION}]”, escaping the version for literal matching rather than
allowing regex wildcards. In the release-notes extraction around the existing
heading logic, validate that the extracted notes are non-empty and call the
established failure path before any upload proceeds.
.github/workflows/ci.yml-84-87 (1)

84-87: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Confirm that python is on PATH in the build job.

This job never runs actions/setup-python, and uv python install does not guarantee a python executable on PATH. If the runner image stops shipping a bare python, this step fails. uv run --no-project python removes the assumption.

Also confirm that exactly one wheel matches dist/*.whl, because python -m zipfile -l accepts a single archive argument.

🛠️ Proposed change
       - name: Verify the wheel ships py.typed
         run: |
-          python -m zipfile -l dist/*.whl | grep -q 'recursive_resolver/py.typed' \
+          uv run --no-project python -m zipfile -l dist/*.whl | grep -q 'recursive_resolver/py.typed' \
             || { echo "py.typed missing from the wheel"; exit 1; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 84 - 87, Update the “Verify the wheel
ships py.typed” step to invoke Python through “uv run --no-project python”
instead of assuming a bare python executable is on PATH, and ensure the wheel
check first resolves exactly one file from dist/*.whl before passing it to
zipfile.
scripts/check_authors.py-120-120 (1)

120-120: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Read the commit message file as UTF-8.

Path.read_text() uses the platform default encoding. Git writes commit messages as UTF-8. On a system with a non-UTF-8 default, this raises UnicodeDecodeError for an accented name in a trailer.

🛠️ Proposed change
-        problems = check_message(Path(argv[1]).read_text(), "commit message")
+        problems = check_message(Path(argv[1]).read_text(encoding="utf-8"), "commit message")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_authors.py` at line 120, Update the commit-message read in the
check_authors flow to explicitly decode Path(argv[1]) as UTF-8 before passing it
to check_message, preserving the existing "commit message" label and validation
behavior.
scripts/check_authors.py-60-61 (1)

60-61: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

\[bot\] rejects every GitHub App author, not only AI tools.

The pattern matches dependabot[bot], renovate[bot], and github-actions[bot]. The authors CI job runs on every pull request, so a dependency-bump PR fails the check for a reason the policy does not intend. Restrict the pattern to the AI app names, or allowlist the known non-AI bots.

🛠️ Proposed change
-    # The GitHub App convention, e.g. "copilot[bot]" or "openai-codex[bot]".
-    r"\[bot\]",
+    # The GitHub App convention, e.g. "copilot[bot]" or "openai-codex[bot]".
+    # Scoped to AI apps: dependabot[bot] and renovate[bot] are not authors of
+    # this kind and must not trip the check.
+    r"\b(?:copilot|codex|claude|devin|cursor|gemini|chatgpt)[\w-]*\[bot\]",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_authors.py` around lines 60 - 61, Update the author-matching
regex in the authors validation logic to reject only the intended AI GitHub App
names, such as copilot[bot] and openai-codex[bot], rather than every author
ending in [bot]. Preserve acceptance of known non-AI bots including
dependabot[bot], renovate[bot], and github-actions[bot].
scripts/release.sh-452-454 (1)

452-454: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The final message prints the PyPI URL, not the GitHub release URL.

This block confirms the GitHub release. Line 454 repeats the PyPI link from line 411.

🛠️ Proposed change
 echo
 ok "GitHub release ${TAG} created"
-echo "  https://pypi.org/project/${PKG_NAME}/${VERSION}/"
+echo "  https://github.com/${REPO_SLUG}/releases/tag/${TAG}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/release.sh` around lines 452 - 454, Update the final confirmation
block after “GitHub release ${TAG} created” to print the GitHub release URL
instead of the PyPI URL, reusing the script’s existing GitHub repository and TAG
variables or URL format.
pyproject.toml-10-11 (1)

10-11: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Constrain Hatchling for PEP 639 license metadata.

requires = ["hatchling"] allows old Hatchling releases that either fail on license-files or emit incompatible metadata; lock a sufficiently recent bound, such as Hatchling with final PEP 639/metadata 2.4 support, so scripts/release.sh receives License-Expression and included license files as promised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` around lines 10 - 11, Constrain the Hatchling build
requirement in the project configuration to a sufficiently recent version that
supports PEP 639 and metadata 2.4, while retaining the existing license and
license-files declarations so release.sh produces License-Expression metadata
and includes the specified license files.
tests/test_cli.py-80-87 (1)

80-87: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Three CLI tests build a real RecursiveResolver and so depend on the dnssec extra. These tests omit the patch("recursive_resolver.cli.RecursiveResolver") context used by every other test in the file. main therefore constructs a real resolver with the CLI default dnssec=True, and RecursiveResolver.__init__ raises DNSSECUnavailableError when cryptography_available() is false. In such an environment main returns 2 from the constructor handler at src/recursive_resolver/cli.py lines 114-116, and each assertion fails for a reason unrelated to what it tests.

  • tests/test_cli.py#L80-L87: test_unknown_rdtype_is_reported_cleanly and test_invalid_name_is_reported_cleanly both assert == 1; add "--no-dnssec" to each argv list so the constructor never reaches the cryptography check.
  • tests/test_cli.py#L180-L182: test_an_unknown_cache_depth_is_reported_cleanly asserts "unknown cache depth" on stderr, but the DNSSEC check precedes cache construction in __init__, so the message becomes DNSSECUnavailableError; add "--no-dnssec" to the argv list here as well.

If every CI matrix job installs the dnssec extra, no change is needed. Confirm with the script in the comment at lines 80-87.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cli.py` around lines 80 - 87, Update tests/test_cli.py lines 80-87
in test_unknown_rdtype_is_reported_cleanly and
test_invalid_name_is_reported_cleanly to pass --no-dnssec in each main argument
list; also update tests/test_cli.py lines 180-182 in
test_an_unknown_cache_depth_is_reported_cleanly with --no-dnssec, ensuring these
tests exercise their intended validation paths without requiring the DNSSEC
extra.
src/recursive_resolver/cli.py-118-127 (1)

118-127: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle BrokenPipeError so piped output does not print a traceback.

The plain output path is documented as pipeable. If the reader closes early, for example recursive-resolver example.com | head -1, the interpreter raises BrokenPipeError on flush and prints a traceback at exit. Catch it and exit quietly.

🛡️ Proposed fix
     try:
         if args.trace:
             return _run_trace(resolver, args)
         return _run_query(resolver, args)
+    except BrokenPipeError:
+        # The reader (head, grep -q, ...) closed the pipe. Exit quietly.
+        os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
+        return 0
     except ResolverError as exc:

Add import os at the top of the module.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/cli.py` around lines 118 - 127, Update the CLI
execution flow around the existing ResolverError handling to catch
BrokenPipeError from piped output and exit quietly with no traceback; add the
required os import and use the appropriate quiet-exit behavior for the process.
src/recursive_resolver/cli.py-178-188 (1)

178-188: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

--text is ignored in trace mode.

_run_query honours --text and prints concatenated character-strings. _run_trace always prints answer.records, in both JSON and plain mode. A user who runs --trace --text example.com TXT receives presentation format with the " " seam, which is the exact DKIM/SPF trap that Answer.text_values() exists to avoid. The flag combination is accepted, so the silent difference is surprising.

Either apply the same conversion, or reject --text with --trace.

🐛 Proposed fix: share the value conversion
+def _values_for(answer: Answer, args: argparse.Namespace) -> list[str] | None:
+    """Records honouring --text, or None if --text does not apply to the type."""
+    if not args.text:
+        return answer.records
+    try:
+        return answer.text_values()
+    except TypeError as exc:
+        print(f"--text: {exc}", file=sys.stderr)
+        return None

Then in _run_trace:

     answer, trace = resolver.trace_answer(args.domain, args.rdtype)
+    values = _values_for(answer, args) if answer is not None else None
+    if answer is not None and values is None:
+        return 2
     if args.json_output:
@@
-            "records": answer.records if answer else None,
+            "records": values,
@@
         if answer is not None:
             _note_dnssec(answer, args)
-            for record in answer.records:
+            for record in values or []:
                 print(record)

_run_query can then call _values_for as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/cli.py` around lines 178 - 188, Update _run_trace to
honor --text by using the shared value-conversion helper, such as _values_for,
when rendering answer records instead of printing answer.records directly. Reuse
the same conversion in _run_query so normal and trace modes produce identical
text values, while preserving existing JSON and plain output behavior when
--text is not set.
tests/test_cli.py-14-22 (1)

14-22: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Import dns.rdatatype and dns.name explicitly.

_answer uses dns.rdatatype.from_text and dns.name.from_text, but _answer only imports dns.rdata, dns.rdataclass, and dns.rrset. Add explicit imports for the modules this helper uses so the test does not depend on dnspython’s internal import side effects.

🛡️ Proposed fix
 def _answer(records: str = "1.2.3.4", rdtype: str = "A", dnssec=ValidationState.SECURE) -> Answer:
+    import dns.name
     import dns.rdata
     import dns.rdataclass
+    import dns.rdatatype
     import dns.rrset
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cli.py` around lines 14 - 22, Update the test helper _answer to
explicitly import dns.rdatatype and dns.name before using
dns.rdatatype.from_text and dns.name.from_text, preserving the existing RRset
construction behavior.
src/recursive_resolver/resolver.py-854-857 (1)

854-857: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use a cryptographically secure source for the two anti-attacker randomisations.

Both call sites justify randomisation as a defence against an attacker choosing the order. Line 855 states that a deterministic slice is attacker-orderable. random is the Mersenne Twister, which is seeded predictably and whose state is recoverable from enough observed outputs. Using it for a control whose stated purpose is to deny an attacker control over selection is inconsistent.

The change is local and has no measurable cost at this call rate.

🔒️ Proposed fix
-import random
+import secrets
+_RANDOM = secrets.SystemRandom()
-            ns_names = random.sample(ns_names, self.limits.max_ns_per_referral)
+            ns_names = _RANDOM.sample(ns_names, self.limits.max_ns_per_referral)
     def _order_servers(self, nameservers: list[str]) -> list[str]:
         servers = self.address_filter.filter(nameservers)
-        random.shuffle(servers)
+        _RANDOM.shuffle(servers)
         return servers

Also applies to: 1265-1268

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/resolver.py` around lines 854 - 857, Replace the
standard `random` module calls at both anti-attacker randomisation
sites—including the `ns_names` sampling near the referral limit check and the
corresponding call near the second site—with a cryptographically secure
randomness source. Preserve the existing sample sizes and selection behavior
while ensuring both selections use the secure source consistently.

Source: Linters/SAST tools

src/recursive_resolver/resolver.py-244-246 (1)

244-246: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

An empty trust_anchors tuple silently falls back to the IANA anchors.

Line 246 tests truthiness. RecursiveResolver(trust_anchors=()) therefore constructs DNSSECValidator() with the built-in IANA anchors, not with an empty anchor set. The declared type is tuple[str, ...] | None, and None already means "use the defaults". A caller who passes () expects the opposite of what happens.

Test against None so the two cases stay distinct. If an empty tuple is invalid input, reject it explicitly.

🐛 Proposed fix
         self._validator: DNSSECValidator | None = None
         if dnssec:
-            self._validator = DNSSECValidator(trust_anchors=trust_anchors) if trust_anchors else DNSSECValidator()
+            self._validator = (
+                DNSSECValidator(trust_anchors=trust_anchors) if trust_anchors is not None else DNSSECValidator()
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/resolver.py` around lines 244 - 246, Update the
validator initialization in RecursiveResolver to distinguish trust_anchors=None
from an explicitly empty tuple: only use DNSSECValidator() for None, and pass
any provided tuple, including (), as trust_anchors. If empty tuples are intended
to be invalid, validate and reject them explicitly instead.
src/recursive_resolver/dnssec.py-512-523 (1)

512-523: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Apply the NSEC3 iteration cap in prove_wildcard.

prove_no_ds, prove_nxdomain, and prove_nodata all reject NSEC3 records above self.max_nsec3_iterations before hashing. prove_wildcard does not. A hostile zone can therefore supply a wildcard proof with a very high iteration count, and each _nsec3_covers call performs that many hash rounds. The query budget limits the number of hashes, not the cost of one hash.

🛡️ Proposed fix
         params = nsec3s[0][0]
+        if params.iterations > self.max_nsec3_iterations:
+            logger.debug("NSEC3 iteration count %d exceeds cap", params.iterations)
+            return False
         return any(self._nsec3_covers(next_closer, n, params, budget) for n in nsec3s)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/dnssec.py` around lines 512 - 523, Update
prove_wildcard around the validated NSEC3 records and before any _nsec3_covers
call to reject records whose iteration count exceeds self.max_nsec3_iterations,
matching the guards in prove_no_ds, prove_nxdomain, and prove_nodata; preserve
the existing false-return behavior for disallowed proofs and ensure over-limit
records are not hashed.
🧹 Nitpick comments (16)
scripts/release.sh (1)

400-408: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Reconsider --skip-existing on the real PyPI upload.

On TestPyPI the flag is useful for retries. On PyPI it converts "this version is already published" into a silent success. The script then tags and creates a GitHub release for artifacts that PyPI never accepted. Dropping the flag makes the conflict visible.

🛠️ Proposed change
-        python -m twine upload --skip-existing dist/*
+        python -m twine upload dist/*
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/release.sh` around lines 400 - 408, Remove the --skip-existing option
from the python -m twine upload command in the real PyPI release block, while
leaving the TestPyPI upload behavior unchanged so duplicate-version conflicts
fail visibly before tagging or creating the GitHub release.
.github/workflows/ci.yml (2)

64-72: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add timeout-minutes to the jobs, in particular integration.

The integration job queries live DNS. If resolution hangs, the job runs to the 6-hour default limit and consumes the runner. A short explicit timeout bounds the failure.

⏱️ Proposed change
   integration:
     runs-on: ubuntu-latest
+    timeout-minutes: 20
     steps:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 64 - 72, Add an explicit short
timeout-minutes setting to the integration job, and apply the same job-level
timeout to the other CI jobs as appropriate. Keep the existing integration steps
and test command unchanged.

27-27: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Set persist-credentials: false on the checkout steps.

No job pushes to the repository. The default checkout stores the GITHUB_TOKEN in .git/config, which widens the blast radius of any compromised step. zizmor flags all four checkouts.

🔒 Proposed change for each checkout step
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false

For the authors job, add it next to the existing fetch-depth:

       - uses: actions/checkout@v4
         with:
           # The whole history, so the base commit of the PR is present.
           fetch-depth: 0
+          persist-credentials: false

Also applies to: 51-54, 67-67, 77-77

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml at line 27, Update all four actions/checkout@v4
steps in the workflow, including those in the authors and other referenced jobs,
to set persist-credentials to false alongside the existing checkout options.
Preserve the current checkout behavior and other configuration.

Source: Linters/SAST tools

Makefile (1)

64-75: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Anchor the version regexes, and consider reusing one extractor.

re.search(r'version = "(.+?)"', ...) is not anchored, so it returns the first match anywhere in pyproject.toml, not necessarily the [project] version. scripts/release.sh anchors the same lookup with awk -F'"' '/^version = /', so the two paths can disagree. Line 75 also interpolates the version into a grep pattern, where . matches any character.

♻️ Proposed change
-VERSION := $(shell python3 -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))")
+VERSION := $(shell python3 -c "import re; print(re.search(r'^version = \"(.+?)\"', open('pyproject.toml').read(), re.M).group(1))")
 
 release: ## Interactive release to PyPI and GitHub (see CONTRIBUTING.md)
 	./scripts/release.sh
 
 release-check: check-all ## Everything the release script checks, without publishing
 	`@echo` "Verifying version consistency..."
-	`@PY_VER`=$$(python3 -c "import re; print(re.search(r'__version__ = \"(.+?)\"', open('src/recursive_resolver/__init__.py').read()).group(1))"); \
+	`@PY_VER`=$$(python3 -c "import re; print(re.search(r'^__version__ = \"(.+?)\"', open('src/recursive_resolver/__init__.py').read(), re.M).group(1))"); \
 	if [ "$$PY_VER" != "$(VERSION)" ]; then \
 		echo "ERROR: Version mismatch: __init__.py=$$PY_VER vs pyproject.toml=$(VERSION)"; exit 1; \
 	fi
-	`@grep` -q "\[$(VERSION)\]" CHANGELOG.md || { echo "ERROR: no CHANGELOG.md entry for $(VERSION)"; exit 1; }
+	`@grep` -qF "[$(VERSION)]" CHANGELOG.md || { echo "ERROR: no CHANGELOG.md entry for $(VERSION)"; exit 1; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 64 - 75, Update the Makefile VERSION extraction and
release-check version lookup to match the anchored top-level version assignment
used by scripts/release.sh, preferably by reusing a single extractor. Also make
the CHANGELOG validation treat $(VERSION) literally rather than as an unescaped
grep regular expression, while preserving the existing consistency checks.
Dockerfile (1)

8-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the uv image tag.

ghcr.io/astral-sh/uv:latest makes the image non-reproducible and pulls unreviewed uv releases into the build. Pin a version tag, or a digest for full reproducibility.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` at line 8, Update the Dockerfile COPY source to replace the
floating ghcr.io/astral-sh/uv:latest tag with a specific reviewed uv version tag
or immutable digest, keeping the existing /uv and /uvx binaries copied into
/usr/local/bin/.
src/recursive_resolver/cli.py (1)

80-81: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider rejecting --no-dnssec --require-dnssec at parse time.

The two flags are contradictory. With both set, the resolver is built with dnssec=False, require_dnssec=True, and every lookup fails later with DNSSECInsecureError instead of a usage message. A mutually exclusive group reports the conflict immediately with exit code 2.

♻️ Proposed refactor
-    parser.add_argument("--no-dnssec", action="store_true", help="Disable DNSSEC validation")
-    parser.add_argument("--require-dnssec", action="store_true", help="Fail unless the answer is DNSSEC-authenticated")
+    dnssec_group = parser.add_mutually_exclusive_group()
+    dnssec_group.add_argument("--no-dnssec", action="store_true", help="Disable DNSSEC validation")
+    dnssec_group.add_argument(
+        "--require-dnssec", action="store_true", help="Fail unless the answer is DNSSEC-authenticated"
+    )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/cli.py` around lines 80 - 81, Update the CLI argument
definitions around the DNSSEC options to place --no-dnssec and --require-dnssec
in a mutually exclusive argparse group, so conflicting flags are rejected during
parsing with the standard usage error and exit code 2.
tests/test_resolver.py (1)

11-11: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

No change needed for the sibling conftest import.

pytest does not override importmode, and tests/__init__.py is empty, so from conftest import ... resolves with the current test execution setup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_resolver.py` at line 11, No code changes are required for the from
conftest import in tests/test_resolver.py; retain the existing import because it
resolves correctly under the current pytest configuration.
src/recursive_resolver/budget.py (1)

113-136: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the two DNSSEC fields in the QueryBudget docstring.

The Args block lists max_queries, max_nx_targets, max_referrals and deadline. It omits max_signature_validations and max_nsec3_hashes, which are real fields with their own spend methods. Limits documents both.

♻️ Proposed docstring addition
         max_nx_targets: Total NS-hostname resolutions allowed to fail.
         max_referrals: Total referrals allowed to be followed.
+        max_signature_validations: Total RRSIG verifications allowed (KeyTrap).
+        max_nsec3_hashes: Total NSEC3 hash computations allowed (KeyTrap).
         deadline: Absolute ``time.monotonic()`` value after which the
             resolution is abandoned.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/budget.py` around lines 113 - 136, Update the
QueryBudget docstring’s Args section to document max_signature_validations and
max_nsec3_hashes alongside the other budget fields, matching the corresponding
limit descriptions and existing spend-method behavior.
src/recursive_resolver/cache.py (2)

317-345: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Delegation lookups return the live cached object, unlike answer lookups.

get_answer copies the RRset through _isolate so the cache and the caller never share mutable state. get_delegation and closest_delegation return the stored Delegation directly. Delegation.addresses and Delegation.ns_names are mutable lists held by the cache and shared across threads.

The current resolver does not mutate them: _starting_point builds a new list through address_filter.filter. The hazard is a future caller that appends to delegation.addresses and silently rewrites the cached zone cut for every thread.

♻️ Proposed isolation for delegation reads
     def get_delegation(self, zone: dns.name.Name) -> Delegation | None:
         """Look up the cached delegation for exactly this zone."""
         with self._lock:
             entry = self._get((_DELEGATION, zone, 0, 0))
             if entry is None:
                 return None
             delegation: Delegation = entry.rrset
-            return delegation
+            return replace(delegation, addresses=list(delegation.addresses), ns_names=list(delegation.ns_names))

Apply the same copy in closest_delegation, and copy the lists in put_delegation so the producer cannot mutate the stored entry either.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/cache.py` around lines 317 - 345, Isolate mutable
delegation data at cache boundaries to prevent callers and producers from
sharing cached lists. Update get_delegation and closest_delegation to return
copied Delegation objects, and update put_delegation to copy addresses and
ns_names before storing; preserve the existing lookup and filtering behavior.

247-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

stats counts only answer lookups.

get_answer updates stats.hits and stats.misses. get_nxdomain, get_nodata, get_delegation and closest_delegation do not. A reported hit_rate therefore ignores the negative and delegation traffic, which is the majority of lookups in an iterative resolver. If that is deliberate, state it in the CacheStats docstring so an operator does not read the value as a whole-cache hit rate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/cache.py` around lines 247 - 303, Update CacheStats
documentation to explicitly state that stats.hits, stats.misses, and hit_rate
cover only get_answer lookups, excluding get_nxdomain, get_nodata,
get_delegation, and closest_delegation traffic. Do not change the lookup
methods’ accounting unless the intended contract is to make hit_rate represent
all cache lookups.
tests/test_security.py (2)

39-42: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

_resolver is duplicated across two test modules.

tests/test_internals.py lines 44-47 defines the same helper with the same two defaults. Both modules already import from conftest. Move the helper into tests/conftest.py so the DNSSEC and cache defaults for unit tests are set in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_security.py` around lines 39 - 42, Move the duplicated _resolver
helper into tests/conftest.py, preserving its dnssec=False and
cache_enabled=False defaults and RecursiveResolver construction. Remove the
local definitions from both test_security.py and test_internals.py, allowing
both modules to reuse the shared conftest helper.

504-521: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Narrow the suppressed exception type.

contextlib.suppress(Exception) hides every failure inside _verify_denial, including an AttributeError or TypeError introduced by a later refactor. The two assertions that follow would still pass, because attempted is populated by the first _send_query call.

The comment states the intent: the call may fail because the stub serves no real signatures. That failure is a ResolverError. Suppress only that type so a genuine breakage still surfaces.

♻️ Proposed change
-        with patch.object(resolver, "_send_query", side_effect=send), contextlib.suppress(Exception):
+        with patch.object(resolver, "_send_query", side_effect=send), contextlib.suppress(ResolverError):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_security.py` around lines 504 - 521, In the _verify_denial test
invocation, replace the broad contextlib.suppress(Exception) with suppression of
only ResolverError, preserving the existing _send_query patch and assertions.
Ensure unexpected AttributeError, TypeError, or other failures propagate
normally.
src/recursive_resolver/singleflight.py (1)

62-68: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Waiters re-raise one shared exception instance.

Every waiter for the same key executes raise call.error on the same exception object. Each raise writes __traceback__ on that shared instance, concurrently, from several threads. Two effects follow: the traceback grows on each re-raise, and a caller inspecting __traceback__ sees frames from threads it never ran.

The behaviour is correct in the sense that the right exception type and message reach every caller, so this is a diagnostics concern rather than a resolution bug.

♻️ Proposed change to give each waiter its own traceback
         if call.error is not None:
-            raise call.error
+            raise call.error.with_traceback(None)

Alternatively, record the exception and re-raise a new instance of the same class per waiter, if preserving the leader's traceback is not required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/singleflight.py` around lines 62 - 68, Update the
waiter error path in the singleflight resolution method around call.error so
each waiter raises its own exception instance instead of re-raising the shared
exception object. Preserve the original exception type and message while
avoiding mutation of the shared exception’s traceback; leave the timeout
fallback and successful call.value path unchanged.
src/recursive_resolver/resolver.py (2)

890-893: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add parentheses to the glue rdtype test.

The condition relies on and binding tighter than or. It evaluates as A or (AAAA and not self.ipv4_only), which is the intended behaviour, and the tests confirm it. It reads as (A or AAAA) and not self.ipv4_only, which would reject all IPv4 glue in the default ipv4_only=True mode.

This is the address-selection path for nameservers. A later edit made on the misread meaning breaks glue selection silently. Make the grouping explicit.

♻️ Proposed clarification
-            if rrset.rdtype == dns.rdatatype.A or rrset.rdtype == dns.rdatatype.AAAA and not self.ipv4_only:
-                pass
-            else:
+            if rrset.rdtype == dns.rdatatype.A:
+                pass
+            elif rrset.rdtype == dns.rdatatype.AAAA and not self.ipv4_only:
+                pass
+            else:
                 continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/resolver.py` around lines 890 - 893, In the glue
rdtype condition, add explicit parentheses around the AAAA branch so it remains
equivalent to A or (AAAA and not self.ipv4_only). Preserve IPv4 glue selection
when ipv4_only is true and keep the existing continue behavior for unsupported
records.

479-479: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The loop bound makes max_referrals unreachable.

The delegation loop runs at most self.max_depth iterations, default 20. Every referral, every NXDOMAIN sibling retry and every lame-server retry consumes one iteration. Limits.max_referrals defaults to 130, so note_referral can never raise: the loop raises MaxDepthError first, at iteration 21.

Two consequences. max_referrals is dead configuration at the default settings, and a caller who raises it to bound a hostile zone gets no effect. budget.py documents it as Unbound's MAX_REFERRAL_COUNT control.

Either derive the loop bound from self.limits.max_referrals as well, or state in the Limits docstring that max_depth is the binding limit for a single _resolve_iterative frame and that max_referrals bounds the whole call tree.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/resolver.py` at line 479, The delegation loop in
_resolve_iterative currently caps iterations at self.max_depth, making
Limits.max_referrals ineffective. Update the loop bound to account for both
limits so referral, NXDOMAIN sibling, and lame-server retries cannot prevent
note_referral from enforcing max_referrals; preserve MaxDepthError behavior when
the depth limit is the binding constraint.
tests/test_dnssec_signed.py (1)

410-463: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse _wildcard_chain(proof=False) instead of rebuilding the chain.

_wildcard_chain already accepts proof and builds the same root, leaf, forged answer, delegation, DNSKEY table, and send function. The proof=False branch is never used, and this test duplicates about 50 lines of that setup. Call self._wildcard_chain(proof=False) here so both wildcard tests share one fixture.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dnssec_signed.py` around lines 410 - 463, Replace the duplicated
setup in test_the_resolver_refuses_an_unproven_wildcard_answer with the existing
self._wildcard_chain(proof=False) fixture, reusing its returned resolver/send
context and preserving the DNSSECValidationError assertion for the wildcard
failure. Remove the locally constructed zones, responses, table, state, and send
function so both wildcard tests share the same chain.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e22bb8d4-4ec0-4982-b92b-7c38e395c191

📥 Commits

Reviewing files that changed from the base of the PR and between 267711e and 81569bb.

📒 Files selected for processing (49)
  • .github/ISSUE_TEMPLATE/bug_report.yml
  • .github/ISSUE_TEMPLATE/config.yml
  • .github/ISSUE_TEMPLATE/feature_request.yml
  • .github/pull_request_template.md
  • .github/workflows/ci.yml
  • CHANGELOG.md
  • CONTRIBUTING.md
  • Dockerfile
  • LICENSE
  • Makefile
  • README.md
  • SECURITY.md
  • THIRD-PARTY.md
  • pyproject.toml
  • scripts/check_authors.py
  • scripts/collect_domains_1_tranco.py
  • scripts/collect_domains_2_ct.py
  • scripts/collect_domains_3_zone.py
  • scripts/collect_domains_diverse.py
  • scripts/diff_harness.py
  • scripts/prepare_test_domains.py
  • scripts/release.sh
  • src/recursive_resolver/__init__.py
  • src/recursive_resolver/__main__.py
  • src/recursive_resolver/addresses.py
  • src/recursive_resolver/budget.py
  • src/recursive_resolver/cache.py
  • src/recursive_resolver/cli.py
  • src/recursive_resolver/dnssec.py
  • src/recursive_resolver/exceptions.py
  • src/recursive_resolver/py.typed
  • src/recursive_resolver/resolver.py
  • src/recursive_resolver/roots.py
  • src/recursive_resolver/singleflight.py
  • tests/conftest.py
  • tests/signed_zone.py
  • tests/test_cache.py
  • tests/test_check_authors.py
  • tests/test_cli.py
  • tests/test_csv.py
  • tests/test_dnssec.py
  • tests/test_dnssec_chain.py
  • tests/test_dnssec_signed.py
  • tests/test_integration.py
  • tests/test_internals.py
  • tests/test_resolver.py
  • tests/test_security.py
  • tests/test_singleflight.py
  • tests/test_transport.py

Comment thread CONTRIBUTING.md Outdated
Comment thread README.md Outdated
Comment thread scripts/diff_harness.py Outdated
Comment thread scripts/diff_harness.py Outdated
Comment thread tests/test_cache.py
import dns.rdatatype
import dns.rrset
import pytest
from conftest import make_response

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

from conftest import make_response breaks collection in CI.

CI reports ModuleNotFoundError: No module named 'conftest' at this line for every matrix job. The tests directory is not on sys.path during collection. Fix the pytest configuration rather than each import.

🧰 Tools
🪛 GitHub Actions: CI / 0_test (3.14).txt

[error] 17-17: pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 1_test (3.13).txt

[error] 17-17: uv run pytest -m "not integration" -q --no-cov failed during test collection: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 2_test (3.10).txt

[error] 17-17: pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 3_test (3.11).txt

[error] 17-17: Test collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 4_test (3.12).txt

[error] 17-17: Test collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 6_integration.txt

[error] 17-17: uv run pytest -q --cov failed during collection: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / integration

[error] 17-17: Test collection failed because the module 'conftest' could not be imported.

🪛 GitHub Actions: CI / test (3.10)

[error] 17-17: Pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.11)

[error] 17-17: Test collection failed during 'uv run pytest -m "not integration" -q --no-cov': ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.12)

[error] 17-17: Test collection failed under uv run pytest -m "not integration" -q --no-cov: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.13)

[error] 17-17: pytest collection failed during 'uv run pytest -m "not integration" -q --no-cov': ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.14)

[error] 17-17: Pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dnssec_chain.py` at line 17, Update the pytest configuration so
the tests directory is added to the import path during collection, allowing
tests such as test_dnssec_chain.py to resolve make_response from conftest. Fix
the shared pytest configuration rather than changing individual imports.

Comment on lines +18 to +19
from conftest import make_response
from signed_zone import SignedZone, rrset_of

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

from conftest import ... and from signed_zone import ... break collection in CI.

CI reports ModuleNotFoundError: No module named 'conftest' at line 18 for every matrix job. signed_zone resolves the same way and will fail next. Both imports need the tests directory on sys.path.

🧰 Tools
🪛 GitHub Actions: CI / 0_test (3.14).txt

[error] 18-18: pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 1_test (3.13).txt

[error] 18-18: uv run pytest -m "not integration" -q --no-cov failed during test collection: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 2_test (3.10).txt

[error] 18-18: pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 3_test (3.11).txt

[error] 18-18: Test collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 4_test (3.12).txt

[error] 18-18: Test collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 6_integration.txt

[error] 18-18: uv run pytest -q --cov failed during collection: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / integration

[error] 18-18: Test collection failed because the module 'conftest' could not be imported.

🪛 GitHub Actions: CI / test (3.10)

[error] 18-18: Pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.11)

[error] 18-18: Test collection failed during 'uv run pytest -m "not integration" -q --no-cov': ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.12)

[error] 18-18: Test collection failed under uv run pytest -m "not integration" -q --no-cov: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.13)

[error] 18-18: pytest collection failed during 'uv run pytest -m "not integration" -q --no-cov': ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.14)

[error] 18-18: Pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_dnssec_signed.py` around lines 18 - 19, Update the imports in
tests/test_dnssec_signed.py to resolve conftest and signed_zone through the
tests package/path, ensuring both modules are discoverable during CI test
collection without relying on the tests directory being implicitly added to
sys.path.

Comment thread tests/test_internals.py Outdated
from unittest.mock import patch

import pytest
from conftest import make_response, referral, root_to_com

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

from conftest import ... breaks collection in CI.

CI reports ModuleNotFoundError: No module named 'conftest' at this line for every matrix job.

🧰 Tools
🪛 GitHub Actions: CI / 0_test (3.14).txt

[error] 10-10: pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 1_test (3.13).txt

[error] 10-10: uv run pytest -m "not integration" -q --no-cov failed during test collection: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 2_test (3.10).txt

[error] 10-10: pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 3_test (3.11).txt

[error] 10-10: Test collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 4_test (3.12).txt

[error] 10-10: Test collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 6_integration.txt

[error] 10-10: uv run pytest -q --cov failed during collection: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / integration

[error] 10-10: Test collection failed because the module 'conftest' could not be imported.

🪛 GitHub Actions: CI / test (3.10)

[error] 10-10: Pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.11)

[error] 10-10: Test collection failed during 'uv run pytest -m "not integration" -q --no-cov': ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.12)

[error] 10-10: Test collection failed under uv run pytest -m "not integration" -q --no-cov: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.13)

[error] 10-10: pytest collection failed during 'uv run pytest -m "not integration" -q --no-cov': ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.14)

[error] 10-10: Pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_singleflight.py` at line 10, Update the imports in
tests/test_singleflight.py so the test module resolves the shared fixtures
without relying on a top-level conftest module import. Preserve the existing
make_response, referral, and root_to_com references while using the repository’s
supported test import pattern.

Comment thread tests/test_transport.py
import dns.query
import dns.rdatatype
import pytest
from conftest import make_response

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

from conftest import make_response breaks collection in CI.

CI reports ModuleNotFoundError: No module named 'conftest' at this line for every matrix job.

🧰 Tools
🪛 GitHub Actions: CI / 0_test (3.14).txt

[error] 18-18: pytest collection failed: ModuleNotFoundError: No module named 'conftest'. Eight test modules failed during collection; the command exited with code 2.

🪛 GitHub Actions: CI / 1_test (3.13).txt

[error] 18-18: uv run pytest -m "not integration" -q --no-cov failed during test collection: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 2_test (3.10).txt

[error] 18-18: pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 3_test (3.11).txt

[error] 18-18: Test collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 4_test (3.12).txt

[error] 18-18: Test collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / 6_integration.txt

[error] 18-18: uv run pytest -q --cov failed during collection: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / integration

[error] 18-18: Test collection failed because the module 'conftest' could not be imported.

🪛 GitHub Actions: CI / test (3.10)

[error] 18-18: Pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.11)

[error] 18-18: Test collection failed during 'uv run pytest -m "not integration" -q --no-cov': ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.12)

[error] 18-18: Test collection failed under uv run pytest -m "not integration" -q --no-cov: ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.13)

[error] 18-18: pytest collection failed during 'uv run pytest -m "not integration" -q --no-cov': ModuleNotFoundError: No module named 'conftest'.

🪛 GitHub Actions: CI / test (3.14)

[error] 18-18: Pytest collection failed: ModuleNotFoundError: No module named 'conftest'.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_transport.py` at line 18, Update the import in the affected test
module so it resolves the shared make_response fixture/helper through the
project’s supported test-package import path rather than importing conftest as a
top-level module. Preserve use of make_response while ensuring pytest collection
works from CI’s execution context.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
tests/test_cache.py (1)

163-188: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test reader isolation for ns_names.

Delegation.addresses and Delegation.ns_names are mutable. The reader-isolation check mutates only addresses. A regression that shares ns_names between readers would pass.

Proposed test addition
         first.addresses.append("7.7.7.7")
+        first.ns_names.append("evil.example.com.")
         second = cache.closest_delegation(dns.name.from_text("www.example.com."))
         assert second is not None
         assert second.addresses == ["1.2.3.4"]
+        assert second.ns_names == ["ns1.example.com."]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cache.py` around lines 163 - 188, The test
test_a_cached_delegation_is_isolated_from_its_producer_and_its_readers must also
verify reader isolation for Delegation.ns_names. Mutate first.ns_names after the
initial read, then obtain the next delegation and assert its ns_names remains
the original value, alongside the existing addresses assertion.
Makefile (1)

72-79: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Split the release-check recipe if checkmake is enforced.

checkmake reports seven recipe lines for release-check, above its maxbodylength limit of five. Move the version and changelog checks into helper targets or a script, then keep release-check as a short dependency list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 72 - 79, Refactor the release-check target so its
recipe has no more than five lines for checkmake compliance. Move the Python
version-consistency and CHANGELOG validation into helper targets or a script,
then make release-check depend on check-all and those helpers while preserving
the existing validations and success output.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Makefile`:
- Line 78: Update the changelog validation command in the Makefile to require
the fixed version marker at the beginning of a line, ensuring only a column-1
“## [$(VERSION)]” section heading satisfies the check while preserving
fixed-string matching.

In `@src/recursive_resolver/cli.py`:
- Around line 208-209: Update _run_trace so a missing answer produces a defined
JSON failure shape instead of emitting null records and dnssec alongside
plain-text stderr; handle answer is None before the success JSON path or include
an explicit error field, preserve status 1, and add a regression test for
--trace --json with (None, []).
- Around line 129-134: Update the BrokenPipeError handler in main to store the
descriptor returned by os.open(os.devnull, os.O_WRONLY), duplicate it onto
stdout, and close it in a finally block when it differs from
sys.stdout.fileno().

In `@src/recursive_resolver/dnssec.py`:
- Around line 199-200: Update DNSSECValidator.__init__’s empty trust_anchors
error guidance to match the constructor’s actual API: retain rejection of an
empty tuple, but instruct callers to omit trust_anchors, or update
initialization to accept and normalize None before validation.
- Around line 531-532: Update prove_wildcard and its next-closer coverage loop
to reject every NSEC3 record whose iterations exceed max_nsec3_iterations, not
just the first RRset. Before adopting first-RRset parameters, validate that
salt, algorithm, and iteration values are consistent across all records; add a
multi-record wildcard NSEC3 regression test containing an over-limit later row.

---

Nitpick comments:
In `@Makefile`:
- Around line 72-79: Refactor the release-check target so its recipe has no more
than five lines for checkmake compliance. Move the Python version-consistency
and CHANGELOG validation into helper targets or a script, then make
release-check depend on check-all and those helpers while preserving the
existing validations and success output.

In `@tests/test_cache.py`:
- Around line 163-188: The test
test_a_cached_delegation_is_isolated_from_its_producer_and_its_readers must also
verify reader isolation for Delegation.ns_names. Mutate first.ns_names after the
initial read, then obtain the next delegation and assert its ns_names remains
the original value, alongside the existing addresses assertion.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5a6d6661-56a2-408c-8041-910cf1214346

📥 Commits

Reviewing files that changed from the base of the PR and between 81569bb and f233d5c.

📒 Files selected for processing (25)
  • .github/ISSUE_TEMPLATE/bug_report.yml
  • .github/workflows/ci.yml
  • CONTRIBUTING.md
  • Dockerfile
  • Makefile
  • README.md
  • pyproject.toml
  • scripts/check_authors.py
  • scripts/collect_domains_diverse.py
  • scripts/diff_harness.py
  • scripts/release.sh
  • src/recursive_resolver/budget.py
  • src/recursive_resolver/cache.py
  • src/recursive_resolver/cli.py
  • src/recursive_resolver/dnssec.py
  • src/recursive_resolver/resolver.py
  • src/recursive_resolver/singleflight.py
  • tests/conftest.py
  • tests/test_cache.py
  • tests/test_cli.py
  • tests/test_dnssec.py
  • tests/test_dnssec_signed.py
  • tests/test_internals.py
  • tests/test_security.py
  • tests/test_transport.py
🚧 Files skipped from review as they are similar to previous changes (20)
  • Dockerfile
  • .github/ISSUE_TEMPLATE/bug_report.yml
  • tests/test_dnssec_signed.py
  • .github/workflows/ci.yml
  • src/recursive_resolver/singleflight.py
  • scripts/check_authors.py
  • pyproject.toml
  • src/recursive_resolver/budget.py
  • CONTRIBUTING.md
  • tests/test_internals.py
  • scripts/diff_harness.py
  • tests/test_dnssec.py
  • tests/test_transport.py
  • tests/test_security.py
  • README.md
  • tests/test_cli.py
  • scripts/collect_domains_diverse.py
  • scripts/release.sh
  • src/recursive_resolver/cache.py
  • src/recursive_resolver/resolver.py

Comment thread Makefile Outdated
Comment thread src/recursive_resolver/cli.py
Comment thread src/recursive_resolver/cli.py
Comment thread src/recursive_resolver/dnssec.py Outdated
Comment on lines +199 to +200
if not trust_anchors:
raise ValueError("trust_anchors must not be empty; pass None to use the IANA root anchors")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the empty-anchor error guidance.

DNSSECValidator.__init__ rejects None, but Line [200] tells direct callers to pass None to use the defaults. Keep the empty-tuple rejection, but say to omit trust_anchors or accept and normalize None.

Proposed fix
-            raise ValueError("trust_anchors must not be empty; pass None to use the IANA root anchors")
+            raise ValueError("trust_anchors must not be empty; omit trust_anchors to use the IANA root anchors")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not trust_anchors:
raise ValueError("trust_anchors must not be empty; pass None to use the IANA root anchors")
if not trust_anchors:
raise ValueError("trust_anchors must not be empty; omit trust_anchors to use the IANA root anchors")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/dnssec.py` around lines 199 - 200, Update
DNSSECValidator.__init__’s empty trust_anchors error guidance to match the
constructor’s actual API: retain rejection of an empty tuple, but instruct
callers to omit trust_anchors, or update initialization to accept and normalize
None before validation.

Comment on lines +531 to +532
if params.iterations > self.max_nsec3_iterations:
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 10 \
  'def prove_wildcard|nsec3s\[0\]\[0\]|_nsec3_covers|test_prove_wildcard_cap|nsec3s=\[' \
  src/recursive_resolver/dnssec.py tests

Repository: sylvinus/recursive-resolver

Length of output: 37447


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== dnssec.py section =="
sed -n '360,545p' src/recursive_resolver/dnssec.py

echo
echo "== tests around wildcard NSEC3 cap =="
sed -n '740,935p' tests/test_dnssec.py

echo
echo "== search all NSEC3 caps in prove functions =="
rg -n "nsec3s\[0\]\[0\]|iterations > self.max_nsec3_iterations|_nsec3_covers|_nsec3_owner" src/recursive_resolver/dnssec.py tests/test_dnssec.py

Repository: sylvinus/recursive-resolver

Length of output: 20025


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
text = Path("src/recursive_resolver/dnssec.py").read_text()
idx = text.index("def prove_wildcard")
func = text[idx:text.index("\n    def ", idx + 1)]
print("proven_wildcard_source:")
print(func)

checks = {
    "checks_first_iteration_cap": "if params.iterations > self.max_nsec3_iterations",
    "uses_first_params": "params = nsec3s[0][0]",
    "coverage_scans_each_nsec3": "any(self._nsec3_covers(next_closer, n, params, budget) for n in nsec3s)",
    "no_second_iteration_cap": "if self._nsec3_covers(next_closer, n, nsec3[0], nsec3, budget)",
}
for label, needle in checks.items():
    print(f"{label}: {needle in func}")
PY

Repository: sylvinus/recursive-resolver

Length of output: 2760


Reject over-limit NSEC3 rows in wildcard proofs.

prove_wildcard caps only the first NSEC3 RRset, but the next-closer coverage check iterates over all rows. A safe covering first RRset can make the proof return True even when a later NSEC3 row has too many iterations. Apply the cap to each NSEC3 record used in coverage checks, and check for inconsistent salt/algorithm/iteration values before adopting the first RRset parameters. Add a multi-record wildcard NSEC3 regression test with one over-limit row.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/recursive_resolver/dnssec.py` around lines 531 - 532, Update
prove_wildcard and its next-closer coverage loop to reject every NSEC3 record
whose iterations exceed max_nsec3_iterations, not just the first RRset. Before
adopting first-RRset parameters, validate that salt, algorithm, and iteration
values are consistent across all records; add a multi-record wildcard NSEC3
regression test containing an over-limit later row.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR is a broad pre-0.1.0 hardening and release-readiness pass for recursive-resolver, adding DNSSEC validation, stronger security controls (SSRF/address filtering, work budgets, downgrade resistance), improved CLI/packaging, and significantly expanded unit + integration coverage plus CI/release tooling.

Changes:

  • Added core hardening primitives: per-resolution work budgets, address filtering to prevent DNS-driven SSRF, delegation/negative caching improvements, and concurrent single-flight deduplication.
  • Added DNSSEC chain-of-trust support (including root trust anchors) and expanded public API/errors to make validation states and failures explicit.
  • Added substantial test coverage (unit + live-DNS integration), plus CI workflows, contribution/security docs, and a hermetic release script.

Reviewed changes

Copilot reviewed 48 out of 49 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
THIRD-PARTY.md Adds third-party attribution and derived-work notes.
tests/test_transport.py New tests for transport-layer exception translation and retry logic.
tests/test_singleflight.py New tests for concurrent de-duplication and resolver-level herd collapse.
tests/test_integration.py Expanded live-DNS integration coverage including DNSSEC, DKIM, cache, and trace behavior.
tests/test_dnssec_chain.py New detailed unit tests for DNSSEC chain plumbing and validation branches.
tests/test_csv.py Improves bulk differential harness documentation and behavior (DNSSEC disabled for fair comparison).
tests/test_cli.py Reworks CLI tests to match new structured output, DNSSEC notes, trace, and --text behavior.
tests/test_check_authors.py Adds tests for commit-author AI-tool detection script.
tests/test_cache.py Expands cache tests to cover answers, negative caching, delegation caching, and resolver integration.
tests/signed_zone.py Adds an in-memory signed-zone fixture for deterministic offline DNSSEC tests.
tests/conftest.py Adds shared DNS message builders and pytest options used across the expanded suite.
src/recursive_resolver/singleflight.py Implements single-flight deduplication for concurrent identical resolutions.
src/recursive_resolver/roots.py Adds root trust anchors alongside root hints.
src/recursive_resolver/py.typed Marks the package as typed for downstream type checkers.
src/recursive_resolver/exceptions.py Expands and documents the public exception hierarchy (input, DNSSEC, budget, etc.).
src/recursive_resolver/cli.py Major CLI expansion: DNSSEC flags, cache controls, EDNS payload, JSON output, trace, and TXT joining.
src/recursive_resolver/cache.py Reworks cache to use dns.name.Name keys, adds negative + delegation caching, and shared LRU.
src/recursive_resolver/budget.py Adds shared per-resolution query/crypto work budgets (NXNSAttack/KeyTrap defenses).
src/recursive_resolver/addresses.py Adds nameserver IP filtering to prevent DNS-driven SSRF via glue/NS resolution.
src/recursive_resolver/main.py Fixes module entrypoint guard for python -m recursive_resolver.
src/recursive_resolver/init.py Exports new public API surface (Answer, ValidationState, Limits, cache/address helpers, new errors).
SECURITY.md Adds security policy + explicit threat model and mitigations.
scripts/release.sh Adds an interactive hermetic Docker-based release script with artifact verification and smoke tests.
scripts/prepare_test_domains.py Minor robustness/cleanup improvements when building corpora.
scripts/diff_harness.py Adds standalone differential-testing harness against dig/stub resolvers across configs.
scripts/collect_domains_diverse.py Adds corpus builder for diverse pathological DNS cases.
scripts/collect_domains_3_zone.py Refines corpus collection strategy and data sources.
scripts/collect_domains_2_ct.py Formatting/clarity changes for CT-based corpus collection.
scripts/collect_domains_1_tranco.py Minor docstring punctuation/clarity update.
scripts/check_authors.py Adds CI check to reject AI tools in author/co-author fields.
pyproject.toml Updates packaging metadata, dependency floors/extras, sdist include list, and strict test/coverage settings.
Makefile Updates dev targets, adds stricter check/check-all flows, and wires in the release script.
LICENSE Updates copyright years.
Dockerfile Pins uv image tag and syncs extras for reproducible dev container builds.
CONTRIBUTING.md Adds detailed dev workflow, licensing guidance, AI contribution policy, and release process.
CHANGELOG.md Adds initial changelog skeleton for 0.1.0.
.github/workflows/ci.yml Adds CI matrix, authorship check, integration+coverage gate, and build verification.
.github/pull_request_template.md Adds PR checklist aligned with security/testing/release expectations.
.github/ISSUE_TEMPLATE/feature_request.yml Adds feature request template.
.github/ISSUE_TEMPLATE/config.yml Configures issue templates and security contact link.
.github/ISSUE_TEMPLATE/bug_report.yml Adds bug report template with trace and explicit-resolver guidance.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/recursive_resolver/singleflight.py Outdated
Comment on lines +66 to +72
if call.error is not None:
# Every waiter for this key re-raises the *same* exception object,
# and each `raise` writes __traceback__ on it. Without this, several
# threads concurrently append to one traceback and a caller ends up
# inspecting frames from threads it never ran. Dropping it gives
# each waiter a traceback rooted at its own call site.
raise call.error.with_traceback(None)
Comment on lines +170 to +175
@staticmethod
def _name(name: dns.name.Name | str) -> dns.name.Name:
if isinstance(name, dns.name.Name):
return name
return dns.name.from_text(name)

Comment on lines +182 to +185
def _clamp(self, ttl: int, negative: bool = False) -> int:
ceiling = self.max_negative_ttl if negative else self.max_ttl
return max(self.min_ttl, min(int(ttl), ceiling))

@sylvinus
sylvinus merged commit c90b35b into main Aug 9, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants