Add AlienVault OTX domain threat intelligence source

This commit is contained in:
nemunaire 2026-05-15 21:32:00 +08:00
commit faae2f80c5
3 changed files with 358 additions and 0 deletions

View file

@ -21,6 +21,7 @@ widely-used reputation systems.
| Disconnect.me | downloaded list | no | user (default on) | | Disconnect.me | downloaded list | no | user (default on) |
| OISD | downloaded list | no | user (default on) | | OISD | downloaded list | no | user (default on) |
| VirusTotal v3 | HTTPS lookup | yes (admin) | admin | | VirusTotal v3 | HTTPS lookup | yes (admin) | admin |
| AlienVault OTX | HTTPS lookup | free (admin) | admin |
### Obtaining API keys ### Obtaining API keys
@ -42,6 +43,11 @@ widely-used reputation systems.
3. Free tier: 4 requests/minute, 500 requests/day. No billing required. 3. Free tier: 4 requests/minute, 500 requests/day. No billing required.
4. The public API key is sufficient; premium keys unlock higher quotas. 4. The public API key is sufficient; premium keys unlock higher quotas.
**AlienVault OTX** (option: `otx_api_key`)
1. Register a free account at [otx.alienvault.com](https://otx.alienvault.com/).
2. Go to *Settings → API Integration* to find your personal OTX key.
3. Free, no documented rate limits for the indicator lookup API.
DNS-based blocklists are queried in parallel. The OpenPhish feed is DNS-based blocklists are queried in parallel. The OpenPhish feed is
downloaded once per hour by the provider and cached in memory. downloaded once per hour by the provider and cached in memory.

208
checker/otx.go Normal file
View 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
View 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)
}
}