Initial commit
This commit is contained in:
commit
66cf1fc9aa
30 changed files with 2735 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
checker-blacklist
|
||||||
|
checker-blacklist.so
|
||||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
ARG CHECKER_VERSION=custom-build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-blacklist .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /checker-blacklist /checker-blacklist
|
||||||
|
USER 65534:65534
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/checker-blacklist"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 The happyDomain Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the “Software”), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
CHECKER_NAME := checker-blacklist
|
||||||
|
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||||
|
CHECKER_VERSION ?= custom-build
|
||||||
|
|
||||||
|
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||||
|
|
||||||
|
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||||
|
|
||||||
|
.PHONY: all plugin docker test clean
|
||||||
|
|
||||||
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
|
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||||
|
|
||||||
|
plugin: $(CHECKER_NAME).so
|
||||||
|
|
||||||
|
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||||
|
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -tags standalone ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
26
NOTICE
Normal file
26
NOTICE
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
checker-dummy
|
||||||
|
Copyright (c) 2026 The happyDomain Authors
|
||||||
|
|
||||||
|
This product is licensed under the MIT License (see LICENSE).
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
Third-party notices
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
This product includes software developed as part of the checker-sdk-go
|
||||||
|
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||||
|
under the Apache License, Version 2.0:
|
||||||
|
|
||||||
|
checker-sdk-go
|
||||||
|
Copyright 2020-2026 The happyDomain Authors
|
||||||
|
|
||||||
|
This product includes software developed as part of the happyDomain
|
||||||
|
project (https://happydomain.org).
|
||||||
|
|
||||||
|
Portions of this code were originally written for the happyDomain
|
||||||
|
server (licensed under AGPL-3.0 and a commercial license) and are
|
||||||
|
made available there under the Apache License, Version 2.0 to enable
|
||||||
|
a permissively licensed ecosystem of checker plugins.
|
||||||
|
|
||||||
|
You may obtain a copy of the Apache License 2.0 at:
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
109
README.md
Normal file
109
README.md
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# checker-blacklist
|
||||||
|
|
||||||
|
happyDomain checker that flags whether a domain is currently listed on
|
||||||
|
widely-used reputation systems.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
| Source | Type | API key needed | Configurable |
|
||||||
|
|-----------------------|-----------------|----------------|--------------|
|
||||||
|
| Spamhaus DBL | DNS-based DBL | no | admin (default on) |
|
||||||
|
| SURBL multi | DNS-based DBL | no | admin (default on) |
|
||||||
|
| URIBL multi | DNS-based DBL | no | admin (default on) |
|
||||||
|
| Extra DNSBL zones | DNS-based DBL | no | admin |
|
||||||
|
| Google Safe Browsing | HTTPS lookup | yes (admin) | admin |
|
||||||
|
| OpenPhish public feed | downloaded list | no | user (default on) |
|
||||||
|
| abuse.ch URLhaus | HTTPS lookup | optional Auth-Key (admin) | user (default on) |
|
||||||
|
| VirusTotal v3 | HTTPS lookup | yes (admin) | admin |
|
||||||
|
|
||||||
|
DNS-based blocklists are queried in parallel. The OpenPhish feed is
|
||||||
|
downloaded once per hour by the provider and cached in memory.
|
||||||
|
|
||||||
|
## Common failure scenarios surfaced in the HTML report
|
||||||
|
|
||||||
|
The report opens with a diagnosis-first "Action required" section that
|
||||||
|
lists the most common, high-impact problems with a one-shot remediation:
|
||||||
|
|
||||||
|
1. **Listed on Spamhaus DBL / SURBL / URIBL**: direct lookup link and
|
||||||
|
removal procedure URL per operator.
|
||||||
|
2. **Flagged by Google Safe Browsing**: link to Google Search Console's
|
||||||
|
security-issues review request.
|
||||||
|
3. **Listed in the OpenPhish feed**: instructions to treat the host as
|
||||||
|
compromised (audit recently-added files, rotate credentials), plus a
|
||||||
|
link to OpenPhish feedback.
|
||||||
|
4. **Listed in URLhaus (active malware distribution)**: direct link to
|
||||||
|
the abuse.ch reference page and per-URL takedown notification flow.
|
||||||
|
5. **VirusTotal multi-vendor flag**: Critical when at least one vendor
|
||||||
|
reports `malicious`, Warning when only `suspicious`. Lists the
|
||||||
|
flagging engines and links to the VT GUI page for re-scan / vendor
|
||||||
|
contact.
|
||||||
|
6. **DNSBL query refused / API quota exhausted**: most public resolvers
|
||||||
|
are blocked by DBL/URIBL operators; surfaced as a warning so it does
|
||||||
|
not pollute the OK status.
|
||||||
|
|
||||||
|
A per-source detail table follows for full context (return codes, TXT
|
||||||
|
records, threat types, sample phishing URLs).
|
||||||
|
|
||||||
|
## Adding a new source
|
||||||
|
|
||||||
|
Every reputation backend implements the `Source` interface in its own
|
||||||
|
file and registers itself from `init()`. Skeleton:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() { Register(&mySource{}) }
|
||||||
|
|
||||||
|
type mySource struct{}
|
||||||
|
|
||||||
|
func (*mySource) ID() string { return "mybl" }
|
||||||
|
func (*mySource) Name() string { return "My Blocklist" }
|
||||||
|
|
||||||
|
func (*mySource) Options() SourceOptions {
|
||||||
|
return SourceOptions{
|
||||||
|
Admin: []sdk.CheckerOptionField{ /* … */ },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*mySource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
||||||
|
res := SourceResult{SourceID: "mybl", SourceName: "My Blocklist", Enabled: true}
|
||||||
|
// …populate Listed / Severity / Reasons / Evidence / Reference / Error
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*mySource) Diagnose(res SourceResult) Diagnosis {
|
||||||
|
return Diagnosis{Severity: SeverityCrit, Title: "Listed", Detail: "…"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it: rules, the report, metrics, the standalone `/check` form
|
||||||
|
and the definition pick the new source up automatically. Sources that
|
||||||
|
need richer rendering (a per-vendor table, etc.) additionally
|
||||||
|
implement `RenderDetail(SourceResult) (template.HTML, error)`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make # standalone binary (HTTP server + /check form)
|
||||||
|
make plugin # checker-blacklist.so for happyDomain dynamic load
|
||||||
|
make docker # container image
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running standalone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./checker-blacklist -listen :8080
|
||||||
|
# then GET /check, /definition, /health, …
|
||||||
|
```
|
||||||
|
|
||||||
|
The standalone binary embeds an `interactive` form on `GET /check` so a
|
||||||
|
human can paste a domain and run the full pipeline without happyDomain.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT (checker code); Apache 2.0 SDK dependency.
|
||||||
57
checker/collect.go
Normal file
57
checker/collect.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
"golang.org/x/net/publicsuffix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect fans out the registered sources concurrently and folds their
|
||||||
|
// results into a single observation. Adding a new source means
|
||||||
|
// implementing the Source interface in its own file and calling
|
||||||
|
// Register(...) from init(); Collect needs no changes.
|
||||||
|
func (p *blacklistProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||||
|
domain := normalizeDomain(stringOpt(opts, "domain_name"))
|
||||||
|
if domain == "" {
|
||||||
|
// Standalone /check form posts "domain"; happyDomain auto-fills
|
||||||
|
// "domain_name". Accept both so the path stays uniform.
|
||||||
|
domain = normalizeDomain(stringOpt(opts, "domain"))
|
||||||
|
}
|
||||||
|
|
||||||
|
registered, _ := publicsuffix.EffectiveTLDPlusOne(domain)
|
||||||
|
if registered == "" {
|
||||||
|
registered = domain
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &BlacklistData{
|
||||||
|
Domain: domain,
|
||||||
|
RegisteredDomain: registered,
|
||||||
|
CollectedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := Sources()
|
||||||
|
per := make([][]SourceResult, len(sources))
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, s := range sources {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, s Source) {
|
||||||
|
defer wg.Done()
|
||||||
|
per[i] = s.Query(ctx, domain, registered, opts)
|
||||||
|
}(i, s)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for _, batch := range per {
|
||||||
|
data.Results = append(data.Results, batch...)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDomain(s string) string {
|
||||||
|
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(s), "."))
|
||||||
|
}
|
||||||
54
checker/definition.go
Normal file
54
checker/definition.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is overridden at link time by the standalone or plugin entrypoints.
|
||||||
|
var Version = "built-in"
|
||||||
|
|
||||||
|
// Definition assembles the checker definition by aggregating each
|
||||||
|
// registered Source's options into the SDK's audience-grouped layout.
|
||||||
|
// Adding a source automatically adds its option fields here: no edit
|
||||||
|
// to this file needed.
|
||||||
|
func Definition() *sdk.CheckerDefinition {
|
||||||
|
opts := sdk.CheckerOptionsDocumentation{
|
||||||
|
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: "domain_name",
|
||||||
|
Label: "Domain name",
|
||||||
|
AutoFill: sdk.AutoFillDomainName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, s := range Sources() {
|
||||||
|
o := s.Options()
|
||||||
|
opts.AdminOpts = append(opts.AdminOpts, o.Admin...)
|
||||||
|
opts.UserOpts = append(opts.UserOpts, o.User...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sdk.CheckerDefinition{
|
||||||
|
ID: "blacklist",
|
||||||
|
Name: "Blacklist & reputation",
|
||||||
|
Version: Version,
|
||||||
|
|
||||||
|
Availability: sdk.CheckerAvailability{
|
||||||
|
ApplyToDomain: true,
|
||||||
|
},
|
||||||
|
ObservationKeys: []sdk.ObservationKey{ObservationKeyBlacklist},
|
||||||
|
|
||||||
|
Options: opts,
|
||||||
|
Rules: Rules(),
|
||||||
|
|
||||||
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
|
Min: 30 * time.Minute,
|
||||||
|
Max: 24 * time.Hour,
|
||||||
|
Default: 6 * time.Hour,
|
||||||
|
},
|
||||||
|
|
||||||
|
HasHTMLReport: true,
|
||||||
|
HasMetrics: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
74
checker/dnsbl_test.go
Normal file
74
checker/dnsbl_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeSpamhausDBL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
ip string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{"127.0.1.2", "Spam"},
|
||||||
|
{"127.0.1.4", "Phishing"},
|
||||||
|
{"127.0.1.5", "Malware"},
|
||||||
|
{"127.0.1.6", "Botnet"},
|
||||||
|
{"127.0.1.255", "refused"},
|
||||||
|
{"127.255.255.254", "refused"},
|
||||||
|
{"127.0.1.99", "code"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := decodeSpamhausDBL(net.ParseIP(c.ip))
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0], c.contains) {
|
||||||
|
t.Errorf("decodeSpamhausDBL(%s) = %v, want substring %q", c.ip, got, c.contains)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeSURBLMulti(t *testing.T) {
|
||||||
|
got := decodeSURBLMulti(net.ParseIP("127.0.0.12")) // 4 + 8
|
||||||
|
if len(got) != 2 || !strings.Contains(got[0], "Phishing") || !strings.Contains(got[1], "Malware") {
|
||||||
|
t.Errorf("decodeSURBLMulti = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeURIBLMulti(t *testing.T) {
|
||||||
|
got := decodeURIBLMulti(net.ParseIP("127.0.0.2"))
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0], "black") {
|
||||||
|
t.Errorf("decodeURIBLMulti(black) = %v", got)
|
||||||
|
}
|
||||||
|
got = decodeURIBLMulti(net.ParseIP("127.0.0.1"))
|
||||||
|
if len(got) != 1 || !strings.Contains(got[0], "blocked") {
|
||||||
|
t.Errorf("decodeURIBLMulti(refused) = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitList(t *testing.T) {
|
||||||
|
got := splitList("a, b\nc;d e")
|
||||||
|
want := []string{"a", "b", "c", "d", "e"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("splitList = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if !slices.Contains(got, "c") {
|
||||||
|
t.Errorf("expected 'c' in %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDomain(t *testing.T) {
|
||||||
|
if got := normalizeDomain(" Example.COM. "); got != "example.com" {
|
||||||
|
t.Errorf("normalizeDomain = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatURL(t *testing.T) {
|
||||||
|
if got := formatURL("https://x/?q=%s", "abc"); got != "https://x/?q=abc" {
|
||||||
|
t.Errorf("formatURL = %q", got)
|
||||||
|
}
|
||||||
|
if got := formatURL("https://x/", "abc"); got != "https://x/" {
|
||||||
|
t.Errorf("formatURL noplaceholder = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
checker/httpclient.go
Normal file
32
checker/httpclient.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sharedHTTPClient is reused across sources so connection pooling and
|
||||||
|
// keep-alives kick in. Per-call deadlines are expressed via
|
||||||
|
// context.WithTimeout on the request context, not on the client.
|
||||||
|
var sharedHTTPClient = &http.Client{Timeout: 60 * time.Second}
|
||||||
|
|
||||||
|
// httpDo executes req on the shared client, reads up to maxBytes from
|
||||||
|
// the response body, and returns the body, the HTTP status code and any
|
||||||
|
// error. Status-code semantics differ per API (404 means "unknown" on
|
||||||
|
// VirusTotal, body-level fields drive URLhaus, …) so the caller decides
|
||||||
|
// how to interpret status; this helper only handles the boilerplate
|
||||||
|
// common to every JSON-ish source.
|
||||||
|
func httpDo(req *http.Request, maxBytes int64) (body []byte, status int, err error) {
|
||||||
|
resp, err := sharedHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err = io.ReadAll(io.LimitReader(resp.Body, maxBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
return body, resp.StatusCode, nil
|
||||||
|
}
|
||||||
89
checker/interactive.go
Normal file
89
checker/interactive.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
//go:build standalone
|
||||||
|
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderForm builds the standalone form by aggregating each registered
|
||||||
|
// source's option fields. The "domain" field is hard-coded since it
|
||||||
|
// applies to every source; everything else is contributed by sources.
|
||||||
|
func (p *blacklistProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
|
fields := []sdk.CheckerOptionField{
|
||||||
|
{
|
||||||
|
Id: "domain",
|
||||||
|
Type: "string",
|
||||||
|
Label: "Domain",
|
||||||
|
Placeholder: "example.com",
|
||||||
|
Description: "Domain to test against the configured reputation sources.",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, s := range Sources() {
|
||||||
|
o := s.Options()
|
||||||
|
fields = append(fields, o.Admin...)
|
||||||
|
fields = append(fields, o.User...)
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseForm walks every option field declared by the sources and reads
|
||||||
|
// it from the form. The generic loop means a new source's fields
|
||||||
|
// appear in /check automatically.
|
||||||
|
func (p *blacklistProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||||
|
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||||
|
if domain == "" {
|
||||||
|
return nil, errors.New("a domain is required")
|
||||||
|
}
|
||||||
|
opts := sdk.CheckerOptions{
|
||||||
|
"domain": domain,
|
||||||
|
"domain_name": domain,
|
||||||
|
}
|
||||||
|
for _, s := range Sources() {
|
||||||
|
o := s.Options()
|
||||||
|
for _, f := range append(append([]sdk.CheckerOptionField{}, o.Admin...), o.User...) {
|
||||||
|
raw := strings.TrimSpace(r.FormValue(f.Id))
|
||||||
|
if raw == "" {
|
||||||
|
if f.Type == "bool" {
|
||||||
|
opts[f.Id] = boolDefault(f.Default)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch f.Type {
|
||||||
|
case "bool":
|
||||||
|
opts[f.Id] = parseFormBool(raw, true)
|
||||||
|
case "number", "uint":
|
||||||
|
if n, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||||
|
opts[f.Id] = n
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
opts[f.Id] = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFormBool(s string, defaultVal bool) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||||
|
case "":
|
||||||
|
return defaultVal
|
||||||
|
case "true", "on", "1", "yes":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolDefault(v any) bool {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
204
checker/openphish.go
Normal file
204
checker/openphish.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
const openPhishFeedURL = "https://openphish.com/feed.txt"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&openPhishSource{
|
||||||
|
cache: newPhishCache(openPhishFeedURL, 1*time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPhishSource downloads the public OpenPhish feed once per cache
|
||||||
|
// TTL and matches the registered domain (and all subdomains) against
|
||||||
|
// every URL in the feed. The cache is per-source-instance so it lives
|
||||||
|
// for as long as the process.
|
||||||
|
type openPhishSource struct {
|
||||||
|
cache *phishCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*openPhishSource) ID() string { return "openphish" }
|
||||||
|
func (*openPhishSource) Name() string { return "OpenPhish feed" }
|
||||||
|
|
||||||
|
func (*openPhishSource) Options() SourceOptions {
|
||||||
|
return SourceOptions{
|
||||||
|
User: []sdk.CheckerOptionField{
|
||||||
|
{
|
||||||
|
Id: "enable_openphish",
|
||||||
|
Type: "bool",
|
||||||
|
Label: "Use the OpenPhish public feed",
|
||||||
|
Description: "Download the OpenPhish public feed (refreshed every 12h) and check the domain against it.",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *openPhishSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
||||||
|
if !sdk.GetBoolOption(opts, "enable_openphish", true) || registered == "" {
|
||||||
|
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: false}}
|
||||||
|
}
|
||||||
|
|
||||||
|
urls, size, fetched, err := s.cache.lookup(ctx, registered)
|
||||||
|
res := SourceResult{
|
||||||
|
SourceID: s.ID(), SourceName: s.Name(), Enabled: true,
|
||||||
|
Reference: "https://openphish.com/",
|
||||||
|
Details: mustJSON(map[string]any{"feed_size": size, "fetched_at": fetched}),
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
// Fall through with whatever the cache could provide.
|
||||||
|
}
|
||||||
|
if len(urls) > 0 {
|
||||||
|
res.Listed = true
|
||||||
|
res.Severity = SeverityCrit
|
||||||
|
res.Reasons = []string{"Phishing"}
|
||||||
|
for _, u := range urls {
|
||||||
|
res.Evidence = append(res.Evidence, Evidence{Label: "URL", Value: u})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*openPhishSource) Diagnose(res SourceResult) Diagnosis {
|
||||||
|
urls := make([]string, 0, len(res.Evidence))
|
||||||
|
for _, e := range res.Evidence {
|
||||||
|
urls = append(urls, e.Value)
|
||||||
|
}
|
||||||
|
previewN := min(len(urls), 5)
|
||||||
|
return Diagnosis{
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Title: "Listed in the OpenPhish phishing feed",
|
||||||
|
Detail: fmt.Sprintf(
|
||||||
|
"%d URL(s) hosted on this domain are tracked as phishing by OpenPhish. Treat the host as compromised: rotate credentials, audit recently-added files (look for /wp-includes/, /uploads/, lookalike admin paths), then request review at OpenPhish. Examples: %s",
|
||||||
|
len(urls), joinNonEmpty(urls[:previewN], ", "),
|
||||||
|
),
|
||||||
|
Fix: "https://openphish.com/feedback.html",
|
||||||
|
FixIsURL: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- feed cache ----------
|
||||||
|
|
||||||
|
type phishCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
urls []string
|
||||||
|
byHost map[string][]string
|
||||||
|
fetchedAt time.Time
|
||||||
|
lastAttemptAt time.Time
|
||||||
|
refreshing bool
|
||||||
|
ttl time.Duration
|
||||||
|
failBackoff time.Duration
|
||||||
|
feedURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPhishCache(feedURL string, ttl time.Duration) *phishCache {
|
||||||
|
if feedURL == "" {
|
||||||
|
feedURL = openPhishFeedURL
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 1 * time.Hour
|
||||||
|
}
|
||||||
|
return &phishCache{ttl: ttl, feedURL: feedURL, failBackoff: 1 * time.Minute}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *phishCache) lookup(ctx context.Context, domain string) (urls []string, size int, fetchedAt time.Time, err error) {
|
||||||
|
domain = strings.ToLower(strings.TrimSuffix(domain, "."))
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
stale := c.byHost == nil || time.Since(c.fetchedAt) > c.ttl
|
||||||
|
doRefresh := stale && !c.refreshing && time.Since(c.lastAttemptAt) > c.failBackoff
|
||||||
|
if doRefresh {
|
||||||
|
c.refreshing = true
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
if doRefresh {
|
||||||
|
// Fetch without holding the cache lock so concurrent lookups
|
||||||
|
// can still serve stale data. Only one refresh runs at a time.
|
||||||
|
newURLs, newByHost, ferr := c.fetch(ctx)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.refreshing = false
|
||||||
|
c.lastAttemptAt = time.Now()
|
||||||
|
if ferr == nil {
|
||||||
|
c.urls = newURLs
|
||||||
|
c.byHost = newByHost
|
||||||
|
c.fetchedAt = c.lastAttemptAt
|
||||||
|
} else {
|
||||||
|
err = ferr
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
for host, hostURLs := range c.byHost {
|
||||||
|
if host == domain || strings.HasSuffix(host, "."+domain) {
|
||||||
|
urls = append(urls, hostURLs...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size = len(c.urls)
|
||||||
|
fetchedAt = c.fetchedAt
|
||||||
|
c.mu.Unlock()
|
||||||
|
return urls, size, fetchedAt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *phishCache) fetch(ctx context.Context) ([]string, map[string][]string, error) {
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, c.feedURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "happydomain-checker-blacklist/1.0")
|
||||||
|
|
||||||
|
resp, err := sharedHTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, nil, fmt.Errorf("openphish HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
urls := make([]string, 0, 8192)
|
||||||
|
byHost := make(map[string][]string, 8192)
|
||||||
|
scanner := bufio.NewScanner(io.LimitReader(resp.Body, 64<<20))
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
urls = append(urls, line)
|
||||||
|
if h := hostOfURL(line); h != "" {
|
||||||
|
byHost[h] = append(byHost[h], line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return urls, byHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostOfURL(s string) string {
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(u.Hostname())
|
||||||
|
}
|
||||||
52
checker/provider.go
Normal file
52
checker/provider.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Provider() sdk.ObservationProvider { return &blacklistProvider{} }
|
||||||
|
|
||||||
|
type blacklistProvider struct{}
|
||||||
|
|
||||||
|
func (p *blacklistProvider) Key() sdk.ObservationKey { return ObservationKeyBlacklist }
|
||||||
|
func (p *blacklistProvider) Definition() *sdk.CheckerDefinition { return Definition() }
|
||||||
|
|
||||||
|
// ExtractMetrics turns the (now uniform) per-source results into a
|
||||||
|
// small set of generic gauges. Source-specific metrics (VT engine
|
||||||
|
// counts, URLhaus URL count, …) live in SourceResult.Details and are
|
||||||
|
// rendered in the HTML report; metrics here stay coarse so the
|
||||||
|
// scheduler / Prometheus side does not have to know which sources are
|
||||||
|
// installed.
|
||||||
|
func (p *blacklistProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) {
|
||||||
|
var data BlacklistData
|
||||||
|
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics := []sdk.CheckMetric{
|
||||||
|
{
|
||||||
|
Name: "blacklist_total_hits", Value: float64(data.TotalHits()),
|
||||||
|
Unit: "results", Timestamp: collectedAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, r := range data.Results {
|
||||||
|
if !r.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := 0.0
|
||||||
|
if r.Listed {
|
||||||
|
v = 1
|
||||||
|
}
|
||||||
|
metrics = append(metrics, sdk.CheckMetric{
|
||||||
|
Name: "blacklist_source_listed",
|
||||||
|
Value: v,
|
||||||
|
Unit: "bool",
|
||||||
|
Labels: map[string]string{"source": r.SourceID, "subject": r.Subject},
|
||||||
|
Timestamp: collectedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return metrics, nil
|
||||||
|
}
|
||||||
341
checker/report.go
Normal file
341
checker/report.go
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetHTMLReport renders a generic, source-agnostic HTML report. The
|
||||||
|
// per-source rich detail (VT vendor table, URLhaus URL list, …) is
|
||||||
|
// rendered by sources implementing DetailRenderer; everything else
|
||||||
|
// (headline, action-required cards, summary table) walks the uniform
|
||||||
|
// SourceResult envelope without any source-specific switch.
|
||||||
|
func (p *blacklistProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||||
|
var data BlacklistData
|
||||||
|
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
|
||||||
|
return "", fmt.Errorf("decode blacklist data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
view := reportView{
|
||||||
|
Domain: data.Domain,
|
||||||
|
RegisteredDomain: data.RegisteredDomain,
|
||||||
|
CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"),
|
||||||
|
TotalHits: data.TotalHits(),
|
||||||
|
Diagnoses: diagnose(&data),
|
||||||
|
Sections: buildSections(&data),
|
||||||
|
CSS: template.CSS(reportCSS),
|
||||||
|
}
|
||||||
|
view.Headline, view.HeadlineClass = headline(view.TotalHits)
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := reportTemplate.Execute(&b, view); err != nil {
|
||||||
|
return "", fmt.Errorf("render blacklist report: %w", err)
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportView struct {
|
||||||
|
Domain string
|
||||||
|
RegisteredDomain string
|
||||||
|
CollectedAt string
|
||||||
|
TotalHits int
|
||||||
|
Headline string
|
||||||
|
HeadlineClass string
|
||||||
|
Diagnoses []Diagnosis
|
||||||
|
Sections []sourceSection
|
||||||
|
CSS template.CSS
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceSection is one rendered card per Source (not per result): a
|
||||||
|
// multi-result source like DNSBL is collapsed to a single section that
|
||||||
|
// lists each subject as a row. Rich sources contribute extra HTML via
|
||||||
|
// their RenderDetail implementation; plain sources fall back to the
|
||||||
|
// generic Reasons/Evidence rendering.
|
||||||
|
type sourceSection struct {
|
||||||
|
SourceID string
|
||||||
|
SourceName string
|
||||||
|
StatusLabel string
|
||||||
|
StatusClass string
|
||||||
|
Subjects []subjectRow
|
||||||
|
RichHTML template.HTML
|
||||||
|
Reference string
|
||||||
|
}
|
||||||
|
|
||||||
|
type subjectRow struct {
|
||||||
|
Subject string
|
||||||
|
StatusLabel string
|
||||||
|
StatusClass string
|
||||||
|
Reasons []string
|
||||||
|
Evidence []Evidence
|
||||||
|
LookupURL string
|
||||||
|
RemovalURL string
|
||||||
|
Reference string
|
||||||
|
Error string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func headline(hits int) (string, string) {
|
||||||
|
switch hits {
|
||||||
|
case 0:
|
||||||
|
return "Domain is clean across all configured reputation sources.", SeverityOK
|
||||||
|
case 1:
|
||||||
|
return "Domain is currently listed on 1 source. Act now: a single listing already breaks email delivery and browser access.", SeverityCrit
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Domain is currently listed on %d sources. This is severe: most mail and browsers will block access.", hits), SeverityCrit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// diagnose builds the action-required cards by delegating to each
|
||||||
|
// listed result's source. The generic code only orders cards by
|
||||||
|
// severity; the wording and remediation are owned by the source.
|
||||||
|
func diagnose(d *BlacklistData) []Diagnosis {
|
||||||
|
byID := make(map[string]Source, len(Sources()))
|
||||||
|
for _, s := range Sources() {
|
||||||
|
byID[s.ID()] = s
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []Diagnosis
|
||||||
|
for _, r := range d.Results {
|
||||||
|
if !r.Listed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s, ok := byID[r.SourceID]; ok {
|
||||||
|
out = append(out, s.Diagnose(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Errors are surfaced as warnings so a flaky source is visible
|
||||||
|
// without dominating the page.
|
||||||
|
for _, r := range d.Results {
|
||||||
|
if r.Error == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := "Could not query " + r.SourceName
|
||||||
|
if r.Subject != "" && r.Subject != r.SourceName {
|
||||||
|
title = fmt.Sprintf("Could not query %s (%s)", r.SourceName, r.Subject)
|
||||||
|
}
|
||||||
|
out = append(out, Diagnosis{
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Title: title,
|
||||||
|
Detail: r.Error + ": the listing status of this source is unknown for this run.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return sevRank(out[i].Severity) < sevRank(out[j].Severity) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sevRank(s string) int {
|
||||||
|
switch s {
|
||||||
|
case SeverityCrit:
|
||||||
|
return 0
|
||||||
|
case SeverityWarn:
|
||||||
|
return 1
|
||||||
|
case SeverityInfo:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSections groups results by source, collapses multi-result
|
||||||
|
// sources into a single card, and asks each source for its rich detail
|
||||||
|
// HTML when applicable.
|
||||||
|
func buildSections(d *BlacklistData) []sourceSection {
|
||||||
|
byID := make(map[string]Source, len(Sources()))
|
||||||
|
order := make([]string, 0, len(Sources()))
|
||||||
|
for _, s := range Sources() {
|
||||||
|
byID[s.ID()] = s
|
||||||
|
order = append(order, s.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped := make(map[string][]SourceResult)
|
||||||
|
for _, r := range d.Results {
|
||||||
|
grouped[r.SourceID] = append(grouped[r.SourceID], r)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]sourceSection, 0, len(grouped))
|
||||||
|
for _, id := range order {
|
||||||
|
results := grouped[id]
|
||||||
|
if len(results) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
section := sourceSection{
|
||||||
|
SourceID: id,
|
||||||
|
SourceName: byID[id].Name(),
|
||||||
|
}
|
||||||
|
section.StatusLabel, section.StatusClass = sectionStatus(results)
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Reference != "" && section.Reference == "" {
|
||||||
|
section.Reference = r.Reference
|
||||||
|
}
|
||||||
|
section.Subjects = append(section.Subjects, subjectRow{
|
||||||
|
Subject: subjectLabel(byID[id].Name(), r),
|
||||||
|
StatusLabel: subjectStatusLabel(r),
|
||||||
|
StatusClass: subjectStatusClass(r),
|
||||||
|
Reasons: r.Reasons,
|
||||||
|
Evidence: r.Evidence,
|
||||||
|
LookupURL: r.LookupURL,
|
||||||
|
RemovalURL: r.RemovalURL,
|
||||||
|
Reference: r.Reference,
|
||||||
|
Error: r.Error,
|
||||||
|
Disabled: !r.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Rich detail: use the first listed result's payload (single-
|
||||||
|
// subject sources have at most one). Plain sources skip this.
|
||||||
|
if dr, ok := byID[id].(DetailRenderer); ok {
|
||||||
|
for _, r := range results {
|
||||||
|
if !r.Listed && len(r.Details) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
html, err := dr.RenderDetail(r)
|
||||||
|
if err == nil && html != "" {
|
||||||
|
section.RichHTML = html
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, section)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sectionStatus(results []SourceResult) (string, string) {
|
||||||
|
listed, errs, enabled := 0, 0, 0
|
||||||
|
for _, r := range results {
|
||||||
|
if r.Enabled {
|
||||||
|
enabled++
|
||||||
|
}
|
||||||
|
if r.Listed {
|
||||||
|
listed++
|
||||||
|
} else if r.Error != "" {
|
||||||
|
errs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case enabled == 0:
|
||||||
|
return "Disabled", "muted"
|
||||||
|
case listed > 0:
|
||||||
|
return fmt.Sprintf("LISTED (%d)", listed), "crit"
|
||||||
|
case errs > 0:
|
||||||
|
return "Errors", "warn"
|
||||||
|
}
|
||||||
|
return "Clean", "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
func subjectLabel(srcName string, r SourceResult) string {
|
||||||
|
if r.Subject != "" && r.Subject != srcName {
|
||||||
|
return r.Subject
|
||||||
|
}
|
||||||
|
return srcName
|
||||||
|
}
|
||||||
|
|
||||||
|
func subjectStatusLabel(r SourceResult) string {
|
||||||
|
switch {
|
||||||
|
case !r.Enabled:
|
||||||
|
return "Disabled"
|
||||||
|
case r.Listed:
|
||||||
|
return "LISTED"
|
||||||
|
case r.Error != "":
|
||||||
|
return "Error"
|
||||||
|
}
|
||||||
|
return "Clean"
|
||||||
|
}
|
||||||
|
|
||||||
|
func subjectStatusClass(r SourceResult) string {
|
||||||
|
switch {
|
||||||
|
case !r.Enabled:
|
||||||
|
return "muted"
|
||||||
|
case r.Listed:
|
||||||
|
return r.Severity
|
||||||
|
case r.Error != "":
|
||||||
|
return "warn"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
var reportTemplate = template.Must(template.New("blacklist").Parse(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Blacklist report — {{.Domain}}</title>
|
||||||
|
<style>{{.CSS}}</style>
|
||||||
|
</head>
|
||||||
|
<body><main>
|
||||||
|
<h1>Blacklist & reputation</h1>
|
||||||
|
<p class="meta"><code>{{.Domain}}</code>{{if and .RegisteredDomain (ne .RegisteredDomain .Domain)}} (queried as <code>{{.RegisteredDomain}}</code>){{end}} · collected {{.CollectedAt}}</p>
|
||||||
|
|
||||||
|
<section class="headline status-{{.HeadlineClass}}"><strong>{{.Headline}}</strong></section>
|
||||||
|
|
||||||
|
{{with .Diagnoses}}<section class="diagnosis">
|
||||||
|
<h2>Action required</h2>
|
||||||
|
{{range .}}<article class="finding sev-{{.Severity}}">
|
||||||
|
<h3>{{.Title}}</h3>
|
||||||
|
<p>{{.Detail}}</p>
|
||||||
|
{{if .LookupURL}}<p><a href="{{.LookupURL}}" target="_blank" rel="noreferrer">Public lookup</a>{{if .RemovalURL}} · <a href="{{.RemovalURL}}" target="_blank" rel="noreferrer">Removal procedure</a>{{end}}</p>{{else if .FixIsURL}}<p><a href="{{.Fix}}" target="_blank" rel="noreferrer">{{.Fix}}</a></p>{{else if .Fix}}<pre class="fix">{{.Fix}}</pre>{{end}}
|
||||||
|
</article>
|
||||||
|
{{end}}</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{range .Sections}}<section class="src">
|
||||||
|
<h2>{{.SourceName}} <span class="badge status-{{.StatusClass}}">{{.StatusLabel}}</span></h2>
|
||||||
|
{{if .RichHTML}}{{.RichHTML}}{{end}}
|
||||||
|
{{if .Subjects}}<table>
|
||||||
|
<thead><tr><th>Subject</th><th>Status</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Subjects}}<tr class="row-{{.StatusClass}}">
|
||||||
|
<td><strong>{{.Subject}}</strong></td>
|
||||||
|
<td>{{.StatusLabel}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .Disabled}}<span class="muted">disabled</span>
|
||||||
|
{{else if .Error}}{{.Error}}
|
||||||
|
{{else}}
|
||||||
|
{{range .Reasons}}<div>{{.}}</div>{{end}}
|
||||||
|
{{if .Evidence}}<details><summary>{{len .Evidence}} evidence item(s)</summary><ul>{{range .Evidence}}<li><code>{{.Value}}</code>{{with .Status}} <small>({{.}})</small>{{end}}</li>{{end}}</ul></details>{{end}}
|
||||||
|
{{if .LookupURL}}<div><a href="{{.LookupURL}}" target="_blank" rel="noreferrer">Lookup</a>{{if .RemovalURL}} · <a href="{{.RemovalURL}}" target="_blank" rel="noreferrer">Request removal</a>{{end}}</div>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}</tbody>
|
||||||
|
</table>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
</main></body></html>`))
|
||||||
|
|
||||||
|
const reportCSS = `body{font-family:system-ui,sans-serif;margin:0;background:#fafbfc;color:#1b1f23;}
|
||||||
|
main{max-width:980px;margin:0 auto;padding:1.5rem;}
|
||||||
|
h1{margin:0 0 .25rem 0;}
|
||||||
|
.meta{color:#586069;margin:0 0 1rem 0;}
|
||||||
|
section{margin-bottom:2rem;}
|
||||||
|
h2{border-bottom:1px solid #e1e4e8;padding-bottom:.25rem;}
|
||||||
|
.badge{font-size:.7rem;padding:.1rem .4rem;border-radius:3px;vertical-align:middle;background:#eee;color:#1b1f23;font-weight:600;}
|
||||||
|
.badge.status-crit{background:#ffeef0;color:#d73a49;}
|
||||||
|
.badge.status-warn{background:#fff5d4;color:#b08800;}
|
||||||
|
.badge.status-ok{background:#dcffe4;color:#22863a;}
|
||||||
|
.badge.status-muted{background:#eee;color:#586069;}
|
||||||
|
.headline{padding:.75rem 1rem;border-radius:4px;margin-bottom:1.5rem;}
|
||||||
|
.headline.status-ok{background:#dcffe4;border-left:4px solid #22863a;}
|
||||||
|
.headline.status-crit{background:#ffeef0;border-left:4px solid #d73a49;}
|
||||||
|
.finding{border-left:4px solid;padding:.75rem 1rem;margin:.75rem 0;background:#fff;border-radius:4px;}
|
||||||
|
.finding h3{margin:0 0 .25rem 0;font-size:1rem;}
|
||||||
|
.finding.sev-crit{border-color:#d73a49;}
|
||||||
|
.finding.sev-warn{border-color:#dbab09;}
|
||||||
|
.finding.sev-info{border-color:#0366d6;}
|
||||||
|
.fix{background:#1b1f23;color:#fafbfc;padding:.5rem .75rem;border-radius:4px;overflow-x:auto;font-size:.85rem;}
|
||||||
|
table{width:100%;border-collapse:collapse;background:#fff;}
|
||||||
|
th,td{padding:.5rem .75rem;border-bottom:1px solid #e1e4e8;text-align:left;vertical-align:top;}
|
||||||
|
tr.row-crit td:nth-child(2){color:#d73a49;font-weight:600;}
|
||||||
|
tr.row-warn td:nth-child(2){color:#b08800;font-weight:600;}
|
||||||
|
tr.row-ok td:nth-child(2){color:#22863a;font-weight:600;}
|
||||||
|
tr.row-muted td:nth-child(2){color:#586069;}
|
||||||
|
.ok{color:#22863a;}
|
||||||
|
.warn{color:#b08800;}
|
||||||
|
.muted{color:#586069;}
|
||||||
|
code{font-size:.85rem;}
|
||||||
|
small{color:#586069;}
|
||||||
|
details{margin:.25rem 0;}`
|
||||||
81
checker/report_test.go
Normal file
81
checker/report_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiagnoseAndReportRender(t *testing.T) {
|
||||||
|
d := &BlacklistData{
|
||||||
|
Domain: "example.com",
|
||||||
|
RegisteredDomain: "example.com",
|
||||||
|
CollectedAt: time.Now(),
|
||||||
|
Results: []SourceResult{
|
||||||
|
{
|
||||||
|
SourceID: "dnsbl", SourceName: "Spamhaus DBL",
|
||||||
|
Subject: "dbl.spamhaus.org",
|
||||||
|
Enabled: true, Listed: true, Severity: SeverityCrit,
|
||||||
|
Reasons: []string{"Phishing domain"},
|
||||||
|
LookupURL: "https://check.spamhaus.org/results/?query=example.com",
|
||||||
|
RemovalURL: "https://www.spamhaus.org/dbl/removal/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: "dnsbl", SourceName: "URIBL multi",
|
||||||
|
Subject: "multi.uribl.com",
|
||||||
|
Enabled: true, Error: "i/o timeout",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: "openphish", SourceName: "OpenPhish feed",
|
||||||
|
Enabled: true, Listed: true, Severity: SeverityCrit,
|
||||||
|
Evidence: []Evidence{{Label: "URL", Value: "http://example.com/login"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
diags := diagnose(d)
|
||||||
|
if len(diags) < 2 {
|
||||||
|
t.Fatalf("expected at least 2 diagnoses, got %d", len(diags))
|
||||||
|
}
|
||||||
|
if diags[0].Severity != SeverityCrit {
|
||||||
|
t.Errorf("first diagnosis severity = %q, want crit", diags[0].Severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &blacklistProvider{}
|
||||||
|
html, err := p.GetHTMLReport(staticCtx{data: jsonOf(t, d)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHTMLReport: %v", err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Spamhaus DBL", "Action required", "OpenPhish"} {
|
||||||
|
if !strings.Contains(html, want) {
|
||||||
|
t.Errorf("report missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadline(t *testing.T) {
|
||||||
|
if h, c := headline(0); c != SeverityOK || !strings.Contains(h, "clean") {
|
||||||
|
t.Errorf("headline(0) = %q/%q", h, c)
|
||||||
|
}
|
||||||
|
if h, c := headline(1); c != SeverityCrit || !strings.Contains(h, "1") {
|
||||||
|
t.Errorf("headline(1) = %q/%q", h, c)
|
||||||
|
}
|
||||||
|
if h, c := headline(3); c != SeverityCrit || !strings.Contains(h, "3") {
|
||||||
|
t.Errorf("headline(3) = %q/%q", h, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSectionStatus(t *testing.T) {
|
||||||
|
if l, c := sectionStatus([]SourceResult{{Enabled: true, Listed: true, Severity: SeverityCrit}}); c != "crit" || !strings.HasPrefix(l, "LISTED") {
|
||||||
|
t.Errorf("sectionStatus listed = %q/%q", l, c)
|
||||||
|
}
|
||||||
|
if l, c := sectionStatus([]SourceResult{{Enabled: true}}); c != "ok" || l != "Clean" {
|
||||||
|
t.Errorf("sectionStatus clean = %q/%q", l, c)
|
||||||
|
}
|
||||||
|
if l, c := sectionStatus([]SourceResult{{Enabled: false}}); c != "muted" || l != "Disabled" {
|
||||||
|
t.Errorf("sectionStatus disabled = %q/%q", l, c)
|
||||||
|
}
|
||||||
|
if l, c := sectionStatus([]SourceResult{{Enabled: true, Error: "boom"}}); c != "warn" || l != "Errors" {
|
||||||
|
t.Errorf("sectionStatus error = %q/%q", l, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
checker/rule.go
Normal file
110
checker/rule.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rules returns the rule set surfaced to happyDomain. After the
|
||||||
|
// registry refactor we expose a single, generic rule that emits one
|
||||||
|
// CheckState per source result: the per-source verdict lives in
|
||||||
|
// CheckState.Subject (the source name) and CheckState.Code carries the
|
||||||
|
// canonical hit / clean / disabled / error flavour.
|
||||||
|
func Rules() []sdk.CheckRule {
|
||||||
|
return []sdk.CheckRule{&sourceRule{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
type sourceRule struct{}
|
||||||
|
|
||||||
|
func (*sourceRule) Name() string { return "source_listed" }
|
||||||
|
func (*sourceRule) Description() string {
|
||||||
|
return "Emits one state per reputation source: Critical/Warning when the source flags the domain, OK when clean, Info when the source is disabled, and Warning on transient query errors."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
var data BlacklistData
|
||||||
|
if err := obs.Get(ctx, ObservationKeyBlacklist, &data); err != nil {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Message: fmt.Sprintf("failed to get observation: %v", err),
|
||||||
|
Code: "blacklist_obs_error",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data.Results) == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusInfo, Message: "No reputation sources registered.",
|
||||||
|
Code: "blacklist_no_sources",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]sdk.CheckState, 0, len(data.Results))
|
||||||
|
for _, r := range data.Results {
|
||||||
|
out = append(out, evaluateOne(r))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateOne(r SourceResult) sdk.CheckState {
|
||||||
|
subj := r.SourceName
|
||||||
|
if r.Subject != "" && r.Subject != r.SourceName {
|
||||||
|
subj = r.SourceName + " / " + r.Subject
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !r.Enabled:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusUnknown, Subject: subj,
|
||||||
|
Message: subj + ": disabled or not configured.",
|
||||||
|
Code: "source_disabled",
|
||||||
|
}
|
||||||
|
case r.BlockedQuery:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusError,
|
||||||
|
Subject: subj,
|
||||||
|
Message: fmt.Sprintf("%s: resolver is blocked, result unreliable: %s", subj, joinNonEmpty(r.Reasons, "; ")),
|
||||||
|
Code: "source_resolver_blocked",
|
||||||
|
}
|
||||||
|
case r.Error != "":
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusWarn, Subject: subj,
|
||||||
|
Message: subj + ": query failed: " + r.Error,
|
||||||
|
Code: "source_error",
|
||||||
|
}
|
||||||
|
case r.Listed:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: severityToStatus(r.Severity),
|
||||||
|
Subject: subj,
|
||||||
|
Message: fmt.Sprintf("Listed in %s: %s", subj, joinNonEmpty(r.Reasons, "; ")),
|
||||||
|
Code: "source_listed",
|
||||||
|
Meta: map[string]any{
|
||||||
|
"source_id": r.SourceID,
|
||||||
|
"reasons": r.Reasons,
|
||||||
|
"lookup_url": r.LookupURL,
|
||||||
|
"removal_url": r.RemovalURL,
|
||||||
|
"reference": r.Reference,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusOK, Subject: subj,
|
||||||
|
Message: subj + ": clean.",
|
||||||
|
Code: "source_clean",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func severityToStatus(sev string) sdk.Status {
|
||||||
|
switch sev {
|
||||||
|
case SeverityCrit:
|
||||||
|
return sdk.StatusCrit
|
||||||
|
case SeverityWarn:
|
||||||
|
return sdk.StatusWarn
|
||||||
|
case SeverityInfo:
|
||||||
|
return sdk.StatusInfo
|
||||||
|
case SeverityOK:
|
||||||
|
return sdk.StatusOK
|
||||||
|
}
|
||||||
|
return sdk.StatusCrit
|
||||||
|
}
|
||||||
187
checker/safebrowsing.go
Normal file
187
checker/safebrowsing.go
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() { Register(&safeBrowsingSource{endpoint: safeBrowsingEndpoint}) }
|
||||||
|
|
||||||
|
const safeBrowsingEndpoint = "https://safebrowsing.googleapis.com/v4/threatMatches:find?key=%s"
|
||||||
|
|
||||||
|
// safeBrowsingSource calls Google Safe Browsing v4. The endpoint is
|
||||||
|
// kept on the struct so tests can swap it for httptest.
|
||||||
|
type safeBrowsingSource struct {
|
||||||
|
endpoint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*safeBrowsingSource) ID() string { return "google_safe_browsing" }
|
||||||
|
func (*safeBrowsingSource) Name() string { return "Google Safe Browsing" }
|
||||||
|
|
||||||
|
func (*safeBrowsingSource) Options() SourceOptions {
|
||||||
|
return SourceOptions{
|
||||||
|
Admin: []sdk.CheckerOptionField{
|
||||||
|
{
|
||||||
|
Id: "google_safe_browsing_api_key",
|
||||||
|
Type: "string",
|
||||||
|
Label: "Google Safe Browsing API key",
|
||||||
|
Description: "Google Cloud API key with the Safe Browsing API enabled. Leave empty to skip Safe Browsing lookups.",
|
||||||
|
Secret: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "google_safe_browsing_client_id",
|
||||||
|
Type: "string",
|
||||||
|
Label: "Safe Browsing client ID",
|
||||||
|
Default: "happydomain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "google_safe_browsing_client_version",
|
||||||
|
Type: "string",
|
||||||
|
Label: "Safe Browsing client version",
|
||||||
|
Default: "1.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *safeBrowsingSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
||||||
|
apiKey := stringOpt(opts, "google_safe_browsing_api_key")
|
||||||
|
if apiKey == "" {
|
||||||
|
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: false}}
|
||||||
|
}
|
||||||
|
if registered == "" {
|
||||||
|
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}}
|
||||||
|
}
|
||||||
|
clientID := stringOptDefault(opts, "google_safe_browsing_client_id", "happydomain")
|
||||||
|
clientVersion := stringOptDefault(opts, "google_safe_browsing_client_version", "1.0")
|
||||||
|
|
||||||
|
res := SourceResult{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"client": map[string]string{"clientId": clientID, "clientVersion": clientVersion},
|
||||||
|
"threatInfo": map[string]any{
|
||||||
|
"threatTypes": []string{
|
||||||
|
"MALWARE", "SOCIAL_ENGINEERING",
|
||||||
|
"UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION",
|
||||||
|
},
|
||||||
|
"platformTypes": []string{"ANY_PLATFORM"},
|
||||||
|
"threatEntryTypes": []string{"URL"},
|
||||||
|
"threatEntries": []map[string]string{
|
||||||
|
{"url": "http://" + registered + "/"},
|
||||||
|
{"url": "https://" + registered + "/"},
|
||||||
|
{"url": registered},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
url := fmt.Sprintf(s.endpoint, apiKey)
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(buf))
|
||||||
|
if err != nil {
|
||||||
|
res.Error = redactSecret(err.Error(), apiKey)
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
raw, status, err := httpDo(req, 1<<20)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = redactSecret(err.Error(), apiKey)
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
res.Error = fmt.Sprintf("HTTP %d: %s", status, redactSecret(truncate(string(raw), 200), apiKey))
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Matches []struct {
|
||||||
|
ThreatType string `json:"threatType"`
|
||||||
|
PlatformType string `json:"platformType"`
|
||||||
|
Threat struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
} `json:"threat"`
|
||||||
|
} `json:"matches"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||||
|
res.Error = "decode: " + err.Error()
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parsed.Matches) == 0 {
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
res.Listed = true
|
||||||
|
res.Severity = SeverityCrit
|
||||||
|
res.Reference = "https://transparencyreport.google.com/safe-browsing/search?url=" + registered
|
||||||
|
seenType := map[string]bool{}
|
||||||
|
for _, m := range parsed.Matches {
|
||||||
|
if !seenType[m.ThreatType] {
|
||||||
|
seenType[m.ThreatType] = true
|
||||||
|
res.Reasons = append(res.Reasons, m.ThreatType)
|
||||||
|
}
|
||||||
|
res.Evidence = append(res.Evidence, Evidence{
|
||||||
|
Label: "URL",
|
||||||
|
Value: m.Threat.URL,
|
||||||
|
Status: m.ThreatType,
|
||||||
|
Extra: map[string]string{"platform": m.PlatformType},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*safeBrowsingSource) Diagnose(res SourceResult) Diagnosis {
|
||||||
|
return Diagnosis{
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Title: "Flagged by Google Safe Browsing",
|
||||||
|
Detail: fmt.Sprintf(
|
||||||
|
"Threat type(s): %s. Visitors using Chrome, Firefox, Safari and most major browsers see a red interstitial when opening any URL on this domain. Investigate compromised pages, clean them, then request a review through Google Search Console: listings typically clear within 24h after a successful review.",
|
||||||
|
joinNonEmpty(res.Reasons, ", "),
|
||||||
|
),
|
||||||
|
Fix: "https://search.google.com/search-console/security-issues",
|
||||||
|
FixIsURL: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redactSecret removes occurrences of secret from s. Used to scrub API
|
||||||
|
// keys out of transport errors before they reach the report payload:
|
||||||
|
// *url.Error renders the full request URL, which for Safe Browsing
|
||||||
|
// includes ?key=… as a query parameter.
|
||||||
|
func redactSecret(s, secret string) string {
|
||||||
|
if secret == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(s, secret, "REDACTED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringOpt(opts sdk.CheckerOptions, key string) string {
|
||||||
|
v, _ := sdk.GetOption[string](opts, key)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringOptDefault(opts sdk.CheckerOptions, key, def string) string {
|
||||||
|
if v := stringOpt(opts, key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
146
checker/source.go
Normal file
146
checker/source.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"html/template"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Severity strings shared between sources, rules, and the HTML report.
|
||||||
|
const (
|
||||||
|
SeverityCrit = "crit"
|
||||||
|
SeverityWarn = "warn"
|
||||||
|
SeverityInfo = "info"
|
||||||
|
SeverityOK = "ok"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source is the contract every reputation source implements. The
|
||||||
|
// registry collects one Source per backend (DNSBL family, Safe
|
||||||
|
// Browsing, URLhaus, VirusTotal, OpenPhish, …); Collect fans out over
|
||||||
|
// the registry concurrently and folds the per-source results into the
|
||||||
|
// observation payload. Adding a new source is a single file plus a
|
||||||
|
// `Register(...)` call in init().
|
||||||
|
//
|
||||||
|
// A Source returns *one or more* SourceResult values. Most sources
|
||||||
|
// return exactly one (`{Listed, Reasons, …}`); the DNSBL family returns
|
||||||
|
// one result per zone. Returning many results from one Source keeps the
|
||||||
|
// definition tidy (one ID, one set of options, one rule entry) while
|
||||||
|
// still surfacing per-zone detail in the report.
|
||||||
|
type Source interface {
|
||||||
|
ID() string
|
||||||
|
Name() string
|
||||||
|
|
||||||
|
// Options contributes the option fields the source needs. They are
|
||||||
|
// merged into the global CheckerDefinition at startup.
|
||||||
|
Options() SourceOptions
|
||||||
|
|
||||||
|
// Query runs the source against `registered` (the eTLD+1 of the
|
||||||
|
// target domain) and returns one result per logical sub-target. The
|
||||||
|
// implementation should never return nil: when the source is
|
||||||
|
// disabled, return a single SourceResult with Enabled=false.
|
||||||
|
Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult
|
||||||
|
|
||||||
|
// Diagnose produces the action-required card for a *listed* result.
|
||||||
|
// Implementations should focus on the operator's next step; the
|
||||||
|
// generic report wraps it with the title bar and severity styling.
|
||||||
|
// Called only when SourceResult.Listed is true.
|
||||||
|
Diagnose(res SourceResult) Diagnosis
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetailRenderer is an optional interface a Source can implement when
|
||||||
|
// the generic SourceResult shape (Reasons + Evidence + URLs) cannot
|
||||||
|
// fully express its output. Examples: VirusTotal's per-vendor verdict
|
||||||
|
// table, URLhaus' URL list with online/offline status. The returned
|
||||||
|
// HTML fragment is dropped into the source's section verbatim and is
|
||||||
|
// expected to be safe (use html/template or template.HTMLEscape).
|
||||||
|
type DetailRenderer interface {
|
||||||
|
Source
|
||||||
|
RenderDetail(res SourceResult) (template.HTML, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceOptions describes the option fields a source contributes to the
|
||||||
|
// CheckerDefinition. Audiences map directly to the SDK's
|
||||||
|
// CheckerOptionsDocumentation buckets.
|
||||||
|
type SourceOptions struct {
|
||||||
|
Admin []sdk.CheckerOptionField
|
||||||
|
User []sdk.CheckerOptionField
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceResult is the unified envelope every source produces. Source-
|
||||||
|
// specific structured data lives in Details (json.RawMessage), so the
|
||||||
|
// generic code (rules, headline, base diagnosis card, summary table)
|
||||||
|
// can operate on the envelope without source-specific switches; the
|
||||||
|
// rich report sections fish Details back through DetailRenderer.
|
||||||
|
type SourceResult struct {
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
SourceName string `json:"source_name"`
|
||||||
|
Subject string `json:"subject,omitempty"` // e.g. zone label for DNSBL
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Listed bool `json:"listed"`
|
||||||
|
BlockedQuery bool `json:"blocked_query,omitempty"` // resolver blocked, not a real listing
|
||||||
|
Severity string `json:"severity,omitempty"` // when Listed
|
||||||
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
|
Evidence []Evidence `json:"evidence,omitempty"`
|
||||||
|
LookupURL string `json:"lookup_url,omitempty"`
|
||||||
|
RemovalURL string `json:"removal_url,omitempty"`
|
||||||
|
Reference string `json:"reference,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Details json.RawMessage `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evidence is a single observation that supports a verdict. Keeping it
|
||||||
|
// loosely typed (Label/Value/Status + free-form Extra) covers DNSBL
|
||||||
|
// return codes, OpenPhish URLs, URLhaus URLs, VT engine verdicts, …
|
||||||
|
// without growing the schema for each source.
|
||||||
|
type Evidence struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Extra map[string]string `json:"extra,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnosis is the action-required card surfaced at the top of the
|
||||||
|
// report. Sources build it in their Diagnose method.
|
||||||
|
type Diagnosis struct {
|
||||||
|
Severity string
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
Fix string
|
||||||
|
FixIsURL bool
|
||||||
|
LookupURL string
|
||||||
|
RemovalURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- registry ----------
|
||||||
|
|
||||||
|
var (
|
||||||
|
registryMu sync.RWMutex
|
||||||
|
registry []Source
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register adds a Source to the global registry. Intended to be called
|
||||||
|
// from init(). Panics on duplicate IDs so misconfigurations fail loudly
|
||||||
|
// at startup rather than producing silently-overlapping rules/options.
|
||||||
|
func Register(s Source) {
|
||||||
|
registryMu.Lock()
|
||||||
|
defer registryMu.Unlock()
|
||||||
|
for _, existing := range registry {
|
||||||
|
if existing.ID() == s.ID() {
|
||||||
|
panic("checker-blacklist: duplicate source ID " + s.ID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry = append(registry, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources returns a snapshot of the registered sources, in registration
|
||||||
|
// order. Callers must not mutate the slice.
|
||||||
|
func Sources() []Source {
|
||||||
|
registryMu.RLock()
|
||||||
|
defer registryMu.RUnlock()
|
||||||
|
out := make([]Source, len(registry))
|
||||||
|
copy(out, registry)
|
||||||
|
return out
|
||||||
|
}
|
||||||
40
checker/source_test.go
Normal file
40
checker/source_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRegisteredSourcesAreSane is a smoke test that runs over every
|
||||||
|
// init()-registered source and verifies basic invariants. New sources
|
||||||
|
// added later are picked up automatically.
|
||||||
|
func TestRegisteredSourcesAreSane(t *testing.T) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, s := range Sources() {
|
||||||
|
if s.ID() == "" || s.Name() == "" {
|
||||||
|
t.Errorf("source has empty ID or Name: %+v", s)
|
||||||
|
}
|
||||||
|
if seen[s.ID()] {
|
||||||
|
t.Errorf("duplicate source ID: %s", s.ID())
|
||||||
|
}
|
||||||
|
seen[s.ID()] = true
|
||||||
|
|
||||||
|
o := s.Options()
|
||||||
|
for _, f := range append(append([]any{}, toAny(o.Admin)...), toAny(o.User)...) {
|
||||||
|
_ = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// At least the built-in sources are present.
|
||||||
|
for _, want := range []string{"dnsbl", "google_safe_browsing", "openphish", "urlhaus", "virustotal"} {
|
||||||
|
if !seen[want] {
|
||||||
|
t.Errorf("missing built-in source %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toAny[T any](in []T) []any {
|
||||||
|
out := make([]any, len(in))
|
||||||
|
for i, v := range in {
|
||||||
|
out[i] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
26
checker/testhelpers_test.go
Normal file
26
checker/testhelpers_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticCtx is a minimal sdk.ReportContext used by report tests.
|
||||||
|
type staticCtx struct {
|
||||||
|
data json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s staticCtx) Data() json.RawMessage { return s.data }
|
||||||
|
func (staticCtx) Related(sdk.ObservationKey) []sdk.RelatedObservation { return nil }
|
||||||
|
func (staticCtx) States() []sdk.CheckState { return nil }
|
||||||
|
|
||||||
|
func jsonOf(t *testing.T, v any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
51
checker/types.go
Normal file
51
checker/types.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// Package checker implements the happyDomain blacklist checker.
|
||||||
|
//
|
||||||
|
// It tells you whether a domain is currently listed on widely-used
|
||||||
|
// reputation systems (DNS-based blocklists, Google Safe Browsing,
|
||||||
|
// OpenPhish, URLhaus, VirusTotal, …). Every source plugs into a small
|
||||||
|
// internal registry: adding a new one is a single file declaring a
|
||||||
|
// Source implementation and a Register call from init().
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ObservationKeyBlacklist is the unique observation key produced by this
|
||||||
|
// checker. Persisted in storage and referenced by the definition.
|
||||||
|
const ObservationKeyBlacklist = "blacklist"
|
||||||
|
|
||||||
|
// BlacklistData is the snapshot Collect produces for one domain. The
|
||||||
|
// per-source structs that used to live here are gone; the report and
|
||||||
|
// rules walk Results directly and the source-specific extras are kept
|
||||||
|
// in SourceResult.Details (json.RawMessage) when needed.
|
||||||
|
type BlacklistData struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
RegisteredDomain string `json:"registered_domain,omitempty"`
|
||||||
|
CollectedAt time.Time `json:"collected_at"`
|
||||||
|
Results []SourceResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalHits returns the number of distinct *sources* (not subjects)
|
||||||
|
// where the domain is currently flagged. A multi-zone source like
|
||||||
|
// DNSBL counts as many hits as it has listed zones, mirroring the way
|
||||||
|
// the report visualises severity.
|
||||||
|
func (d *BlacklistData) TotalHits() int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range d.Results {
|
||||||
|
if r.Listed {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterListed returns the subset of results that are currently
|
||||||
|
// flagged. Order is preserved (registration → query order).
|
||||||
|
func (d *BlacklistData) FilterListed() []SourceResult {
|
||||||
|
out := make([]SourceResult, 0, len(d.Results))
|
||||||
|
for _, r := range d.Results {
|
||||||
|
if r.Listed {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
201
checker/urlhaus.go
Normal file
201
checker/urlhaus.go
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
const urlhausHostEndpoint = "https://urlhaus-api.abuse.ch/v1/host/"
|
||||||
|
|
||||||
|
func init() { Register(&urlhausSource{endpoint: urlhausHostEndpoint}) }
|
||||||
|
|
||||||
|
type urlhausSource struct {
|
||||||
|
endpoint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*urlhausSource) ID() string { return "urlhaus" }
|
||||||
|
func (*urlhausSource) Name() string { return "abuse.ch URLhaus" }
|
||||||
|
|
||||||
|
func (*urlhausSource) Options() SourceOptions {
|
||||||
|
return SourceOptions{
|
||||||
|
User: []sdk.CheckerOptionField{
|
||||||
|
{
|
||||||
|
Id: "enable_urlhaus",
|
||||||
|
Type: "bool",
|
||||||
|
Label: "Use abuse.ch URLhaus",
|
||||||
|
Description: "Query the URLhaus host endpoint for active malware-distribution URLs hosted on the domain.",
|
||||||
|
Default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Admin: []sdk.CheckerOptionField{
|
||||||
|
{
|
||||||
|
Id: "urlhaus_auth_key",
|
||||||
|
Type: "string",
|
||||||
|
Label: "URLhaus Auth-Key",
|
||||||
|
Description: "abuse.ch URLhaus Auth-Key (free, requires an abuse.ch account). Required: the URLhaus API rejects anonymous requests with HTTP 401. Without this key the source is disabled.",
|
||||||
|
Secret: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlhausDetails is the source-specific extras kept in
|
||||||
|
// SourceResult.Details so the rich detail renderer can show a per-URL
|
||||||
|
// table with online/offline state, threat type, tags and date added.
|
||||||
|
type urlhausDetails struct {
|
||||||
|
URLs []urlhausURL `json:"urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type urlhausURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Threat string `json:"threat"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
DateAdded string `json:"date_added,omitempty"`
|
||||||
|
Reference string `json:"reference,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *urlhausSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
||||||
|
authKey := stringOpt(opts, "urlhaus_auth_key")
|
||||||
|
if !sdk.GetBoolOption(opts, "enable_urlhaus", true) || registered == "" || authKey == "" {
|
||||||
|
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: false}}
|
||||||
|
}
|
||||||
|
|
||||||
|
res := SourceResult{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
form := url.Values{"host": {registered}}
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, s.endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", "happydomain-checker-blacklist/1.0")
|
||||||
|
if authKey != "" {
|
||||||
|
req.Header.Set("Auth-Key", authKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, status, err := httpDo(req, 4<<20)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
res.Error = fmt.Sprintf("HTTP %d: %s", status, truncate(string(body), 200))
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
QueryStatus string `json:"query_status"`
|
||||||
|
Reference string `json:"urlhaus_reference"`
|
||||||
|
URLs []struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Status string `json:"url_status"`
|
||||||
|
Threat string `json:"threat"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
DateAdded string `json:"date_added"`
|
||||||
|
Reference string `json:"urlhaus_reference"`
|
||||||
|
} `json:"urls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
res.Error = "decode: " + err.Error()
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Reference = parsed.Reference
|
||||||
|
switch parsed.QueryStatus {
|
||||||
|
case "ok":
|
||||||
|
if len(parsed.URLs) == 0 {
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
res.Listed = true
|
||||||
|
res.Severity = SeverityCrit
|
||||||
|
threats := map[string]bool{}
|
||||||
|
details := urlhausDetails{}
|
||||||
|
for _, u := range parsed.URLs {
|
||||||
|
if u.Threat != "" && !threats[u.Threat] {
|
||||||
|
threats[u.Threat] = true
|
||||||
|
res.Reasons = append(res.Reasons, u.Threat)
|
||||||
|
}
|
||||||
|
res.Evidence = append(res.Evidence, Evidence{
|
||||||
|
Label: "URL", Value: u.URL, Status: u.Status,
|
||||||
|
})
|
||||||
|
details.URLs = append(details.URLs, urlhausURL{
|
||||||
|
URL: u.URL, Status: u.Status, Threat: u.Threat,
|
||||||
|
Tags: u.Tags, DateAdded: u.DateAdded, Reference: u.Reference,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
res.Details = mustJSON(details)
|
||||||
|
case "no_results":
|
||||||
|
// Clean.
|
||||||
|
case "invalid_host", "http_post_expected":
|
||||||
|
res.Error = "rejected query: " + parsed.QueryStatus
|
||||||
|
default:
|
||||||
|
res.Error = "query_status=" + parsed.QueryStatus
|
||||||
|
}
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*urlhausSource) Diagnose(res SourceResult) Diagnosis {
|
||||||
|
online := 0
|
||||||
|
for _, e := range res.Evidence {
|
||||||
|
if e.Status == "online" {
|
||||||
|
online++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Diagnosis{
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Title: "Listed in abuse.ch URLhaus (active malware distribution)",
|
||||||
|
Detail: fmt.Sprintf(
|
||||||
|
"%d URL(s) tracked, %d still online; threat type(s): %s. URLhaus indexes URLs that actively serve malware payloads. Treat the host as compromised: take the offending pages offline, audit the web stack (CMS plugins, recently-uploaded files, cron jobs), then submit a takedown notification through the URLhaus reference page.",
|
||||||
|
len(res.Evidence), online, joinNonEmpty(res.Reasons, ", "),
|
||||||
|
),
|
||||||
|
Fix: res.Reference,
|
||||||
|
FixIsURL: res.Reference != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderDetail renders the URLhaus URL table. Implementing
|
||||||
|
// DetailRenderer keeps the rich per-source view alongside the source
|
||||||
|
// implementation rather than scattered in the report code.
|
||||||
|
func (*urlhausSource) RenderDetail(res SourceResult) (template.HTML, error) {
|
||||||
|
var d urlhausDetails
|
||||||
|
if len(res.Details) > 0 {
|
||||||
|
if err := json.Unmarshal(res.Details, &d); err != nil {
|
||||||
|
return "", fmt.Errorf("urlhaus: decode details: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(d.URLs) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := urlhausDetailTpl.Execute(&b, d); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return template.HTML(b.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var urlhausDetailTpl = template.Must(template.New("urlhaus_detail").Parse(`
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>URL</th><th>Status</th><th>Threat</th><th>Tags</th><th>Added</th></tr></thead>
|
||||||
|
<tbody>{{range .URLs}}<tr class="row-crit">
|
||||||
|
<td><code>{{.URL}}</code>{{with .Reference}} <a href="{{.}}" target="_blank" rel="noreferrer">↗</a>{{end}}</td>
|
||||||
|
<td>{{.Status}}</td>
|
||||||
|
<td>{{.Threat}}</td>
|
||||||
|
<td>{{range .Tags}}<span>{{.}} </span>{{end}}</td>
|
||||||
|
<td><small>{{.DateAdded}}</small></td>
|
||||||
|
</tr>{{end}}</tbody>
|
||||||
|
</table>`))
|
||||||
91
checker/urlhaus_test.go
Normal file
91
checker/urlhaus_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestURLhausSource_NoResults(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"query_status":"no_results"}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := &urlhausSource{endpoint: srv.URL}
|
||||||
|
results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "urlhaus_auth_key": "k"})
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
r := results[0]
|
||||||
|
if !r.Enabled || r.Listed || r.Error != "" {
|
||||||
|
t.Fatalf("expected enabled+clean, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLhausSource_Listed(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = r.ParseForm()
|
||||||
|
if r.FormValue("host") == "" {
|
||||||
|
t.Errorf("missing host form value")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"query_status":"ok",
|
||||||
|
"urlhaus_reference":"https://urlhaus.abuse.ch/host/example.com/",
|
||||||
|
"urls":[
|
||||||
|
{"url":"http://example.com/payload.exe","url_status":"online","threat":"malware_download","tags":["exe","emotet"],"date_added":"2024-01-01","urlhaus_reference":"https://urlhaus.abuse.ch/url/1/"}
|
||||||
|
]
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := &urlhausSource{endpoint: srv.URL}
|
||||||
|
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "urlhaus_auth_key": "k"})[0]
|
||||||
|
if !r.Listed || len(r.Evidence) != 1 {
|
||||||
|
t.Fatalf("expected 1 listed evidence, got %+v", r)
|
||||||
|
}
|
||||||
|
if r.Evidence[0].Status != "online" {
|
||||||
|
t.Errorf("evidence status = %q", r.Evidence[0].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Details should round-trip.
|
||||||
|
var d urlhausDetails
|
||||||
|
if err := json.Unmarshal(r.Details, &d); err != nil || len(d.URLs) != 1 || d.URLs[0].Threat != "malware_download" {
|
||||||
|
t.Errorf("details round-trip wrong: %+v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rich detail renderer should produce a non-empty table.
|
||||||
|
html, err := s.RenderDetail(r)
|
||||||
|
if err != nil || !strings.Contains(string(html), "payload.exe") {
|
||||||
|
t.Errorf("RenderDetail: html=%q err=%v", html, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLhausSource_HTTPError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte("missing key"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := &urlhausSource{endpoint: srv.URL}
|
||||||
|
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "urlhaus_auth_key": "k"})[0]
|
||||||
|
if r.Error == "" || !strings.Contains(r.Error, "401") {
|
||||||
|
t.Errorf("expected 401 error, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURLhausSource_Disabled(t *testing.T) {
|
||||||
|
s := &urlhausSource{endpoint: "http://nope"}
|
||||||
|
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": false})[0]
|
||||||
|
if r.Enabled {
|
||||||
|
t.Errorf("expected disabled, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
220
checker/virustotal.go
Normal file
220
checker/virustotal.go
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
const virusTotalEndpoint = "https://www.virustotal.com/api/v3/domains/"
|
||||||
|
|
||||||
|
func init() { Register(&virusTotalSource{endpoint: virusTotalEndpoint}) }
|
||||||
|
|
||||||
|
type virusTotalSource struct {
|
||||||
|
endpoint string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*virusTotalSource) ID() string { return "virustotal" }
|
||||||
|
func (*virusTotalSource) Name() string { return "VirusTotal" }
|
||||||
|
|
||||||
|
func (*virusTotalSource) Options() SourceOptions {
|
||||||
|
return SourceOptions{
|
||||||
|
Admin: []sdk.CheckerOptionField{
|
||||||
|
{
|
||||||
|
Id: "virustotal_api_key",
|
||||||
|
Type: "string",
|
||||||
|
Label: "VirusTotal API key",
|
||||||
|
Description: "VirusTotal v3 API key. Free tier is limited to 4 req/min and 500 req/day.",
|
||||||
|
Secret: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vtDetails persists the structured VT response so the rich detail
|
||||||
|
// renderer can show the per-vendor verdict table and the
|
||||||
|
// {malicious,suspicious,harmless,undetected} counts.
|
||||||
|
type vtDetails struct {
|
||||||
|
Malicious int `json:"malicious"`
|
||||||
|
Suspicious int `json:"suspicious"`
|
||||||
|
Harmless int `json:"harmless"`
|
||||||
|
Undetected int `json:"undetected"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Reputation int `json:"reputation"`
|
||||||
|
Vendors []vtVendorVerdict `json:"vendors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vtVendorVerdict struct {
|
||||||
|
Engine string `json:"engine"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *virusTotalSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
|
||||||
|
apiKey := stringOpt(opts, "virustotal_api_key")
|
||||||
|
if apiKey == "" {
|
||||||
|
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: false}}
|
||||||
|
}
|
||||||
|
if registered == "" {
|
||||||
|
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
res := SourceResult{
|
||||||
|
SourceID: s.ID(), SourceName: s.Name(), Enabled: true,
|
||||||
|
Reference: "https://www.virustotal.com/gui/domain/" + registered,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, s.endpoint+registered, nil)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
req.Header.Set("x-apikey", apiKey)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
body, status, err := httpDo(req, 4<<20)
|
||||||
|
if err != nil {
|
||||||
|
res.Error = err.Error()
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
// VT has never seen this domain → quiet "not listed".
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
res.Error = fmt.Sprintf("HTTP %d: %s", status, truncate(string(body), 200))
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Data struct {
|
||||||
|
Attributes struct {
|
||||||
|
LastAnalysisStats struct {
|
||||||
|
Harmless int `json:"harmless"`
|
||||||
|
Malicious int `json:"malicious"`
|
||||||
|
Suspicious int `json:"suspicious"`
|
||||||
|
Undetected int `json:"undetected"`
|
||||||
|
Timeout int `json:"timeout"`
|
||||||
|
} `json:"last_analysis_stats"`
|
||||||
|
Reputation int `json:"reputation"`
|
||||||
|
LastAnalysisRes map[string]struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
EngineName string `json:"engine_name"`
|
||||||
|
} `json:"last_analysis_results"`
|
||||||
|
} `json:"attributes"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
res.Error = "decode: " + err.Error()
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := parsed.Data.Attributes.LastAnalysisStats
|
||||||
|
d := vtDetails{
|
||||||
|
Malicious: stats.Malicious,
|
||||||
|
Suspicious: stats.Suspicious,
|
||||||
|
Harmless: stats.Harmless,
|
||||||
|
Undetected: stats.Undetected,
|
||||||
|
Total: stats.Harmless + stats.Malicious + stats.Suspicious + stats.Undetected + stats.Timeout,
|
||||||
|
Reputation: parsed.Data.Attributes.Reputation,
|
||||||
|
}
|
||||||
|
for engine, v := range parsed.Data.Attributes.LastAnalysisRes {
|
||||||
|
if v.Category != "malicious" && v.Category != "suspicious" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := v.EngineName
|
||||||
|
if name == "" {
|
||||||
|
name = engine
|
||||||
|
}
|
||||||
|
d.Vendors = append(d.Vendors, vtVendorVerdict{Engine: name, Category: v.Category, Result: v.Result})
|
||||||
|
}
|
||||||
|
sort.Slice(d.Vendors, func(i, j int) bool {
|
||||||
|
if d.Vendors[i].Category != d.Vendors[j].Category {
|
||||||
|
return d.Vendors[i].Category == "malicious"
|
||||||
|
}
|
||||||
|
return d.Vendors[i].Engine < d.Vendors[j].Engine
|
||||||
|
})
|
||||||
|
res.Details = mustJSON(d)
|
||||||
|
|
||||||
|
if d.Malicious == 0 && d.Suspicious == 0 {
|
||||||
|
// Clean.
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
res.Listed = true
|
||||||
|
if d.Malicious > 0 {
|
||||||
|
res.Severity = SeverityCrit
|
||||||
|
} else {
|
||||||
|
res.Severity = SeverityWarn
|
||||||
|
}
|
||||||
|
for _, v := range d.Vendors {
|
||||||
|
res.Reasons = append(res.Reasons, v.Engine)
|
||||||
|
res.Evidence = append(res.Evidence, Evidence{
|
||||||
|
Label: "Engine", Value: v.Engine, Status: v.Category,
|
||||||
|
Extra: map[string]string{"result": v.Result},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return []SourceResult{res}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*virusTotalSource) Diagnose(res SourceResult) Diagnosis {
|
||||||
|
var d vtDetails
|
||||||
|
_ = json.Unmarshal(res.Details, &d)
|
||||||
|
previewN := min(len(d.Vendors), 5)
|
||||||
|
preview := make([]string, 0, previewN)
|
||||||
|
for _, v := range d.Vendors[:previewN] {
|
||||||
|
preview = append(preview, v.Engine)
|
||||||
|
}
|
||||||
|
gravity := "Suspicious"
|
||||||
|
sev := SeverityWarn
|
||||||
|
if d.Malicious > 0 {
|
||||||
|
gravity = "Malicious"
|
||||||
|
sev = SeverityCrit
|
||||||
|
}
|
||||||
|
return Diagnosis{
|
||||||
|
Severity: sev,
|
||||||
|
Title: fmt.Sprintf("VirusTotal: %d/%d engine(s) flagged the domain (%s)", d.Malicious+d.Suspicious, d.Total, gravity),
|
||||||
|
Detail: fmt.Sprintf(
|
||||||
|
"Reputation %d. Vendors flagging this domain include: %s. Open the VirusTotal page to see the per-engine verdicts and the related URLs/downloads. If you believe the verdicts are stale, request a re-scan from the VirusTotal page; for false positives, contact each engine vendor directly (VT does not arbitrate).",
|
||||||
|
d.Reputation, joinNonEmpty(preview, ", "),
|
||||||
|
),
|
||||||
|
Fix: res.Reference,
|
||||||
|
FixIsURL: res.Reference != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*virusTotalSource) RenderDetail(res SourceResult) (template.HTML, error) {
|
||||||
|
var d vtDetails
|
||||||
|
if len(res.Details) > 0 {
|
||||||
|
if err := json.Unmarshal(res.Details, &d); err != nil {
|
||||||
|
return "", fmt.Errorf("virustotal: decode details: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Total == 0 && len(d.Vendors) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := vtDetailTpl.Execute(&b, d); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return template.HTML(b.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var vtDetailTpl = template.Must(template.New("vt_detail").Parse(`
|
||||||
|
<p>Engines: <strong{{if gt .Malicious 0}} class="warn"{{end}}>{{.Malicious}} malicious</strong>, <strong>{{.Suspicious}} suspicious</strong>, {{.Harmless}} harmless, {{.Undetected}} undetected (total {{.Total}}). Reputation score: <strong>{{.Reputation}}</strong>.</p>
|
||||||
|
{{if .Vendors}}<table>
|
||||||
|
<thead><tr><th>Engine</th><th>Verdict</th><th>Result</th></tr></thead>
|
||||||
|
<tbody>{{range .Vendors}}<tr class="row-{{if eq .Category "malicious"}}crit{{else}}warn{{end}}">
|
||||||
|
<td>{{.Engine}}</td><td>{{.Category}}</td><td>{{.Result}}</td>
|
||||||
|
</tr>{{end}}</tbody>
|
||||||
|
</table>{{end}}`))
|
||||||
81
checker/virustotal_test.go
Normal file
81
checker/virustotal_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newVTServer(t *testing.T, status int, body string) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("x-apikey") == "" {
|
||||||
|
t.Errorf("missing x-apikey header")
|
||||||
|
}
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
return srv.URL + "/", srv.Close
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVTSource_NoKey(t *testing.T) {
|
||||||
|
s := &virusTotalSource{endpoint: virusTotalEndpoint}
|
||||||
|
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{})[0]
|
||||||
|
if r.Enabled {
|
||||||
|
t.Errorf("expected disabled without API key, got %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVTSource_Listed(t *testing.T) {
|
||||||
|
body := `{"data":{"attributes":{
|
||||||
|
"reputation":-25,
|
||||||
|
"last_analysis_stats":{"harmless":50,"malicious":3,"suspicious":1,"undetected":40,"timeout":0},
|
||||||
|
"last_analysis_results":{
|
||||||
|
"E1":{"category":"malicious","result":"phishing","engine_name":"E1"},
|
||||||
|
"E2":{"category":"suspicious","result":"susp","engine_name":"E2"},
|
||||||
|
"E3":{"category":"harmless","result":"clean","engine_name":"E3"}
|
||||||
|
}
|
||||||
|
}}}`
|
||||||
|
endpoint, stop := newVTServer(t, http.StatusOK, body)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
s := &virusTotalSource{endpoint: endpoint}
|
||||||
|
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"virustotal_api_key": "k"})[0]
|
||||||
|
if !r.Listed || r.Severity != SeverityCrit {
|
||||||
|
t.Errorf("expected listed+crit, got %+v", r)
|
||||||
|
}
|
||||||
|
var d vtDetails
|
||||||
|
if err := json.Unmarshal(r.Details, &d); err != nil {
|
||||||
|
t.Fatalf("details decode: %v", err)
|
||||||
|
}
|
||||||
|
if d.Malicious != 3 || d.Suspicious != 1 || d.Reputation != -25 {
|
||||||
|
t.Errorf("counts wrong: %+v", d)
|
||||||
|
}
|
||||||
|
if len(d.Vendors) != 2 || d.Vendors[0].Category != "malicious" {
|
||||||
|
t.Errorf("vendor ordering wrong: %+v", d.Vendors)
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := s.RenderDetail(r)
|
||||||
|
if err != nil || !strings.Contains(string(html), "malicious") {
|
||||||
|
t.Errorf("RenderDetail html=%q err=%v", html, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVTSource_NotFound(t *testing.T) {
|
||||||
|
endpoint, stop := newVTServer(t, http.StatusNotFound, `{"error":{"code":"NotFoundError"}}`)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
s := &virusTotalSource{endpoint: endpoint}
|
||||||
|
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"virustotal_api_key": "k"})[0]
|
||||||
|
if r.Listed || r.Error != "" {
|
||||||
|
t.Errorf("404 should be quiet not-listed: %+v", r)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.Reference, "example.com") {
|
||||||
|
t.Errorf("reference URL missing: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
go.mod
Normal file
8
go.mod
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
module git.happydns.org/checker-blacklist
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.happydns.org/checker-sdk-go v1.5.0
|
||||||
|
golang.org/x/net v0.34.0
|
||||||
|
)
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||||
|
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||||
|
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||||
|
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||||
27
main.go
Normal file
27
main.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
bl "git.happydns.org/checker-blacklist/checker"
|
||||||
|
"git.happydns.org/checker-sdk-go/checker/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is the standalone binary's version. Override at link time:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X main.Version=1.2.3" .
|
||||||
|
var Version = "custom-build"
|
||||||
|
|
||||||
|
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
bl.Version = Version
|
||||||
|
|
||||||
|
srv := server.New(bl.Provider())
|
||||||
|
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
plugin/plugin.go
Normal file
20
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Command plugin is the happyDomain plugin entrypoint for the blacklist
|
||||||
|
// checker.
|
||||||
|
//
|
||||||
|
// Build with `go build -buildmode=plugin -o checker-blacklist.so ./plugin`.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
bl "git.happydns.org/checker-blacklist/checker"
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Version = "custom-build"
|
||||||
|
|
||||||
|
// NewCheckerPlugin is the symbol resolved by happyDomain when loading
|
||||||
|
// the .so file. It returns the checker definition and the observation
|
||||||
|
// provider that the host registers globally.
|
||||||
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
|
bl.Version = Version
|
||||||
|
return bl.Definition(), bl.Provider(), nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue