Initial commit
This commit is contained in:
commit
66cf1fc9aa
30 changed files with 2735 additions and 0 deletions
338
checker/dnsbl.go
Normal file
338
checker/dnsbl.go
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue