Replace per-source enable booleans with SourcePrecheck and bump SDK to v1.9.0
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing

Sources that always work (botvrij, disconnect, oisd, openphish, phishtank, quad9) drop their user-facing enable_* option; the rule's enabled/disabled state is now solely controlled by the SDK rule toggle. Sources that require credentials (criminalip, malwarebazaar, otx, pulsedive, safebrowsing, threatfox, urlhaus, virustotal) instead implement the new SourcePrecheck interface so the host UI can surface "not configured" before attempting a query.
This commit is contained in:
nemunaire 2026-05-20 14:25:40 +08:00
commit c3cda1f104
25 changed files with 189 additions and 175 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

71
checker/precheck_test.go Normal file
View file

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

View file

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

View file

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

View file

@ -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{

View file

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

View file

@ -57,6 +57,17 @@ type Source interface {
Evaluate(r SourceResult) (listed bool, severity string)
}
// SourcePrecheck is an optional interface a Source can implement to
// declare whether the current options are sufficient for it to run.
// Used to surface "rule unavailable because the operator hasn't
// configured the credentials yet" in the host UI via the SDK's
// RulePrecheck contract. Returning nil means "ready to run"; any error
// is shown verbatim to the operator.
type SourcePrecheck interface {
Source
Precheck(ctx context.Context, opts sdk.CheckerOptions) error
}
// DetailRenderer is an optional interface a Source can implement when
// the generic SourceResult shape (Reasons + Evidence + URLs) cannot
// fully express its output. Examples: VirusTotal's per-vendor verdict

View file

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

View file

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

View file

@ -25,17 +25,15 @@ type urlhausSource struct {
func (*urlhausSource) ID() string { return "urlhaus" }
func (*urlhausSource) Name() string { return "abuse.ch URLhaus" }
func (s *urlhausSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "urlhaus_auth_key") == "" {
return fmt.Errorf("URLhaus Auth-Key is not configured")
}
return nil
}
func (*urlhausSource) Options() SourceOptions {
return SourceOptions{
User: []sdk.CheckerOptionField{
{
Id: "enable_urlhaus",
Type: "bool",
Label: "Use abuse.ch URLhaus",
Description: "Query the URLhaus host endpoint for active malware-distribution URLs hosted on the domain.",
Default: true,
},
},
Admin: []sdk.CheckerOptionField{
{
Id: "urlhaus_auth_key",
@ -66,7 +64,7 @@ type urlhausURL struct {
func (s *urlhausSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
authKey := stringOpt(opts, "urlhaus_auth_key")
if !sdk.GetBoolOption(opts, "enable_urlhaus", true) || registered == "" || authKey == "" {
if registered == "" || authKey == "" {
return disabledResult(s.ID(), s.Name())
}

View file

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

View file

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

2
go.mod
View file

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

4
go.sum
View file

@ -1,4 +1,4 @@
git.happydns.org/checker-sdk-go v1.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=