From 6719e21b51f07d5b2ac444544357e124076cb516 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 17 May 2026 23:56:28 +0800 Subject: [PATCH 1/3] Use ctx.States() for headline and diagnoses in HTML report Drive listed-count and action-required cards from rule states when available; fall back to raw observation data when states are absent. --- checker/report.go | 53 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/checker/report.go b/checker/report.go index c823cf7..5abd8a8 100644 --- a/checker/report.go +++ b/checker/report.go @@ -21,16 +21,17 @@ func (p *blacklistProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) return "", fmt.Errorf("decode blacklist data: %w", err) } + states := ctx.States() view := reportView{ Domain: data.Domain, RegisteredDomain: data.RegisteredDomain, CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"), TotalHits: data.TotalHits(), - Diagnoses: diagnose(&data), + Diagnoses: diagnoseFromStates(states, &data), Sections: buildSections(&data), CSS: template.CSS(reportCSS), } - view.Headline, view.HeadlineClass = headline(view.TotalHits) + view.Headline, view.HeadlineClass = headlineFromStates(states, view.TotalHits) var b bytes.Buffer if err := reportTemplate.Execute(&b, view); err != nil { @@ -129,6 +130,54 @@ func diagnose(d *BlacklistData) []Diagnosis { return out } +func headlineFromStates(states []sdk.CheckState, fallbackHits int) (string, string) { + if len(states) == 0 { + return headline(fallbackHits) + } + listed := 0 + for _, st := range states { + if st.Code == "source_listed" { + listed++ + } + } + return headline(listed) +} + +func diagnoseFromStates(states []sdk.CheckState, d *BlacklistData) []Diagnosis { + if len(states) == 0 { + return diagnose(d) + } + var out []Diagnosis + for _, st := range states { + if st.Status != sdk.StatusCrit && st.Status != sdk.StatusWarn { + continue + } + switch st.Code { + case "source_listed": + diag := Diagnosis{ + Severity: SeverityCrit, + Title: "Listed in " + st.Subject, + Detail: st.Message, + } + if v, ok := st.Meta["lookup_url"].(string); ok { + diag.LookupURL = v + } + if v, ok := st.Meta["removal_url"].(string); ok { + diag.RemovalURL = v + } + out = append(out, diag) + case "source_error", "source_resolver_blocked": + out = append(out, Diagnosis{ + Severity: SeverityWarn, + Title: "Could not query " + st.Subject, + Detail: st.Message, + }) + } + } + sort.SliceStable(out, func(i, j int) bool { return sevRank(out[i].Severity) < sevRank(out[j].Severity) }) + return out +} + func sevRank(s string) int { switch s { case SeverityCrit: From ce59a976d54922874f4ce114799e458a81e2183b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 19 May 2026 22:10:32 +0800 Subject: [PATCH 2/3] Expose one rule per source with rule-scoped options Each registered Source now becomes its own CheckRule (name = source ID) implementing CheckRuleWithOptions, so the host can toggle blacklists individually and the per-source option fields show up under the rule that owns them instead of one flat global option list. Collect honours the host's per-rule enable map (via the SDK's RuleEnabled context helper) and skips the network call for disabled sources entirely, not just their evaluation. --- checker/collect.go | 5 +++ checker/definition.go | 12 ++----- checker/rule.go | 81 +++++++++++++++++++++---------------------- go.mod | 2 +- go.sum | 4 +-- 5 files changed, 51 insertions(+), 53 deletions(-) diff --git a/checker/collect.go b/checker/collect.go index 446a195..076f98e 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -38,6 +38,11 @@ func (p *blacklistProvider) Collect(ctx context.Context, opts sdk.CheckerOptions var wg sync.WaitGroup for i, s := range sources { + // The host disables a source by disabling its rule (rule name == source ID). + // Skip the network call entirely; Evaluate is short-circuited host-side. + if !sdk.RuleEnabled(ctx, s.ID()) { + continue + } wg.Add(1) go func(i int, s Source) { defer wg.Done() diff --git a/checker/definition.go b/checker/definition.go index 14c67e9..fcbd9aa 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -9,10 +9,9 @@ import ( // Version is overridden at link time by the standalone or plugin entrypoints. var Version = "built-in" -// Definition assembles the checker definition by aggregating each -// registered Source's options into the SDK's audience-grouped layout. -// Adding a source automatically adds its option fields here: no edit -// to this file needed. +// Definition assembles the checker definition. Per-source option fields +// live on each per-source rule (CheckRuleWithOptions); the global Options +// only carries the shared domain target. func Definition() *sdk.CheckerDefinition { opts := sdk.CheckerOptionsDocumentation{ DomainOpts: []sdk.CheckerOptionDocumentation{ @@ -23,11 +22,6 @@ func Definition() *sdk.CheckerDefinition { }, }, } - for _, s := range Sources() { - o := s.Options() - opts.AdminOpts = append(opts.AdminOpts, o.Admin...) - opts.UserOpts = append(opts.UserOpts, o.User...) - } return &sdk.CheckerDefinition{ ID: "blacklist", diff --git a/checker/rule.go b/checker/rule.go index 72d5c38..d904836 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -7,23 +7,39 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Rules returns the rule set surfaced to happyDomain. After the -// registry refactor we expose a single, generic rule that emits one -// CheckState per source result: the per-source verdict lives in -// CheckState.Subject (the source name) and CheckState.Code carries the -// canonical hit / clean / disabled / error flavour. +// Rules returns one rule per registered Source. The host can enable or +// disable each independently, and each rule owns the option fields its +// Source contributes (CheckRuleWithOptions). The rule name is the +// Source ID; downstream a disabled rule both skips evaluation and +// skips the underlying network call in Collect. func Rules() []sdk.CheckRule { - return []sdk.CheckRule{&sourceRule{}} + srcs := Sources() + rules := make([]sdk.CheckRule, 0, len(srcs)) + for _, s := range srcs { + rules = append(rules, &sourceRule{src: s}) + } + return rules } -type sourceRule struct{} - -func (*sourceRule) Name() string { return "source_listed" } -func (*sourceRule) Description() string { - return "Emits one state per reputation source: Critical/Warning when the source flags the domain, OK when clean, Info when the source is disabled, and Warning on transient query errors." +type sourceRule struct { + src Source } -func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { +func (r *sourceRule) Name() string { return r.src.ID() } + +func (r *sourceRule) Description() string { + return fmt.Sprintf("%s reputation lookup. Emits Critical/Warning when the source flags the domain, OK when clean, Info when the source is disabled or not configured, and Warning on transient query errors.", r.src.Name()) +} + +func (r *sourceRule) Options() sdk.CheckerOptionsDocumentation { + o := r.src.Options() + return sdk.CheckerOptionsDocumentation{ + AdminOpts: o.Admin, + UserOpts: o.User, + } +} + +func (r *sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var data BlacklistData if err := obs.Get(ctx, ObservationKeyBlacklist, &data); err != nil { return []sdk.CheckState{{ @@ -33,25 +49,19 @@ func (*sourceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts }} } - if len(data.Results) == 0 { - return []sdk.CheckState{{ - 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{} + var out []sdk.CheckState + for _, res := range data.Results { + if res.SourceID != r.src.ID() { + continue } - out = append(out, evaluateOne(r, src)) + out = append(out, evaluateOne(res, r.src)) + } + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Message: r.src.Name() + ": no result (source skipped or disabled).", + Code: "source_disabled", + }} } return out } @@ -105,17 +115,6 @@ func evaluateOne(r SourceResult, src Source) sdk.CheckState { } } -// unknownSource is a sentinel used when a SourceResult references a source ID -// that is no longer in the registry. Evaluate always returns (false, ""). -type unknownSource struct{} - -func (unknownSource) ID() string { return "" } -func (unknownSource) Name() string { return "unknown" } -func (unknownSource) Options() SourceOptions { return SourceOptions{} } -func (unknownSource) Query(_ context.Context, _, _ string, _ sdk.CheckerOptions) []SourceResult { return nil } -func (unknownSource) Diagnose(_ SourceResult) Diagnosis { return Diagnosis{} } -func (unknownSource) Evaluate(_ SourceResult) (bool, string) { return false, "" } - func severityToStatus(sev string) sdk.Status { switch sev { case SeverityCrit: diff --git a/go.mod b/go.mod index 042aaf7..cc71d5d 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.5.0 + git.happydns.org/checker-sdk-go v1.8.0 golang.org/x/net v0.34.0 ) diff --git a/go.sum b/go.sum index 5631c56..d14c68e 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= -git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.8.0 h1:2lhcSc16rnCaszdQi1nerszb2c3fVh5XNS11pLrXuK4= +git.happydns.org/checker-sdk-go v1.8.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= From c3cda1f10446fbf1ff16ae445e5a43ffd0e7a41a Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Wed, 20 May 2026 14:25:40 +0800 Subject: [PATCH 3/3] Replace per-source enable booleans with SourcePrecheck and bump SDK to v1.9.0 Sources that always work (botvrij, disconnect, oisd, openphish, phishtank, quad9) drop their user-facing enable_* option; the rule's enabled/disabled state is now solely controlled by the SDK rule toggle. Sources that require credentials (criminalip, malwarebazaar, otx, pulsedive, safebrowsing, threatfox, urlhaus, virustotal) instead implement the new SourcePrecheck interface so the host UI can surface "not configured" before attempting a query. --- checker/botvrij.go | 14 +------ checker/botvrij_test.go | 18 ++------- checker/criminalip.go | 7 ++++ checker/disconnect.go | 15 +------- checker/disconnect_test.go | 18 ++------- checker/malwarebazaar.go | 18 ++++----- checker/malwarebazaar_test.go | 16 ++------ checker/oisd.go | 11 +----- checker/oisd_test.go | 18 ++------- checker/openphish.go | 14 +------ checker/otx.go | 7 ++++ checker/phishtank.go | 11 +----- checker/precheck_test.go | 71 +++++++++++++++++++++++++++++++++++ checker/pulsedive.go | 7 ++++ checker/quad9.go | 14 +------ checker/rule.go | 10 +++++ checker/safebrowsing.go | 7 ++++ checker/source.go | 11 ++++++ checker/threatfox.go | 18 ++++----- checker/threatfox_test.go | 16 ++------ checker/urlhaus.go | 18 ++++----- checker/urlhaus_test.go | 12 +++--- checker/virustotal.go | 7 ++++ go.mod | 2 +- go.sum | 4 +- 25 files changed, 189 insertions(+), 175 deletions(-) create mode 100644 checker/precheck_test.go diff --git a/checker/botvrij.go b/checker/botvrij.go index dbc4348..05c09c4 100644 --- a/checker/botvrij.go +++ b/checker/botvrij.go @@ -33,21 +33,11 @@ func (*botvrijSource) ID() string { return "botvrij" } func (*botvrijSource) Name() string { return "Botvrij.eu domain blocklist" } func (*botvrijSource) Options() SourceOptions { - return SourceOptions{ - User: []sdk.CheckerOptionField{ - { - Id: "enable_botvrij", - Type: "bool", - Label: "Use Botvrij.eu domain blocklist", - Description: "Download the Botvrij.eu public domain blocklist (refreshed every 6h) and check the domain against it.", - Default: true, - }, - }, - } + return SourceOptions{} } func (s *botvrijSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if !sdk.GetBoolOption(opts, "enable_botvrij", true) || registered == "" { + if registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/botvrij_test.go b/checker/botvrij_test.go index 74366b4..b9c466f 100644 --- a/checker/botvrij_test.go +++ b/checker/botvrij_test.go @@ -5,8 +5,6 @@ import ( "net/http" "net/http/httptest" "testing" - - sdk "git.happydns.org/checker-sdk-go/checker" ) const botvrijFakeFeed = `# Botvrij.eu IOC list - domains @@ -23,7 +21,7 @@ func TestBotvrijSource_Listed_ExactMatch(t *testing.T) { defer srv.Close() s := &botvrijSource{cache: newBotvrijCache(srv.URL)} - r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_botvrij": true})[0] + r := s.Query(context.Background(), "evil.com", "evil.com", nil)[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled and no error, got %+v", r) @@ -44,7 +42,7 @@ func TestBotvrijSource_Listed_SubdomainInFeed(t *testing.T) { // Feed has "malware.example.org"; querying registered "example.org" should match. s := &botvrijSource{cache: newBotvrijCache(srv.URL)} - r := s.Query(context.Background(), "sub.example.org", "example.org", sdk.CheckerOptions{"enable_botvrij": true})[0] + r := s.Query(context.Background(), "sub.example.org", "example.org", nil)[0] if len(r.Evidence) != 1 || r.Evidence[0].Value != "malware.example.org" { t.Errorf("expected subdomain match, got %+v", r.Evidence) @@ -61,7 +59,7 @@ func TestBotvrijSource_NotListed(t *testing.T) { defer srv.Close() s := &botvrijSource{cache: newBotvrijCache(srv.URL)} - r := s.Query(context.Background(), "safe.com", "safe.com", sdk.CheckerOptions{"enable_botvrij": true})[0] + r := s.Query(context.Background(), "safe.com", "safe.com", nil)[0] if !r.Enabled || r.Error != "" || len(r.Evidence) != 0 { t.Fatalf("expected clean result, got %+v", r) @@ -71,14 +69,6 @@ func TestBotvrijSource_NotListed(t *testing.T) { } } -func TestBotvrijSource_Disabled(t *testing.T) { - s := &botvrijSource{cache: newBotvrijCache("http://nope")} - r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_botvrij": false})[0] - if r.Enabled { - t.Errorf("expected disabled, got %+v", r) - } -} - func TestBotvrijSource_HTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -86,7 +76,7 @@ func TestBotvrijSource_HTTPError(t *testing.T) { defer srv.Close() s := &botvrijSource{cache: newBotvrijCache(srv.URL)} - r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_botvrij": true})[0] + r := s.Query(context.Background(), "evil.com", "evil.com", nil)[0] if r.Error == "" || r.Error != "botvrij HTTP 500" { t.Errorf("expected HTTP 500 error, got %q", r.Error) diff --git a/checker/criminalip.go b/checker/criminalip.go index d98f2b5..d15371d 100644 --- a/checker/criminalip.go +++ b/checker/criminalip.go @@ -19,6 +19,13 @@ type criminalIPSource struct{ endpoint string } func (*criminalIPSource) ID() string { return "criminal_ip" } func (*criminalIPSource) Name() string { return "Criminal IP" } +func (s *criminalIPSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if stringOpt(opts, "criminal_ip_api_key") == "" { + return fmt.Errorf("Criminal IP API key is not configured") + } + return nil +} + func (*criminalIPSource) Options() SourceOptions { return SourceOptions{ Admin: []sdk.CheckerOptionField{ diff --git a/checker/disconnect.go b/checker/disconnect.go index ae93adf..73861b3 100644 --- a/checker/disconnect.go +++ b/checker/disconnect.go @@ -26,23 +26,10 @@ func (*disconnectSource) ID() string { return "disconnect" } func (*disconnectSource) Name() string { return "Disconnect.me" } func (*disconnectSource) Options() SourceOptions { - return SourceOptions{ - User: []sdk.CheckerOptionField{ - { - Id: "enable_disconnect", - Type: "bool", - Label: "Use the Disconnect.me tracking-protection list", - Description: "Check the domain against the Disconnect.me blocklist used by Firefox Enhanced Tracking Protection, Brave, and uBlock Origin.", - Default: true, - }, - }, - } + return SourceOptions{} } func (s *disconnectSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if !sdk.GetBoolOption(opts, "enable_disconnect", true) { - return disabledResult(s.ID(), s.Name()) - } if registered == "" { return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}} } diff --git a/checker/disconnect_test.go b/checker/disconnect_test.go index 7a5e325..fc17c55 100644 --- a/checker/disconnect_test.go +++ b/checker/disconnect_test.go @@ -6,8 +6,6 @@ import ( "net/http/httptest" "testing" "time" - - sdk "git.happydns.org/checker-sdk-go/checker" ) const disconnectFakeFeed = `{ @@ -42,7 +40,7 @@ func TestDisconnectSource_Listed(t *testing.T) { defer srv.Close() s := newDisconnectTestSource(srv) - r := s.Query(context.Background(), "tracker.com", "tracker.com", sdk.CheckerOptions{"enable_disconnect": true})[0] + r := s.Query(context.Background(), "tracker.com", "tracker.com", nil)[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled with no error, got %+v", r) @@ -75,7 +73,7 @@ func TestDisconnectSource_SubdomainInFeed(t *testing.T) { // Feed has "sub.example.org"; querying registered domain "example.org" should match. s := newDisconnectTestSource(srv) - r := s.Query(context.Background(), "example.org", "example.org", sdk.CheckerOptions{"enable_disconnect": true})[0] + r := s.Query(context.Background(), "example.org", "example.org", nil)[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled with no error, got %+v", r) @@ -92,7 +90,7 @@ func TestDisconnectSource_NotListed(t *testing.T) { defer srv.Close() s := newDisconnectTestSource(srv) - r := s.Query(context.Background(), "clean.example.com", "clean.example.com", sdk.CheckerOptions{"enable_disconnect": true})[0] + r := s.Query(context.Background(), "clean.example.com", "clean.example.com", nil)[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled with no error, got %+v", r) @@ -105,14 +103,6 @@ func TestDisconnectSource_NotListed(t *testing.T) { } } -func TestDisconnectSource_Disabled(t *testing.T) { - s := &disconnectSource{cache: newFeedCache(time.Hour, disconnectFetch("http://nope"))} - r := s.Query(context.Background(), "tracker.com", "tracker.com", sdk.CheckerOptions{"enable_disconnect": false})[0] - if r.Enabled { - t.Errorf("expected disabled result, got %+v", r) - } -} - func TestDisconnectSource_HTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -120,7 +110,7 @@ func TestDisconnectSource_HTTPError(t *testing.T) { defer srv.Close() s := newDisconnectTestSource(srv) - r := s.Query(context.Background(), "tracker.com", "tracker.com", sdk.CheckerOptions{"enable_disconnect": true})[0] + r := s.Query(context.Background(), "tracker.com", "tracker.com", nil)[0] if r.Error == "" { t.Errorf("expected error on HTTP 500, got empty error") } diff --git a/checker/malwarebazaar.go b/checker/malwarebazaar.go index 9e1af6b..6bd5b71 100644 --- a/checker/malwarebazaar.go +++ b/checker/malwarebazaar.go @@ -22,6 +22,13 @@ type malwareBazaarSource struct { func (*malwareBazaarSource) ID() string { return "malwarebazaar" } func (*malwareBazaarSource) Name() string { return "abuse.ch MalwareBazaar" } +func (s *malwareBazaarSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if stringOpt(opts, "malwarebazaar_auth_key") == "" { + return fmt.Errorf("MalwareBazaar Auth-Key is not configured") + } + return nil +} + func (*malwareBazaarSource) Options() SourceOptions { return SourceOptions{ Admin: []sdk.CheckerOptionField{ @@ -33,21 +40,12 @@ func (*malwareBazaarSource) Options() SourceOptions { Secret: true, }, }, - User: []sdk.CheckerOptionField{ - { - Id: "enable_malwarebazaar", - Type: "bool", - Label: "Use abuse.ch MalwareBazaar", - Description: "Search MalwareBazaar for malware samples tagged with the domain (typically C2 infrastructure or delivery hosts).", - Default: true, - }, - }, } } func (s *malwareBazaarSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { authKey := stringOpt(opts, "malwarebazaar_auth_key") - if !sdk.GetBoolOption(opts, "enable_malwarebazaar", true) || registered == "" || authKey == "" { + if registered == "" || authKey == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/malwarebazaar_test.go b/checker/malwarebazaar_test.go index 62bc24a..3988ed4 100644 --- a/checker/malwarebazaar_test.go +++ b/checker/malwarebazaar_test.go @@ -17,7 +17,7 @@ func TestMalwareBazaarSource_NoResults(t *testing.T) { defer srv.Close() s := &malwareBazaarSource{endpoint: srv.URL} - results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "malwarebazaar_auth_key": "k"}) + results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"malwarebazaar_auth_key": "k"}) if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } @@ -48,7 +48,7 @@ func TestMalwareBazaarSource_Listed(t *testing.T) { defer srv.Close() s := &malwareBazaarSource{endpoint: srv.URL} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "malwarebazaar_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"malwarebazaar_auth_key": "k"})[0] if len(r.Evidence) != 1 { t.Fatalf("expected 1 evidence item, got %+v", r) } @@ -71,23 +71,15 @@ func TestMalwareBazaarSource_HTTPError(t *testing.T) { defer srv.Close() s := &malwareBazaarSource{endpoint: srv.URL} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true, "malwarebazaar_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"malwarebazaar_auth_key": "k"})[0] if r.Error == "" { t.Errorf("expected error, got %+v", r) } } -func TestMalwareBazaarSource_Disabled(t *testing.T) { - s := &malwareBazaarSource{endpoint: "http://nope"} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": false})[0] - if r.Enabled { - t.Errorf("expected disabled, got %+v", r) - } -} - func TestMalwareBazaarSource_NoAuthKey(t *testing.T) { s := &malwareBazaarSource{endpoint: "http://nope"} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_malwarebazaar": true})[0] + r := s.Query(context.Background(), "example.com", "example.com", nil)[0] if r.Enabled { t.Errorf("expected disabled when no auth key, got %+v", r) } diff --git a/checker/oisd.go b/checker/oisd.go index 39773a1..4b3dc0d 100644 --- a/checker/oisd.go +++ b/checker/oisd.go @@ -37,15 +37,6 @@ func (*oisdSource) Name() string { return "OISD domain blocklist" } func (*oisdSource) Options() SourceOptions { return SourceOptions{ - User: []sdk.CheckerOptionField{ - { - Id: "enable_oisd", - Type: "bool", - Label: "Use the OISD domain blocklist", - Description: "Download the OISD domain blocklist (refreshed every 24h) and check the domain against it.", - Default: true, - }, - }, Admin: []sdk.CheckerOptionField{ { Id: "oisd_variant", @@ -59,7 +50,7 @@ func (*oisdSource) Options() SourceOptions { } func (s *oisdSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if !sdk.GetBoolOption(opts, "enable_oisd", true) || registered == "" { + if registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/oisd_test.go b/checker/oisd_test.go index 93655b2..12db986 100644 --- a/checker/oisd_test.go +++ b/checker/oisd_test.go @@ -5,8 +5,6 @@ import ( "net/http" "net/http/httptest" "testing" - - sdk "git.happydns.org/checker-sdk-go/checker" ) const oisdFakeFeed = `! OISD big domainswild @@ -23,7 +21,7 @@ func TestOisdSource_Listed_ExactMatch(t *testing.T) { defer srv.Close() s := &oisdSource{bigCache: newOisdCache(srv.URL)} - r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_oisd": true})[0] + r := s.Query(context.Background(), "evil.com", "evil.com", nil)[0] if !r.Enabled || r.Error != "" { t.Fatalf("expected enabled and no error, got %+v", r) @@ -45,7 +43,7 @@ func TestOisdSource_Listed_SubdomainInFeed(t *testing.T) { // Feed has "*.malware.example.org" → stored as "malware.example.org"; querying // registered "example.org" should match via suffix check. s := &oisdSource{bigCache: newOisdCache(srv.URL)} - r := s.Query(context.Background(), "sub.example.org", "example.org", sdk.CheckerOptions{"enable_oisd": true})[0] + r := s.Query(context.Background(), "sub.example.org", "example.org", nil)[0] if len(r.Evidence) != 1 || r.Evidence[0].Value != "malware.example.org" { t.Errorf("expected subdomain match, got %+v", r.Evidence) @@ -62,7 +60,7 @@ func TestOisdSource_NotListed(t *testing.T) { defer srv.Close() s := &oisdSource{bigCache: newOisdCache(srv.URL)} - r := s.Query(context.Background(), "safe.com", "safe.com", sdk.CheckerOptions{"enable_oisd": true})[0] + r := s.Query(context.Background(), "safe.com", "safe.com", nil)[0] if !r.Enabled || r.Error != "" || len(r.Evidence) != 0 { t.Fatalf("expected clean result, got %+v", r) @@ -72,14 +70,6 @@ func TestOisdSource_NotListed(t *testing.T) { } } -func TestOisdSource_Disabled(t *testing.T) { - s := &oisdSource{bigCache: newOisdCache("http://nope")} - r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_oisd": false})[0] - if r.Enabled { - t.Errorf("expected disabled, got %+v", r) - } -} - func TestOisdSource_HTTPError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) @@ -87,7 +77,7 @@ func TestOisdSource_HTTPError(t *testing.T) { defer srv.Close() s := &oisdSource{bigCache: newOisdCache(srv.URL)} - r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"enable_oisd": true})[0] + r := s.Query(context.Background(), "evil.com", "evil.com", nil)[0] if r.Error == "" || r.Error != "oisd HTTP 500" { t.Errorf("expected HTTP 500 error, got %q", r.Error) diff --git a/checker/openphish.go b/checker/openphish.go index e175f73..042f4f2 100644 --- a/checker/openphish.go +++ b/checker/openphish.go @@ -32,21 +32,11 @@ func (*openPhishSource) ID() string { return "openphish" } func (*openPhishSource) Name() string { return "OpenPhish feed" } func (*openPhishSource) Options() SourceOptions { - return SourceOptions{ - User: []sdk.CheckerOptionField{ - { - Id: "enable_openphish", - Type: "bool", - Label: "Use the OpenPhish public feed", - Description: "Download the OpenPhish public feed (refreshed every 12h) and check the domain against it.", - Default: true, - }, - }, - } + return SourceOptions{} } func (s *openPhishSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if !sdk.GetBoolOption(opts, "enable_openphish", true) || registered == "" { + if registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/otx.go b/checker/otx.go index d4e39cf..41300f5 100644 --- a/checker/otx.go +++ b/checker/otx.go @@ -21,6 +21,13 @@ type otxSource struct{ endpoint string } func (*otxSource) ID() string { return "otx" } func (*otxSource) Name() string { return "AlienVault OTX" } +func (s *otxSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if stringOpt(opts, "otx_api_key") == "" { + return fmt.Errorf("AlienVault OTX API key is not configured") + } + return nil +} + func (*otxSource) Options() SourceOptions { return SourceOptions{ Admin: []sdk.CheckerOptionField{ diff --git a/checker/phishtank.go b/checker/phishtank.go index d3505b4..12d808e 100644 --- a/checker/phishtank.go +++ b/checker/phishtank.go @@ -40,20 +40,11 @@ func (*phishTankSource) Options() SourceOptions { Default: "12", }, }, - User: []sdk.CheckerOptionField{ - { - Id: "enable_phishtank", - Type: "bool", - Label: "Use the PhishTank feed", - Description: "Download the PhishTank verified phishing list and check the domain against it.", - Default: true, - }, - }, } } func (s *phishTankSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if !sdk.GetBoolOption(opts, "enable_phishtank", true) || registered == "" { + if registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/precheck_test.go b/checker/precheck_test.go new file mode 100644 index 0000000..79d159e --- /dev/null +++ b/checker/precheck_test.go @@ -0,0 +1,71 @@ +package checker + +import ( + "context" + "strings" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// TestSourcePrechecks covers every Source that implements SourcePrecheck: +// without the required option Precheck must return a non-nil error, and +// with the option set it must return nil. +func TestSourcePrechecks(t *testing.T) { + cases := []struct { + name string + src SourcePrecheck + optKey string + hint string // substring expected in the error message + }{ + {"safebrowsing", &safeBrowsingSource{}, "google_safe_browsing_api_key", "Safe Browsing"}, + {"virustotal", &virusTotalSource{}, "virustotal_api_key", "VirusTotal"}, + {"otx", &otxSource{}, "otx_api_key", "OTX"}, + {"pulsedive", &pulsediveSource{}, "pulsedive_api_key", "Pulsedive"}, + {"criminalip", &criminalIPSource{}, "criminal_ip_api_key", "Criminal IP"}, + {"malwarebazaar", &malwareBazaarSource{endpoint: "http://nope"}, "malwarebazaar_auth_key", "MalwareBazaar"}, + {"threatfox", &threatFoxSource{endpoint: "http://nope"}, "threatfox_auth_key", "ThreatFox"}, + {"urlhaus", &urlhausSource{endpoint: "http://nope"}, "urlhaus_auth_key", "URLhaus"}, + } + + for _, c := range cases { + t.Run(c.name+"/missing", func(t *testing.T) { + err := c.src.Precheck(context.Background(), nil) + if err == nil { + t.Fatalf("expected error when %q is missing, got nil", c.optKey) + } + if !strings.Contains(err.Error(), c.hint) { + t.Errorf("error %q does not mention %q", err.Error(), c.hint) + } + }) + t.Run(c.name+"/set", func(t *testing.T) { + err := c.src.Precheck(context.Background(), sdk.CheckerOptions{c.optKey: "k"}) + if err != nil { + t.Errorf("expected nil when %q is set, got %v", c.optKey, err) + } + }) + } +} + +// TestSourceRule_PrecheckDelegation ensures sourceRule satisfies +// sdk.RulePrecheck and that the delegation through SourcePrecheck +// works end-to-end. Sources that do not implement SourcePrecheck must +// report "available" (nil error). +func TestSourceRule_PrecheckDelegation(t *testing.T) { + gated := &sourceRule{src: &urlhausSource{endpoint: "http://nope"}} + if err := gated.Precheck(context.Background(), nil); err == nil { + t.Errorf("urlhaus sourceRule.Precheck with empty opts: expected error, got nil") + } + if err := gated.Precheck(context.Background(), sdk.CheckerOptions{"urlhaus_auth_key": "k"}); err != nil { + t.Errorf("urlhaus sourceRule.Precheck with key set: expected nil, got %v", err) + } + + open := &sourceRule{src: &botvrijSource{cache: newBotvrijCache("http://nope")}} + if err := open.Precheck(context.Background(), nil); err != nil { + t.Errorf("botvrij sourceRule.Precheck: expected nil (no SourcePrecheck), got %v", err) + } + + // Confirm the type assertion the SDK server relies on succeeds. + var _ sdk.RulePrecheck = gated + var _ sdk.RulePrecheck = open +} diff --git a/checker/pulsedive.go b/checker/pulsedive.go index edfeb1d..987d7bd 100644 --- a/checker/pulsedive.go +++ b/checker/pulsedive.go @@ -20,6 +20,13 @@ type pulsediveSource struct{ endpoint string } func (*pulsediveSource) ID() string { return "pulsedive" } func (*pulsediveSource) Name() string { return "Pulsedive" } +func (s *pulsediveSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if stringOpt(opts, "pulsedive_api_key") == "" { + return fmt.Errorf("Pulsedive API key is not configured") + } + return nil +} + func (*pulsediveSource) Options() SourceOptions { return SourceOptions{ Admin: []sdk.CheckerOptionField{ diff --git a/checker/quad9.go b/checker/quad9.go index 7bf6094..6f92782 100644 --- a/checker/quad9.go +++ b/checker/quad9.go @@ -36,21 +36,11 @@ func (*quad9Source) ID() string { return "quad9" } func (*quad9Source) Name() string { return "Quad9 secure DNS" } func (*quad9Source) Options() SourceOptions { - return SourceOptions{ - User: []sdk.CheckerOptionField{ - { - Id: "enable_quad9", - Type: "bool", - Label: "Use Quad9 secure DNS check", - Description: "Compare Quad9's secure resolver (9.9.9.9) against its unsecured peer (9.9.9.10). A domain that resolves on the unsecured but returns NXDOMAIN on the secure resolver is blocked by Quad9's threat intelligence.", - Default: true, - }, - }, - } + return SourceOptions{} } func (s *quad9Source) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { - if !sdk.GetBoolOption(opts, "enable_quad9", true) || registered == "" { + if registered == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/rule.go b/checker/rule.go index d904836..ce0cc70 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -31,6 +31,16 @@ func (r *sourceRule) Description() string { return fmt.Sprintf("%s reputation lookup. Emits Critical/Warning when the source flags the domain, OK when clean, Info when the source is disabled or not configured, and Warning on transient query errors.", r.src.Name()) } +// Precheck satisfies sdk.RulePrecheck for every sourceRule. Sources +// that need credentials (or any other runtime prerequisite) opt in via +// SourcePrecheck; sources that always work return nil here. +func (r *sourceRule) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if p, ok := r.src.(SourcePrecheck); ok { + return p.Precheck(ctx, opts) + } + return nil +} + func (r *sourceRule) Options() sdk.CheckerOptionsDocumentation { o := r.src.Options() return sdk.CheckerOptionsDocumentation{ diff --git a/checker/safebrowsing.go b/checker/safebrowsing.go index f04ec70..efaa17f 100644 --- a/checker/safebrowsing.go +++ b/checker/safebrowsing.go @@ -25,6 +25,13 @@ type safeBrowsingSource struct { func (*safeBrowsingSource) ID() string { return "google_safe_browsing" } func (*safeBrowsingSource) Name() string { return "Google Safe Browsing" } +func (s *safeBrowsingSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if stringOpt(opts, "google_safe_browsing_api_key") == "" { + return fmt.Errorf("Google Safe Browsing API key is not configured") + } + return nil +} + func (*safeBrowsingSource) Options() SourceOptions { return SourceOptions{ Admin: []sdk.CheckerOptionField{ diff --git a/checker/source.go b/checker/source.go index 03f47fc..1320994 100644 --- a/checker/source.go +++ b/checker/source.go @@ -57,6 +57,17 @@ type Source interface { Evaluate(r SourceResult) (listed bool, severity string) } +// SourcePrecheck is an optional interface a Source can implement to +// declare whether the current options are sufficient for it to run. +// Used to surface "rule unavailable because the operator hasn't +// configured the credentials yet" in the host UI via the SDK's +// RulePrecheck contract. Returning nil means "ready to run"; any error +// is shown verbatim to the operator. +type SourcePrecheck interface { + Source + Precheck(ctx context.Context, opts sdk.CheckerOptions) error +} + // DetailRenderer is an optional interface a Source can implement when // the generic SourceResult shape (Reasons + Evidence + URLs) cannot // fully express its output. Examples: VirusTotal's per-vendor verdict diff --git a/checker/threatfox.go b/checker/threatfox.go index 4df96fa..f38bd8f 100644 --- a/checker/threatfox.go +++ b/checker/threatfox.go @@ -22,6 +22,13 @@ type threatFoxSource struct { func (*threatFoxSource) ID() string { return "threatfox" } func (*threatFoxSource) Name() string { return "abuse.ch ThreatFox" } +func (s *threatFoxSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if stringOpt(opts, "threatfox_auth_key") == "" { + return fmt.Errorf("ThreatFox Auth-Key is not configured") + } + return nil +} + func (*threatFoxSource) Options() SourceOptions { return SourceOptions{ Admin: []sdk.CheckerOptionField{ @@ -33,21 +40,12 @@ func (*threatFoxSource) Options() SourceOptions { Secret: true, }, }, - User: []sdk.CheckerOptionField{ - { - Id: "enable_threatfox", - Type: "bool", - Label: "Use abuse.ch ThreatFox", - Description: "Query ThreatFox for indicators of compromise (C2 servers, malware, phishing) associated with the domain.", - Default: true, - }, - }, } } func (s *threatFoxSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { authKey := stringOpt(opts, "threatfox_auth_key") - if !sdk.GetBoolOption(opts, "enable_threatfox", true) || registered == "" || authKey == "" { + if registered == "" || authKey == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/threatfox_test.go b/checker/threatfox_test.go index 5e5f12f..a1ff480 100644 --- a/checker/threatfox_test.go +++ b/checker/threatfox_test.go @@ -17,7 +17,7 @@ func TestThreatFoxSource_NoResult(t *testing.T) { defer srv.Close() s := &threatFoxSource{endpoint: srv.URL} - results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "threatfox_auth_key": "k"}) + results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"threatfox_auth_key": "k"}) if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } @@ -50,7 +50,7 @@ func TestThreatFoxSource_Listed(t *testing.T) { defer srv.Close() s := &threatFoxSource{endpoint: srv.URL} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "threatfox_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"threatfox_auth_key": "k"})[0] if len(r.Evidence) != 1 { t.Fatalf("expected 1 evidence item, got %+v", r) } @@ -73,23 +73,15 @@ func TestThreatFoxSource_HTTPError(t *testing.T) { defer srv.Close() s := &threatFoxSource{endpoint: srv.URL} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true, "threatfox_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"threatfox_auth_key": "k"})[0] if r.Error == "" { t.Errorf("expected error, got %+v", r) } } -func TestThreatFoxSource_Disabled(t *testing.T) { - s := &threatFoxSource{endpoint: "http://nope"} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": false})[0] - if r.Enabled { - t.Errorf("expected disabled, got %+v", r) - } -} - func TestThreatFoxSource_NoAuthKey(t *testing.T) { s := &threatFoxSource{endpoint: "http://nope"} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_threatfox": true})[0] + r := s.Query(context.Background(), "example.com", "example.com", nil)[0] if r.Enabled { t.Errorf("expected disabled when no auth key, got %+v", r) } diff --git a/checker/urlhaus.go b/checker/urlhaus.go index 1bac676..8f00305 100644 --- a/checker/urlhaus.go +++ b/checker/urlhaus.go @@ -25,17 +25,15 @@ type urlhausSource struct { func (*urlhausSource) ID() string { return "urlhaus" } func (*urlhausSource) Name() string { return "abuse.ch URLhaus" } +func (s *urlhausSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if stringOpt(opts, "urlhaus_auth_key") == "" { + return fmt.Errorf("URLhaus Auth-Key is not configured") + } + return nil +} + func (*urlhausSource) Options() SourceOptions { return SourceOptions{ - User: []sdk.CheckerOptionField{ - { - Id: "enable_urlhaus", - Type: "bool", - Label: "Use abuse.ch URLhaus", - Description: "Query the URLhaus host endpoint for active malware-distribution URLs hosted on the domain.", - Default: true, - }, - }, Admin: []sdk.CheckerOptionField{ { Id: "urlhaus_auth_key", @@ -66,7 +64,7 @@ type urlhausURL struct { func (s *urlhausSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { authKey := stringOpt(opts, "urlhaus_auth_key") - if !sdk.GetBoolOption(opts, "enable_urlhaus", true) || registered == "" || authKey == "" { + if registered == "" || authKey == "" { return disabledResult(s.ID(), s.Name()) } diff --git a/checker/urlhaus_test.go b/checker/urlhaus_test.go index 9128efd..045322f 100644 --- a/checker/urlhaus_test.go +++ b/checker/urlhaus_test.go @@ -19,7 +19,7 @@ func TestURLhausSource_NoResults(t *testing.T) { defer srv.Close() s := &urlhausSource{endpoint: srv.URL} - results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "urlhaus_auth_key": "k"}) + results := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"urlhaus_auth_key": "k"}) if len(results) != 1 { t.Fatalf("expected 1 result, got %d", len(results)) } @@ -48,7 +48,7 @@ func TestURLhausSource_Listed(t *testing.T) { defer srv.Close() s := &urlhausSource{endpoint: srv.URL} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "urlhaus_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"urlhaus_auth_key": "k"})[0] if len(r.Evidence) != 1 { t.Fatalf("expected 1 evidence item, got %+v", r) } @@ -80,16 +80,16 @@ func TestURLhausSource_HTTPError(t *testing.T) { defer srv.Close() s := &urlhausSource{endpoint: srv.URL} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": true, "urlhaus_auth_key": "k"})[0] + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"urlhaus_auth_key": "k"})[0] if r.Error == "" || !strings.Contains(r.Error, "401") { t.Errorf("expected 401 error, got %+v", r) } } -func TestURLhausSource_Disabled(t *testing.T) { +func TestURLhausSource_NoAuthKey(t *testing.T) { s := &urlhausSource{endpoint: "http://nope"} - r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"enable_urlhaus": false})[0] + r := s.Query(context.Background(), "example.com", "example.com", nil)[0] if r.Enabled { - t.Errorf("expected disabled, got %+v", r) + t.Errorf("expected disabled when no auth key, got %+v", r) } } diff --git a/checker/virustotal.go b/checker/virustotal.go index 14d1132..c245cd2 100644 --- a/checker/virustotal.go +++ b/checker/virustotal.go @@ -24,6 +24,13 @@ type virusTotalSource struct { func (*virusTotalSource) ID() string { return "virustotal" } func (*virusTotalSource) Name() string { return "VirusTotal" } +func (s *virusTotalSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error { + if stringOpt(opts, "virustotal_api_key") == "" { + return fmt.Errorf("VirusTotal API key is not configured") + } + return nil +} + func (*virusTotalSource) Options() SourceOptions { return SourceOptions{ Admin: []sdk.CheckerOptionField{ diff --git a/go.mod b/go.mod index cc71d5d..f161980 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,6 @@ module git.happydns.org/checker-blacklist go 1.25.0 require ( - git.happydns.org/checker-sdk-go v1.8.0 + git.happydns.org/checker-sdk-go v1.9.0 golang.org/x/net v0.34.0 ) diff --git a/go.sum b/go.sum index d14c68e..9c26370 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,4 @@ -git.happydns.org/checker-sdk-go v1.8.0 h1:2lhcSc16rnCaszdQi1nerszb2c3fVh5XNS11pLrXuK4= -git.happydns.org/checker-sdk-go v1.8.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-sdk-go v1.9.0 h1:orBRymir+p6PMHVa4focryPKhTVWT7JAv6u9Ido5KF0= +git.happydns.org/checker-sdk-go v1.9.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=