checker-blacklist/checker/otx.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

215 lines
5.9 KiB
Go

package checker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const otxEndpoint = "https://otx.alienvault.com/api/v1/indicators/domain/"
func init() { Register(&otxSource{endpoint: otxEndpoint}) }
type otxSource struct{ endpoint string }
func (*otxSource) ID() string { return "otx" }
func (*otxSource) Name() string { return "AlienVault OTX" }
func (s *otxSource) Precheck(ctx context.Context, opts sdk.CheckerOptions) error {
if stringOpt(opts, "otx_api_key") == "" {
return fmt.Errorf("AlienVault OTX API key is not configured")
}
return nil
}
func (*otxSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{
{
Id: "otx_api_key",
Type: "string",
Label: "AlienVault OTX API key",
Description: "Free OTX API key from otx.alienvault.com. Leave empty to skip OTX lookups.",
Secret: true,
},
},
}
}
type otxDetails struct {
PulseCount int `json:"pulse_count"`
Reputation int `json:"reputation"`
Pulses []otxPulse `json:"pulses"`
}
type otxPulse struct {
Name string `json:"name"`
Tags []string `json:"tags,omitempty"`
MalwareFamilies []string `json:"malware_families,omitempty"`
Adversary string `json:"adversary,omitempty"`
Created string `json:"created,omitempty"`
}
func (s *otxSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
apiKey := stringOpt(opts, "otx_api_key")
if apiKey == "" {
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: false}}
}
if registered == "" {
return []SourceResult{{SourceID: s.ID(), SourceName: s.Name(), Enabled: true}}
}
res := SourceResult{
SourceID: s.ID(),
SourceName: s.Name(),
Enabled: true,
Reference: "https://otx.alienvault.com/indicator/domain/" + registered,
}
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, s.endpoint+registered+"/general", nil)
if err != nil {
res.Error = err.Error()
return []SourceResult{res}
}
req.Header.Set("X-OTX-API-KEY", apiKey)
req.Header.Set("Accept", "application/json")
body, status, err := httpDo(req, 4<<20)
if err != nil {
res.Error = err.Error()
return []SourceResult{res}
}
if status == http.StatusNotFound {
return []SourceResult{res}
}
if status != http.StatusOK {
res.Error = fmt.Sprintf("HTTP %d: %s", status, truncate(string(body), 200))
return []SourceResult{res}
}
var parsed struct {
Reputation int `json:"reputation"`
PulseInfo struct {
Count int `json:"count"`
Pulses []struct {
Name string `json:"name"`
Tags []string `json:"tags"`
MalwareFamilies []struct {
DisplayName string `json:"display_name"`
} `json:"malware_families"`
Adversary string `json:"adversary"`
Created string `json:"created"`
} `json:"pulses"`
} `json:"pulse_info"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
res.Error = "decode: " + err.Error()
return []SourceResult{res}
}
d := otxDetails{
PulseCount: parsed.PulseInfo.Count,
Reputation: parsed.Reputation,
}
seenReason := map[string]bool{}
for _, p := range parsed.PulseInfo.Pulses {
pulse := otxPulse{
Name: p.Name,
Tags: p.Tags,
Adversary: p.Adversary,
Created: p.Created,
}
for _, mf := range p.MalwareFamilies {
pulse.MalwareFamilies = append(pulse.MalwareFamilies, mf.DisplayName)
if !seenReason[mf.DisplayName] {
seenReason[mf.DisplayName] = true
res.Reasons = append(res.Reasons, mf.DisplayName)
}
}
if p.Adversary != "" && !seenReason[p.Adversary] {
seenReason[p.Adversary] = true
res.Reasons = append(res.Reasons, p.Adversary)
}
d.Pulses = append(d.Pulses, pulse)
res.Evidence = append(res.Evidence, Evidence{
Label: "Pulse",
Value: p.Name,
Status: "threat",
})
}
res.Details = mustJSON(d)
return []SourceResult{res}
}
func (*otxSource) Evaluate(r SourceResult) (bool, string) {
if !r.Enabled || r.Error != "" || len(r.Evidence) == 0 {
return false, ""
}
var d otxDetails
_ = json.Unmarshal(r.Details, &d)
if d.Reputation < -1 {
return true, SeverityCrit
}
return true, SeverityWarn
}
func (*otxSource) Diagnose(res SourceResult) Diagnosis {
var d otxDetails
_ = json.Unmarshal(res.Details, &d)
detail := fmt.Sprintf(
"%d threat pulse(s) reference this domain (OTX reputation score: %d). Indicators: %s. "+
"Review the pulse details on AlienVault OTX to understand the threat context and take corrective action.",
d.PulseCount, d.Reputation, joinNonEmpty(res.Reasons, ", "),
)
sev := SeverityWarn
if d.Reputation < -1 {
sev = SeverityCrit
}
return Diagnosis{
Severity: sev,
Title: "Listed in AlienVault OTX threat pulses",
Detail: detail,
Fix: res.Reference,
FixIsURL: res.Reference != "",
}
}
func (*otxSource) RenderDetail(res SourceResult) (template.HTML, error) {
var d otxDetails
if len(res.Details) > 0 {
if err := json.Unmarshal(res.Details, &d); err != nil {
return "", fmt.Errorf("otx: decode details: %w", err)
}
}
if len(d.Pulses) == 0 {
return "", nil
}
var b bytes.Buffer
if err := otxDetailTpl.Execute(&b, d); err != nil {
return "", err
}
return template.HTML(b.String()), nil
}
var otxDetailTpl = template.Must(template.New("otx_detail").Parse(`
<p>OTX reputation score: <strong>{{.Reputation}}</strong>. Pulse count: <strong>{{.PulseCount}}</strong>.</p>
<table>
<thead><tr><th>Pulse</th><th>Malware families</th><th>Adversary</th><th>Tags</th><th>Created</th></tr></thead>
<tbody>{{range .Pulses}}<tr class="row-crit">
<td>{{.Name}}</td>
<td>{{range .MalwareFamilies}}<span>{{.}} </span>{{end}}</td>
<td>{{.Adversary}}</td>
<td>{{range .Tags}}<span>{{.}} </span>{{end}}</td>
<td><small>{{.Created}}</small></td>
</tr>{{end}}</tbody>
</table>`))