Find dangling DNS records and subdomain-takeover setups, plus CAA and DNSSEC checks, via DoH (MIT, free; pipx install) https://errant.solutions/tools/dns-hygiene-checker.html
  • Python 93.6%
  • Shell 6.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-11-09 14:33:56 -08:00
.forgejo/workflows ci(mutation): publish the baseline to the mutation-baseline store from the job step (the gate's container has no GITHUB_ACTIONS so its store_push never ran; the store held 3f5fc06a25 with 0 survivors and no full run, so run 62 flagged all 119 survivors in the changed code as NEW); tests: pin wildcard_cert_verdict's TLS outcomes and suffix_match's dot-only strip (kills x_wildcard_cert_verdict 21/24, x_suffix_match 9). Floor unchanged at 87 (local full run 1192 killed + 197 timeout of 1447 = 96%) 2026-11-09 14:33:56 -08:00
ci ci(mutation): floor 91 -> 87, the first honest full-run score is 87.42% (1265 of 1447 mutants killed, 182 survived; nightly run 57). The old 91 was measured while parallel mutmut children collided on 127.0.0.1:443 and errored out (counted as caught); b1526dc fixed the collisions, so the score dropped to its true value. Raise it again as survivors are killed. 2026-11-06 16:48:35 -08:00
tests ci(mutation): publish the baseline to the mutation-baseline store from the job step (the gate's container has no GITHUB_ACTIONS so its store_push never ran; the store held 3f5fc06a25 with 0 survivors and no full run, so run 62 flagged all 119 survivors in the changed code as NEW); tests: pin wildcard_cert_verdict's TLS outcomes and suffix_match's dot-only strip (kills x_wildcard_cert_verdict 21/24, x_suffix_match 9). Floor unchanged at 87 (local full run 1192 killed + 197 timeout of 1447 = 96%) 2026-11-09 14:33:56 -08:00
.gitignore nightly mutation: incremental (only functions changed since the last green run) except a weekly full run on the repo's own weekday; replace-queued concurrency; staggered cron 2026-10-18 12:22:48 -07:00
CHANGELOG.md 1.3.1: say next to the 'possibly claimable' signal that the unclaimed page looks the same on a verified domain 2026-11-06 09:00:54 -08:00
claims.json 1.3.1: say next to the 'possibly claimable' signal that the unclaimed page looks the same on a verified domain 2026-11-06 09:00:54 -08:00
dns_hygiene_checker.py 1.3.1: say next to the 'possibly claimable' signal that the unclaimed page looks the same on a verified domain 2026-11-06 09:00:54 -08:00
LICENSE Remove stray bytecode and scratch note; add .gitignore 2026-10-16 14:07:53 -07:00
pyproject.toml Test depth: takeover list aligned with can-i-take-over-xyz (CloudFront/Fastly/Firebase/... verify ownership: WARN, not FAIL; 60 claimable suffixes, label-boundary matching), accuracy suite vs that list, claims ledger, nightly mutation + Python matrix + install channels 2026-10-16 23:49:06 -07:00
README.md 1.3.1: say next to the 'possibly claimable' signal that the unclaimed page looks the same on a verified domain 2026-11-06 09:00:54 -08:00

dns-hygiene-checker

Find dangling DNS records before an attacker does. Checks common subdomains for the classic subdomain-takeover precondition (a CNAME pointing at a deprovisioned/unclaimed cloud resource — S3, GitHub Pages, Heroku, Azure, Netlify, Fastly, etc.), plus a few other everyday DNS-hygiene issues: stale A records that no longer answer, dangling SPF include: domains, and wildcard DNS without a certificate that covers it.

Goes beyond a DNS-only check: for claimable-service hostnames that do resolve at the DNS layer (many do, generically), it also does an HTTP-layer fingerprint check, because platforms like Heroku and GitHub Pages resolve fine but return a distinctive "no such app" / "there isn't a GitHub Pages site here" page when the hostname isn't actually claimed. That is a strong signal, but not always a takeover: some hosts (GitHub Pages, Vercel, Azure App Service, Pantheon) let the domain's owner verify the domain, after which other accounts can't attach it. For those the tool says "possibly claimable" and tells you to check your account, instead of calling it claimable. The "unclaimed" page only means nothing is published there; it looks the same on a verified domain that nobody else can claim, and the finding says so.

Zero dependencies beyond the Python standard library. Uses Cloudflare's free DNS-over-HTTPS resolver (no local dig/host binary needed, no API key, no signup).

Install

pipx install git+https://git.canutethegreat.com/CanuteTheGreat/dns-hygiene-checker.git        # latest release branch; puts `dns-hygiene-checker` on PATH
pipx install git+https://git.canutethegreat.com/CanuteTheGreat/dns-hygiene-checker.git@v1.3.1  # a pinned release
brew install canutethegreat/errant-tools/dns-hygiene-checker   # after: brew tap canutethegreat/errant-tools https://git.canutethegreat.com/CanuteTheGreat/homebrew-errant-tools
scoop install errant-tools/dns-hygiene-checker                 # after: scoop bucket add errant-tools https://git.canutethegreat.com/CanuteTheGreat/scoop-errant-tools

Or download the single file (stdlib-only Python 3.7+) and run it directly: curl -fsSLO https://errant.solutions/downloads/dns_hygiene_checker.py && python3 dns_hygiene_checker.py --help. Every release has a changelog, the wheel, the sdist and a SHA256SUMS file. Not on PyPI yet, so plain pipx install dns-hygiene-checker doesn't work until it's published there.

Usage

dns-hygiene-checker example.com
dns-hygiene-checker example.com --subdomains www,mail,staging,old-blog
dns-hygiene-checker example.com --ct                        # + every name seen in Certificate Transparency
dns-hygiene-checker example.com --zone-file example.com.zone # + every name in a BIND zone file / export
CLOUDFLARE_API_TOKEN=... dns-hygiene-checker example.com --cloudflare --only-discovered
dns-hygiene-checker example.com --json
dns-hygiene-checker example.com --doh-url https://dns.google/resolve

Which names get checked: the built-in list of ~33 common names (or --subdomains), plus anything discovered: --ct reads Certificate Transparency logs through crt.sh and falls back to Cert Spotter when crt.sh is down; --zone-file reads owner names from a zone file; --cloudflare lists every record in the domain's Cloudflare zone (token with Zone:DNS:Read in CLOUDFLARE_API_TOKEN). --only-discovered drops the built-in list.

--doh-url picks any DNS-over-HTTPS endpoint that speaks the JSON API (default: Cloudflare's). Exit codes: 0 no FAIL findings, 1 at least one FAIL (takeover risk), 2 the resolver or a discovery source couldn't be queried, so the report is incomplete. Python 3.7+.

What it flags:

  • CNAME whose target doesn't resolve (WARN; FAIL when the target is one of 60 claimable cloud hostname patterns (services rated takeover-able by can-i-take-over-xyz; ones that verify domain ownership, e.g. CloudFront or Fastly, stay WARN)), or that resolves to a platform page saying the hostname is unclaimed (FAIL; ~30 HTTP fingerprints)
  • Providers that let the owner verify the domain (GitHub Pages, Vercel, Netlify, Azure App Service, Pantheon): an "unclaimed" page there is WARN possibly claimable: <provider> blocks takeover if the domain is verified with <mechanism>; check your account, not FAIL, followed by Not published; may still be unclaimable if the domain is verified. Where the proof is visible in DNS it is looked up: Azure's asuid.<host> TXT (found: INFO; missing: FAIL, claimable) and Vercel's _vercel.<domain> TXT (found: INFO). GitHub's TXT name contains your account name, so it can't be checked from outside. Hosts with no such check in their docs (Heroku, S3 website endpoints, Surge, Read the Docs, Ghost(Pro) and others) stay FAIL, claimable
  • NS delegations whose nameserver hostnames don't resolve (FAIL when none do: whoever registers that nameserver's domain controls the subdomain)
  • MX (apex and every checked name) and common SRV records pointing at names that don't resolve (WARN)
  • A records where some or all addresses no longer answer on 80/443 (WARN)
  • SPF include: domains that don't exist, or exist without an SPF record (WARN)
  • wildcard DNS: checks the TLS certificate actually served for a made-up subdomain (OK if it covers it, WARN if browsers would get a certificate error)
  • CAA: none at the apex means any CA may issue certificates for the domain (WARN)
  • DNSSEC: no DS record at the parent (WARN); signed and validated (OK)

--no-apex-checks skips the MX/SRV/CAA/DNSSEC checks.

Example output

=== example.com ===
  [FAIL ] forgotten.example.com: CNAME -> forgotten-app.herokuapp.com resolves at DNS but
          HTTP 404 with unclaimed-resource fingerprint ("no such app") on
          forgotten-app.herokuapp.com — claimable: this IS an active subdomain-takeover
          opportunity, the cloud platform itself is telling you nobody has claimed this
          hostname. Remove the CNAME or claim it now.
  [WARN ] docs.example.com: CNAME -> old-docs.github.io resolves at DNS but HTTP 404 with
          unclaimed-resource fingerprint ("there isn't a github pages site here") on
          old-docs.github.io — possibly claimable: GitHub Pages blocks takeover if the domain
          is verified with a TXT record at _github-pages-challenge-<owner>.<domain>; check
          your account. Not published; may still be unclaimable if the domain is verified: the
          unclaimed page looks the same either way. Remove the CNAME if nothing is published there.
  [OK   ] www.example.com: A -> 93.184.216.34 (responsive on 80/443)

Part of the Errant Solutions free/pay-what-you-want toolset — see https://errant.solutions for the rest (sshd-hardening-auditor, tls-cert-watchdog, mail-security-auditor, aws-waste-finder, docker-baseline-auditor, nginx-config-auditor, cert-transparency-watcher, security-txt-auditor).

Pay what you want (including $0) if it's useful: https://errant.solutions

License

MIT — see LICENSE.