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=