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