From c3cda1f10446fbf1ff16ae445e5a43ffd0e7a41a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 20 May 2026 14:25:40 +0800 Subject: [PATCH] Replace per-source enable booleans with SourcePrecheck and bump SDK to v1.9.0 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. --- checker/botvrij.go | 14 +------ checker/botvrij_test.go | 18 ++------- checker/criminalip.go | 7 ++++ checker/disconnect.go | 15 +------- checker/disconnect_test.go | 18 ++------- checker/malwarebazaar.go | 18 ++++----- checker/malwarebazaar_test.go | 16 ++------ checker/oisd.go | 11 +----- checker/oisd_test.go | 18 ++------- checker/openphish.go | 14 +------ checker/otx.go | 7 ++++ checker/phishtank.go | 11 +----- checker/precheck_test.go | 71 +++++++++++++++++++++++++++++++++++ checker/pulsedive.go | 7 ++++ checker/quad9.go | 14 +------ checker/rule.go | 10 +++++ checker/safebrowsing.go | 7 ++++ checker/source.go | 11 ++++++ checker/threatfox.go | 18 ++++----- checker/threatfox_test.go | 16 ++------ checker/urlhaus.go | 18 ++++----- checker/urlhaus_test.go | 12 +++--- checker/virustotal.go | 7 ++++ go.mod | 2 +- go.sum | 4 +- 25 files changed, 189 insertions(+), 175 deletions(-) create mode 100644 checker/precheck_test.go diff --git a/checker/botvrij.go b/checker/botvrij.go index dbc4348..05c09c4 100644 --- a/checker/botvrij.go +++ b/checker/botvrij.go @@ -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()) } diff --git a/checker/botvrij_test.go b/checker/botvrij_test.go index 74366b4..b9c466f 100644 --- a/checker/botvrij_test.go +++ b/checker/botvrij_test.go @@ -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) diff --git a/checker/criminalip.go b/checker/criminalip.go index d98f2b5..d15371d 100644 --- a/checker/criminalip.go +++ b/checker/criminalip.go @@ -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{ diff --git a/checker/disconnect.go b/checker/disconnect.go index ae93adf..73861b3 100644 --- a/checker/disconnect.go +++ b/checker/disconnect.go @@ -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}} } diff --git a/checker/disconnect_test.go b/checker/disconnect_test.go index 7a5e325..fc17c55 100644 --- a/checker/disconnect_test.go +++ b/checker/disconnect_test.go @@ -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") } diff --git a/checker/malwarebazaar.go b/checker/malwarebazaar.go index 9e1af6b..6bd5b71 100644 --- a/checker/malwarebazaar.go +++ b/checker/malwarebazaar.go @@ -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()) } diff --git a/checker/malwarebazaar_test.go b/checker/malwarebazaar_test.go index 62bc24a..3988ed4 100644 --- a/checker/malwarebazaar_test.go +++ b/checker/malwarebazaar_test.go @@ -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) } diff --git a/checker/oisd.go b/checker/oisd.go index 39773a1..4b3dc0d 100644 --- a/checker/oisd.go +++ b/checker/oisd.go @@ -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()) } diff --git a/checker/oisd_test.go b/checker/oisd_test.go index 93655b2..12db986 100644 --- a/checker/oisd_test.go +++ b/checker/oisd_test.go @@ -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) diff --git a/checker/openphish.go b/checker/openphish.go index e175f73..042f4f2 100644 --- a/checker/openphish.go +++ b/checker/openphish.go @@ -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()) } diff --git a/checker/otx.go b/checker/otx.go index d4e39cf..41300f5 100644 --- a/checker/otx.go +++ b/checker/otx.go @@ -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{ diff --git a/checker/phishtank.go b/checker/phishtank.go index d3505b4..12d808e 100644 --- a/checker/phishtank.go +++ b/checker/phishtank.go @@ -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()) } diff --git a/checker/precheck_test.go b/checker/precheck_test.go new file mode 100644 index 0000000..79d159e --- /dev/null +++ b/checker/precheck_test.go @@ -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 +} diff --git a/checker/pulsedive.go b/checker/pulsedive.go index edfeb1d..987d7bd 100644 --- a/checker/pulsedive.go +++ b/checker/pulsedive.go @@ -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{ diff --git a/checker/quad9.go b/checker/quad9.go index 7bf6094..6f92782 100644 --- a/checker/quad9.go +++ b/checker/quad9.go @@ -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()) } diff --git a/checker/rule.go b/checker/rule.go index d904836..ce0cc70 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -31,6 +31,16 @@ 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{ diff --git a/checker/safebrowsing.go b/checker/safebrowsing.go index f04ec70..efaa17f 100644 --- a/checker/safebrowsing.go +++ b/checker/safebrowsing.go @@ -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{ diff --git a/checker/source.go b/checker/source.go index 03f47fc..1320994 100644 --- a/checker/source.go +++ b/checker/source.go @@ -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 diff --git a/checker/threatfox.go b/checker/threatfox.go index 4df96fa..f38bd8f 100644 --- a/checker/threatfox.go +++ b/checker/threatfox.go @@ -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()) } diff --git a/checker/threatfox_test.go b/checker/threatfox_test.go index 5e5f12f..a1ff480 100644 --- a/checker/threatfox_test.go +++ b/checker/threatfox_test.go @@ -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) } diff --git a/checker/urlhaus.go b/checker/urlhaus.go index 1bac676..8f00305 100644 --- a/checker/urlhaus.go +++ b/checker/urlhaus.go @@ -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()) } diff --git a/checker/urlhaus_test.go b/checker/urlhaus_test.go index 9128efd..045322f 100644 --- a/checker/urlhaus_test.go +++ b/checker/urlhaus_test.go @@ -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) } } diff --git a/checker/virustotal.go b/checker/virustotal.go index 14d1132..c245cd2 100644 --- a/checker/virustotal.go +++ b/checker/virustotal.go @@ -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{ diff --git a/go.mod b/go.mod index cc71d5d..f161980 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module git.happydns.org/checker-blacklist go 1.25.0 require ( - git.happydns.org/checker-sdk-go v1.8.0 + git.happydns.org/checker-sdk-go v1.9.0 golang.org/x/net v0.34.0 ) diff --git a/go.sum b/go.sum index d14c68e..9c26370 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -git.happydns.org/checker-sdk-go v1.8.0 h1:2lhcSc16rnCaszdQi1nerszb2c3fVh5XNS11pLrXuK4= -git.happydns.org/checker-sdk-go v1.8.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=