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.
215 lines
5.9 KiB
Go
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>`))
|