After clamd returns a verdict, scanStream queries VirusTotal by the file's
SHA-256 and attaches the finding under scan.virustotal. The call never
blocks, never changes the overall verdict, and never ships without a key
— enrichment is informational and strictly additive.
Shape in the signed scan report:
scan.virustotal: {status: 'known', positives, total, scan_date, signatures}
| {status: 'unknown'}
| {status: 'rate_limited'}
| {status: 'error', reason}
| (absent) — enrichment disabled
Rate-limited to VIRUSTOTAL_RATE_PER_MIN (default 4, matching the free
tier) via a sliding window; the service never fails the request when the
quota is spent, it just skips the enrichment that time. Hashes are
cached in-process so a resubmitted file does not re-spend quota.
The upload POST response passes the virustotal field through on both the
success and error branches so the submit UI can display it alongside the
clamd verdict.
uploads.js passes the SHA-256 it already computed into scanStream rather
than making scanStream hash a second time — the current call site is the
only one that needs the hash downstream.
105 lines
3.7 KiB
JavaScript
105 lines
3.7 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|