Compare commits

...

3 commits

Author SHA1 Message Date
c3cda1f104 Replace per-source enable booleans with SourcePrecheck and bump SDK to v1.9.0
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Sources that always work (botvrij, disconnect, oisd, openphish, phishtank, quad9) drop their user-facing enable_* option; the rule's enabled/disabled state is now solely controlled by the SDK rule toggle. Sources that require credentials (criminalip, malwarebazaar, otx, pulsedive, safebrowsing, threatfox, urlhaus, virustotal) instead implement the new SourcePrecheck interface so the host UI can surface "not configured" before attempting a query.
2026-05-20 14:26:42 +08:00
ce59a976d5 Expose one rule per source with rule-scoped options
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Each registered Source now becomes its own CheckRule (name = source ID)
implementing CheckRuleWithOptions, so the host can toggle blacklists
individually and the per-source option fields show up under the rule
that owns them instead of one flat global option list.

Collect honours the host's per-rule enable map (via the SDK's
RuleEnabled context helper) and skips the network call for disabled
sources entirely, not just their evaluation.
2026-05-19 22:12:31 +08:00
6719e21b51 Use ctx.States() for headline and diagnoses in HTML report
Drive listed-count and action-required cards from rule states when
available; fall back to raw observation data when states are absent.
2026-05-19 22:12:26 +08:00
28 changed files with 286 additions and 225 deletions

View file

@ -33,21 +33,11 @@ func (*botvrijSource) ID() string { return "botvrij" }
func (*botvrijSource) Name() string { return "Botvrij.eu domain blocklist" }
func (*botvrijSource) Options() SourceOptions {
return SourceOptions{
User: []sdk.CheckerOptionField{
{
Id: "enable_botvrij",
Type: "bool",
Label: "Use Botvrij.eu domain blocklist",
Description: "Download the Botvrij.eu public domain blocklist (refreshed every 6h) and check the domain against it.",
Default: true,
},
},
}
return SourceOptions{}
}
func (s *botvrijSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
if !sdk.GetBoolOption(opts, "enable_botvrij", true) || registered == "" {
if registered == "" {
return disabledResult(s.ID(), s.Name())
}

View file

@ -5,8 +5,6 @@ import (
"net/http"
"net/http/httptest"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const botvrijFakeFeed = `# Botvrij.eu IOC list - domains
@ -23,7 +21,7 @@ func TestBotvrijSource_Listed_ExactMatch(t *testing.T) {
defer srv.Close()
s := &botvrijSource{cache: newBotvrijCache(srv.URL)}
r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_botvrij": true})[0]
r := s.Query(context.Background(), "evil.com", "evil.com", nil)[0]
if !r.Enabled || r.Error != "" {
t.Fatalf("expected enabled and no error, got %+v", r)
@ -44,7 +42,7 @@ func TestBotvrijSource_Listed_SubdomainInFeed(t *testing.T) {
// Feed has "malware.example.org"; querying registered "example.org" should match.
s := &botvrijSource{cache: newBotvrijCache(srv.URL)}
r := s.Query(context.Background(), "sub.example.org", "example.org", sdk.CheckerOptions{"enable_botvrij": true})[0]
r := s.Query(context.Background(), "sub.example.org", "example.org", nil)[0]
if len(r.Evidence) != 1 || r.Evidence[0].Value != "malware.example.org" {
t.Errorf("expected subdomain match, got %+v", r.Evidence)
@ -61,7 +59,7 @@ func TestBotvrijSource_NotListed(t *testing.T) {
defer srv.Close()
s := &botvrijSource{cache: newBotvrijCache(srv.URL)}
r := s.Query(context.Background(), "safe.com", "safe.com", sdk.CheckerOptions{"enable_botvrij": true})[0]
r := s.Query(context.Background(), "safe.com", "safe.com", nil)[0]
if !r.Enabled || r.Error != "" || len(r.Evidence) != 0 {
t.Fatalf("expected clean result, got %+v", r)
@ -71,14 +69,6 @@ func TestBotvrijSource_NotListed(t *testing.T) {
}
}
func TestBotvrijSource_Disabled(t *testing.T) {
s := &botvrijSource{cache: newBotvrijCache("http://nope")}
r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_botvrij": false})[0]
if r.Enabled {
t.Errorf("expected disabled, got %+v", r)
}
}
func TestBotvrijSource_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@ -86,7 +76,7 @@ func TestBotvrijSource_HTTPError(t *testing.T) {
defer srv.Close()
s := &botvrijSource{cache: newBotvrijCache(srv.URL)}
r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_botvrij": true})[0]
r := s.Query(context.Background(), "evil.com", "evil.com", nil)[0]
if r.Error == "" || r.Error != "botvrij HTTP 500" {
t.Errorf("expected HTTP 500 error, got %q", r.Error)

View file

@ -38,6 +38,11 @@ func (p *blacklistProvider) Collect(ctx context.Context, opts sdk.CheckerOptions
var wg sync.WaitGroup
for i, s := range sources {
// The host disables a source by disabling its rule (rule name == source ID).
// Skip the network call entirely; Evaluate is short-circuited host-side.
if !sdk.RuleEnabled(ctx, s.ID()) {
continue
}
wg.Add(1)
go func(i int, s Source) {
defer wg.Done()

View file

@ -19,6 +19,13 @@ type criminalIPSource struct{ endpoint string }
func (*criminalIPSource) ID() string { return "criminal_ip" }
func (*criminalIPSource) Name() string { return "Criminal IP" }
func (s *criminalIPSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "criminal_ip_api_key") == "" {
return fmt.Errorf("Criminal IP API key is not configured")
}
return nil
}
func (*criminalIPSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{

View file

@ -9,10 +9,9 @@ import (
// 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.
// Definition assembles the checker definition. Per-source option fields
// live on each per-source rule (CheckRuleWithOptions); the global Options
// only carries the shared domain target.
func Definition() *sdk.CheckerDefinition {
opts := sdk.CheckerOptionsDocumentation{
DomainOpts: []sdk.CheckerOptionDocumentation{
@ -23,11 +22,6 @@ func Definition() *sdk.CheckerDefinition {
},
},
}
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",

View file

@ -26,23 +26,10 @@ func (*disconnectSource) ID() string { return "disconnect" }
func (*disconnectSource) Name() string { return "Disconnect.me" }
func (*disconnectSource) Options() SourceOptions {
return SourceOptions{
User: []sdk.CheckerOptionField{
{
Id: "enable_disconnect",
Type: "bool",
Label: "Use the Disconnect.me tracking-protection list",
Description: "Check the domain against the Disconnect.me blocklist used by Firefox Enhanced Tracking Protection, Brave, and uBlock Origin.",
Default: true,
},
},
}
return SourceOptions{}
}
func (s *disconnectSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
if !sdk.GetBoolOption(opts, "enable_disconnect", true) {
return disabledResult(s.ID(), s.Name())
}
if registered == "" {
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}}
}

View file

@ -6,8 +6,6 @@ import (
"net/http/httptest"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const disconnectFakeFeed = `{
@ -42,7 +40,7 @@ func TestDisconnectSource_Listed(t *testing.T) {
defer srv.Close()
s := newDisconnectTestSource(srv)
r := s.Query(context.Background(), "tracker.com", "tracker.com", sdk.CheckerOptions{"enable_disconnect": true})[0]
r := s.Query(context.Background(), "tracker.com", "tracker.com", nil)[0]
if !r.Enabled || r.Error != "" {
t.Fatalf("expected enabled with no error, got %+v", r)
@ -75,7 +73,7 @@ func TestDisconnectSource_SubdomainInFeed(t *testing.T) {
// Feed has "sub.example.org"; querying registered domain "example.org" should match.
s := newDisconnectTestSource(srv)
r := s.Query(context.Background(), "example.org", "example.org", sdk.CheckerOptions{"enable_disconnect": true})[0]
r := s.Query(context.Background(), "example.org", "example.org", nil)[0]
if !r.Enabled || r.Error != "" {
t.Fatalf("expected enabled with no error, got %+v", r)
@ -92,7 +90,7 @@ func TestDisconnectSource_NotListed(t *testing.T) {
defer srv.Close()
s := newDisconnectTestSource(srv)
r := s.Query(context.Background(), "clean.example.com", "clean.example.com", sdk.CheckerOptions{"enable_disconnect": true})[0]
r := s.Query(context.Background(), "clean.example.com", "clean.example.com", nil)[0]
if !r.Enabled || r.Error != "" {
t.Fatalf("expected enabled with no error, got %+v", r)
@ -105,14 +103,6 @@ func TestDisconnectSource_NotListed(t *testing.T) {
}
}
func TestDisconnectSource_Disabled(t *testing.T) {
s := &disconnectSource{cache: newFeedCache(time.Hour, disconnectFetch("http://nope"))}
r := s.Query(context.Background(), "tracker.com", "tracker.com", sdk.CheckerOptions{"enable_disconnect": false})[0]
if r.Enabled {
t.Errorf("expected disabled result, got %+v", r)
}
}
func TestDisconnectSource_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@ -120,7 +110,7 @@ func TestDisconnectSource_HTTPError(t *testing.T) {
defer srv.Close()
s := newDisconnectTestSource(srv)
r := s.Query(context.Background(), "tracker.com", "tracker.com", sdk.CheckerOptions{"enable_disconnect": true})[0]
r := s.Query(context.Background(), "tracker.com", "tracker.com", nil)[0]
if r.Error == "" {
t.Errorf("expected error on HTTP 500, got empty error")
}

View file

@ -22,6 +22,13 @@ type malwareBazaarSource struct {
func (*malwareBazaarSource) ID() string { return "malwarebazaar" }
func (*malwareBazaarSource) Name() string { return "abuse.ch MalwareBazaar" }
func (s *malwareBazaarSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "malwarebazaar_auth_key") == "" {
return fmt.Errorf("MalwareBazaar Auth-Key is not configured")
}
return nil
}
func (*malwareBazaarSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{
@ -33,21 +40,12 @@ func (*malwareBazaarSource) Options() SourceOptions {
Secret: true,
},
},
User: []sdk.CheckerOptionField{
{
Id: "enable_malwarebazaar",
Type: "bool",
Label: "Use abuse.ch MalwareBazaar",
Description: "Search MalwareBazaar for malware samples tagged with the domain (typically C2 infrastructure or delivery hosts).",
Default: true,
},
},
}
}
func (s *malwareBazaarSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
authKey := stringOpt(opts, "malwarebazaar_auth_key")
if !sdk.GetBoolOption(opts, "enable_malwarebazaar", true) || registered == "" || authKey == "" {
if registered == "" || authKey == "" {
return disabledResult(s.ID(), s.Name())
}

View file

@ -17,7 +17,7 @@ func TestMalwareBazaarSource_NoResults(t *testing.T) {
defer srv.Close()
s := &malwareBazaarSource{endpoint: srv.URL}
results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "malwarebazaar_auth_key": "k"})
results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"malwarebazaar_auth_key": "k"})
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
@ -48,7 +48,7 @@ func TestMalwareBazaarSource_Listed(t *testing.T) {
defer srv.Close()
s := &malwareBazaarSource{endpoint: srv.URL}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "malwarebazaar_auth_key": "k"})[0]
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"malwarebazaar_auth_key": "k"})[0]
if len(r.Evidence) != 1 {
t.Fatalf("expected 1 evidence item, got %+v", r)
}
@ -71,23 +71,15 @@ func TestMalwareBazaarSource_HTTPError(t *testing.T) {
defer srv.Close()
s := &malwareBazaarSource{endpoint: srv.URL}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "malwarebazaar_auth_key": "k"})[0]
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"malwarebazaar_auth_key": "k"})[0]
if r.Error == "" {
t.Errorf("expected error, got %+v", r)
}
}
func TestMalwareBazaarSource_Disabled(t *testing.T) {
s := &malwareBazaarSource{endpoint: "http://nope"}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": false})[0]
if r.Enabled {
t.Errorf("expected disabled, got %+v", r)
}
}
func TestMalwareBazaarSource_NoAuthKey(t *testing.T) {
s := &malwareBazaarSource{endpoint: "http://nope"}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true})[0]
r := s.Query(context.Background(), "example.com", "example.com", nil)[0]
if r.Enabled {
t.Errorf("expected disabled when no auth key, got %+v", r)
}

View file

@ -37,15 +37,6 @@ func (*oisdSource) Name() string { return "OISD domain blocklist" }
func (*oisdSource) Options() SourceOptions {
return SourceOptions{
User: []sdk.CheckerOptionField{
{
Id: "enable_oisd",
Type: "bool",
Label: "Use the OISD domain blocklist",
Description: "Download the OISD domain blocklist (refreshed every 24h) and check the domain against it.",
Default: true,
},
},
Admin: []sdk.CheckerOptionField{
{
Id: "oisd_variant",
@ -59,7 +50,7 @@ func (*oisdSource) Options() SourceOptions {
}
func (s *oisdSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
if !sdk.GetBoolOption(opts, "enable_oisd", true) || registered == "" {
if registered == "" {
return disabledResult(s.ID(), s.Name())
}

View file

@ -5,8 +5,6 @@ import (
"net/http"
"net/http/httptest"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const oisdFakeFeed = `! OISD big domainswild
@ -23,7 +21,7 @@ func TestOisdSource_Listed_ExactMatch(t *testing.T) {
defer srv.Close()
s := &oisdSource{bigCache: newOisdCache(srv.URL)}
r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_oisd": true})[0]
r := s.Query(context.Background(), "evil.com", "evil.com", nil)[0]
if !r.Enabled || r.Error != "" {
t.Fatalf("expected enabled and no error, got %+v", r)
@ -45,7 +43,7 @@ func TestOisdSource_Listed_SubdomainInFeed(t *testing.T) {
// Feed has "*.malware.example.org" → stored as "malware.example.org"; querying
// registered "example.org" should match via suffix check.
s := &oisdSource{bigCache: newOisdCache(srv.URL)}
r := s.Query(context.Background(), "sub.example.org", "example.org", sdk.CheckerOptions{"enable_oisd": true})[0]
r := s.Query(context.Background(), "sub.example.org", "example.org", nil)[0]
if len(r.Evidence) != 1 || r.Evidence[0].Value != "malware.example.org" {
t.Errorf("expected subdomain match, got %+v", r.Evidence)
@ -62,7 +60,7 @@ func TestOisdSource_NotListed(t *testing.T) {
defer srv.Close()
s := &oisdSource{bigCache: newOisdCache(srv.URL)}
r := s.Query(context.Background(), "safe.com", "safe.com", sdk.CheckerOptions{"enable_oisd": true})[0]
r := s.Query(context.Background(), "safe.com", "safe.com", nil)[0]
if !r.Enabled || r.Error != "" || len(r.Evidence) != 0 {
t.Fatalf("expected clean result, got %+v", r)
@ -72,14 +70,6 @@ func TestOisdSource_NotListed(t *testing.T) {
}
}
func TestOisdSource_Disabled(t *testing.T) {
s := &oisdSource{bigCache: newOisdCache("http://nope")}
r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_oisd": false})[0]
if r.Enabled {
t.Errorf("expected disabled, got %+v", r)
}
}
func TestOisdSource_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@ -87,7 +77,7 @@ func TestOisdSource_HTTPError(t *testing.T) {
defer srv.Close()
s := &oisdSource{bigCache: newOisdCache(srv.URL)}
r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_oisd": true})[0]
r := s.Query(context.Background(), "evil.com", "evil.com", nil)[0]
if r.Error == "" || r.Error != "oisd HTTP 500" {
t.Errorf("expected HTTP 500 error, got %q", r.Error)

View file

@ -32,21 +32,11 @@ 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,
},
},
}
return SourceOptions{}
}
func (s *openPhishSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
if !sdk.GetBoolOption(opts, "enable_openphish", true) || registered == "" {
if registered == "" {
return disabledResult(s.ID(), s.Name())
}

View file

@ -21,6 +21,13 @@ type otxSource struct{ endpoint string }
func (*otxSource) ID() string { return "otx" }
func (*otxSource) Name() string { return "AlienVault OTX" }
func (s *otxSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "otx_api_key") == "" {
return fmt.Errorf("AlienVault OTX API key is not configured")
}
return nil
}
func (*otxSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{

View file

@ -40,20 +40,11 @@ func (*phishTankSource) Options() SourceOptions {
Default: "12",
},
},
User: []sdk.CheckerOptionField{
{
Id: "enable_phishtank",
Type: "bool",
Label: "Use the PhishTank feed",
Description: "Download the PhishTank verified phishing list and check the domain against it.",
Default: true,
},
},
}
}
func (s *phishTankSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
if !sdk.GetBoolOption(opts, "enable_phishtank", true) || registered == "" {
if registered == "" {
return disabledResult(s.ID(), s.Name())
}

71
checker/precheck_test.go Normal file
View file

@ -0,0 +1,71 @@
package checker
import (
"context"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// TestSourcePrechecks covers every Source that implements SourcePrecheck:
// without the required option Precheck must return a non-nil error, and
// with the option set it must return nil.
func TestSourcePrechecks(t *testing.T) {
cases := []struct {
name string
src SourcePrecheck
optKey string
hint string // substring expected in the error message
}{
{"safebrowsing", &safeBrowsingSource{}, "google_safe_browsing_api_key", "Safe Browsing"},
{"virustotal", &virusTotalSource{}, "virustotal_api_key", "VirusTotal"},
{"otx", &otxSource{}, "otx_api_key", "OTX"},
{"pulsedive", &pulsediveSource{}, "pulsedive_api_key", "Pulsedive"},
{"criminalip", &criminalIPSource{}, "criminal_ip_api_key", "Criminal IP"},
{"malwarebazaar", &malwareBazaarSource{endpoint: "http://nope"}, "malwarebazaar_auth_key", "MalwareBazaar"},
{"threatfox", &threatFoxSource{endpoint: "http://nope"}, "threatfox_auth_key", "ThreatFox"},
{"urlhaus", &urlhausSource{endpoint: "http://nope"}, "urlhaus_auth_key", "URLhaus"},
}
for _, c := range cases {
t.Run(c.name+"/missing", func(t *testing.T) {
err := c.src.Precheck(context.Background(), nil)
if err == nil {
t.Fatalf("expected error when %q is missing, got nil", c.optKey)
}
if !strings.Contains(err.Error(), c.hint) {
t.Errorf("error %q does not mention %q", err.Error(), c.hint)
}
})
t.Run(c.name+"/set", func(t *testing.T) {
err := c.src.Precheck(context.Background(), sdk.CheckerOptions{c.optKey: "k"})
if err != nil {
t.Errorf("expected nil when %q is set, got %v", c.optKey, err)
}
})
}
}
// TestSourceRule_PrecheckDelegation ensures sourceRule satisfies
// sdk.RulePrecheck and that the delegation through SourcePrecheck
// works end-to-end. Sources that do not implement SourcePrecheck must
// report "available" (nil error).
func TestSourceRule_PrecheckDelegation(t *testing.T) {
gated := &sourceRule{src: &urlhausSource{endpoint: "http://nope"}}
if err := gated.Precheck(context.Background(), nil); err == nil {
t.Errorf("urlhaus sourceRule.Precheck with empty opts: expected error, got nil")
}
if err := gated.Precheck(context.Background(), sdk.CheckerOptions{"urlhaus_auth_key": "k"}); err != nil {
t.Errorf("urlhaus sourceRule.Precheck with key set: expected nil, got %v", err)
}
open := &sourceRule{src: &botvrijSource{cache: newBotvrijCache("http://nope")}}
if err := open.Precheck(context.Background(), nil); err != nil {
t.Errorf("botvrij sourceRule.Precheck: expected nil (no SourcePrecheck), got %v", err)
}
// Confirm the type assertion the SDK server relies on succeeds.
var _ sdk.RulePrecheck = gated
var _ sdk.RulePrecheck = open
}

View file

@ -20,6 +20,13 @@ type pulsediveSource struct{ endpoint string }
func (*pulsediveSource) ID() string { return "pulsedive" }
func (*pulsediveSource) Name() string { return "Pulsedive" }
func (s *pulsediveSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "pulsedive_api_key") == "" {
return fmt.Errorf("Pulsedive API key is not configured")
}
return nil
}
func (*pulsediveSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{

View file

@ -36,21 +36,11 @@ func (*quad9Source) ID() string { return "quad9" }
func (*quad9Source) Name() string { return "Quad9 secure DNS" }
func (*quad9Source) Options() SourceOptions {
return SourceOptions{
User: []sdk.CheckerOptionField{
{
Id: "enable_quad9",
Type: "bool",
Label: "Use Quad9 secure DNS check",
Description: "Compare Quad9's secure resolver (9.9.9.9) against its unsecured peer (9.9.9.10). A domain that resolves on the unsecured but returns NXDOMAIN on the secure resolver is blocked by Quad9's threat intelligence.",
Default: true,
},
},
}
return SourceOptions{}
}
func (s *quad9Source) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
if !sdk.GetBoolOption(opts, "enable_quad9", true) || registered == "" {
if registered == "" {
return disabledResult(s.ID(), s.Name())
}

View file

@ -21,16 +21,17 @@ func (p *blacklistProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error)
return "", fmt.Errorf("decode blacklist data: %w", err)
}
states := ctx.States()
view := reportView{
Domain: data.Domain,
RegisteredDomain: data.RegisteredDomain,
CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"),
TotalHits: data.TotalHits(),
Diagnoses: diagnose(&data),
Diagnoses: diagnoseFromStates(states, &data),
Sections: buildSections(&data),
CSS: template.CSS(reportCSS),
}
view.Headline, view.HeadlineClass = headline(view.TotalHits)
view.Headline, view.HeadlineClass = headlineFromStates(states, view.TotalHits)
var b bytes.Buffer
if err := reportTemplate.Execute(&b, view); err != nil {
@ -129,6 +130,54 @@ func diagnose(d *BlacklistData) []Diagnosis {
return out
}
func headlineFromStates(states []sdk.CheckState, fallbackHits int) (string, string) {
if len(states) == 0 {
return headline(fallbackHits)
}
listed := 0
for _, st := range states {
if st.Code == "source_listed" {
listed++
}
}
return headline(listed)
}
func diagnoseFromStates(states []sdk.CheckState, d *BlacklistData) []Diagnosis {
if len(states) == 0 {
return diagnose(d)
}
var out []Diagnosis
for _, st := range states {
if st.Status != sdk.StatusCrit && st.Status != sdk.StatusWarn {
continue
}
switch st.Code {
case "source_listed":
diag := Diagnosis{
Severity: SeverityCrit,
Title: "Listed in " + st.Subject,
Detail: st.Message,
}
if v, ok := st.Meta["lookup_url"].(string); ok {
diag.LookupURL = v
}
if v, ok := st.Meta["removal_url"].(string); ok {
diag.RemovalURL = v
}
out = append(out, diag)
case "source_error", "source_resolver_blocked":
out = append(out, Diagnosis{
Severity: SeverityWarn,
Title: "Could not query " + st.Subject,
Detail: st.Message,
})
}
}
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:

View file

@ -7,23 +7,49 @@ import (
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.
// Rules returns one rule per registered Source. The host can enable or
// disable each independently, and each rule owns the option fields its
// Source contributes (CheckRuleWithOptions). The rule name is the
// Source ID; downstream a disabled rule both skips evaluation and
// skips the underlying network call in Collect.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{&sourceRule{}}
srcs := Sources()
rules := make([]sdk.CheckRule, 0, len(srcs))
for _, s := range srcs {
rules = append(rules, &sourceRule{src: s})
}
return rules
}
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."
type sourceRule struct {
src Source
}
func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
func (r *sourceRule) Name() string { return r.src.ID() }
func (r *sourceRule) Description() string {
return fmt.Sprintf("%s reputation lookup. Emits Critical/Warning when the source flags the domain, OK when clean, Info when the source is disabled or not configured, and Warning on transient query errors.", r.src.Name())
}
// Precheck satisfies sdk.RulePrecheck for every sourceRule. Sources
// that need credentials (or any other runtime prerequisite) opt in via
// SourcePrecheck; sources that always work return nil here.
func (r *sourceRule) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if p, ok := r.src.(SourcePrecheck); ok {
return p.Precheck(ctx, opts)
}
return nil
}
func (r *sourceRule) Options() sdk.CheckerOptionsDocumentation {
o := r.src.Options()
return sdk.CheckerOptionsDocumentation{
AdminOpts: o.Admin,
UserOpts: o.User,
}
}
func (r *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{{
@ -33,26 +59,20 @@ func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts
}}
}
if len(data.Results) == 0 {
var out []sdk.CheckState
for _, res := range data.Results {
if res.SourceID != r.src.ID() {
continue
}
out = append(out, evaluateOne(res, r.src))
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusInfo, Message: "No reputation sources registered.",
Code: "blacklist_no_sources",
Status: sdk.StatusUnknown,
Message: r.src.Name() + ": no result (source skipped or disabled).",
Code: "source_disabled",
}}
}
byID := make(map[string]Source, len(Sources()))
for _, s := range Sources() {
byID[s.ID()] = s
}
out := make([]sdk.CheckState, 0, len(data.Results))
for _, r := range data.Results {
src, ok := byID[r.SourceID]
if !ok {
src = unknownSource{}
}
out = append(out, evaluateOne(r, src))
}
return out
}
@ -105,17 +125,6 @@ func evaluateOne(r SourceResult, src Source) sdk.CheckState {
}
}
// unknownSource is a sentinel used when a SourceResult references a source ID
// that is no longer in the registry. Evaluate always returns (false, "").
type unknownSource struct{}
func (unknownSource) ID() string { return "" }
func (unknownSource) Name() string { return "unknown" }
func (unknownSource) Options() SourceOptions { return SourceOptions{} }
func (unknownSource) Query(_ context.Context, _, _ string, _ sdk.CheckerOptions) []SourceResult { return nil }
func (unknownSource) Diagnose(_ SourceResult) Diagnosis { return Diagnosis{} }
func (unknownSource) Evaluate(_ SourceResult) (bool, string) { return false, "" }
func severityToStatus(sev string) sdk.Status {
switch sev {
case SeverityCrit:

View file

@ -25,6 +25,13 @@ type safeBrowsingSource struct {
func (*safeBrowsingSource) ID() string { return "google_safe_browsing" }
func (*safeBrowsingSource) Name() string { return "Google Safe Browsing" }
func (s *safeBrowsingSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "google_safe_browsing_api_key") == "" {
return fmt.Errorf("Google Safe Browsing API key is not configured")
}
return nil
}
func (*safeBrowsingSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{

View file

@ -57,6 +57,17 @@ type Source interface {
Evaluate(r SourceResult) (listed bool, severity string)
}
// SourcePrecheck is an optional interface a Source can implement to
// declare whether the current options are sufficient for it to run.
// Used to surface "rule unavailable because the operator hasn't
// configured the credentials yet" in the host UI via the SDK's
// RulePrecheck contract. Returning nil means "ready to run"; any error
// is shown verbatim to the operator.
type SourcePrecheck interface {
Source
Precheck(ctx context.Context, opts sdk.CheckerOptions) error
}
// 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

View file

@ -22,6 +22,13 @@ type threatFoxSource struct {
func (*threatFoxSource) ID() string { return "threatfox" }
func (*threatFoxSource) Name() string { return "abuse.ch ThreatFox" }
func (s *threatFoxSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "threatfox_auth_key") == "" {
return fmt.Errorf("ThreatFox Auth-Key is not configured")
}
return nil
}
func (*threatFoxSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{
@ -33,21 +40,12 @@ func (*threatFoxSource) Options() SourceOptions {
Secret: true,
},
},
User: []sdk.CheckerOptionField{
{
Id: "enable_threatfox",
Type: "bool",
Label: "Use abuse.ch ThreatFox",
Description: "Query ThreatFox for indicators of compromise (C2 servers, malware, phishing) associated with the domain.",
Default: true,
},
},
}
}
func (s *threatFoxSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
authKey := stringOpt(opts, "threatfox_auth_key")
if !sdk.GetBoolOption(opts, "enable_threatfox", true) || registered == "" || authKey == "" {
if registered == "" || authKey == "" {
return disabledResult(s.ID(), s.Name())
}

View file

@ -17,7 +17,7 @@ func TestThreatFoxSource_NoResult(t *testing.T) {
defer srv.Close()
s := &threatFoxSource{endpoint: srv.URL}
results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "threatfox_auth_key": "k"})
results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"threatfox_auth_key": "k"})
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
@ -50,7 +50,7 @@ func TestThreatFoxSource_Listed(t *testing.T) {
defer srv.Close()
s := &threatFoxSource{endpoint: srv.URL}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "threatfox_auth_key": "k"})[0]
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"threatfox_auth_key": "k"})[0]
if len(r.Evidence) != 1 {
t.Fatalf("expected 1 evidence item, got %+v", r)
}
@ -73,23 +73,15 @@ func TestThreatFoxSource_HTTPError(t *testing.T) {
defer srv.Close()
s := &threatFoxSource{endpoint: srv.URL}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "threatfox_auth_key": "k"})[0]
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"threatfox_auth_key": "k"})[0]
if r.Error == "" {
t.Errorf("expected error, got %+v", r)
}
}
func TestThreatFoxSource_Disabled(t *testing.T) {
s := &threatFoxSource{endpoint: "http://nope"}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": false})[0]
if r.Enabled {
t.Errorf("expected disabled, got %+v", r)
}
}
func TestThreatFoxSource_NoAuthKey(t *testing.T) {
s := &threatFoxSource{endpoint: "http://nope"}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true})[0]
r := s.Query(context.Background(), "example.com", "example.com", nil)[0]
if r.Enabled {
t.Errorf("expected disabled when no auth key, got %+v", r)
}

View file

@ -25,17 +25,15 @@ type urlhausSource struct {
func (*urlhausSource) ID() string { return "urlhaus" }
func (*urlhausSource) Name() string { return "abuse.ch URLhaus" }
func (s *urlhausSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "urlhaus_auth_key") == "" {
return fmt.Errorf("URLhaus Auth-Key is not configured")
}
return nil
}
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",
@ -66,7 +64,7 @@ type urlhausURL struct {
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 == "" {
if registered == "" || authKey == "" {
return disabledResult(s.ID(), s.Name())
}

View file

@ -19,7 +19,7 @@ func TestURLhausSource_NoResults(t *testing.T) {
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"})
results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"urlhaus_auth_key": "k"})
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
@ -48,7 +48,7 @@ func TestURLhausSource_Listed(t *testing.T) {
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]
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"urlhaus_auth_key": "k"})[0]
if len(r.Evidence) != 1 {
t.Fatalf("expected 1 evidence item, got %+v", r)
}
@ -80,16 +80,16 @@ func TestURLhausSource_HTTPError(t *testing.T) {
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]
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"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) {
func TestURLhausSource_NoAuthKey(t *testing.T) {
s := &urlhausSource{endpoint: "http://nope"}
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": false})[0]
r := s.Query(context.Background(), "example.com", "example.com", nil)[0]
if r.Enabled {
t.Errorf("expected disabled, got %+v", r)
t.Errorf("expected disabled when no auth key, got %+v", r)
}
}

View file

@ -24,6 +24,13 @@ type virusTotalSource struct {
func (*virusTotalSource) ID() string { return "virustotal" }
func (*virusTotalSource) Name() string { return "VirusTotal" }
func (s *virusTotalSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "virustotal_api_key") == "" {
return fmt.Errorf("VirusTotal API key is not configured")
}
return nil
}
func (*virusTotalSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{

2
go.mod
View file

@ -3,6 +3,6 @@ module git.happydns.org/checker-blacklist
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.5.0
git.happydns.org/checker-sdk-go v1.9.0
golang.org/x/net v0.34.0
)

4
go.sum
View file

@ -1,4 +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=
git.happydns.org/checker-sdk-go v1.9.0 h1:orBRymir+p6PMHVa4focryPKhTVWT7JAv6u9Ido5KF0=
git.happydns.org/checker-sdk-go v1.9.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=