checker-blacklist/checker/virustotal.go

220 lines
6.8 KiB
Go

package checker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"sort"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
const virusTotalEndpoint = "https://www.virustotal.com/api/v3/domains/"
func init() { Register(&virusTotalSource{endpoint: virusTotalEndpoint}) }
type virusTotalSource struct {
endpoint string
}
func (*virusTotalSource) ID() string { return "virustotal" }
func (*virusTotalSource) Name() string { return "VirusTotal" }
func (*virusTotalSource) Options() SourceOptions {
return SourceOptions{
Admin: []sdk.CheckerOptionField{
{
Id: "virustotal_api_key",
Type: "string",
Label: "VirusTotal API key",
Description: "VirusTotal v3 API key. Free tier is limited to 4 req/min and 500 req/day.",
Secret: true,
},
},
}
}
// vtDetails persists the structured VT response so the rich detail
// renderer can show the per-vendor verdict table and the
// {malicious,suspicious,harmless,undetected} counts.
type vtDetails struct {
Malicious int `json:"malicious"`
Suspicious int `json:"suspicious"`
Harmless int `json:"harmless"`
Undetected int `json:"undetected"`
Total int `json:"total"`
Reputation int `json:"reputation"`
Vendors []vtVendorVerdict `json:"vendors"`
}
type vtVendorVerdict struct {
Engine string `json:"engine"`
Category string `json:"category"`
Result string `json:"result"`
}
func (s *virusTotalSource) Query(ctx context.Context, domain, registered string, opts sdk.CheckerOptions) []SourceResult {
apiKey := stringOpt(opts, "virustotal_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://www.virustotal.com/gui/domain/" + registered,
}
reqCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, s.endpoint+registered, nil)
if err != nil {
res.Error = err.Error()
return []SourceResult{res}
}
req.Header.Set("x-apikey", 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 {
// VT has never seen this domain → quiet "not listed".
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 {
Data struct {
Attributes struct {
LastAnalysisStats struct {
Harmless int `json:"harmless"`
Malicious int `json:"malicious"`
Suspicious int `json:"suspicious"`
Undetected int `json:"undetected"`
Timeout int `json:"timeout"`
} `json:"last_analysis_stats"`
Reputation int `json:"reputation"`
LastAnalysisRes map[string]struct {
Category string `json:"category"`
Result string `json:"result"`
EngineName string `json:"engine_name"`
} `json:"last_analysis_results"`
} `json:"attributes"`
} `json:"data"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
res.Error = "decode: " + err.Error()
return []SourceResult{res}
}
stats := parsed.Data.Attributes.LastAnalysisStats
d := vtDetails{
Malicious: stats.Malicious,
Suspicious: stats.Suspicious,
Harmless: stats.Harmless,
Undetected: stats.Undetected,
Total: stats.Harmless + stats.Malicious + stats.Suspicious + stats.Undetected + stats.Timeout,
Reputation: parsed.Data.Attributes.Reputation,
}
for engine, v := range parsed.Data.Attributes.LastAnalysisRes {
if v.Category != "malicious" && v.Category != "suspicious" {
continue
}
name := v.EngineName
if name == "" {
name = engine
}
d.Vendors = append(d.Vendors, vtVendorVerdict{Engine: name, Category: v.Category, Result: v.Result})
}
sort.Slice(d.Vendors, func(i, j int) bool {
if d.Vendors[i].Category != d.Vendors[j].Category {
return d.Vendors[i].Category == "malicious"
}
return d.Vendors[i].Engine < d.Vendors[j].Engine
})
res.Details = mustJSON(d)
if d.Malicious == 0 && d.Suspicious == 0 {
// Clean.
return []SourceResult{res}
}
res.Listed = true
if d.Malicious > 0 {
res.Severity = SeverityCrit
} else {
res.Severity = SeverityWarn
}
for _, v := range d.Vendors {
res.Reasons = append(res.Reasons, v.Engine)
res.Evidence = append(res.Evidence, Evidence{
Label: "Engine", Value: v.Engine, Status: v.Category,
Extra: map[string]string{"result": v.Result},
})
}
return []SourceResult{res}
}
func (*virusTotalSource) Diagnose(res SourceResult) Diagnosis {
var d vtDetails
_ = json.Unmarshal(res.Details, &d)
previewN := min(len(d.Vendors), 5)
preview := make([]string, 0, previewN)
for _, v := range d.Vendors[:previewN] {
preview = append(preview, v.Engine)
}
gravity := "Suspicious"
sev := SeverityWarn
if d.Malicious > 0 {
gravity = "Malicious"
sev = SeverityCrit
}
return Diagnosis{
Severity: sev,
Title: fmt.Sprintf("VirusTotal: %d/%d engine(s) flagged the domain (%s)", d.Malicious+d.Suspicious, d.Total, gravity),
Detail: fmt.Sprintf(
"Reputation %d. Vendors flagging this domain include: %s. Open the VirusTotal page to see the per-engine verdicts and the related URLs/downloads. If you believe the verdicts are stale, request a re-scan from the VirusTotal page; for false positives, contact each engine vendor directly (VT does not arbitrate).",
d.Reputation, joinNonEmpty(preview, ", "),
),
Fix: res.Reference,
FixIsURL: res.Reference != "",
}
}
func (*virusTotalSource) RenderDetail(res SourceResult) (template.HTML, error) {
var d vtDetails
if len(res.Details) > 0 {
if err := json.Unmarshal(res.Details, &d); err != nil {
return "", fmt.Errorf("virustotal: decode details: %w", err)
}
}
if d.Total == 0 && len(d.Vendors) == 0 {
return "", nil
}
var b bytes.Buffer
if err := vtDetailTpl.Execute(&b, d); err != nil {
return "", err
}
return template.HTML(b.String()), nil
}
var vtDetailTpl = template.Must(template.New("vt_detail").Parse(`
<p>Engines: <strong{{if gt .Malicious 0}} class="warn"{{end}}>{{.Malicious}} malicious</strong>, <strong>{{.Suspicious}} suspicious</strong>, {{.Harmless}} harmless, {{.Undetected}} undetected (total {{.Total}}). Reputation score: <strong>{{.Reputation}}</strong>.</p>
{{if .Vendors}}<table>
<thead><tr><th>Engine</th><th>Verdict</th><th>Result</th></tr></thead>
<tbody>{{range .Vendors}}<tr class="row-{{if eq .Category "malicious"}}crit{{else}}warn{{end}}">
<td>{{.Engine}}</td><td>{{.Category}}</td><td>{{.Result}}</td>
</tr>{{end}}</tbody>
</table>{{end}}`))