checker-blacklist/checker/otx.go

208 lines
5.7 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 (*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>`))