Security · python · 23 copies
DNS Security Analyzer
Comprehensive DNS security analysis covering: zone transfer testing (AXFR — if successful, attacker gets complete DNS map of your network), DNSSEC validation, SPF/DKIM/DMARC email authentication records, CAA record checks, detection of DNS tunneling patterns in query logs (high-entropy subdomains, TXT record exfiltration), subdomain enumeration via certificate transparency logs, wildcard DNS detection, and dangling DNS records pointing to decommissioned cloud resources (subdomain takeover risk). Also tests whether nameservers act as open resolvers that can be abused for DDoS amplification.
- Linux
- Windows
- macOS
pip install "dnspython>=2.4" "requests>=2.31" "colorama>=0.4.6"#!/usr/bin/env python3
"""
DNS Security Analyzer
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/dns-security-analyzer
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
pip install "dnspython>=2.4" "requests>=2.31" "colorama>=0.4.6"
"""
import argparse
import json
import math
import re
import socket
import string
import sys
from collections import Counter
from datetime import datetime, timezone
from typing import List, Optional
# Windows consoles default to a legacy code page (cp1252), which cannot encode
# the arrows, box-drawing and check-mark characters used in the output below.
# Printing one raises UnicodeEncodeError and kills the script mid-scan, so make
# stdout UTF-8 and degrade unencodable characters instead of crashing.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
pass # already wrapped, or not a real stream (piped/redirected)
try:
import dns.resolver
import dns.query
import dns.zone
import dns.rdatatype
import dns.exception
except ImportError:
print("ERROR: dnspython not installed — pip install dnspython")
sys.exit(1)
try:
import requests
except ImportError:
print("ERROR: requests not installed — pip install requests")
sys.exit(1)
try:
from colorama import Fore, Style, init as colorama_init
colorama_init(autoreset=True)
except ImportError:
class Fore:
RED = GREEN = YELLOW = CYAN = WHITE = MAGENTA = ''
class Style:
RESET_ALL = BRIGHT = ''
FINDINGS = []
def finding(severity, check, resource, issue, detail='', fix=''):
FINDINGS.append({'severity': severity, 'check': check, 'resource': resource,
'issue': issue, 'detail': detail, 'fix': fix})
color = {'CRITICAL': Fore.RED, 'HIGH': Fore.YELLOW, 'MEDIUM': Fore.CYAN, 'LOW': Fore.WHITE}.get(severity, Fore.WHITE)
mark = '\u2717' if severity in ('CRITICAL', 'HIGH') else '\u26a0'
print(f" {color}{mark} {severity:<10}{Style.RESET_ALL} {resource}")
if detail:
print(f" {Fore.WHITE}{detail}{Style.RESET_ALL}")
if fix:
print(f" {Fore.CYAN}Fix: {fix}{Style.RESET_ALL}")
def ok(resource, note=''):
print(f" {Fore.GREEN}\u2713{Style.RESET_ALL} {resource} {note}")
def resolve_ns(domain: str) -> List[str]:
try:
answers = dns.resolver.resolve(domain, 'NS')
ns_list = []
for rr in answers:
ns_name = str(rr.target).rstrip('.')
try:
ip = socket.gethostbyname(ns_name)
ns_list.append((ns_name, ip))
except socket.gaierror:
ns_list.append((ns_name, None))
return ns_list
except Exception:
return []
def test_zone_transfer(domain: str, nameservers: List):
print(f"\n{Fore.CYAN}[*] ZONE TRANSFER TEST (AXFR){Style.RESET_ALL}")
for ns_name, ns_ip in nameservers:
target = ns_ip or ns_name
try:
zone = dns.zone.from_xfr(dns.query.xfr(target, domain, timeout=5))
records = list(zone.iterate_rdatasets())
finding('CRITICAL', 'AXFR', ns_name,
f'ZONE TRANSFER SUCCEEDED — {len(records)} records exposed',
f'Attacker can enumerate every hostname, IP, and service in {domain}',
f'Restrict AXFR in named.conf: allow-transfer {{ <secondary-ns-ip>; }};')
print(f" {Fore.RED}Exposed records (sample):{Style.RESET_ALL}")
for i, (name, ttl, rdataset) in enumerate(records[:5]):
print(f" {Fore.RED} {name}.{domain} → {rdataset}{Style.RESET_ALL}")
except dns.exception.FormError:
ok(ns_name, 'AXFR REFUSED ✓ (correct)')
except dns.query.TransferError:
ok(ns_name, 'AXFR REFUSED ✓ (correct)')
except Exception as e:
if 'REFUSED' in str(e) or 'denied' in str(e).lower():
ok(ns_name, 'AXFR REFUSED ✓ (correct)')
else:
print(f" {Fore.YELLOW}AXFR test error for {ns_name}: {e}{Style.RESET_ALL}")
def check_dnssec(domain: str):
print(f"\n{Fore.CYAN}[*] DNSSEC{Style.RESET_ALL}")
resolver = dns.resolver.Resolver()
resolver.use_edns(0, dns.flags.DO, 4096)
try:
answers = resolver.resolve(domain, 'DNSKEY')
ok('DNSKEY', f'{len(list(answers))} key(s) found')
try:
resolver.resolve(domain, 'RRSIG')
ok('RRSIG', 'Signatures present — chain of trust active ✓')
except Exception:
finding('HIGH', 'DNSSEC', 'RRSIG', 'DNSKEY present but no RRSIG signatures',
'DNSSEC is partially configured — responses are not signed',
'Sign zone records with your DNS provider DNSSEC signing tool')
except dns.resolver.NoAnswer:
finding('MEDIUM', 'DNSSEC', domain, 'DNSSEC not enabled — no DNSKEY record',
'DNS responses can be forged (cache poisoning / BGP hijack attacks)',
'Enable DNSSEC at your DNS registrar — most support it at no extra cost')
except Exception:
finding('MEDIUM', 'DNSSEC', domain, 'DNSSEC status could not be determined', '', '')
def check_spf(domain: str):
try:
answers = dns.resolver.resolve(domain, 'TXT')
for rr in answers:
txt = b''.join(rr.strings).decode('utf-8', errors='replace')
if txt.startswith('v=spf1'):
print(f" SPF: {Fore.WHITE}{txt[:80]}{Style.RESET_ALL}")
if '+all' in txt:
finding('CRITICAL', 'SPF', domain, '+all — anyone can send as this domain',
'SPF record explicitly permits ALL senders — phishing trivially bypassed',
'Change +all to -all immediately and test with mail-tester.com')
elif '~all' in txt:
finding('MEDIUM', 'SPF', domain, '~all SoftFail — spoofed emails may reach inbox',
'With ~all, spoofed emails are marked but not rejected',
'Change ~all to -all after testing that all legitimate mail sources are listed')
elif '-all' in txt:
ok(domain, 'SPF -all (HardFail) ✓ — unauthorized senders rejected')
else:
finding('MEDIUM', 'SPF', domain, 'No all mechanism found', '', 'Add -all at end of SPF record')
return
finding('HIGH', 'SPF', domain, 'No SPF record found',
f'Anyone can send email claiming to be from @{domain}',
f'Add TXT record: v=spf1 include:_spf.yourmailprovider.com -all')
except Exception:
finding('HIGH', 'SPF', domain, 'SPF lookup failed', '', '')
def check_dmarc(domain: str):
dmarc_domain = f'_dmarc.{domain}'
try:
answers = dns.resolver.resolve(dmarc_domain, 'TXT')
for rr in answers:
txt = b''.join(rr.strings).decode('utf-8', errors='replace')
if txt.startswith('v=DMARC1'):
print(f" DMARC: {Fore.WHITE}{txt[:100]}{Style.RESET_ALL}")
if 'p=none' in txt:
finding('HIGH', 'DMARC', domain, 'p=none — monitoring only, no enforcement',
'Spoofed emails are logged but NOT blocked or quarantined',
'Start with p=quarantine to move spoofed mail to spam; upgrade to p=reject')
elif 'p=quarantine' in txt:
finding('LOW', 'DMARC', domain, 'p=quarantine — not fully enforced',
'Spoofed emails go to spam but are not rejected',
'Upgrade to p=reject after confirming rua reports show no legitimate mail flagged')
elif 'p=reject' in txt:
ok(domain, 'DMARC p=reject ✓ — spoofed emails fully blocked')
return
finding('HIGH', 'DMARC', domain, 'No DMARC record found',
'No visibility into email spoofing attempts against this domain',
f'Add TXT record for _dmarc.{domain}: v=DMARC1; p=none; rua=mailto:dmarc-reports@{domain}')
except Exception:
finding('HIGH', 'DMARC', domain, 'DMARC lookup failed', '', '')
def check_dkim_selectors(domain: str):
SELECTORS = ['default', 'google', 'k1', 'mail', 'smtp', 'selector1', 'selector2', 'dkim', 'protonmail']
found = []
for sel in SELECTORS:
try:
dns.resolver.resolve(f'{sel}._domainkey.{domain}', 'TXT')
found.append(sel)
except Exception:
pass
if found:
ok(domain, f'DKIM selectors found: {', '.join(found)}')
else:
finding('MEDIUM', 'DKIM', domain, 'No DKIM selectors found for common names',
'Email may be signed under non-standard selector names (not necessarily an issue)',
f'Check your mail provider documentation for the correct DKIM selector')
def check_caa(domain: str):
print(f"\n{Fore.CYAN}[*] CAA RECORDS{Style.RESET_ALL}")
try:
answers = dns.resolver.resolve(domain, 'CAA')
cas = [str(rr) for rr in answers]
ok(domain, f'CAA configured: {'; '.join(cas[:3])}')
except dns.resolver.NoAnswer:
finding('MEDIUM', 'CAA', domain, 'No CAA record — any CA can issue TLS certificates',
'Without CAA, a rogue CA could issue a cert for your domain enabling MITM attacks',
f'Add: 0 issue "letsencrypt.org" or your CA of choice')
except Exception:
finding('MEDIUM', 'CAA', domain, 'CAA lookup failed', '', '')
def enumerate_subdomains_crt(domain: str) -> List[str]:
print(f"\n{Fore.CYAN}[*] SUBDOMAINS (Certificate Transparency — passive){Style.RESET_ALL}")
try:
resp = requests.get(f'https://crt.sh/?q=%.{domain}&output=json', timeout=15)
resp.raise_for_status()
data = resp.json()
subs = set()
for entry in data:
name = entry.get('name_value', '')
for sub in name.split('\n'):
sub = sub.strip().lstrip('*.')
if sub.endswith(f'.{domain}') or sub == domain:
subs.add(sub)
print(f" Found {len(subs)} unique subdomains via crt.sh")
return list(subs)
except Exception as e:
print(f" {Fore.YELLOW}crt.sh lookup failed: {e}{Style.RESET_ALL}")
return []
TAKEOVER_PATTERNS = [
(r'github\.io', 'GitHub Pages — check if page returns 404'),
(r'herokuapp\.com', 'Heroku — check if app name is available'),
(r's3\.amazonaws\.com', 'S3 bucket — check if bucket exists'),
(r'azurewebsites\.net', 'Azure Web App — check if app exists'),
(r'netlify\.app', 'Netlify — check if site is active'),
(r'wpengine\.com', 'WP Engine — check if account exists'),
(r'ghost\.io', 'Ghost — check if blog exists'),
]
def check_subdomain_takeover(subs: List[str], domain: str):
print(f"\n{Fore.CYAN}[*] SUBDOMAIN TAKEOVER CHECK{Style.RESET_ALL}")
for sub in subs[:100]: # cap to avoid rate limiting
try:
answers = dns.resolver.resolve(sub, 'CNAME')
for rr in answers:
cname = str(rr.target).rstrip('.')
for pattern, description in TAKEOVER_PATTERNS:
if re.search(pattern, cname, re.IGNORECASE):
# Check if target resolves / returns 404
try:
resp = requests.get(f'https://{cname}', timeout=5, allow_redirects=True)
if resp.status_code == 404:
finding('HIGH', 'Subdomain Takeover', sub,
f'CNAME → {cname} ({description})',
f'Target returns 404 — attacker can claim this resource and serve content as {sub}',
f'Remove DNS record for {sub} or reclaim the resource at {cname}')
else:
ok(sub, f'CNAME → {cname} (active)')
except Exception:
finding('MEDIUM', 'Subdomain Takeover', sub,
f'CNAME → {cname} ({description}) — could not verify',
'Manually verify the target is still claimed',
f'Check {cname} is active and owned by your organization')
except Exception:
pass
def check_open_resolver(nameservers: List):
print(f"\n{Fore.CYAN}[*] OPEN RESOLVER CHECK{Style.RESET_ALL}")
for ns_name, ns_ip in nameservers:
if not ns_ip:
continue
try:
resolver = dns.resolver.Resolver(configure=False)
resolver.nameservers = [ns_ip]
resolver.timeout = 3
resolver.lifetime = 3
resolver.resolve('google.com', 'A')
finding('HIGH', 'Open Resolver', ns_name,
'Accepts recursive queries from external IPs',
'Open resolvers can be abused for DNS amplification DDoS attacks (up to 70x amplification)',
f'Restrict recursion in named.conf: allow-recursion {{ <trusted-nets>; }};')
except dns.resolver.NoNameservers:
ok(ns_name, 'Not an open resolver ✓')
except Exception:
ok(ns_name, 'Not an open resolver ✓')
def entropy(s: str) -> float:
if not s:
return 0.0
freq = Counter(s)
length = len(s)
return -sum((c/length) * math.log2(c/length) for c in freq.values())
def analyze_dns_tunneling(log_path: str):
print(f"\n{Fore.CYAN}[*] DNS TUNNELING DETECTION — {log_path}{Style.RESET_ALL}")
HIGH_ENTROPY = 3.8
try:
with open(log_path, 'r', errors='replace') as fh:
lines = fh.readlines()[:50000]
except Exception as e:
print(f" ERROR reading log: {e}"); return
subdomain_re = re.compile(r'query\[A\]\s+([\w\.-]+)\.', re.IGNORECASE)
query_counts: Counter = Counter()
high_entropy_queries = []
for line in lines:
m = subdomain_re.search(line)
if m:
fqdn = m.group(1)
parts = fqdn.split('.')
if len(parts) > 2:
sub = parts[0]
if len(sub) > 20:
ent = entropy(sub)
if ent > HIGH_ENTROPY:
high_entropy_queries.append((sub, ent, fqdn))
query_counts[fqdn] += 1
if high_entropy_queries:
finding('HIGH', 'DNS Tunneling', log_path,
f'{len(high_entropy_queries)} high-entropy subdomains detected',
'High-entropy subdomains are a strong indicator of DNS tunneling (data exfiltration via DNS)',
'Block with RPZ or DNS firewall; investigate source IPs making these queries')
for sub, ent, fqdn in sorted(high_entropy_queries, key=lambda x: -x[1])[:5]:
print(f" {Fore.RED}entropy={ent:.2f} {fqdn}{Style.RESET_ALL}")
else:
ok(log_path, f'No high-entropy patterns detected in {len(lines)} log lines')
# Top queried domains
top = query_counts.most_common(5)
if top:
print(f" Top queried: {', '.join(f'{d} ({c}x)' for d, c in top)}")
# ─── MAIN ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description='DNS Security Analyzer')
parser.add_argument('--domain', required=True)
parser.add_argument('--nameservers', default='', help='Comma-separated nameserver hostnames')
parser.add_argument('--wordlist', default='', help='Subdomain brute-force wordlist')
parser.add_argument('--query-log', default='', help='DNS query log for tunneling detection')
parser.add_argument('--output', default='', help='Output JSON report path')
args = parser.parse_args()
domain = args.domain.lower().strip()
print(f"{Fore.CYAN}[*] DNS Security Analyzer | Domain: {domain}{Style.RESET_ALL}")
if args.nameservers:
nameservers = [(ns.strip(), None) for ns in args.nameservers.split(',')]
for i, (ns, _) in enumerate(nameservers):
try:
ip = socket.gethostbyname(ns)
nameservers[i] = (ns, ip)
except Exception:
pass
else:
nameservers = resolve_ns(domain)
if nameservers:
ns_str = ', '.join(f'{n} ({ip})' for n, ip in nameservers)
print(f"{Fore.CYAN}[*] Nameservers: {ns_str}{Style.RESET_ALL}")
else:
print(f"{Fore.YELLOW}WARNING: Could not resolve nameservers for {domain}{Style.RESET_ALL}")
test_zone_transfer(domain, nameservers)
check_dnssec(domain)
print(f"\n{Fore.CYAN}[*] EMAIL SECURITY (SPF / DKIM / DMARC){Style.RESET_ALL}")
check_spf(domain)
check_dkim_selectors(domain)
check_dmarc(domain)
check_caa(domain)
subs = enumerate_subdomains_crt(domain)
if subs:
check_subdomain_takeover(subs, domain)
check_open_resolver(nameservers)
if args.query_log:
analyze_dns_tunneling(args.query_log)
# Summary
counts = {s: sum(1 for f in FINDINGS if f['severity'] == s) for s in ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']}
print(f"\n{'─'*70}")
print(f"{Fore.RED}{counts['CRITICAL']} CRITICAL{Style.RESET_ALL} "
f"{Fore.YELLOW}{counts['HIGH']} HIGH{Style.RESET_ALL} "
f"{Fore.CYAN}{counts['MEDIUM']} MEDIUM{Style.RESET_ALL} "
f"{counts['LOW']} LOW")
output = args.output or f"dns_audit_{datetime.now().strftime('%Y-%m-%d')}.json"
report = {'domain': domain, 'generated': datetime.now(timezone.utc).isoformat(),
'summary': counts, 'findings': FINDINGS}
with open(output, 'w') as fh:
json.dump(report, fh, indent=2)
print(f"{Fore.GREEN}Report saved: {output}{Style.RESET_ALL}")
return 2 if counts['CRITICAL'] else (1 if counts['HIGH'] or counts['MEDIUM'] else 0)
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
--domaintextrequiredThe domain to analyse, e.g. example.com. Every check runs against it.
--nameserverstextComma-separated nameservers to test, instead of the domain's own NS records. Use it to test a specific server, or one that is not yet authoritative.
--wordlisttextSubdomain wordlist for brute-force enumeration. Omit to skip that check entirely — it is the only part of the run that generates significant query volume.
--query-logtextA DNS query log to scan for tunneling patterns: high-entropy labels and abnormal query rates. Omit to skip. The log is read locally and never uploaded.
--outputtextWrite the full JSON report to this path.
What This Solves
DNS is the part of your infrastructure most likely to be both externally visible and nobody's job. The findings it produces are not subtle: An open AXFR. If a zone transfer succeeds against your nameserver, anyone on the internet can download a complete map of your network - every host, every internal name, every forgotten test system. It is the single highest-value reconnaissance target and it is one line of configuration away from being closed. No DMARC, or a DMARC policy of `p=none`. Anyone can send mail as your domain. `p=none` is the policy that looks configured and enforces nothing. Missing SPF, DKIM, CAA. The three records that respectively say who may send your mail, how it is signed, and which CAs may issue your certificates. DNSSEC not validating. Or configured and broken, which is worse than absent. Subdomain takeover candidates. A CNAME pointing at a cloud service nobody owns any more. DNS tunnelling in your query logs. High-entropy labels and abnormal query rates - the signature of data leaving through port 53. Run it against your own domains before someone else does.
Is It Safe to Run - Read This First
**Only analyse domains you own or are authorised to assess.** Most of what this does is ordinary DNS lookups, which are unremarkable. Two parts are not: The AXFR test is an attempted zone transfer. Against a domain you do not own, that is a reconnaissance attempt and it will be logged as one. Against your own, it is the single most valuable check here. --wordlist is subdomain brute force. It generates query volume - potentially a lot of it - against your nameservers, and it looks exactly like enumeration because it is enumeration. **Omit it unless you mean it.** It is the only part of the run that is noisy; everything else is a handful of lookups. Nothing is modified. No records are changed, nothing is written to any nameserver. `--query-log` reads a file **on your machine**. It is parsed locally and never uploaded anywhere - worth knowing, because DNS query logs are sensitive.
Before You Start
1. Python 3.9 or newer: python --version 2. The libraries: pip install "dnspython>=2.4" "requests>=2.31" "colorama>=0.4.6" 3. Authorisation for the domain. See above. 4. Outbound DNS. A corporate resolver that intercepts queries will change the answers you get - the SPF and DMARC lookups will still work, but the AXFR test from behind a filtering resolver tells you about the resolver, not about your nameserver.
Your First Run
One argument: python dns_security_analyzer.py --domain example.com That runs everything except the two opt-in checks - zone transfer, DNSSEC, SPF, DKIM, DMARC, CAA, open resolver and subdomain takeover. It is a handful of queries and takes seconds. Read the AXFR result first. If any nameserver allowed the transfer, stop reading and go fix that. Then, deliberately: # against specific nameservers, e.g. one not yet authoritative python dns_security_analyzer.py --domain example.com \ --nameservers ns1.example.com,ns2.example.com # subdomain enumeration - noisy, see the safety section python dns_security_analyzer.py --domain example.com \ --wordlist subdomains.txt # tunnelling detection against your own logs python dns_security_analyzer.py --domain example.com \ --query-log /var/log/named/queries.log
What the Output Means
The run is sectioned by check, with findings graded CRITICAL, HIGH, MEDIUM or LOW, each carrying the remediation. The findings worth understanding before you see them: AXFR succeeded - CRITICAL A nameserver transferred the zone to you. Anyone can do the same. The fix is in the message: restrict `allow-transfer` to your secondaries only. Do this today; everything else on the list can wait. AXFR REFUSED The correct answer, printed per nameserver so you can see that **every** one refused. One permissive secondary is enough to expose the zone, and it is usually the one nobody remembers. SPF missing, or ending in `?all` / `+all` `-all` means "reject anything else", `~all` means "treat it as suspicious". `?all` and `+all` mean the record exists and permits the world, which is the configuration that passes a checklist and stops nothing. DMARC `p=none` The record is present and enforces nothing. It is the right starting point for a rollout and the wrong place to stay - and it is where most domains have been sitting for years. DKIM selectors Checked against common selector names. A domain signing with an unusual selector may report nothing found while DKIM works perfectly - confirm against your mail provider before treating it as a finding. No CAA record Any CA may issue a certificate for your domain. Low severity, trivial to fix, and the thing that limits the blast radius of a CA compromise. Subdomain takeover candidate A CNAME pointing at a service that no longer resolves or is unclaimed. Verify by hand before reporting - the detection is necessarily heuristic, and a temporarily broken service looks the same. DNS tunnelling indicators From --query-log: high-entropy labels, abnormal query rates to one domain. **Indicators, not proof.** Some CDNs and security products generate traffic that looks identical. Investigate the destination before concluding anything.
Exit Codes for Cron and CI
0 nothing above low severity 1 high or medium findings 2 critical findings - in practice, an open zone transfer 130 you pressed Ctrl-C A monthly run over your domains is cheap and catches the record someone removed: 0 6 1 * * cd /opt/netops && python dns_security_analyzer.py \ --domain example.com --output /var/log/dns-audit.json Do not put --wordlist in a scheduled job. Enumeration every night against your own nameservers is load and noise for a check whose answer rarely changes.
Every Flag
--domain NAME REQUIRED. The domain to analyse. Every check runs against it. --nameservers LIST Comma-separated nameservers to test instead of the domain's own NS records. Use it to test one specific server, or one that is not yet authoritative. --wordlist PATH Subdomain wordlist for brute-force enumeration. Omit to skip - this is the only part of the run that generates significant query volume. --query-log PATH A DNS query log to scan for tunnelling patterns. Omit to skip. Read locally, never uploaded. --output PATH Write the full JSON report here.
When Something Fails
ModuleNotFoundError: dns `pip install "dnspython>=2.4"`. The package is `dnspython`, the import is `dns` - a classic source of confusion. Every lookup times out Outbound DNS is blocked, or your resolver is intercepting. Confirm with `dig @8.8.8.8 example.com` from the same host. AXFR "test error" rather than refused or succeeded The nameserver did not answer on TCP 53 at all. That is usually a firewall between you and it - which is not the same as the transfer being refused. Test from somewhere with TCP 53 reachability before recording the zone as protected. No DKIM found but mail is signed The selector is not in the common list. DKIM selectors are arbitrary and provider-specific; get yours from your mail provider and verify by hand. SPF reported as too permissive and you believe it is fine Check the last mechanism. `?all` and `+all` permit everything, which is almost never what anyone intends. `~all` or `-all` is what you want. Subdomain takeover candidates that are not real The check is heuristic and a temporarily broken service looks the same as an unclaimed one. Verify ownership of the target before putting it in a report. Tunnelling indicators on legitimate traffic Some CDNs and endpoint security products encode data in DNS labels by design. Look at the destination domain before escalating - if it belongs to a vendor you deployed, that is the answer. The subdomain scan takes forever It is one query per word. Use a smaller wordlist; the first few hundred common names find almost everything that matters.