checker-blacklist/checker/pulsedive.go

163 lines
4.3 KiB
Go

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