Files
MalinandClaude Opus 5.5 237a0f1632 feat: DNS tools API for zonemaster.es
Propagation, global resolver testing (UDP/TCP/DoH), security scan with
history tracker, infrastructure detection, DNS graph, Pi-hole/AdGuard
blocklist checks and DNSBL blacklist checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:44:45 +02:00

359 lines
15 KiB
Python

"""DNS security scan. Every check returns a language-neutral result:
{'id': 'spf', 'status': 'pass'|'warn'|'fail'|'info', 'data': {...}}
The GUI turns (id, status, data) into prose in each locale, so nothing here
is user-facing text. Results are kept per domain for the tracker view."""
import random
import re
import string
from concurrent.futures import ThreadPoolExecutor
import dns.flags
import dns.query
import dns.rdatatype
import dns.zone
from . import dnsutil
_pool = ThreadPoolExecutor(max_workers=32, thread_name_prefix='sec')
WEIGHTS = {'dnssec': 15, 'ns': 10, 'soa': 5, 'axfr': 10, 'spf': 12, 'dmarc': 12,
'dkim': 6, 'mx': 5, 'caa': 7, 'mta_sts': 3, 'tls_rpt': 2,
'dangling': 13}
POINTS = {'pass': 1.0, 'warn': 0.5, 'fail': 0.0}
DKIM_SELECTORS = ['default', 'dkim', 'mail', 'selector1', 'selector2', 'google',
'k1', 'k2', 'k3', 's1', 's2', 'smtp', 'mx', 'zoho', 'zmail',
'protonmail', 'protonmail2', 'protonmail3', 'fm1', 'fm2', 'fm3',
'mandrill', 'mailjet', 'sendgrid', 'smtpapi', 'everlytic', 'sig1',
'ionos', 'key1', 'key2', 'dk', 'mailchimp', 'pm', 'hs1', 'hs2']
SUBDOMAINS = ['www', 'mail', 'webmail', 'blog', 'shop', 'tienda', 'store', 'app', 'api',
'dev', 'staging', 'test', 'beta', 'cdn', 'static', 'img', 'assets', 'docs',
'help', 'support', 'status', 'portal', 'admin', 'm', 'autodiscover',
'news', 'landing', 'go', 'promo', 'campus', 'crm']
# CNAME targets whose owner name can be claimed by anyone once the resource is
# deleted (see can-i-take-over-xyz). A CNAME to one of these that no longer
# resolves is a subdomain takeover waiting to happen.
TAKEOVER_SUFFIXES = ['github.io', 'herokuapp.com', 'herokudns.com', 'azurewebsites.net',
'cloudapp.net', 'cloudapp.azure.com', 'trafficmanager.net',
'blob.core.windows.net', 'azureedge.net', 's3.amazonaws.com',
's3-website', 'elasticbeanstalk.com', 'netlify.app', 'netlify.com',
'vercel.app', 'surge.sh', 'ghost.io', 'myshopify.com',
'pantheonsite.io', 'readthedocs.io', 'bitbucket.io',
'wordpress.com', 'zendesk.com', 'helpscoutdocs.com', 'tumblr.com',
'webflow.io', 'fly.dev', 'agilecrm.com', 'unbouncepages.com',
'strikinglydns.com', 'wixdns.net', 'uservoice.com', 'teamwork.com',
'pages.dev', 'onrender.com', 'smartjob.com']
def _check(cid, status, **data):
return {'id': cid, 'status': status, 'data': data}
VALIDATORS = [('cloudflare', '1.1.1.1'), ('google', '8.8.8.8'), ('quad9', '9.9.9.9'),
('opendns', '208.67.222.222')]
def check_dnssec(domain):
ds = dnsutil.resolve(domain, 'DS')
keys = dnsutil.resolve(domain, 'DNSKEY')
algs = sorted({int(k.split()[2]) for k in keys}) if keys else []
# Ask several validators: they do not all implement every algorithm
# (Cloudflare and Google treat Ed448, alg 16, as insecure), so one
# resolver's missing AD bit does not mean the zone is unsigned.
# Broken validation shows as SERVFAIL that goes away with CD set.
def ask(v):
name, ip = v
r, _, _ = dnsutil.query(ip, domain, 'SOA', dnssec=True)
if r is None:
return name, None
if dnsutil.rcode_text(r) == 'SERVFAIL':
rcd, _, _ = dnsutil.query(ip, domain, 'SOA', dnssec=True, cd=True)
if rcd is not None and dnsutil.rcode_text(rcd) == 'NOERROR':
return name, 'bogus'
return name, 'secure' if r.flags & dns.flags.AD else 'insecure'
verdicts = {n: v for n, v in _pool.map(ask, VALIDATORS) if v}
secure = sorted(n for n, v in verdicts.items() if v == 'secure')
insecure = sorted(n for n, v in verdicts.items() if v == 'insecure')
bogus = sorted(n for n, v in verdicts.items() if v == 'bogus')
data = dict(ds=len(ds), dnskey=len(keys), validated=bool(secure), algorithms=algs,
validators=verdicts)
if bogus:
return _check('dnssec', 'fail', bogus=True, bogus_on=bogus, **data)
if ds and secure:
weak = [a for a in algs if a in (1, 3, 5, 6, 7, 12)]
status = 'warn' if weak or insecure else 'pass'
return _check('dnssec', status, weak_algorithms=weak, not_validated_by=insecure, **data)
if ds and keys:
return _check('dnssec', 'warn', not_validated_by=insecure, **data)
if keys and not ds:
return _check('dnssec', 'warn', missing_ds=True, **data)
return _check('dnssec', 'fail', unsigned=True, **data)
def _ns_info(domain):
names = sorted(n.rstrip('.') for n in dnsutil.resolve(domain, 'NS'))
info = []
for n in names:
v4 = [i for i in dnsutil.resolve(n, 'A') if dnsutil.is_public_ip(i)]
v6 = dnsutil.resolve(n, 'AAAA')
info.append({'name': n, 'ipv4': v4, 'ipv6': v6})
return info
def check_ns(domain, ns):
if not ns:
return _check('ns', 'fail', count=0)
nets = {'.'.join(i.split('.')[:3]) for n in ns for i in n['ipv4']}
has_v6 = any(n['ipv6'] for n in ns)
status = 'pass'
if len(ns) < 2 or len(nets) < 2:
status = 'fail' if len(ns) < 2 else 'warn'
elif not has_v6:
status = 'warn'
return _check('ns', status, count=len(ns), subnets=len(nets), ipv6=has_v6,
servers=[n['name'] for n in ns])
def check_soa(domain, ns):
serials, lame = {}, []
def ask(n):
if not n['ipv4']:
return n['name'], None
r, _, _ = dnsutil.query(n['ipv4'][0], domain, 'SOA', rd=False)
if r is None or not (r.flags & dns.flags.AA):
return n['name'], None
a = [x for x in dnsutil.answers(r) if x['type'] == 'SOA']
return n['name'], int(a[0]['value'].split()[2]) if a else None
for name, serial in _pool.map(ask, ns):
if serial is None:
lame.append(name)
else:
serials[name] = serial
if not serials:
return _check('soa', 'fail', lame=lame, serials={})
status = 'pass' if len(set(serials.values())) == 1 and not lame else 'warn'
return _check('soa', status, lame=lame, serials=serials)
def check_axfr(domain, ns):
open_on = []
def try_xfr(n):
for ip in n['ipv4'][:1]:
try:
z = dns.zone.from_xfr(dns.query.xfr(ip, domain, timeout=4, lifetime=8))
return n['name'], len(z.nodes)
except Exception:
return n['name'], None
return n['name'], None
for name, nodes in _pool.map(try_xfr, ns[:6]):
if nodes:
open_on.append({'server': name, 'names': nodes})
return _check('axfr', 'fail' if open_on else 'pass', open=open_on)
_SPF_MECH = re.compile(r'^([+\-~?]?)(include|a|mx|ptr|exists|redirect)(?:[:=]|$)', re.I)
def spf_lookups(record, depth=0, seen=None):
"""Count DNS-querying mechanisms per RFC 7208 §4.6.4, following includes.
Returns (count, [include domains that do not resolve])."""
seen = seen if seen is not None else set()
count, broken = 0, []
for term in record.split()[1:]:
m = _SPF_MECH.match(term)
if not m:
continue
count += 1
mech = m.group(2).lower()
if mech in ('include', 'redirect') and depth < 10:
target = term.split(':' if mech == 'include' else '=', 1)[1].lower().rstrip('.')
if target in seen:
continue
seen.add(target)
sub = [t for t in dnsutil.txt_values(target) if t.lower().startswith('v=spf1')]
if not sub:
broken.append(target)
continue
c, b = spf_lookups(sub[0], depth + 1, seen)
count += c
broken += b
return count, broken
def check_spf(domain):
recs = [t for t in dnsutil.txt_values(domain) if t.lower().startswith('v=spf1')]
if not recs:
return _check('spf', 'fail', present=False)
if len(recs) > 1:
return _check('spf', 'fail', present=True, multiple=len(recs), record=recs[0])
rec = recs[0]
lookups, broken = spf_lookups(rec)
all_q = re.search(r'\s([+\-~?]?)all\b', ' ' + rec)
qual = all_q.group(1) or '+' if all_q else None
issues = []
if qual in ('+', '?'):
issues.append('permissive_all')
if qual is None and 'redirect=' not in rec:
issues.append('no_all')
if lookups > 10:
issues.append('too_many_lookups')
if re.search(r'\bptr\b', rec):
issues.append('ptr')
if broken:
issues.append('broken_include')
status = 'pass'
if 'permissive_all' in issues or 'too_many_lookups' in issues:
status = 'fail'
elif issues or qual == '~':
status = 'warn' if issues else 'pass'
return _check('spf', status, present=True, record=rec, lookups=lookups,
all=qual, issues=issues, broken=broken)
def check_dmarc(domain):
recs = [t for t in dnsutil.txt_values('_dmarc.' + domain) if t.lower().startswith('v=dmarc1')]
if not recs:
return _check('dmarc', 'fail', present=False)
tags = dict(kv.strip().split('=', 1) for kv in recs[0].split(';') if '=' in kv)
tags = {k.strip().lower(): v.strip() for k, v in tags.items()}
policy = tags.get('p', '').lower()
pct = int(tags.get('pct', '100')) if tags.get('pct', '100').isdigit() else 100
status = {'reject': 'pass', 'quarantine': 'pass', 'none': 'warn'}.get(policy, 'fail')
if status == 'pass' and pct < 100:
status = 'warn'
return _check('dmarc', status, present=True, record=recs[0], policy=policy,
subdomain_policy=tags.get('sp'), pct=pct, rua='rua' in tags)
def check_dkim(domain):
def probe(sel):
vals = dnsutil.txt_values(f'{sel}._domainkey.{domain}')
return sel if any('p=' in v for v in vals) else None
found = [s for s in _pool.map(probe, DKIM_SELECTORS) if s]
# Not finding a selector proves nothing — they are not enumerable.
return _check('dkim', 'pass' if found else 'info', selectors=found,
probed=len(DKIM_SELECTORS))
def check_mx(domain):
mx = []
for v in dnsutil.resolve(domain, 'MX'):
pref, host = v.split(None, 1)
mx.append({'pref': int(pref), 'host': host.rstrip('.')})
mx.sort(key=lambda m: m['pref'])
if not mx:
return _check('mx', 'info', count=0)
if len(mx) == 1 and mx[0]['host'] in ('', '.'):
return _check('mx', 'info', null_mx=True, count=0)
bad = []
for m in mx:
if dnsutil.resolve(m['host'], 'CNAME'):
bad.append({'host': m['host'], 'problem': 'cname'})
elif not dnsutil.addresses(m['host']):
bad.append({'host': m['host'], 'problem': 'no_address'})
status = 'fail' if len(bad) == len(mx) else 'warn' if bad else 'pass'
return _check('mx', status, count=len(mx), hosts=mx, problems=bad)
def check_caa(domain):
# CAA is inherited from the closest parent that has one (RFC 8659).
labels = domain.split('.')
for i in range(len(labels) - 1):
name = '.'.join(labels[i:])
recs = dnsutil.resolve(name, 'CAA')
if recs:
issuers = sorted({r.split(None, 2)[2].strip('"') for r in recs
if r.split()[1] in ('issue', 'issuewild')})
iodef = any(r.split()[1] == 'iodef' for r in recs)
return _check('caa', 'pass', at=name, issuers=issuers, iodef=iodef)
return _check('caa', 'warn', present=False)
def check_mta_sts(domain):
recs = [t for t in dnsutil.txt_values('_mta-sts.' + domain) if t.lower().startswith('v=stsv1')]
return _check('mta_sts', 'pass' if recs else 'info', present=bool(recs))
def check_tls_rpt(domain):
recs = [t for t in dnsutil.txt_values('_smtp._tls.' + domain) if t.lower().startswith('v=tlsrptv1')]
return _check('tls_rpt', 'pass' if recs else 'info', present=bool(recs))
def check_bimi(domain):
recs = [t for t in dnsutil.txt_values('default._bimi.' + domain) if t.lower().startswith('v=bimi1')]
return _check('bimi', 'pass' if recs else 'info', present=bool(recs))
def check_dangling(domain):
def probe(sub):
name = f'{sub}.{domain}'
chain = dnsutil.cname_chain(name)
if not chain:
return None
target = chain[-1]
rcode, vals, _ = dnsutil.resolve_full(target, 'A')
if not vals:
rcode6, vals6, _ = dnsutil.resolve_full(target, 'AAAA')
vals = vals6
risky = any(target.endswith(s) or s in target for s in TAKEOVER_SUFFIXES)
if vals:
return {'name': name, 'target': target, 'state': 'ok', 'service': risky}
return {'name': name, 'target': target, 'state': 'dangling', 'rcode': rcode,
'service': risky}
rows = [r for r in _pool.map(probe, SUBDOMAINS) if r]
dangling = [r for r in rows if r['state'] == 'dangling']
status = 'fail' if any(r['service'] for r in dangling) else 'warn' if dangling else 'pass'
return _check('dangling', status, checked=len(SUBDOMAINS), cnames=rows,
dangling=dangling)
def check_wildcard(domain):
label = ''.join(random.choices(string.ascii_lowercase + string.digits, k=20))
vals = dnsutil.addresses(f'{label}.{domain}', v6=False)
return _check('wildcard', 'info', wildcard=bool(vals), answers=vals[:4])
def scan(domain):
rcode, soa, _ = dnsutil.resolve_full(domain, 'SOA')
if rcode == 'NXDOMAIN':
raise dnsutil.BadInput('domain does not exist')
ns = _ns_info(domain)
jobs = [
_pool.submit(check_dnssec, domain), _pool.submit(check_ns, domain, ns),
_pool.submit(check_soa, domain, ns), _pool.submit(check_axfr, domain, ns),
_pool.submit(check_spf, domain), _pool.submit(check_dmarc, domain),
_pool.submit(check_dkim, domain), _pool.submit(check_mx, domain),
_pool.submit(check_caa, domain), _pool.submit(check_mta_sts, domain),
_pool.submit(check_tls_rpt, domain), _pool.submit(check_bimi, domain),
_pool.submit(check_dangling, domain), _pool.submit(check_wildcard, domain),
]
checks = [j.result() for j in jobs]
# A domain with no mail (no MX, or null MX) is not penalised on mail checks
# beyond SPF/DMARC, which still matter: they stop others spoofing it.
mx = next(c for c in checks if c['id'] == 'mx')
no_mail = mx['data'].get('count', 0) == 0
total = got = 0.0
for c in checks:
w = WEIGHTS.get(c['id'])
# 'info' means "could not tell / optional": it neither helps nor hurts.
if not w or c['status'] == 'info' or (no_mail and c['id'] in ('dkim', 'mta_sts', 'tls_rpt')):
continue
total += w
got += w * POINTS[c['status']]
score = round(100 * got / total) if total else 0
grade = 'A' if score >= 90 else 'B' if score >= 75 else 'C' if score >= 60 else 'D' if score >= 40 else 'F'
return {'domain': domain, 'score': score, 'grade': grade, 'no_mail': no_mail,
'checks': checks}