Async DNS scanner and change detector with provider inference and enterprise signal detection.
- Scans A, AAAA, MX, NS, TXT records plus
_dmarc,_domainkey,_mta-stssubdomains and PTR records - Infers email provider from MX records (Google Workspace, Microsoft 365, Proofpoint, Mimecast, etc.)
- Infers DNS provider from NS records (Cloudflare, AWS Route 53, Azure DNS, Vercel, Netlify, etc.)
- Infers host provider from PTR records (AWS, GCP, Azure, Cloudflare, Fastly, DigitalOcean, etc.)
- Detects enterprise TXT signals (Stripe, HubSpot, Atlassian, Salesforce, Zoom, DocuSign, and more)
- Detects SPF and DMARC records with policy extraction
- Diffs two DNS snapshots and emits typed, severity-ranked change events
- Two backends: fast dany Go binary or pure dnspython fallback
pip install dns-change-detectorimport asyncio
from dns_change_detector import scan_domain, extract_signals, detect_changes
async def main():
# Take two snapshots over time
old = extract_signals(await scan_domain("stripe.com"))
# ... time passes ...
new = extract_signals(await scan_domain("stripe.com"))
for change in detect_changes(old, new):
print(f"{change.severity:6} {change.change_type}: {change.description}")
asyncio.run(main())from dns_change_detector import scan_domains, extract_signals
async def scan_batch():
responses = await scan_domains(
["stripe.com", "github.com", "cloudflare.com"],
concurrency=50,
)
return [extract_signals(r) for r in responses]detect_changes(old, new) returns a list of DomainChange objects. Each has:
change_type— string key (see table below)severity—"high","medium", or"low"description— human-readable summaryold_value/new_value— the before/after values
change_type |
Severity | Trigger |
|---|---|---|
spf_added |
high | SPF record appeared |
spf_removed |
high | SPF record disappeared |
spf_changed |
medium | SPF record content changed |
dmarc_added |
high | DMARC record appeared |
dmarc_removed |
high | DMARC record disappeared |
dmarc_policy_changed |
high/medium | Policy changed (high if jumps a level) |
email_provider_changed |
high | MX provider changed (e.g. Google → Microsoft) |
email_provider_added |
medium | Email provider first detected |
email_provider_removed |
high | Email provider removed |
mx_changed |
low | MX records changed within same provider |
dns_provider_changed |
high | NS provider changed (e.g. Cloudflare → Route 53) |
dns_provider_detected |
medium | DNS provider first detected |
dns_provider_removed |
medium | DNS provider removed |
ns_changed |
low | NS records changed within same provider |
host_provider_changed |
high | Host provider changed (e.g. AWS → GCP) |
host_provider_detected |
low | Host provider first detected |
host_provider_removed |
medium | Host provider removed |
a_records_changed |
low | A records changed within same host provider |
cname_added |
medium | CNAME target added |
cname_removed |
medium | CNAME target removed |
cname_changed |
medium | CNAME target changed |
enterprise_signal_added |
medium | Enterprise TXT signal appeared |
enterprise_signal_removed |
medium | Enterprise TXT signal removed |
The inference tables are public and extensible — mutate them before calling extract_signals:
from dns_change_detector import MX_PROVIDERS, NS_PROVIDERS, PTR_PROVIDERS, TXT_SIGNALS
# Add a custom email provider
MX_PROVIDERS.append(("mail.mycompany.com", "My Company Mail"))
# Add a custom enterprise TXT signal
TXT_SIGNALS.append(("myapp-domain-verification=", "myapp", "My App"))| Table | Key format | Infers |
|---|---|---|
MX_PROVIDERS |
(hostname_substr, display_name) |
DNSSignals.email_provider |
NS_PROVIDERS |
(hostname_substr, display_name) |
DNSSignals.dns_provider |
PTR_PROVIDERS |
(hostname_substr, display_name) |
DNSSignals.host_provider |
TXT_SIGNALS |
(txt_substr, signal_key, display_name) |
DNSSignals.enterprise_signals |
# Scan a single domain (pretty-printed JSON)
dns-scan stripe.com
# Scan multiple domains (one JSON object per line)
dns-scan stripe.com github.com cloudflare.com
# Scan from a file (one domain per line, # comments ignored)
dns-scan --file domains.txt
# Custom resolver and concurrency
dns-scan --file domains.txt --server 1.1.1.1 --concurrency 100By default the library uses pure Python (dnspython). For higher throughput, configure the optional dany Go backend:
import dns_change_detector.scanner as scanner
scanner.DANY_BINARY = "/usr/local/bin/dany"
scanner.DANY_NAMESERVER = "8.8.8.8" # required on macOS (no /etc/resolv.conf)
scanner.DANY_TIMEOUT = 20.0 # seconds per domainIf dany is in your PATH it is detected and used automatically — no explicit configuration needed.
Full DNS scan of a single domain. Uses the dany backend when available, otherwise falls back to dnspython.
Scan a list of domains with bounded concurrency. Safe to call with thousands of domains.
Extract normalized signals from a DNSResponse. The optional asn_reader is a geoip2.database.Reader opened against a GeoLite2-ASN database; when provided it serves as a fallback for host provider detection when PTR records are absent.
DNSSignals fields:
| Field | Type | Description |
|---|---|---|
domain |
str |
Domain name |
email_provider |
str | None |
Inferred from MX records |
dns_provider |
str | None |
Inferred from NS records |
host_provider |
str | None |
Inferred from PTR records |
has_spf |
bool |
SPF record present |
spf_record |
str | None |
Raw SPF record |
has_dmarc |
bool |
DMARC record present |
dmarc_policy |
str | None |
none, quarantine, or reject |
dmarc_record |
str | None |
Raw DMARC record |
enterprise_signals |
frozenset[str] |
Signal keys (e.g. "stripe", "hubspot") |
cname_target |
str | None |
First CNAME record |
cname_service |
str | None |
Inferred from CNAME (e.g. "Shopify") |
a_records |
frozenset[str] |
A record IPs |
ns_records |
frozenset[str] |
NS hostnames |
mx_records |
frozenset[str] |
Raw MX records |
txt_records |
frozenset[str] |
Apex TXT records |
Diff two DNSSignals snapshots. Both must be for the same domain. Returns an empty list when nothing changed.
DomainChange fields: domain, change_type, severity, description, old_value, new_value.
MIT