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:
parent
c8bcac5a72
commit
a5559ad98f
2 changed files with 195 additions and 0 deletions
189
checker/criminalip.go
Normal file
189
checker/criminalip.go
Normal 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 != "",
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue