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.
189 lines
5.2 KiB
Go
189 lines
5.2 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 (*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 != "",
|
|
}
|
|
}
|