/** * apps/worker — files pipeline: virus scan step (TASK-0102 §3). * * Scans the LOCAL TEMP COPY (the original object is never executed or parsed * by anything but the metadata parsers): * * - `CLAMD_SOCKET` set → clamd LOCAL protocol `zCONTSCAN` over the unix * socket (what `clamdscan` does — verified by strace). * - `CLAMAV_HOST` set → clamd REMOTE protocol `zINSTREAM` over TCP. * - neither set → dev mode: WARN + skip, * `scanReport { engine: "none", skipped: true, reason }`. * * INFECTED is reported via the returned ScanReport (the finalize step * transitions the row VIRUS_DETECTED, emits `file.virus_detected`, notifies * admins — NO thumbnails, NO search index, NO `file.uploaded`). * * Production fail-closed: `assertScannerConfigured` is called at worker boot * (index.ts) — `NODE_ENV=production` with no scanner refuses to start. */ import { readFile } from "node:fs/promises"; import { getClamdVersion, scanFileViaSocket, scanStreamViaTcp, type ScanReport } from "../../../lib/clamav.js"; import { RetryableError } from "../errors.js"; import type { FilePipelineContext } from "../types.js"; import type { WorkerEnv } from "../../../config.js"; import { getLogger } from "../../../logger.js"; const log = getLogger("files.scan"); /** Worker boot gate (TASK-0102 §3): production requires a scanner. */ export function assertScannerConfigured(env: WorkerEnv): void { if (env.NODE_ENV === "production" && !env.CLAMD_SOCKET && !env.CLAMAV_HOST) { throw new Error( "worker refusing to boot: NODE_ENV=production requires a ClamAV scanner " + "(set CLAMD_SOCKET or CLAMAV_HOST/CLAMAV_PORT) — fail closed (TASK-0102 §3)" ); } } export interface ScanOutcome { report: ScanReport; infected: boolean; } /** Run the scan step. Throws RetryableError on clamd transport failure. */ export async function scanStep(ctx: FilePipelineContext): Promise { const { env } = ctx; if (!env.CLAMD_SOCKET && !env.CLAMAV_HOST) { log.warn("file scan skipped (CLAMD_SOCKET not configured)"); return { report: { engine: "none", scanned: false, infected: false, skipped: true, reason: "CLAMD_SOCKET not configured (dev mode)" }, infected: false }; } let report: ScanReport; if (env.CLAMD_SOCKET) { report = await scanFileViaSocket(env.CLAMD_SOCKET, ctx.temp.path, env.CLAMAV_TIMEOUT_MS); } else { const buffer = await readFile(ctx.temp.path); report = await scanStreamViaTcp(env.CLAMAV_HOST, env.CLAMAV_PORT, buffer, env.CLAMAV_TIMEOUT_MS); } if (report.error) { // clamd transport failure / timeout → RETRYABLE (backoff, 3 attempts). throw new RetryableError(`clamav scan failed: ${report.error}`); } // Best effort: attach the engine version to the report (spec: scanReport // carries { engine, version, infected, signature }). if (report.scanned) { report.version = await getClamdVersion({ socket: env.CLAMD_SOCKET, host: env.CLAMAV_HOST, port: env.CLAMAV_PORT }); } if (report.scanned && report.infected) { log.error( { fileId: ctx.job.fileId, signature: report.signature }, "VIRUS DETECTED — quarantining file (no thumbs/index/event)" ); } return { report, infected: report.scanned && report.infected }; }