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}}.
+| Pulse | Malware families | Adversary | Tags | Created |
|---|---|---|---|---|
| {{.Name}} | +{{range .MalwareFamilies}}{{.}} {{end}} | +{{.Adversary}} | +{{range .Tags}}{{.}} {{end}} | +{{.Created}} | +