338 lines
8.8 KiB
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
|
|
}
|