diff --git a/README.md b/README.md index ca3844d..0058fd3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ widely-used reputation systems. | AlienVault OTX | HTTPS lookup | free (admin) | admin | | Pulsedive | HTTPS lookup | free (admin) | admin | | Criminal IP | HTTPS lookup | yes (admin) | admin | +| Quad9 secure DNS | DNS comparison | no | user (default on) | ### Obtaining API keys diff --git a/checker/quad9.go b/checker/quad9.go new file mode 100644 index 0000000..7bf6094 --- /dev/null +++ b/checker/quad9.go @@ -0,0 +1,127 @@ +package checker + +import ( + "context" + "fmt" + "net" + "sync" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func init() { + Register(&quad9Source{ + secure: newDNSResolver("9.9.9.9:53"), + unsecured: newDNSResolver("9.9.9.10:53"), + }) +} + +type quad9Source struct { + secure *net.Resolver + unsecured *net.Resolver +} + +func newDNSResolver(addr string) *net.Resolver { + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { + d := &net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, "udp", addr) + }, + } +} + +func (*quad9Source) ID() string { return "quad9" } +func (*quad9Source) Name() string { return "Quad9 secure DNS" } + +func (*quad9Source) Options() SourceOptions { + return SourceOptions{ + User: []sdk.CheckerOptionField{ + { + Id: "enable_quad9", + Type: "bool", + Label: "Use Quad9 secure DNS check", + Description: "Compare Quad9's secure resolver (9.9.9.9) against its unsecured peer (9.9.9.10). A domain that resolves on the unsecured but returns NXDOMAIN on the secure resolver is blocked by Quad9's threat intelligence.", + Default: true, + }, + }, + } +} + +func (s *quad9Source) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { + if !sdk.GetBoolOption(opts, "enable_quad9", true) || registered == "" { + return disabledResult(s.ID(), s.Name()) + } + + res := SourceResult{ + SourceID: s.ID(), + SourceName: s.Name(), + Enabled: true, + LookupURL: "https://www.quad9.net/result/?domain=" + registered, + } + + var ( + secureAddrs, unsecuredAddrs []net.IPAddr + secureErr, unsecuredErr error + wg sync.WaitGroup + ) + queryCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + wg.Add(2) + go func() { + defer wg.Done() + secureAddrs, secureErr = s.secure.LookupIPAddr(queryCtx, registered) + }() + go func() { + defer wg.Done() + unsecuredAddrs, unsecuredErr = s.unsecured.LookupIPAddr(queryCtx, registered) + }() + wg.Wait() + + // Suppress the unused variable warning — secureAddrs is only used as an + // existence check; the actual IPs are not surfaced. + _ = secureAddrs + + // If the unsecured resolver can't resolve the domain either, it simply + // doesn't exist — not a Quad9 block. + if unsecuredErr != nil || len(unsecuredAddrs) == 0 { + if unsecuredErr != nil { + if dnsErr, ok := unsecuredErr.(*net.DNSError); !ok || !dnsErr.IsNotFound { + res.Error = "unsecured resolver: " + unsecuredErr.Error() + } + } + return []SourceResult{res} + } + + // Domain resolves on unsecured. Check whether the secure resolver blocked it. + if dnsErr, ok := secureErr.(*net.DNSError); ok && dnsErr.IsNotFound { + res.Reasons = []string{"Blocked by Quad9 threat intelligence"} + res.Evidence = append(res.Evidence, Evidence{ + Label: "Secure resolver (9.9.9.9)", + Value: "NXDOMAIN", + }) + } else if secureErr != nil { + res.Error = "secure resolver: " + secureErr.Error() + } + + return []SourceResult{res} +} + +func (*quad9Source) Evaluate(r SourceResult) (bool, string) { + return evidenceEval(r, SeverityCrit) +} + +func (*quad9Source) Diagnose(res SourceResult) Diagnosis { + return Diagnosis{ + Severity: SeverityCrit, + Title: "Blocked by Quad9 secure DNS resolver", + Detail: fmt.Sprintf( + "This domain is on Quad9's threat intelligence blocklist: the secure resolver (9.9.9.9) returns NXDOMAIN while the unsecured peer (9.9.9.10) resolves normally. Quad9 aggregates feeds from 18+ threat intel partners (abuse.ch, Bambenek, CINS, DShield, etc.). Visitors whose ISP or device uses Quad9 cannot reach this domain. Submit a false-positive report at the Quad9 contact page if you believe the listing is incorrect.", + ), + Fix: "https://www.quad9.net/support/contact/", + FixIsURL: true, + LookupURL: res.LookupURL, + } +} diff --git a/checker/source_test.go b/checker/source_test.go index 8f432b8..c98c8b8 100644 --- a/checker/source_test.go +++ b/checker/source_test.go @@ -24,7 +24,7 @@ func TestRegisteredSourcesAreSane(t *testing.T) { } } // At least the built-in sources are present. - for _, want := range []string{"dnsbl", "google_safe_browsing", "openphish", "pulsedive", "urlhaus", "virustotal"} { + for _, want := range []string{"dnsbl", "google_safe_browsing", "openphish", "pulsedive", "quad9", "urlhaus", "virustotal"} { if !seen[want] { t.Errorf("missing built-in source %q", want) }