Add Pulsedive domain threat intelligence source

This commit is contained in:
nemunaire 2026-05-15 21:39:43 +08:00
commit c8bcac5a72
4 changed files with 273 additions and 1 deletions

View file

@ -22,6 +22,7 @@ widely-used reputation systems.
| OISD | downloaded list | no | user (default on) | | OISD | downloaded list | no | user (default on) |
| VirusTotal v3 | HTTPS lookup | yes (admin) | admin | | VirusTotal v3 | HTTPS lookup | yes (admin) | admin |
| AlienVault OTX | HTTPS lookup | free (admin) | admin | | AlienVault OTX | HTTPS lookup | free (admin) | admin |
| Pulsedive | HTTPS lookup | free (admin) | admin |
### Obtaining API keys ### Obtaining API keys
@ -48,6 +49,11 @@ widely-used reputation systems.
2. Go to *Settings → API Integration* to find your personal OTX key. 2. Go to *Settings → API Integration* to find your personal OTX key.
3. Free, no documented rate limits for the indicator lookup API. 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 DNS-based blocklists are queried in parallel. The OpenPhish feed is
downloaded once per hour by the provider and cached in memory. downloaded once per hour by the provider and cached in memory.

163
checker/pulsedive.go Normal file
View file

@ -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 != "",
}
}

103
checker/pulsedive_test.go Normal file
View file

@ -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")
}
}

View file

@ -24,7 +24,7 @@ func TestRegisteredSourcesAreSane(t *testing.T) {
} }
} }
// At least the built-in sources are present. // 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] { if !seen[want] {
t.Errorf("missing built-in source %q", want) t.Errorf("missing built-in source %q", want)
} }