~/2026/01/28/python-discovery-crawling-dns-zones-for-orphaned-records.md

Python: Discovery – Crawling DNS Zones for Orphaned Records

---
author: 
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.
  • dnspython 2.1 or later (pip install dnspython). The script uses dns.query.inbound_xfr(), which replaced the dns.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 -SecureSecondaries on Set-DnsServerPrimaryZone (NoTransfer, TransferAnyServer, TransferToZoneNameServer, TransferToSecureServers); BIND uses allow-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:

bash
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:

bash
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
text
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:

powershell
<#
.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

python
"""
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-ADComputer with LastLogonDate) 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.DNSException subclass) rather than returning an empty zone, so the script fails loudly instead of reporting "no orphans". timeout is per response message and lifetime caps the whole transfer.
  • Relative names. Zones transferred with dnspython are relativized to the origin by default (@ for the apex, www rather than www.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