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 |
| 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.

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