From faae2f80c52f7cf5330e70b223aa9bbe7c33454b Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Fri, 15 May 2026 21:32:00 +0800 Subject: [PATCH] Add AlienVault OTX domain threat intelligence source --- README.md | 6 ++ checker/otx.go | 208 ++++++++++++++++++++++++++++++++++++++++++++ checker/otx_test.go | 144 ++++++++++++++++++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 checker/otx.go create mode 100644 checker/otx_test.go diff --git a/README.md b/README.md index e630ad1..9cf3eb3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ widely-used reputation systems. | Disconnect.me | downloaded list | no | user (default on) | | OISD | downloaded list | no | user (default on) | | VirusTotal v3 | HTTPS lookup | yes (admin) | admin | +| AlienVault OTX | HTTPS lookup | free (admin) | admin | ### Obtaining API keys @@ -42,6 +43,11 @@ widely-used reputation systems. 3. Free tier: 4 requests/minute, 500 requests/day. No billing required. 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 downloaded once per hour by the provider and cached in memory. diff --git a/checker/otx.go b/checker/otx.go new file mode 100644 index 0000000..d4e39cf --- /dev/null +++ b/checker/otx.go @@ -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(` +

OTX reputation score: {{.Reputation}}. Pulse count: {{.PulseCount}}.

+ + +{{range .Pulses}} + + + + + +{{end}} +
PulseMalware familiesAdversaryTagsCreated
{{.Name}}{{range .MalwareFamilies}}{{.}} {{end}}{{.Adversary}}{{range .Tags}}{{.}} {{end}}{{.Created}}
`)) diff --git a/checker/otx_test.go b/checker/otx_test.go new file mode 100644 index 0000000..2da5846 --- /dev/null +++ b/checker/otx_test.go @@ -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) + } +}