diff --git a/checker/botvrij.go b/checker/botvrij.go index 05c09c4..dbc4348 100644 --- a/checker/botvrij.go +++ b/checker/botvrij.go @@ -33,11 +33,21 @@ func (*botvrijSource) ID() string { return "botvrij" } func (*botvrijSource) Name() string { return "Botvrij.eu domain blocklist" } func (*botvrijSource) Options() SourceOptions { - return 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, + }, + }, + } } func (s *botvrijSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if registered == "" { + if !sdk.GetBoolOption(opts, "enable_botvrij", true) || registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/botvrij_test.go b/checker/botvrij_test.go index b9c466f..74366b4 100644 --- a/checker/botvrij_test.go +++ b/checker/botvrij_test.go @@ -5,6 +5,8 @@ import ( "net/http" "net/http/httptest" "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" ) const botvrijFakeFeed = `# Botvrij.eu IOC list - domains @@ -21,7 +23,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", nil)[0] + r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_botvrij": true})[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled and no error, got %+v", r) @@ -42,7 +44,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", nil)[0] + r := s.Query(context.Background(), "sub.example.org", "example.org", sdk.CheckerOptions{"enable_botvrij": true})[0] if len(r.Evidence) != 1 || r.Evidence[0].Value != "malware.example.org" { t.Errorf("expected subdomain match, got %+v", r.Evidence) @@ -59,7 +61,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", nil)[0] + r := s.Query(context.Background(), "safe.com", "safe.com", sdk.CheckerOptions{"enable_botvrij": true})[0] if !r.Enabled || r.Error != "" || len(r.Evidence) != 0 { t.Fatalf("expected clean result, got %+v", r) @@ -69,6 +71,14 @@ 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) @@ -76,7 +86,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", nil)[0] + r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_botvrij": true})[0] if r.Error == "" || r.Error != "botvrij HTTP 500" { t.Errorf("expected HTTP 500 error, got %q", r.Error) diff --git a/checker/collect.go b/checker/collect.go index 076f98e..446a195 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -38,11 +38,6 @@ 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() diff --git a/checker/criminalip.go b/checker/criminalip.go index d15371d..d98f2b5 100644 --- a/checker/criminalip.go +++ b/checker/criminalip.go @@ -19,13 +19,6 @@ 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/definition.go b/checker/definition.go index fcbd9aa..14c67e9 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -9,9 +9,10 @@ import ( // Version is overridden at link time by the standalone or plugin entrypoints. var Version = "built-in" -// 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. +// Definition assembles the checker definition by aggregating each +// registered Source's options into the SDK's audience-grouped layout. +// Adding a source automatically adds its option fields here: no edit +// to this file needed. func Definition() *sdk.CheckerDefinition { opts := sdk.CheckerOptionsDocumentation{ DomainOpts: []sdk.CheckerOptionDocumentation{ @@ -22,6 +23,11 @@ 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", diff --git a/checker/disconnect.go b/checker/disconnect.go index 73861b3..ae93adf 100644 --- a/checker/disconnect.go +++ b/checker/disconnect.go @@ -26,10 +26,23 @@ func (*disconnectSource) ID() string { return "disconnect" } func (*disconnectSource) Name() string { return "Disconnect.me" } func (*disconnectSource) Options() SourceOptions { - return 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, + }, + }, + } } 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 fc17c55..7a5e325 100644 --- a/checker/disconnect_test.go +++ b/checker/disconnect_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "testing" "time" + + sdk "git.happydns.org/checker-sdk-go/checker" ) const disconnectFakeFeed = `{ @@ -40,7 +42,7 @@ func TestDisconnectSource_Listed(t *testing.T) { defer srv.Close() s := newDisconnectTestSource(srv) - r := s.Query(context.Background(), "tracker.com", "tracker.com", nil)[0] + r := s.Query(context.Background(), "tracker.com", "tracker.com", sdk.CheckerOptions{"enable_disconnect": true})[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled with no error, got %+v", r) @@ -73,7 +75,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", nil)[0] + r := s.Query(context.Background(), "example.org", "example.org", sdk.CheckerOptions{"enable_disconnect": true})[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled with no error, got %+v", r) @@ -90,7 +92,7 @@ func TestDisconnectSource_NotListed(t *testing.T) { defer srv.Close() s := newDisconnectTestSource(srv) - r := s.Query(context.Background(), "clean.example.com", "clean.example.com", nil)[0] + r := s.Query(context.Background(), "clean.example.com", "clean.example.com", sdk.CheckerOptions{"enable_disconnect": true})[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled with no error, got %+v", r) @@ -103,6 +105,14 @@ 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) @@ -110,7 +120,7 @@ func TestDisconnectSource_HTTPError(t *testing.T) { defer srv.Close() s := newDisconnectTestSource(srv) - r := s.Query(context.Background(), "tracker.com", "tracker.com", nil)[0] + r := s.Query(context.Background(), "tracker.com", "tracker.com", sdk.CheckerOptions{"enable_disconnect": true})[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 6bd5b71..9e1af6b 100644 --- a/checker/malwarebazaar.go +++ b/checker/malwarebazaar.go @@ -22,13 +22,6 @@ 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{ @@ -40,12 +33,21 @@ 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 registered == "" || authKey == "" { + if !sdk.GetBoolOption(opts, "enable_malwarebazaar", true) || registered == "" || authKey == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/malwarebazaar_test.go b/checker/malwarebazaar_test.go index 3988ed4..62bc24a 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{"malwarebazaar_auth_key": "k"}) + results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "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{"malwarebazaar_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "malwarebazaar_auth_key": "k"})[0] if len(r.Evidence) != 1 { t.Fatalf("expected 1 evidence item, got %+v", r) } @@ -71,15 +71,23 @@ 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{"malwarebazaar_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "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", nil)[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true})[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 4b3dc0d..39773a1 100644 --- a/checker/oisd.go +++ b/checker/oisd.go @@ -37,6 +37,15 @@ 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", @@ -50,7 +59,7 @@ func (*oisdSource) Options() SourceOptions { } func (s *oisdSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if registered == "" { + if !sdk.GetBoolOption(opts, "enable_oisd", true) || registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/oisd_test.go b/checker/oisd_test.go index 12db986..93655b2 100644 --- a/checker/oisd_test.go +++ b/checker/oisd_test.go @@ -5,6 +5,8 @@ import ( "net/http" "net/http/httptest" "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" ) const oisdFakeFeed = `! OISD big domainswild @@ -21,7 +23,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", nil)[0] + r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_oisd": true})[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled and no error, got %+v", r) @@ -43,7 +45,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", nil)[0] + r := s.Query(context.Background(), "sub.example.org", "example.org", sdk.CheckerOptions{"enable_oisd": true})[0] if len(r.Evidence) != 1 || r.Evidence[0].Value != "malware.example.org" { t.Errorf("expected subdomain match, got %+v", r.Evidence) @@ -60,7 +62,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", nil)[0] + r := s.Query(context.Background(), "safe.com", "safe.com", sdk.CheckerOptions{"enable_oisd": true})[0] if !r.Enabled || r.Error != "" || len(r.Evidence) != 0 { t.Fatalf("expected clean result, got %+v", r) @@ -70,6 +72,14 @@ 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) @@ -77,7 +87,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", nil)[0] + r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_oisd": true})[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 042f4f2..e175f73 100644 --- a/checker/openphish.go +++ b/checker/openphish.go @@ -32,11 +32,21 @@ func (*openPhishSource) ID() string { return "openphish" } func (*openPhishSource) Name() string { return "OpenPhish feed" } func (*openPhishSource) Options() SourceOptions { - return SourceOptions{} + return SourceOptions{ + User: []sdk.CheckerOptionField{ + { + Id: "enable_openphish", + Type: "bool", + Label: "Use the OpenPhish public feed", + Description: "Download the OpenPhish public feed (refreshed every 12h) and check the domain against it.", + Default: true, + }, + }, + } } func (s *openPhishSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if registered == "" { + if !sdk.GetBoolOption(opts, "enable_openphish", true) || registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/otx.go b/checker/otx.go index 41300f5..d4e39cf 100644 --- a/checker/otx.go +++ b/checker/otx.go @@ -21,13 +21,6 @@ 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 12d808e..d3505b4 100644 --- a/checker/phishtank.go +++ b/checker/phishtank.go @@ -40,11 +40,20 @@ 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 registered == "" { + if !sdk.GetBoolOption(opts, "enable_phishtank", true) || registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/precheck_test.go b/checker/precheck_test.go deleted file mode 100644 index 79d159e..0000000 --- a/checker/precheck_test.go +++ /dev/null @@ -1,71 +0,0 @@ -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 987d7bd..edfeb1d 100644 --- a/checker/pulsedive.go +++ b/checker/pulsedive.go @@ -20,13 +20,6 @@ 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 6f92782..7bf6094 100644 --- a/checker/quad9.go +++ b/checker/quad9.go @@ -36,11 +36,21 @@ func (*quad9Source) ID() string { return "quad9" } func (*quad9Source) Name() string { return "Quad9 secure DNS" } func (*quad9Source) Options() SourceOptions { - return SourceOptions{} + return SourceOptions{ + User: []sdk.CheckerOptionField{ + { + Id: "enable_quad9", + Type: "bool", + Label: "Use Quad9 secure DNS check", + Description: "Compare Quad9's secure resolver (9.9.9.9) against its unsecured peer (9.9.9.10). A domain that resolves on the unsecured but returns NXDOMAIN on the secure resolver is blocked by Quad9's threat intelligence.", + Default: true, + }, + }, + } } func (s *quad9Source) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if registered == "" { + if !sdk.GetBoolOption(opts, "enable_quad9", true) || registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/report.go b/checker/report.go index 5abd8a8..c823cf7 100644 --- a/checker/report.go +++ b/checker/report.go @@ -21,17 +21,16 @@ 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: diagnoseFromStates(states, &data), + Diagnoses: diagnose(&data), Sections: buildSections(&data), CSS: template.CSS(reportCSS), } - view.Headline, view.HeadlineClass = headlineFromStates(states, view.TotalHits) + view.Headline, view.HeadlineClass = headline(view.TotalHits) var b bytes.Buffer if err := reportTemplate.Execute(&b, view); err != nil { @@ -130,54 +129,6 @@ 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: diff --git a/checker/rule.go b/checker/rule.go index ce0cc70..72d5c38 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -7,49 +7,23 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// 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. +// Rules returns the rule set surfaced to happyDomain. After the +// registry refactor we expose a single, generic rule that emits one +// CheckState per source result: the per-source verdict lives in +// CheckState.Subject (the source name) and CheckState.Code carries the +// canonical hit / clean / disabled / error flavour. func Rules() []sdk.CheckRule { - srcs := Sources() - rules := make([]sdk.CheckRule, 0, len(srcs)) - for _, s := range srcs { - rules = append(rules, &sourceRule{src: s}) - } - return rules + return []sdk.CheckRule{&sourceRule{}} } -type sourceRule struct { - src Source +type sourceRule struct{} + +func (*sourceRule) Name() string { return "source_listed" } +func (*sourceRule) Description() string { + return "Emits one state per reputation source: Critical/Warning when the source flags the domain, OK when clean, Info when the source is disabled, and Warning on transient query errors." } -func (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 { +func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var data BlacklistData if err := obs.Get(ctx, ObservationKeyBlacklist, &data); err != nil { return []sdk.CheckState{{ @@ -59,20 +33,26 @@ func (r *sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, op }} } - 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 { + if len(data.Results) == 0 { return []sdk.CheckState{{ - Status: sdk.StatusUnknown, - Message: r.src.Name() + ": no result (source skipped or disabled).", - Code: "source_disabled", + Status: sdk.StatusInfo, Message: "No reputation sources registered.", + Code: "blacklist_no_sources", }} } + + 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 } @@ -125,6 +105,17 @@ 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: diff --git a/checker/safebrowsing.go b/checker/safebrowsing.go index efaa17f..f04ec70 100644 --- a/checker/safebrowsing.go +++ b/checker/safebrowsing.go @@ -25,13 +25,6 @@ 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 1320994..03f47fc 100644 --- a/checker/source.go +++ b/checker/source.go @@ -57,17 +57,6 @@ 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 f38bd8f..4df96fa 100644 --- a/checker/threatfox.go +++ b/checker/threatfox.go @@ -22,13 +22,6 @@ 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{ @@ -40,12 +33,21 @@ 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 registered == "" || authKey == "" { + if !sdk.GetBoolOption(opts, "enable_threatfox", true) || registered == "" || authKey == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/threatfox_test.go b/checker/threatfox_test.go index a1ff480..5e5f12f 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{"threatfox_auth_key": "k"}) + results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "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{"threatfox_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "threatfox_auth_key": "k"})[0] if len(r.Evidence) != 1 { t.Fatalf("expected 1 evidence item, got %+v", r) } @@ -73,15 +73,23 @@ 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{"threatfox_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "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", nil)[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true})[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 8f00305..1bac676 100644 --- a/checker/urlhaus.go +++ b/checker/urlhaus.go @@ -25,15 +25,17 @@ 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", @@ -64,7 +66,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 registered == "" || authKey == "" { + if !sdk.GetBoolOption(opts, "enable_urlhaus", true) || registered == "" || authKey == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/urlhaus_test.go b/checker/urlhaus_test.go index 045322f..9128efd 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{"urlhaus_auth_key": "k"}) + results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "urlhaus_auth_key": "k"}) if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } @@ -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{"urlhaus_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "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{"urlhaus_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "urlhaus_auth_key": "k"})[0] if r.Error == "" || !strings.Contains(r.Error, "401") { t.Errorf("expected 401 error, got %+v", r) } } -func TestURLhausSource_NoAuthKey(t *testing.T) { +func TestURLhausSource_Disabled(t *testing.T) { s := &urlhausSource{endpoint: "http://nope"} - r := s.Query(context.Background(), "example.com", "example.com", nil)[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": false})[0] if r.Enabled { - t.Errorf("expected disabled when no auth key, got %+v", r) + t.Errorf("expected disabled, got %+v", r) } } diff --git a/checker/virustotal.go b/checker/virustotal.go index c245cd2..14d1132 100644 --- a/checker/virustotal.go +++ b/checker/virustotal.go @@ -24,13 +24,6 @@ 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 f161980..042aaf7 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.9.0 + git.happydns.org/checker-sdk-go v1.5.0 golang.org/x/net v0.34.0 ) diff --git a/go.sum b/go.sum index 9c26370..5631c56 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -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= +git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= +git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=