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.
This commit is contained in:
nemunaire 2026-05-15 21:56:25 +08:00
commit a5559ad98f
2 changed files with 195 additions and 0 deletions

View file

@ -23,6 +23,7 @@ widely-used reputation systems.
| VirusTotal v3 | HTTPS lookup | yes (admin) | admin | | VirusTotal v3 | HTTPS lookup | yes (admin) | admin |
| AlienVault OTX | HTTPS lookup | free (admin) | admin | | AlienVault OTX | HTTPS lookup | free (admin) | admin |
| Pulsedive | HTTPS lookup | free (admin) | admin | | Pulsedive | HTTPS lookup | free (admin) | admin |
| Criminal IP | HTTPS lookup | yes (admin) | admin |
### Obtaining API keys ### Obtaining API keys
@ -54,6 +55,11 @@ widely-used reputation systems.
2. Go to your profile and copy the API key shown under *API*. 2. Go to your profile and copy the API key shown under *API*.
3. Free tier available; higher quotas with a paid plan. 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 DNS-based blocklists are queried in parallel. The OpenPhish feed is
downloaded once per hour by the provider and cached in memory. downloaded once per hour by the provider and cached in memory.

189
checker/criminalip.go Normal file
View file

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