Skip to content

Add DNSSEC validation check via delv (#3034)#3035

Closed
ChrisJr404 wants to merge 1 commit into
testssl:3.3devfrom
ChrisJr404:feat/dnssec-delv-3034
Closed

Add DNSSEC validation check via delv (#3034)#3035
ChrisJr404 wants to merge 1 commit into
testssl:3.3devfrom
ChrisJr404:feat/dnssec-delv-3034

Conversation

@ChrisJr404
Copy link
Copy Markdown

What this does

Adds a DNSSEC line to the certificate-info section (right after DNS CAA RR and before Certificate Transparency) that calls delv against $NODE and reports one of:

  • fully validated / partially validated — pr_svrty_good, JSON severity OK
  • not signed — pr_svrty_low, JSON severity LOW, fileout text domain not DNSSEC-signed
  • a high-severity DNSSEC failure (broken trust chain, no valid signature found, bogus*, DNSKEY*, NSEC*, *insecure*) — pr_svrty_high, severity HIGH
  • anything else delv complained about (timeouts, SERVFAIL from the configured resolver, generic resolution failure) — pr_warning, severity WARN

The two buckets at the bottom matter: I didn't want a flaky upstream resolver to make testssl.sh claim a domain has been tampered with. So get_dnssec_status() separates "delv really said the chain is bogus" (return 2 → red) from "delv couldn't get an answer" (return 3 → orange warning).

--nodns=min|none and --ip=proxy short-circuit the check before we shell out, mirroring the existing CAA block one screen above. If delv isn't installed, the user sees (no "delv" binary, install bind9 / bind-utils) and the rest of the report continues as before.

A new HAS_DELV global is initialised next to the other resolver HAS_* vars and set in check_resolver_bins(); it's also dumped under --debug so users can quickly see why the DNSSEC check was skipped.

doc/testssl.1.md is updated under the certificate-info bullet list.

Why delv

dig +dnssec only sets the DO bit and trusts whatever AD bit the configured resolver hands back, which is exactly the answer you can't trust if the resolver is the threat. delv performs validation locally against the root trust anchor shipped with BIND, which is the right primitive for an authenticity check. That matches the framing in the issue.

Open question for the maintainer

I deliberately left Dockerfile (opensuse leap) and Dockerfile.alpine alone. Adding bind-utils / bind-tools to the images is a sizing decision (a few extra MB on a deliberately small container) and the existing image already ships ldns for drill. Happy to push a follow-up commit adding them to either or both images, or to leave it for a separate PR. The fallback path means today's Docker users see the "no delv binary" hint and nothing else changes.

Caveats

  • The issue ([Feature request] Implement DNSSEC test with delv? #3034) is from today and there's no maintainer feedback yet on the exact desired UX. I went with the same look-and-feel as the DNS CAA RR (experimental) block since [Feature request] Implement DNSSEC test with delv? #3034 referenced PR DNS HTTPS RR (RFC 9460) for 3.3dev #2866 where that pattern already exists. Happy to iterate on naming, severity choices, or where the line lives.
  • pr_svrty_high for a known-bogus chain is my best guess at the right weight. If you'd rather have it as pr_svrty_medium or as a pr_warning, that's a one-line change.
  • I considered also extending get_caa_rr_record() to fall back to delv when dig/drill/host/nslookup fail, but that's well outside the scope of this PR (one functional change per PR per Coding_Convention.md).

Tests

Local checks against:

$ ./testssl.sh -S example.com   # signed, public resolver chain healthy
   DNSSEC (experimental)        fully validated

$ ./testssl.sh -S example.com --nodns=min
   DNSSEC (experimental)        (instructed to minimize/skip DNS queries)

$ ./testssl.sh -S example.com   # with delv removed from PATH
   DNSSEC (experimental)        (no "delv" binary, install bind9 / bind-utils)

$ ./testssl.sh -S example.com --jsonfile=/tmp/out.json
   ... "id": "DNSSEC <hostCert#1>", "severity": "OK", "finding": "fully validated"

bash -n testssl.sh and shellcheck -S error testssl.sh are clean.

Closes #3034.

Adds a "DNSSEC" line in the certificate-info block, right next to the
DNS CAA RR check. The new get_dnssec_status() function calls delv (the
BIND validating lookup utility) on $NODE and parses delv's status comment
lines:

  - "; fully validated"     -> pr_svrty_good, JSON severity OK
  - "; partially validated" -> pr_svrty_good, JSON severity OK
  - "; unsigned answer"     -> pr_svrty_low,  JSON severity LOW
  - ";; resolution failed:" -> pr_svrty_high if the verdict text matches
                                a real DNSSEC failure ("trust chain",
                                "no valid signature", "bogus", "DNSKEY",
                                "NSEC", "insecure"); otherwise treated
                                as a transient resolver problem and
                                surfaced as pr_warning so we don't
                                misreport a network glitch as a domain
                                that's been tampered with.

If delv is not installed, the line prints a hint pointing at bind9 /
bind-utils and continues. --nodns and --ip=proxy short-circuit the
check the same way the CAA block already does.

A new HAS_DELV global is initialised next to the other resolver
HAS_* vars and set in check_resolver_bins(); it is also dumped under
--debug so users can see why the DNSSEC check was skipped.

Documentation in doc/testssl.1.md is updated under the certificate-info
section listing.

The Docker images are deliberately left alone in this PR; adding
bind-utils (opensuse) / bind-tools (alpine) is a separate sizing
decision for the maintainer.

Closes testssl#3034
@Delicates
Copy link
Copy Markdown

Delicates commented May 7, 2026

Couple of issues:

$dnssec_status belongs to each separate DNS record that is being looked up, not to the certificate or the host/domain.
This is especially important for DANE.

Therefore get_dnssec_status() should be taking 2 arguments - the hostname and the RR type that is being validated.

raw_delv="$(delv "$domain" 2>&1)" performs DNSSEC validation on the A resource records, while the script output could be interpreted as indicating DNSSEC validity of the CAA resource record, which is misleading:
image

So for the sake of full DNSSEC validation, eventually one would expect the testssl.sh report to indicate validity of the following DNS resource records that testssl.sh relies on during its checks:

  • A resource records of the hostname that is being checked
  • AAAA resource records of the hostname that is being checked
  • PTR resource record of the IP address that is being checked
  • MX resource records of the domain that is being checked
  • TLSA resource records of the hostname that is being checked
  • CAA resource records of the hostname that is being checked
  • HTTPS resource records of the hostname that is being checked
  • SRV resource records of the hostname that is being checked

@Delicates
Copy link
Copy Markdown

Also delv doesn't have ; partially validated trust output.
The list of possible trust outputs can be found in delv source code.

Some delv query results can have multiple trust outputs, for example if a DNSSEC signed CNAME record points to an unsigned record, delv output will print ; fully validated followed by DNSSEC signed CNAME aliases and then ; unsigned answer followed by unsigned records.

But I don't think "partial validation" is an appropriate term even in such a case. A record secured with DNSSEC either has a valid trust chain leading up to the root trust anchor, or it doesn't and therefore can be tampered with.

When "partial validation" term is used in the DNSSEC context it usually means:

"Partial validation describes when some of the user's resolvers validate and others do not."

@drwetter
Copy link
Copy Markdown
Collaborator

drwetter commented May 7, 2026

@ChrisJr404 : Thanks a lot, much appreciated! Your PR needs some massage, I should get to it by tomorrow.

As far as the technical aspects are concerned. I am not the expert here, but maybe you can answer some of the questions from @Delicates .

Thanks for your input @Delicates ! At this moment I 'd like not to have every record checked. It should be rather a matter of context. So first check should be A and AAAA. Then e.g. MX if STARTTLS via port 25 is being requested. More: step by step.

@Delicates
Copy link
Copy Markdown

I was thinking more in terms of establishing a scaffolding for DNS lookups which provides with it DNSSEC validation status that can be re-used regardless of which DNS record it is, and that can be later inserted into parts of testssl.sh code that perform specific DNS lookups.

Either way, the A record check doesn't belong in the certificate section, it's probably more appropriate at the very beginning of script execution, next to rDNS. It should also specify which specific DNS record was DNSSEC validated.

Without specifying the RR type I think delv only checks A record, and doesn't check AAAA.

@ChrisJr404
Copy link
Copy Markdown
Author

All good points. To unpack:

  1. Reusable DNS lookup scaffolding makes sense, especially given there are already several places in the script that do their own DNS lookups (CAA, MX, etc). I can rework this PR to introduce a get_dns_record() helper that returns both the record value and the DNSSEC status, and have the cert section consume it instead of calling delv directly. Other call sites can adopt it incrementally.

  2. Agreed that the cert section is the wrong home. Moving the validation status output up next to the initial host setup (where rDNS already lives) makes more sense. The cert section can still reference the validated DNSSEC state if needed, but the work happens once at startup.

  3. Right, delv without an explicit type defaults to A. The scaffolding can take an RR type arg so callers ask for what they need (A/AAAA/MX/TXT/CAA), and the DNSSEC status returned is for that specific record.

If that direction sounds right, I will redo the PR along those lines. If you would rather hold for a broader DNS refactor or have it land in pieces, just let me know which order makes sense.

@drwetter
Copy link
Copy Markdown
Collaborator

drwetter commented May 7, 2026

Thanks! Agree to 1 to 3.

Then: NODNS can have three values: empty / min / none . IIRC min is only used to avoid CAA and PTR lookups . Do we consider DNSSEC validations for A and AAAA records to be kind of mandatory?

In any case: the user needs to know why it wasn't checked. That can be done via return value but feedback should be like in determine_rdns()

@Delicates
Copy link
Copy Markdown

2. Agreed that the cert section is the wrong home. Moving the validation status output up next to the initial host setup (where rDNS already lives) makes more sense. The cert section can still reference the validated DNSSEC state if needed, but the work happens once at startup.

Previously validated at startup DNSSEC state of the A resource record has no relevance to the cert section. For cert section, the validated DNSSEC state of CAA and TLSA resource records is important. Each of these resource records would require their own separate validation checks with get_dns_record().

That's why every mention of DNSSEC in testssl.sh output should indicate which specific resource record has been validated.

With DNSSEC the absence of a resource record can also be validated. That's the difference between these two delv trust outputs:

  • ; negative response, fully validated
  • ; negative response, unsigned answer

I'm starting to think that maybe this warrants a separate DNS section in testssl.sh output with the list of DNS resource records (similar to my post above) that have been discovered, showing a colour-coded validation state of each resource record's presence or absence. NODNS would become relevant for this overall section.

The contents of different resource records themselves can be later examined in the relevant sections of the testssl.sh output.

Do we consider DNSSEC validations for A and AAAA records to be kind of mandatory?

A and AAAA resource records specify which IP address to use for the host.
This is important for making sure that all the testssl.sh tests are being done on connection to the right IP address, instead of some captive portal's IP address for example, or any other man-in-the-middle scenario.

Though you could even have a situation when the A or AAAA resource record provides an IP address validated with DNSSEC, but when you open connection to that hostname, your operating system connects you to a completely different IP address, because the hostname's IP address has been overridden in the /etc/hosts file completely bypassing the DNS system. The /etc/hosts file can't override other RR types though.

Once TLS connection to an IP is established, the certificate presented by the server may also provide hostname validation. But with PKIX validation having thousands of public CAs in hundreds of mass-surveilling jurisdictions, you can't really trust it that much in the absence of DANE validation.

@ChrisJr404
Copy link
Copy Markdown
Author

Good points. I think you are right that the cleaner direction is a dedicated DNS section per RR type with its own validated/unsigned/absence states, not bolted into the cert section.

The scope here is now clearly bigger than the single delv integration this PR proposed. I will close this and refile once the DNS section design is sketched out (per-RR validation, the negative-response distinction between "fully validated" and "unsigned answer", the colour-coded presence/absence display you described). If you have a preference for where that should live in the output (right after the "Service detected" / rDNS lines, or its own --dns section, etc) let me know and I will follow that.

NODNS handling and the delv invocation pattern will carry over.

@ChrisJr404 ChrisJr404 closed this May 7, 2026
@drwetter
Copy link
Copy Markdown
Collaborator

drwetter commented May 8, 2026

@ChrisJr404 : per RR validation is fine, but please no function per RR -- maybe I misunderstood that. That function should just serve as a scaffolding for every RR. Arguments should be FQDN + RR . Return code can be either like you did here or just an error code when the function failed. In that case the message should be returned via echo or printf (fixed strings is best to avoid user determined values).

Where the output appears depends on the RR. As @Delicates suggested for A / AAAA records -- which is the first step we worry about it first -- I'd rather would place that after rDNS . If the client has no IPv6 networking enabled (IPv6_OK equals false) I would just skip that.

Check of RR via DNSSEC can be introduced later and likely their output will be placed where appropriate.

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.

[Feature request] Implement DNSSEC test with delv?

3 participants