package checker import ( "context" "encoding/json" "fmt" "net/http" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) const criminalIPEndpoint = "https://api.criminalip.io/v1/domain/report?query=%s" func init() { Register(&criminalIPSource{endpoint: criminalIPEndpoint}) } type criminalIPSource struct{ endpoint string } func (*criminalIPSource) ID() string { return "criminal_ip" } func (*criminalIPSource) Name() string { return "Criminal IP" } func (*criminalIPSource) Options() SourceOptions { return SourceOptions{ Admin: []sdk.CheckerOptionField{ { Id: "criminal_ip_api_key", Type: "string", Label: "Criminal IP API key", Description: "API key for api.criminalip.io. Free tier: 100 req/day. Leave empty to skip Criminal IP lookups.", Secret: true, }, }, } } func (s *criminalIPSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult { apiKey := stringOpt(opts, "criminal_ip_api_key") if apiKey == "" { return disabledResult(s.ID(), s.Name()) } if registered == "" { return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}} } res := SourceResult{ SourceID: s.ID(), SourceName: s.Name(), Enabled: true, Reference: "https://www.criminalip.io/domain/report/" + registered, } reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() url := fmt.Sprintf(s.endpoint, registered) req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) if err != nil { res.Error = err.Error() return []SourceResult{res} } req.Header.Set("x-api-key", apiKey) 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, redactSecret(truncate(string(body), 200), apiKey)) return []SourceResult{res} } var parsed struct { Status int `json:"status"` Data struct { Score struct { Inbound string `json:"inbound"` Outbound string `json:"outbound"` } `json:"score"` SummaryInfo struct { MaliciousInfo []string `json:"malicious_info"` } `json:"summary_info"` IsMalicious bool `json:"is_malicious"` IsPhishing bool `json:"is_phishing"` IsSpam bool `json:"is_spam"` } `json:"data"` } if err := json.Unmarshal(body, &parsed); err != nil { res.Error = "decode: " + err.Error() return []SourceResult{res} } inbound := parsed.Data.Score.Inbound outbound := parsed.Data.Score.Outbound // Only populate evidence when there is an elevated score. if criminalIPScoreLevel(inbound) > 0 || criminalIPScoreLevel(outbound) > 0 { res.Evidence = append(res.Evidence, Evidence{Label: "Inbound score", Value: inbound}, Evidence{Label: "Outbound score", Value: outbound}, ) seen := map[string]bool{} for _, info := range parsed.Data.SummaryInfo.MaliciousInfo { if info != "" && !seen[info] { seen[info] = true res.Reasons = append(res.Reasons, info) } } if parsed.Data.IsMalicious && !seen["Malicious"] { res.Reasons = append(res.Reasons, "Malicious") } if parsed.Data.IsPhishing && !seen["Phishing"] { res.Reasons = append(res.Reasons, "Phishing") } if parsed.Data.IsSpam && !seen["Spam"] { res.Reasons = append(res.Reasons, "Spam") } } res.Details = mustJSON(map[string]any{ "inbound": inbound, "outbound": outbound, }) return []SourceResult{res} } // criminalIPScoreLevel maps a Criminal IP score string to a numeric level // for comparison: 0=Safe, 1=Low, 2=Moderate, 3=High, 4=Critical. func criminalIPScoreLevel(score string) int { switch score { case "Low": return 1 case "Moderate": return 2 case "High": return 3 case "Critical": return 4 } return 0 // "Safe" or unknown } func (*criminalIPSource) Evaluate(r SourceResult) (bool, string) { if !r.Enabled || r.Error != "" || len(r.Evidence) == 0 { return false, "" } var d struct { Inbound string `json:"inbound"` Outbound string `json:"outbound"` } if len(r.Details) > 0 { _ = json.Unmarshal(r.Details, &d) } maxLevel := criminalIPScoreLevel(d.Inbound) if l := criminalIPScoreLevel(d.Outbound); l > maxLevel { maxLevel = l } switch { case maxLevel >= 3: return true, SeverityCrit case maxLevel == 2: return true, SeverityWarn } return false, "" } func (*criminalIPSource) Diagnose(res SourceResult) Diagnosis { var d struct { Inbound string `json:"inbound"` Outbound string `json:"outbound"` } if len(res.Details) > 0 { _ = json.Unmarshal(res.Details, &d) } sev := SeverityWarn if criminalIPScoreLevel(d.Inbound) >= 3 || criminalIPScoreLevel(d.Outbound) >= 3 { sev = SeverityCrit } return Diagnosis{ Severity: sev, Title: fmt.Sprintf("Criminal IP: inbound %s / outbound %s risk", d.Inbound, d.Outbound), Detail: fmt.Sprintf( "Criminal IP rated this domain with an inbound score of %q and an outbound score of %q. Threat category/reason(s): %s. Review the full report for connected IPs and associated URLs, then investigate any compromised infrastructure.", d.Inbound, d.Outbound, joinNonEmpty(res.Reasons, ", "), ), Fix: res.Reference, FixIsURL: res.Reference != "", } }