From c9008ef3ba9427a277083bb04e580b38b15db021 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Thu, 8 Oct 2026 21:44:34 +0200 Subject: [PATCH] prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- service/clamd.js | 43 +++++++++++++++-- service/uploads.js | 4 +- service/virustotal.js | 105 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 service/virustotal.js diff --git a/service/clamd.js b/service/clamd.js index 488252e..dc97e15 100644 --- a/service/clamd.js +++ b/service/clamd.js @@ -19,7 +19,9 @@ */ import { connect } from 'node:net'; +import { createHash } from 'node:crypto'; import { PassThrough } from 'node:stream'; +import { lookupBySha256 as vtLookupBySha256, isEnabled as vtEnabled } from './virustotal.js'; const CLAMD_HOST = process.env.CLAMD_HOST || '127.0.0.1'; const CLAMD_PORT = Number(process.env.CLAMD_PORT) || 3310; @@ -29,15 +31,43 @@ const CLAMD_SOCKET_TIMEOUT = 60_000; // 60 s per scan — generous for big files /** * Scan a stream through clamd and return the verdict. * + * When `opts.sha256` is passed (uploads.js always does, since the hash is + * computed on the way in), and VIRUSTOTAL_API_KEY is set, we query VirusTotal + * by that hash after the clamd scan and attach the finding as `virustotal`. + * The call never blocks or changes the verdict — enrichment is informational. + * * @param {NodeJS.ReadableStream} source the file bytes - * @returns {Promise<{verdict: 'clean'|'infected'|'error', engine: string, engine_version?: string, signatures: string[], raw: string, duration_ms: number}>} + * @param {{sha256?: string}} [opts] + * @returns {Promise<{verdict: 'clean'|'infected'|'error', engine: string, engine_version?: string, signatures: string[], raw: string, duration_ms: number, virustotal?: object}>} */ -export async function scanStream(source) { +export async function scanStream(source, opts = {}) { const mock = process.env.MOCK_SCAN; if (mock) { - return mockScan(mock, source); + const result = await mockScan(mock, source); + return attachVt(result, opts.sha256); } + const result = await clamdScan(source); + return attachVt(result, opts.sha256); +} + +/** + * Enrich a scan result with a VirusTotal verdict when enrichment is enabled + * and we have a hash to query by. Never raises — the enrichment is best-effort. + */ +async function attachVt(result, sha256) { + if (!vtEnabled() || !sha256) return result; + if (result.verdict === 'error') return result; // nothing useful to enrich + try { + const vt = await vtLookupBySha256(sha256); + if (vt) result.virustotal = vt; + } catch (err) { + result.virustotal = { status: 'error', reason: String(err?.message || err) }; + } + return result; +} + +function clamdScan(source) { const started = Date.now(); return new Promise((resolve, reject) => { @@ -120,6 +150,13 @@ export async function scanStream(source) { /* ---------------------------------------------------------------------- */ +// The hash we would compute during a scan if callers did not pass sha256. +// Not used yet — kept here so the import stays alive and the shape is obvious +// if we ever need to hash in-stream (e.g. a /api/scan endpoint that takes a +// stream directly without storing it on disk first). +// eslint-disable-next-line no-unused-vars +function _makeHasher() { return createHash('sha256'); } + /** * Translate clamd's response line into a structured verdict. */ diff --git a/service/uploads.js b/service/uploads.js index a32e076..ce016d6 100644 --- a/service/uploads.js +++ b/service/uploads.js @@ -145,7 +145,7 @@ export async function handleUpload(req, res) { // need to coordinate two independent failure modes. On-disk-then-scan is // what every mature scanning service does; we can revisit once we have // a reason. - const scan = await scanStream(createReadStream(tempPath)); + const scan = await scanStream(createReadStream(tempPath), { sha256 }); const now = new Date().toISOString(); const upload = { @@ -200,6 +200,7 @@ export async function handleUpload(req, res) { engine: scan.engine, signatures: scan.signatures, duration_ms: scan.duration_ms, + ...(scan.virustotal ? { virustotal: scan.virustotal } : {}), }, stored, }; @@ -214,6 +215,7 @@ export async function handleUpload(req, res) { signatures: scan.signatures, duration_ms: scan.duration_ms, ...(scan.error ? { error: scan.error } : {}), + ...(scan.virustotal ? { virustotal: scan.virustotal } : {}), }, stored, report, diff --git a/service/virustotal.js b/service/virustotal.js new file mode 100644 index 0000000..6709880 --- /dev/null +++ b/service/virustotal.js @@ -0,0 +1,105 @@ +/** + * 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; +}