checker-blacklist/checker/dnsbl.go

338 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package checker
import (
"context"
"encoding/json"
"fmt"
"net"
"slices"
"strings"
"sync"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() { Register(&dnsblSource{}) }
// dnsblSource fans out a configurable list of DNS-based blocklist
// queries. Unlike the other sources, it returns one SourceResult per
// zone (so Spamhaus / SURBL / URIBL each get their own row in the
// report) while remaining a single Source from the registry's point of
// view — one ID, one option group, one rule entry.
type dnsblSource struct{}
func (*dnsblSource) ID() string { return "dnsbl" }
func (*dnsblSource) Name() string { return "DNS blocklists" }
func (*dnsblSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{
{
Id: "disabled_dnsbls",
Type: "string",
Label: "Disabled DNSBL zones",
Description: "Comma-separated list of DNSBL zone suffixes to skip (e.g. \"multi.surbl.org\").",
},
{
Id: "extra_dnsbls",
Type: "string",
Label: "Extra DNSBL zones",
Description: "Comma-separated list of extra DNSBL zone suffixes to query in addition to the defaults. Their return codes are surfaced verbatim.",
},
},
}
}
// DNSBLZone is the table row describing one zone known to this source.
type DNSBLZone struct {
Zone string
Label string
LookupURL string
RemovalURL string
Decode func(ip net.IP) []string
IsBlockedIP func(ip net.IP) bool // returns true when the IP signals a blocked resolver, not a real listing
}
// DefaultDNSBLZones is the curated list shipped with the checker.
// Sources for the return-code semantics:
// - DBL: https://www.spamhaus.org/dbl/
// - SURBL: https://surbl.org/lists/
// - URIBL: https://uribl.com/about.shtml
var DefaultDNSBLZones = []DNSBLZone{
{
Zone: "dbl.spamhaus.org",
Label: "Spamhaus DBL",
LookupURL: "https://check.spamhaus.org/results/?query=%s",
RemovalURL: "https://www.spamhaus.org/dbl/removal/",
Decode: decodeSpamhausDBL,
IsBlockedIP: func(ip net.IP) bool {
s := ip.String()
return s == "127.0.1.255" || s == "127.255.255.254"
},
},
{
Zone: "multi.surbl.org",
Label: "SURBL multi",
LookupURL: "https://surbl.org/surbl-analysis?d=%s",
RemovalURL: "https://surbl.org/surbl-analysis?d=%s",
Decode: decodeSURBLMulti,
},
{
Zone: "multi.uribl.com",
Label: "URIBL multi",
LookupURL: "https://admin.uribl.com/?section=lookup&query=%s",
RemovalURL: "https://admin.uribl.com/?section=remove",
Decode: decodeURIBLMulti,
IsBlockedIP: func(ip net.IP) bool {
v4 := ip.To4()
return v4 != nil && v4[3] == 1
},
},
}
func (s *dnsblSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
zones := zonesFromOptions(opts)
if registered == "" || len(zones) == 0 {
return []SourceResult{{
SourceID: s.ID(), SourceName: s.Name(), Enabled: false,
}}
}
out := make([]SourceResult, len(zones))
var wg sync.WaitGroup
for i, z := range zones {
wg.Add(1)
go func(i int, z DNSBLZone) {
defer wg.Done()
out[i] = s.queryOne(ctx, registered, z)
}(i, z)
}
wg.Wait()
return out
}
func (s *dnsblSource) queryOne(ctx context.Context, registered string, z DNSBLZone) SourceResult {
q := registered + "." + z.Zone
res := SourceResult{
SourceID: s.ID(),
SourceName: z.Label,
Subject: z.Zone,
Enabled: true,
LookupURL: formatURL(z.LookupURL, registered),
RemovalURL: formatURL(z.RemovalURL, registered),
}
addrs, err := net.DefaultResolver.LookupIP(ctx, "ip4", q)
if err != nil {
// NXDOMAIN is the standard "not listed" reply.
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
return res
}
res.Error = err.Error()
return res
}
seen := map[string]bool{}
blockedCount := 0
for _, a := range addrs {
ip := a.To4()
if ip == nil {
continue
}
code := ip.String()
if seen[code] {
continue
}
seen[code] = true
if z.IsBlockedIP != nil && z.IsBlockedIP(ip) {
blockedCount++
}
res.Evidence = append(res.Evidence, Evidence{
Label: "Return code",
Value: code,
})
if z.Decode != nil {
res.Reasons = append(res.Reasons, z.Decode(ip)...)
}
}
if blockedCount > 0 && blockedCount == len(seen) {
// All returned IPs signal a blocked resolver — not a real domain listing.
res.BlockedQuery = true
return res
}
res.Listed = true
res.Severity = SeverityCrit
if len(res.Reasons) == 0 {
res.Reasons = append(res.Reasons, "Listed (no detail decoded)")
}
// TXT lookup is best-effort — operators often embed a pointer URL
// with the precise reason.
if txt, terr := net.DefaultResolver.LookupTXT(ctx, q); terr == nil {
res.Details = mustJSON(map[string]any{"txt": txt, "queried": q})
} else {
res.Details = mustJSON(map[string]any{"queried": q})
}
return res
}
func (*dnsblSource) Diagnose(res SourceResult) Diagnosis {
return Diagnosis{
Severity: SeverityCrit,
Title: fmt.Sprintf("Listed on %s", res.SourceName),
Detail: fmt.Sprintf(
"Reason(s): %s. Senders relaying mail through this domain (or recipients receiving links to it) will see deliveries rejected. Confirm with the lookup link, then follow the operator's removal procedure — automated requests usually take 2472h to propagate.",
joinNonEmpty(res.Reasons, "; "),
),
LookupURL: res.LookupURL,
RemovalURL: res.RemovalURL,
Fix: res.RemovalURL,
FixIsURL: res.RemovalURL != "",
}
}
// ---------- helpers (shared with other sources) ----------
func formatURL(tmpl, domain string) string {
if tmpl == "" {
return ""
}
if !strings.Contains(tmpl, "%s") {
return tmpl
}
return fmt.Sprintf(tmpl, domain)
}
func zonesFromOptions(opts sdk.CheckerOptions) []DNSBLZone {
zones := DefaultDNSBLZones
if disabledRaw, ok := sdk.GetOption[string](opts, "disabled_dnsbls"); ok && disabledRaw != "" {
disabled := splitList(disabledRaw)
filtered := zones[:0:0]
for _, z := range zones {
if slices.Contains(disabled, strings.ToLower(z.Zone)) {
continue
}
filtered = append(filtered, z)
}
zones = filtered
}
if extraRaw, ok := sdk.GetOption[string](opts, "extra_dnsbls"); ok && extraRaw != "" {
for _, e := range splitList(extraRaw) {
zones = append(zones, DNSBLZone{Zone: e, Label: e})
}
}
return zones
}
func splitList(s string) []string {
var out []string
for _, part := range strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == '\n' || r == '\r' || r == ' ' || r == '\t' || r == ';'
}) {
if p := strings.TrimSpace(part); p != "" {
out = append(out, strings.ToLower(p))
}
}
return out
}
func joinNonEmpty(parts []string, sep string) string {
if len(parts) == 0 {
return "listed"
}
return strings.Join(parts, sep)
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic("checker: mustJSON: " + err.Error())
}
return b
}
// ---------- return-code decoders ----------
func decodeSpamhausDBL(ip net.IP) []string {
switch ip.String() {
case "127.0.1.2":
return []string{"Spam domain"}
case "127.0.1.4":
return []string{"Phishing domain"}
case "127.0.1.5":
return []string{"Malware domain"}
case "127.0.1.6":
return []string{"Botnet C&C domain"}
case "127.0.1.102":
return []string{"Abused legit spam"}
case "127.0.1.103":
return []string{"Abused legit spammed redirector"}
case "127.0.1.104":
return []string{"Abused legit phish"}
case "127.0.1.105":
return []string{"Abused legit malware"}
case "127.0.1.106":
return []string{"Abused legit botnet C&C"}
case "127.0.1.255", "127.255.255.254":
return []string{"DBL refused the query (resolver blocked, not a domain listing)"}
}
return []string{"Listed (code " + ip.String() + ")"}
}
func decodeSURBLMulti(ip net.IP) []string {
v4 := ip.To4()
if v4 == nil || v4[0] != 127 {
return []string{"Listed (" + ip.String() + ")"}
}
bits := v4[3]
var out []string
if bits&2 != 0 {
out = append(out, "Listed in SURBL abuse (sa-blacklist)")
}
if bits&4 != 0 {
out = append(out, "Phishing")
}
if bits&8 != 0 {
out = append(out, "Malware")
}
if bits&16 != 0 {
out = append(out, "Cracked / compromised site")
}
if bits&32 != 0 {
out = append(out, "Abuse (general)")
}
if bits&64 != 0 {
out = append(out, "Abused redirector")
}
if len(out) == 0 {
out = append(out, "Listed (code "+ip.String()+")")
}
return out
}
func decodeURIBLMulti(ip net.IP) []string {
v4 := ip.To4()
if v4 == nil || v4[0] != 127 {
return []string{"Listed (" + ip.String() + ")"}
}
bits := v4[3]
if bits == 1 {
return []string{"URIBL: query blocked (resolver on free-use blocklist)"}
}
var out []string
if bits&2 != 0 {
out = append(out, "URIBL black (active spam source)")
}
if bits&4 != 0 {
out = append(out, "URIBL grey (suspicious)")
}
if bits&8 != 0 {
out = append(out, "URIBL red (newly observed)")
}
if len(out) == 0 {
out = append(out, "Listed (code "+ip.String()+")")
}
return out
}