checker-blacklist/checker/criminalip.go
Pierre-Olivier Mercier c3cda1f104
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Replace per-source enable booleans with SourcePrecheck and bump SDK to v1.9.0
Sources that always work (botvrij, disconnect, oisd, openphish, phishtank, quad9) drop their user-facing enable_* option; the rule's enabled/disabled state is now solely controlled by the SDK rule toggle. Sources that require credentials (criminalip, malwarebazaar, otx, pulsedive, safebrowsing, threatfox, urlhaus, virustotal) instead implement the new SourcePrecheck interface so the host UI can surface "not configured" before attempting a query.
2026-05-20 14:26:42 +08:00

196 lines
5.4 KiB
Go

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 (s *criminalIPSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "criminal_ip_api_key") == "" {
return fmt.Errorf("Criminal IP API key is not configured")
}
return nil
}
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 != "",
}
}