Add Pulsedive domain threat intelligence source
This commit is contained in:
parent
faae2f80c5
commit
c8bcac5a72
4 changed files with 273 additions and 1 deletions
|
|
@ -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
163
checker/pulsedive.go
Normal 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
103
checker/pulsedive_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue