checker-blacklist/checker/dnsbl.go

338 lines
8.8 KiB
Go

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 24 to 72h 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
}