From c8bcac5a72909c035f7ec2fb5daeee4779946e8d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 15 May 2026 21:39:43 +0800 Subject: [PATCH] Add Pulsedive domain threat intelligence source --- README.md | 6 ++ checker/pulsedive.go | 163 ++++++++++++++++++++++++++++++++++++++ checker/pulsedive_test.go | 103 ++++++++++++++++++++++++ checker/source_test.go | 2 +- 4 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 checker/pulsedive.go create mode 100644 checker/pulsedive_test.go diff --git a/README.md b/README.md index 9cf3eb3..3c3b41f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ widely-used reputation systems. | OISD | downloaded list | no | user (default on) | | VirusTotal v3 | HTTPS lookup | yes (admin) | admin | | AlienVault OTX | HTTPS lookup | free (admin) | admin | +| Pulsedive | HTTPS lookup | free (admin) | admin | ### Obtaining API keys @@ -48,6 +49,11 @@ widely-used reputation systems. 2. Go to *Settings → API Integration* to find your personal OTX key. 3. Free, no documented rate limits for the indicator lookup API. +**Pulsedive** (option: `pulsedive_api_key`) +1. Register a free account at [pulsedive.com](https://pulsedive.com/). +2. Go to your profile and copy the API key shown under *API*. +3. Free tier available; higher quotas with a paid plan. + DNS-based blocklists are queried in parallel. The OpenPhish feed is downloaded once per hour by the provider and cached in memory. diff --git a/checker/pulsedive.go b/checker/pulsedive.go new file mode 100644 index 0000000..edfeb1d --- /dev/null +++ b/checker/pulsedive.go @@ -0,0 +1,163 @@ +package checker + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +const pulsediveEndpoint = "https://pulsedive.com/api/info.php" + +func init() { Register(&pulsediveSource{endpoint: pulsediveEndpoint}) } + +type pulsediveSource struct{ endpoint string } + +func (*pulsediveSource) ID() string { return "pulsedive" } +func (*pulsediveSource) Name() string { return "Pulsedive" } + +func (*pulsediveSource) Options() SourceOptions { + return SourceOptions{ + Admin: []sdk.CheckerOptionField{ + { + Id: "pulsedive_api_key", + Type: "string", + Label: "Pulsedive API key", + Description: "Pulsedive API key (free account at pulsedive.com). Leave empty to skip Pulsedive lookups.", + Secret: true, + }, + }, + } +} + +type pulsediveDetails struct { + Risk string `json:"risk"` + Threats []pulsediveThreat `json:"threats"` +} + +type pulsediveThreat struct { + Name string `json:"name"` + Category string `json:"category"` + Risk string `json:"risk,omitempty"` +} + +func (s *pulsediveSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { + apiKey := stringOpt(opts, "pulsedive_api_key") + if apiKey == "" || registered == "" { + return disabledResult(s.ID(), s.Name()) + } + + res := SourceResult{ + SourceID: s.ID(), + SourceName: s.Name(), + Enabled: true, + Reference: "https://pulsedive.com/indicator/" + registered, + } + + params := url.Values{ + "indicator": {registered}, + "key": {apiKey}, + } + reqURL := s.endpoint + "?" + params.Encode() + + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, reqURL, nil) + if err != nil { + res.Error = redactSecret(err.Error(), apiKey) + return []SourceResult{res} + } + req.Header.Set("User-Agent", "happydomain-checker-blacklist/1.0") + + body, status, err := httpDo(req, 1<<20) + if err != nil { + res.Error = redactSecret(err.Error(), apiKey) + return []SourceResult{res} + } + if status != http.StatusOK { + res.Error = fmt.Sprintf("HTTP %d: %s", status, truncate(string(body), 200)) + return []SourceResult{res} + } + + // Check for "not found" before full parse — Pulsedive returns 200 + // with {"error": "Indicator not found."} for unknown indicators. + var errEnvelope struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &errEnvelope) == nil && errEnvelope.Error != "" { + return []SourceResult{res} + } + + var parsed struct { + Risk string `json:"risk"` + Threats []struct { + Name string `json:"name"` + Category string `json:"category"` + Risk string `json:"risk"` + } `json:"threats"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + res.Error = "decode: " + err.Error() + return []SourceResult{res} + } + + d := pulsediveDetails{Risk: parsed.Risk} + seen := map[string]bool{} + for _, t := range parsed.Threats { + d.Threats = append(d.Threats, pulsediveThreat{ + Name: t.Name, + Category: t.Category, + Risk: t.Risk, + }) + if !seen[t.Name] { + seen[t.Name] = true + res.Reasons = append(res.Reasons, t.Name) + } + res.Evidence = append(res.Evidence, Evidence{ + Label: "Threat", + Value: t.Name, + Status: t.Category, + }) + } + res.Details = mustJSON(d) + return []SourceResult{res} +} + +func (*pulsediveSource) Evaluate(r SourceResult) (bool, string) { + if !r.Enabled || r.Error != "" || len(r.Evidence) == 0 { + return false, "" + } + var d pulsediveDetails + _ = json.Unmarshal(r.Details, &d) + switch d.Risk { + case "critical", "high": + return true, SeverityCrit + default: + return true, SeverityWarn + } +} + +func (*pulsediveSource) Diagnose(res SourceResult) Diagnosis { + var d pulsediveDetails + _ = json.Unmarshal(res.Details, &d) + previewN := min(len(d.Threats), 5) + names := make([]string, 0, previewN) + for _, t := range d.Threats[:previewN] { + names = append(names, t.Name) + } + return Diagnosis{ + Severity: SeverityCrit, + Title: fmt.Sprintf("Pulsedive risk: %s — %d threat(s) associated", d.Risk, len(d.Threats)), + Detail: fmt.Sprintf( + "Pulsedive assigned a risk of %q to this domain. Associated threat(s): %s. Review the indicator page for feed context, related IPs, and historical activity, then follow up with the relevant threat's removal or remediation procedure.", + d.Risk, joinNonEmpty(names, ", "), + ), + Fix: res.Reference, + FixIsURL: res.Reference != "", + } +} diff --git a/checker/pulsedive_test.go b/checker/pulsedive_test.go new file mode 100644 index 0000000..9018b49 --- /dev/null +++ b/checker/pulsedive_test.go @@ -0,0 +1,103 @@ +package checker + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func newPulsediveServer(t *testing.T, status int, body string) (string, func()) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("key") == "" { + t.Errorf("missing key query parameter") + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + return srv.URL + "/info.php", srv.Close +} + +func TestPulsediveSource_NoKey(t *testing.T) { + s := &pulsediveSource{endpoint: pulsediveEndpoint} + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{})[0] + if r.Enabled { + t.Errorf("expected disabled without API key, got %+v", r) + } +} + +func TestPulsediveSource_Listed_High(t *testing.T) { + body := `{"risk":"high","threats":[{"name":"Emotet","category":"malware","risk":"high"}]}` + endpoint, stop := newPulsediveServer(t, http.StatusOK, body) + defer stop() + + s := &pulsediveSource{endpoint: endpoint} + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"pulsedive_api_key": "k"})[0] + if r.Error != "" { + t.Fatalf("unexpected error: %s", r.Error) + } + if len(r.Evidence) != 1 { + t.Fatalf("expected 1 evidence, got %d", len(r.Evidence)) + } + if listed, severity := s.Evaluate(r); !listed || severity != SeverityCrit { + t.Errorf("expected (true, crit), got (%v, %q)", listed, severity) + } +} + +func TestPulsediveSource_Listed_Medium(t *testing.T) { + body := `{"risk":"medium","threats":[{"name":"SomeSpam","category":"spam","risk":"medium"}]}` + endpoint, stop := newPulsediveServer(t, http.StatusOK, body) + defer stop() + + s := &pulsediveSource{endpoint: endpoint} + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"pulsedive_api_key": "k"})[0] + if listed, severity := s.Evaluate(r); !listed || severity != SeverityWarn { + t.Errorf("expected (true, warn), got (%v, %q)", listed, severity) + } +} + +func TestPulsediveSource_NotFound(t *testing.T) { + endpoint, stop := newPulsediveServer(t, http.StatusOK, `{"error":"Indicator not found."}`) + defer stop() + + s := &pulsediveSource{endpoint: endpoint} + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"pulsedive_api_key": "k"})[0] + if r.Error != "" { + t.Errorf("not-found should be quiet, got error: %s", r.Error) + } + if len(r.Evidence) != 0 { + t.Errorf("expected no evidence, got %d", len(r.Evidence)) + } + if listed, _ := s.Evaluate(r); listed { + t.Errorf("expected not listed for not-found domain") + } +} + +func TestPulsediveSource_Clean(t *testing.T) { + body := `{"risk":"none","threats":[]}` + endpoint, stop := newPulsediveServer(t, http.StatusOK, body) + defer stop() + + s := &pulsediveSource{endpoint: endpoint} + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"pulsedive_api_key": "k"})[0] + if len(r.Evidence) != 0 { + t.Errorf("expected no evidence for clean domain, got %d", len(r.Evidence)) + } + if listed, _ := s.Evaluate(r); listed { + t.Errorf("expected not listed for clean domain") + } +} + +func TestPulsediveSource_HTTPError(t *testing.T) { + endpoint, stop := newPulsediveServer(t, http.StatusInternalServerError, `internal error`) + defer stop() + + s := &pulsediveSource{endpoint: endpoint} + r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"pulsedive_api_key": "k"})[0] + if r.Error == "" { + t.Errorf("expected error on HTTP 500, got clean result") + } +} diff --git a/checker/source_test.go b/checker/source_test.go index 2bd1cd6..8f432b8 100644 --- a/checker/source_test.go +++ b/checker/source_test.go @@ -24,7 +24,7 @@ func TestRegisteredSourcesAreSane(t *testing.T) { } } // At least the built-in sources are present. - for _, want := range []string{"dnsbl", "google_safe_browsing", "openphish", "urlhaus", "virustotal"} { + for _, want := range []string{"dnsbl", "google_safe_browsing", "openphish", "pulsedive", "urlhaus", "virustotal"} { if !seen[want] { t.Errorf("missing built-in source %q", want) }