prometheus/service/virustotal.js

106 lines
3.7 KiB
JavaScript
Raw Normal View History

/**
* 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;
}