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