/** * VirusTotal lookup by SHA-256 — the "did anyone ELSE say this is bad?" layer * that complements the local ClamAV scan. * * Gated on VIRUSTOTAL_API_KEY. Without a key, every call returns `null` and * nothing in the request path blocks. This is a feature, not a bug — a service * running without the enrichment should look identical in behaviour to one * running with it, just missing the extra data in the scan report. * * We query by hash only, never upload the file. VirusTotal's public API returns * a 404 for a hash it has not seen; we surface that as `status: 'unknown'` * so the UI can tell "no data" apart from "clean according to 70 engines." * * Rate limit: a tiny sliding window (default 4 requests per minute, which is * what the free tier allows). When exceeded we skip the call and return * `status: 'rate_limited'` — again, no blocking. * * In-process cache: once we have a verdict for a hash this run, we don't call * again — a quiet publisher re-uploading the same bytes should not spend our * rate budget. */ const API_KEY = process.env.VIRUSTOTAL_API_KEY || ''; const RATE_PER_MIN = Number(process.env.VIRUSTOTAL_RATE_PER_MIN) || 4; const TIMEOUT_MS = 10_000; const cache = new Map(); // sha256 -> result const callLog = []; // epoch ms of recent calls /** * Returns one of: * null — enrichment disabled (no API key) * {status: 'known', positives, total, scan_date, signatures: string[]} * {status: 'unknown'} — VirusTotal has not seen this hash * {status: 'rate_limited'} * {status: 'error', reason} */ export async function lookupBySha256(sha256) { if (!API_KEY) return null; const hex = String(sha256 || '').toLowerCase(); if (!/^[0-9a-f]{64}$/.test(hex)) return { status: 'error', reason: 'bad sha256' }; const cached = cache.get(hex); if (cached) return cached; if (!claimRateSlot()) { return { status: 'rate_limited' }; } let resp; try { resp = await fetch(`https://www.virustotal.com/api/v3/files/${hex}`, { headers: { 'x-apikey': API_KEY, accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS), }); } catch (err) { return { status: 'error', reason: String(err?.message || err) }; } if (resp.status === 404) { const result = { status: 'unknown' }; cache.set(hex, result); return result; } if (!resp.ok) { return { status: 'error', reason: `HTTP ${resp.status}` }; } let body; try { body = await resp.json(); } catch { return { status: 'error', reason: 'non-JSON response' }; } const attrs = body?.data?.attributes || {}; const stats = attrs.last_analysis_stats || {}; const positives = Number(stats.malicious || 0) + Number(stats.suspicious || 0); const total = Object.values(stats).reduce((n, v) => n + Number(v || 0), 0); const scan_date = attrs.last_analysis_date ? new Date(Number(attrs.last_analysis_date) * 1000).toISOString() : null; const signatures = []; const results = attrs.last_analysis_results || {}; for (const [engine, r] of Object.entries(results)) { if (r?.result && (r.category === 'malicious' || r.category === 'suspicious')) { signatures.push(`${engine}: ${r.result}`); } } const result = { status: 'known', positives, total, scan_date, signatures }; cache.set(hex, result); return result; } function claimRateSlot() { const now = Date.now(); const cutoff = now - 60_000; while (callLog.length && callLog[0] < cutoff) callLog.shift(); if (callLog.length >= RATE_PER_MIN) return false; callLog.push(now); return true; } /** True iff VirusTotal is configured (for the admin UI / healthcheck). */ export function isEnabled() { return API_KEY.length > 0; }