From a5559ad98ffdfbebd2cfacff91702d51a7a6ad39 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 15 May 2026 21:56:25 +0800 Subject: [PATCH] Add Criminal IP domain reputation source Implements the Criminal IP API (api.criminalip.io/v1/domain/report) as a new blacklist source. Returns crit for High/Critical inbound or outbound risk scores, warn for Moderate; Safe and Low scores are not flagged. --- README.md | 6 ++ checker/criminalip.go | 189 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 checker/criminalip.go diff --git a/README.md b/README.md index 3c3b41f..fd6c7dc 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ widely-used reputation systems. | VirusTotal v3 | HTTPS lookup | yes (admin) | admin | | AlienVault OTX | HTTPS lookup | free (admin) | admin | | Pulsedive | HTTPS lookup | free (admin) | admin | +| Criminal IP | HTTPS lookup | yes (admin) | admin | ### Obtaining API keys @@ -54,6 +55,11 @@ widely-used reputation systems. 2. Go to your profile and copy the API key shown under *API*. 3. Free tier available; higher quotas with a paid plan. +**Criminal IP** (option: `criminal_ip_api_key`) +1. Register a free account at [criminalip.io](https://www.criminalip.io/). +2. Go to *My Information → API Key* to find your key. +3. Free tier: 100 requests/day. Paid plans unlock higher quotas. + DNS-based blocklists are queried in parallel. The OpenPhish feed is downloaded once per hour by the provider and cached in memory. diff --git a/checker/criminalip.go b/checker/criminalip.go new file mode 100644 index 0000000..d98f2b5 --- /dev/null +++ b/checker/criminalip.go @@ -0,0 +1,189 @@ +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 != "", + } +}