~/2026/01/28/python-discovery-crawling-dns-zones-for-orphaned-records.md
Python: Discovery – Crawling DNS Zones for Orphaned Records
--- author: Tom Lasswell date: read: 3 min in: [scripts, engineering] tags: [python, discovery, network, windows] ---
$ grep -n '^#' post.md
Internal DNS zones only ever seem to grow. Decommissioned servers, one-off project VMs, printers that got replaced two refreshes ago: the A records outlive the hosts by years, and nobody wants to delete a record they can't prove is safe to remove. Rather than trust anyone's memory, this script pulls the zone straight from the nameserver with a zone transfer, then probes every A/AAAA record for signs of life: a handful of common TCP ports and a reverse DNS lookup. Anything with neither gets written to a report as an orphan candidate. It also flags CNAMEs whose in-zone target has no address record left. The output is a starting list for manual triage, not an auto-delete list.
This is the DNS collector from the discovery-first approach to documenting an undocumented network, where the zone export is one of the inputs merged into the inventory.
Requirements
- Python 3.10 or later.
dnspython2.1 or later (pip install dnspython). The script usesdns.query.inbound_xfr(), which replaced thedns.query.xfr()call the first version of this script used;xfr()is deprecated as of dnspython 2.1.- Zone transfer (AXFR) permitted from the host running the script. On Windows DNS the per-zone setting is
-SecureSecondariesonSet-DnsServerPrimaryZone(NoTransfer,TransferAnyServer,TransferToZoneNameServer,TransferToSecureServers); BIND usesallow-transfer. Allow a single automation host rather than opening transfers broadly, or use the PowerShell export in the Usage section instead. - Network line of sight from wherever the script runs to the address ranges being probed. Run it from a management subnet or jump host; a probe from an isolated segment reports plenty of false positives for hosts it simply can't reach.
- Authorization to probe the address ranges the zone points at. It's a light TCP connect check, but it is still a scan.
Usage
Transfer an internal zone and write only the orphan candidates:
python3 dns_orphan_crawler.py corp.example.internal --server dns01.corp.example.internal
Write a full report, including records that were confirmed reachable, and add line-of-business ports to the probe list:
python3 dns_orphan_crawler.py corp.example.internal --server 10.0.0.10 --ports 22,80,443,445,3389,1433,9100 --write-full --output full-report.csv
Transferring corp.example.internal from 10.0.0.10...
412 A/AAAA record(s) and 57 CNAME(s) found. Probing...
38 orphan candidate(s) and 4 dangling CNAME(s) out of 469 record(s). Report: full-report.csv
If the zone lives on Windows DNS and AXFR is locked down (the right default), you can read the zone over the DnsServer module's CIM interface instead, and export the Timestamp column too. Dynamically registered records carry an aging timestamp; static records don't, which is useful context for the Python script's candidate list:
<#
.SYNOPSIS
Export A and CNAME records from a Windows DNS zone to CSV.
.DESCRIPTION
Reads A and CNAME records with Get-DnsServerResourceRecord and writes host name,
type, data, TTL and the aging timestamp (blank for static records).
.PARAMETER ZoneName
DNS zone to export.
.PARAMETER DnsServer
DNS server to query.
.PARAMETER Path
Output CSV path.
.EXAMPLE
.\Export-DnsZoneRecords.ps1 -ZoneName corp.example.internal -DnsServer dns01 -Path .\dns-records.csv
.NOTES
Author : Thomas Lasswell (https://www.techcolumnist.com)
Version : 1.0 (2026-01-28)
Requires: DnsServer module (RSAT DNS Server Tools), read access to the zone
#>
param(
[Parameter(Mandatory = $true)]
[string]$ZoneName,
[Parameter(Mandatory = $true)]
[string]$DnsServer,
[string]$Path = ".\dns-records.csv"
)
$records = foreach ($type in "A", "CName") {
Get-DnsServerResourceRecord -ZoneName $ZoneName -ComputerName $DnsServer -RRType $type
}
$records | ForEach-Object {
$data = if ($_.RecordType -eq "A") { "$($_.RecordData.IPv4Address)" } else { $_.RecordData.HostNameAlias }
[pscustomobject]@{
HostName = $_.HostName
RecordType = $_.RecordType
Data = $data
TimeToLive = $_.TimeToLive
Timestamp = $_.Timestamp
}
} | Export-Csv -Path $Path -NoTypeInformation
Write-Output "Wrote $((Import-Csv -Path $Path).Count) record(s) to $Path"
Script
"""
Crawl a DNS zone via AXFR and flag records that no longer point at a live host.
For each A/AAAA record in the transferred zone, the script attempts lightweight TCP
connection probes and a reverse (PTR) lookup against the record's address. Records
with no reachable port and no PTR are written as orphan candidates. CNAMEs whose
target is inside the zone but has no A/AAAA record are flagged as dangling. Nothing is
deleted.
Reads: the zone via AXFR from the given DNS server.
Writes: a CSV report of candidates (and, with --write-full, every record checked).
Requires: Python 3.10+, dnspython >= 2.1, AXFR permission on the DNS server.
"""
import argparse
import csv
import ipaddress
import socket
from concurrent.futures import ThreadPoolExecutor
import dns.exception
import dns.query
import dns.resolver
import dns.zone
DEFAULT_PORTS = "22,80,443,445,3389"
def parse_args():
parser = argparse.ArgumentParser(description="Crawl a DNS zone for orphaned records.")
parser.add_argument("zone", help="Zone name to transfer, e.g. corp.example.internal")
parser.add_argument("--server", required=True, help="Nameserver (name or IP) to request the AXFR from")
parser.add_argument("--ports", default=DEFAULT_PORTS, help=f"TCP ports to probe (default {DEFAULT_PORTS})")
parser.add_argument("--timeout", type=float, default=1.5, help="Per-connection timeout in seconds")
parser.add_argument("--output", default="orphan-candidates.csv", help="CSV report path")
parser.add_argument("--write-full", action="store_true", help="Also write rows for healthy records")
parser.add_argument("--workers", type=int, default=20, help="Concurrent probe workers")
return parser.parse_args()
def server_address(server):
"""inbound_xfr() wants an IP address, so resolve a hostname first."""
try:
ipaddress.ip_address(server)
return server
except ValueError:
return socket.getaddrinfo(server, 53, proto=socket.IPPROTO_TCP)[0][4][0]
def transfer_zone(zone_name, server_ip):
zone = dns.zone.Zone(zone_name)
dns.query.inbound_xfr(server_ip, zone, timeout=10, lifetime=120)
origin = zone.origin
addresses, cnames = [], []
for rdtype in ("A", "AAAA"):
for name, _ttl, rdata in zone.iterate_rdatas(rdtype):
addresses.append((name.derelativize(origin).to_text(), rdtype, rdata.address))
for name, _ttl, rdata in zone.iterate_rdatas("CNAME"):
target = rdata.target.derelativize(origin)
cnames.append((name.derelativize(origin).to_text(), target))
return addresses, cnames, origin
def has_ptr(ip_address):
try:
return bool(dns.resolver.resolve_address(ip_address, lifetime=2.0))
except dns.exception.DNSException:
return False
def probe_reachable(ip_address, ports, timeout):
for port in ports:
try:
with socket.create_connection((ip_address, port), timeout=timeout):
return port
except OSError:
continue
return None
def check_record(record, ports, timeout):
fqdn, rdtype, ip_address = record
open_port = probe_reachable(ip_address, ports, timeout)
ptr_exists = has_ptr(ip_address)
return {
"fqdn": fqdn,
"type": rdtype,
"data": ip_address,
"open_port": open_port or "",
"has_ptr": ptr_exists,
"finding": "" if (open_port or ptr_exists) else "orphan-candidate",
}
def dangling_cnames(cnames, addresses, origin):
"""CNAMEs pointing inside the zone at a name with no A/AAAA (and no further CNAME)."""
names_with_address = {fqdn for fqdn, _, _ in addresses}
cname_names = {fqdn for fqdn, _ in cnames}
rows = []
for fqdn, target in cnames:
target_text = target.to_text()
if target.is_subdomain(origin) and target_text not in names_with_address | cname_names:
rows.append({"fqdn": fqdn, "type": "CNAME", "data": target_text,
"open_port": "", "has_ptr": "", "finding": "dangling-cname"})
return rows
def main():
args = parse_args()
ports = [int(p) for p in args.ports.split(",") if p.strip()]
server_ip = server_address(args.server)
print(f"Transferring {args.zone} from {server_ip}...")
addresses, cnames, origin = transfer_zone(args.zone, server_ip)
print(f"{len(addresses)} A/AAAA record(s) and {len(cnames)} CNAME(s) found. Probing...")
with ThreadPoolExecutor(max_workers=args.workers) as pool:
results = list(pool.map(lambda r: check_record(r, ports, args.timeout), addresses))
results.extend(dangling_cnames(cnames, addresses, origin))
flagged = [row for row in results if row["finding"]]
rows = results if args.write_full else flagged
with open(args.output, "w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=["fqdn", "type", "data", "open_port", "has_ptr", "finding"])
writer.writeheader()
writer.writerows(sorted(rows, key=lambda row: row["fqdn"]))
orphans = sum(1 for row in flagged if row["finding"] == "orphan-candidate")
dangling = len(flagged) - orphans
print(f"{orphans} orphan candidate(s) and {dangling} dangling CNAME(s) out of {len(addresses) + len(cnames)} record(s). "
f"Report: {args.output}")
if __name__ == "__main__":
main()
Notes
- Candidates, not verdicts. A host that only listens on a port outside the probe list, or sits behind a host firewall that drops everything but its one service port, shows up as an orphan even though it's alive. Cross-check candidates against DHCP leases (
Get-DhcpServerv4Lease) and AD computer objects (Get-ADComputerwithLastLogonDate) before deleting anything. The discovery-first essay has scripts for both. - A missing PTR proves little on its own. Many environments never populated reverse zones, which is why the script only flags a record when both the port probe and the PTR lookup come back empty. The PTR lookup goes through the host's configured resolver, not
--server, so run the script somewhere the internal reverse zones resolve, or every record looks PTR-less. - Transfer failures. A refused transfer raises a dnspython exception (a
dns.exception.DNSExceptionsubclass) rather than returning an empty zone, so the script fails loudly instead of reporting "no orphans".timeoutis per response message andlifetimecaps the whole transfer. - Relative names. Zones transferred with dnspython are relativized to the origin by default (
@for the apex,wwwrather thanwww.corp.example.internal.). The first version of this script built FQDNs with string concatenation, which produced names like@.corp.example.internal.for apex records;derelativize(origin)fixes that. - Dangling CNAMEs are only checked for targets inside the transferred zone. A CNAME pointing at another zone or an external SaaS hostname needs a resolver lookup of its own, and a dangling external CNAME is worth checking for subdomain takeover risk.
- Aging. On Windows DNS, stale dynamically registered records are what aging and scavenging exist for. Static records get a zero timestamp and are never scavenged, so those are the ones this report is really for.
References
- dnspython:
dns.query.inbound_xfrand the deprecation ofdns.query.xfr - dnspython: the
Zoneclass anditerate_rdatas - dnspython: resolver functions (
resolve_address) - Get-DnsServerResourceRecord (DnsServer)
- Set-DnsServerPrimaryZone:
-SecureSecondaries - DNS aging and scavenging in Windows Server
- BIND 9 configuration reference:
allow-transfer