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.
196 lines
5.4 KiB
Go
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 != "",
|
|
}
|
|
}
|