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

121 lines
6.0 KiB
Python

"""Classic DNS blacklists (DNSBL / RHSBL / URIBL).
Domain lists are asked about the domain itself; IP lists about every public
IPv4 address of the domain's web and mail hosts. Queries go straight to each
list's authoritative servers: Spamhaus, URIBL and SURBL refuse (or answer
with a sentinel code) anything relayed through big public resolvers.
Each list's return codes are mapped to a reason; known "you may not query"
codes are reported as unavailable, never as listed."""
from concurrent.futures import ThreadPoolExecutor
from . import dnsutil
_pool = ThreadPoolExecutor(max_workers=48, thread_name_prefix='dnsbl')
# (id, name, zone, info url, {code: reason}, {codes meaning 'query refused'}).
# Integer keys mean the list answers a bitmask in the last octet (SURBL, URIBL).
DOMAIN_LISTS = [
('spamhaus_dbl', 'Spamhaus DBL', 'dbl.spamhaus.org',
'https://check.spamhaus.org/',
{'127.0.1.2': 'spam', '127.0.1.4': 'phish', '127.0.1.5': 'malware',
'127.0.1.6': 'botnet_cc', '127.0.1.102': 'abused_legit_spam',
'127.0.1.103': 'abused_redirector', '127.0.1.104': 'abused_legit_phish',
'127.0.1.105': 'abused_legit_malware', '127.0.1.106': 'abused_legit_botnet_cc'},
{'127.255.255.252', '127.255.255.254', '127.255.255.255'}),
('surbl', 'SURBL multi', 'multi.surbl.org', 'https://www.surbl.org/surbl-analysis',
{8: 'phish', 16: 'malware', 64: 'abuse', 128: 'cracked'},
{'127.0.0.1', '127.0.0.254'}),
('uribl', 'URIBL multi', 'multi.uribl.com', 'https://admin.uribl.com/',
{2: 'black', 4: 'grey', 8: 'red'}, {'127.0.0.1'}),
('sem_uribl', 'SEM URIBL', 'uribl.spameatingmonkey.net',
'https://spameatingmonkey.com/', {'127.0.0.2': 'spam'}, set()),
('sem_fresh', 'SEM FRESH (new domains)', 'fresh.spameatingmonkey.net',
'https://spameatingmonkey.com/', {'127.0.0.2': 'recently_registered'}, set()),
('nordspam', 'Nordspam DBL', 'dbl.nordspam.com', 'https://www.nordspam.com/',
{'127.0.0.2': 'spam'}, set()),
]
IP_LISTS = [
('spamhaus_zen', 'Spamhaus ZEN', 'zen.spamhaus.org', 'https://check.spamhaus.org/',
{'127.0.0.2': 'sbl', '127.0.0.3': 'sbl_css', '127.0.0.4': 'xbl', '127.0.0.9': 'drop',
'127.0.0.10': 'pbl_isp', '127.0.0.11': 'pbl'},
{'127.255.255.252', '127.255.255.254', '127.255.255.255'}),
('spamcop', 'SpamCop', 'bl.spamcop.net', 'https://www.spamcop.net/bl.shtml',
{'127.0.0.2': 'spam'}, set()),
('barracuda', 'Barracuda BRBL', 'b.barracudacentral.org',
'https://www.barracudacentral.org/lookups', {'127.0.0.2': 'spam'}, set()),
('psbl', 'PSBL', 'psbl.surriel.com', 'https://psbl.org/', {'127.0.0.2': 'spam'}, set()),
('mailspike', 'Mailspike', 'bl.mailspike.net', 'https://mailspike.org/',
{'127.0.0.2': 'spam', '127.0.0.10': 'worst', '127.0.0.11': 'very_bad', '127.0.0.12': 'bad',
'127.0.0.13': 'suspicious'}, set()),
('uceprotect1', 'UCEPROTECT L1', 'dnsbl-1.uceprotect.net',
'https://www.uceprotect.net/', {'127.0.0.2': 'spam'}, set()),
('dronebl', 'DroneBL', 'dnsbl.dronebl.org', 'https://dronebl.org/lookup',
{}, set()),
('s5h', 'S5H', 'all.s5h.net', 'https://www.usenix.org.uk/content/rbl.html',
{'127.0.0.2': 'spam'}, set()),
('blocklist_de', 'blocklist.de', 'bl.blocklist.de', 'https://www.blocklist.de/',
{'127.0.0.2': 'attacks'}, set()),
('anonmails', 'Anonmails', 'spam.dnsbl.anonmails.de', 'https://anonmails.de/dnsbl.php',
{'127.0.0.2': 'spam'}, set()),
]
MAX_IPS = 6
def _lookup(spec, qname):
lid, name, zone, url, codes, refused = spec
rcode, vals = dnsutil.query_authoritative(zone, f'{qname}.{zone}', 'A')
row = {'id': lid, 'name': name, 'zone': zone, 'url': url}
if rcode is None:
return {**row, 'state': 'error'}
if rcode == 'NXDOMAIN' or not vals:
return {**row, 'state': 'clean'}
if set(vals) & refused:
return {**row, 'state': 'unavailable', 'codes': vals}
if not all(v.startswith('127.') for v in vals):
# A wildcard / parked list answering junk is not a listing.
return {**row, 'state': 'error', 'codes': vals}
if codes and isinstance(next(iter(codes)), int):
bits = 0
for v in vals:
bits |= int(v.rsplit('.', 1)[1])
reasons = sorted(r for b, r in codes.items() if bits & b) or ['listed']
else:
reasons = sorted({codes.get(v, 'listed') for v in vals})
# Spamhaus PBL only says "this is end-user address space" — not a sin for
# a web server, but a mail server there will be rejected.
return {**row, 'state': 'listed', 'codes': vals, 'reasons': reasons}
def check(domain):
if dnsutil.resolve_full(domain, 'SOA')[0] == 'NXDOMAIN':
raise dnsutil.BadInput('domain does not exist')
# Which IPs to check, and why.
targets = {}
for ip in dnsutil.resolve(domain, 'A'):
targets.setdefault(ip, set()).add('web')
for ip in dnsutil.resolve('www.' + domain, 'A'):
targets.setdefault(ip, set()).add('web')
for v in dnsutil.resolve(domain, 'MX'):
host = v.split(None, 1)[1].rstrip('.')
if host:
for ip in dnsutil.resolve(host, 'A'):
targets.setdefault(ip, set()).add('mail')
ips = [ip for ip in targets if dnsutil.is_public_ip(ip)][:MAX_IPS]
dom_jobs = [_pool.submit(_lookup, s, domain) for s in DOMAIN_LISTS]
ip_jobs = {ip: [_pool.submit(_lookup, s, '.'.join(reversed(ip.split('.'))))
for s in IP_LISTS] for ip in ips}
domain_rows = [j.result() for j in dom_jobs]
ip_rows = [{'ip': ip, 'roles': sorted(targets[ip]), 'ptr': dnsutil.ptr(ip),
'lists': [j.result() for j in jobs]} for ip, jobs in ip_jobs.items()]
listed = sum(r['state'] == 'listed' for r in domain_rows) + \
sum(l['state'] == 'listed' for r in ip_rows for l in r['lists'])
return {'domain': domain, 'domain_lists': domain_rows, 'ips': ip_rows,
'skipped_ips': max(0, len(targets) - len(ips)),
'summary': {'listed': listed,
'checked': len(domain_rows) + sum(len(r['lists']) for r in ip_rows)}}