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.
170 lines
4.5 KiB
Go
170 lines
4.5 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 (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{
|
|
{
|
|
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 != "",
|
|
}
|
|
}
|