prometheus: VirusTotal enrichment, gated on VIRUSTOTAL_API_KEY

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.
This commit is contained in:
Local Dev 2026-10-08 21:44:34 +02:00
parent 9f16ce3128
commit c9008ef3ba
3 changed files with 148 additions and 4 deletions

View file

@ -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.
*/

View file

@ -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,

105
service/virustotal.js Normal file
View file

@ -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;
}