106 lines
3.7 KiB
JavaScript
106 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;
|
||
|
|
}
|