Add AlienVault OTX domain threat intelligence source
This commit is contained in:
parent
1242a381ab
commit
faae2f80c5
3 changed files with 358 additions and 0 deletions
208
checker/otx.go
Normal file
208
checker/otx.go
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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>`))
|
||||
144
checker/otx_test.go
Normal file
144
checker/otx_test.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func newOTXServer(t *testing.T, status int, body string) (string, func()) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-OTX-API-KEY") == "" {
|
||||
t.Errorf("missing X-OTX-API-KEY header")
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
return srv.URL + "/", srv.Close
|
||||
}
|
||||
|
||||
func TestOTXSource_NoKey(t *testing.T) {
|
||||
s := &otxSource{endpoint: otxEndpoint}
|
||||
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{})[0]
|
||||
if r.Enabled {
|
||||
t.Errorf("expected disabled without API key, got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTXSource_Listed(t *testing.T) {
|
||||
body := `{
|
||||
"reputation": 0,
|
||||
"pulse_info": {
|
||||
"count": 1,
|
||||
"pulses": [{
|
||||
"name": "Test Pulse",
|
||||
"tags": ["phishing"],
|
||||
"malware_families": [{"display_name": "Emotet"}],
|
||||
"adversary": "",
|
||||
"created": "2024-01-01T00:00:00.000Z"
|
||||
}]
|
||||
}
|
||||
}`
|
||||
endpoint, stop := newOTXServer(t, http.StatusOK, body)
|
||||
defer stop()
|
||||
|
||||
s := &otxSource{endpoint: endpoint}
|
||||
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"otx_api_key": "k"})[0]
|
||||
|
||||
if len(r.Evidence) != 1 {
|
||||
t.Fatalf("expected 1 evidence entry, got %d", len(r.Evidence))
|
||||
}
|
||||
if listed, severity := s.Evaluate(r); !listed || severity != SeverityWarn {
|
||||
t.Errorf("expected Evaluate()=(true, warn), got (%v, %q)", listed, severity)
|
||||
}
|
||||
|
||||
var d otxDetails
|
||||
if err := json.Unmarshal(r.Details, &d); err != nil {
|
||||
t.Fatalf("details decode: %v", err)
|
||||
}
|
||||
if d.PulseCount != 1 || d.Reputation != 0 {
|
||||
t.Errorf("details wrong: %+v", d)
|
||||
}
|
||||
if len(d.Pulses) != 1 || len(d.Pulses[0].MalwareFamilies) != 1 || d.Pulses[0].MalwareFamilies[0] != "Emotet" {
|
||||
t.Errorf("pulse details wrong: %+v", d.Pulses)
|
||||
}
|
||||
|
||||
html, err := s.RenderDetail(r)
|
||||
if err != nil || !strings.Contains(string(html), "Emotet") {
|
||||
t.Errorf("RenderDetail html=%q err=%v", html, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTXSource_ListedCrit(t *testing.T) {
|
||||
body := `{
|
||||
"reputation": -2,
|
||||
"pulse_info": {
|
||||
"count": 5,
|
||||
"pulses": [{"name": "APT Pulse", "tags": [], "malware_families": [], "adversary": "APT28", "created": ""}]
|
||||
}
|
||||
}`
|
||||
endpoint, stop := newOTXServer(t, http.StatusOK, body)
|
||||
defer stop()
|
||||
|
||||
s := &otxSource{endpoint: endpoint}
|
||||
r := s.Query(context.Background(), "evil.com", "evil.com", sdk.CheckerOptions{"otx_api_key": "k"})[0]
|
||||
|
||||
if listed, severity := s.Evaluate(r); !listed || severity != SeverityCrit {
|
||||
t.Errorf("expected Evaluate()=(true, crit) for reputation -2, got (%v, %q)", listed, severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTXSource_NotFound(t *testing.T) {
|
||||
endpoint, stop := newOTXServer(t, http.StatusNotFound, `{"detail":"Not found"}`)
|
||||
defer stop()
|
||||
|
||||
s := &otxSource{endpoint: endpoint}
|
||||
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"otx_api_key": "k"})[0]
|
||||
|
||||
if r.Error != "" {
|
||||
t.Errorf("404 should be quiet not-listed, got Error=%q", r.Error)
|
||||
}
|
||||
if len(r.Evidence) != 0 {
|
||||
t.Errorf("expected no evidence for 404, got %+v", r.Evidence)
|
||||
}
|
||||
if listed, _ := s.Evaluate(r); listed {
|
||||
t.Errorf("Evaluate() on clean result should return false")
|
||||
}
|
||||
if !strings.Contains(r.Reference, "example.com") {
|
||||
t.Errorf("reference URL missing domain: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTXSource_HTTPError(t *testing.T) {
|
||||
endpoint, stop := newOTXServer(t, http.StatusInternalServerError, `{"error":"internal"}`)
|
||||
defer stop()
|
||||
|
||||
s := &otxSource{endpoint: endpoint}
|
||||
r := s.Query(context.Background(), "example.com", "example.com", sdk.CheckerOptions{"otx_api_key": "k"})[0]
|
||||
|
||||
if r.Error == "" {
|
||||
t.Errorf("expected non-empty Error for HTTP 500, got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOTXSource_NoResults(t *testing.T) {
|
||||
body := `{"reputation": 0, "pulse_info": {"count": 0, "pulses": []}}`
|
||||
endpoint, stop := newOTXServer(t, http.StatusOK, body)
|
||||
defer stop()
|
||||
|
||||
s := &otxSource{endpoint: endpoint}
|
||||
r := s.Query(context.Background(), "clean.com", "clean.com", sdk.CheckerOptions{"otx_api_key": "k"})[0]
|
||||
|
||||
if len(r.Evidence) != 0 {
|
||||
t.Errorf("expected no evidence for clean domain, got %+v", r.Evidence)
|
||||
}
|
||||
if listed, severity := s.Evaluate(r); listed || severity != "" {
|
||||
t.Errorf("Evaluate() on clean domain = (%v, %q), want (false, \"\")", listed, severity)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue