/** * apps/worker — ClamAV scanner client (TASK-0102 §3). * * Two transports, chosen by config (see `@factory/worker` config.ts): * * 1. UNIX socket (`CLAMD_SOCKET` set) — the spec's PRIMARY path. We speak * clamd's LOCAL protocol: * * client → "zCONTSCAN \0" * server → ": OK\0" (clean) * server → ": FOUND\0" (infected) * server → "ERROR: \0" (scan error) * * This is byte-for-byte what the C `clamdscan` binary sends over the * unix socket (verified by strace 2026-09-29): a single `zCONTSCAN` * request and one null-terminated verdict line — NO INIC/INOK handshake * (that handshake belongs to the *remote/TCP* protocol below). * * 2. TCP (`CLAMAV_HOST`/`CLAMAV_PORT` set, no socket) — the clamd REMOTE * protocol (streaming, no on-disk file needed): * * client → "zINSTREAM\n" server → "INIC" * client → "INOK\n" (ready to receive) * client → … (one continuous encoding, chunked writes) * client → "INOC\n" server → "OK" | ": FOUND" * * NOTE (environment, recorded 2026-09-29): in the local * `clamav/clamav` Docker image the TCP INIC-handshake commands * (zINSTREAM / zINFILE / zVERSION) hang after connect while PING and * the unix-socket zCONTSCAN work. The unix socket path is therefore the * reliable local/CI transport and the default the runbook configures. * * The worker downloads the object to a temp file FIRST (TASK-0102 §3: the * object "never stays on disk > scan duration + 5 min cleanup cron") and * scans the LOCAL COPY — the original bytes are never executed or parsed by * any non-metadata tool. */ import { connect, type Socket } from "node:net"; import { getLogger } from "../logger.js"; const log = getLogger("clamav"); /** * The structured scan report persisted to `File.scanReport` (TASK-0102 §3). * * - infected file → `{ engine: "clamav", scanned: true, infected: true, signature, version }` * - clean file → `{ engine: "clamav", scanned: true, infected: false, version }` * - dev skip → `{ engine: "none", scanned: false, skipped: true, reason }` * - scan error → `{ engine: "clamav", scanned: false, error }` (retriable) */ export interface ScanReport { engine: "clamav" | "none"; /** True only when a real clamd scan ran to completion. */ scanned: boolean; infected: boolean; /** e.g. "Eicar-Test-Signature FOUND". Present only when infected. */ signature?: string; /** clamd version (best effort — "unknown" when not retrievable). */ version?: string; /** Dev mode: clamav not configured, file passed through unscanned. */ skipped?: boolean; /** Why skipped / errored. */ reason?: string; error?: string; } export interface ClamavConfig { /** unix socket path (e.g. /var/run/clamav/clamd.sock). Primary transport. */ socket?: string; /** TCP host (fallback transport when no socket). */ host?: string; /** TCP port (default 3310). */ port?: number; /** Scan timeout (ms). Large files can take a while; default 120 s. */ timeoutMs?: number; } const DEFAULT_TIMEOUT_MS = 120_000; const VERSION_TIMEOUT_MS = 3_000; /** True when either a socket or a TCP endpoint is configured. */ export function isClamavConfigured(cfg: ClamavConfig): boolean { return Boolean(cfg.socket || cfg.host); } // ── UNIX socket: zCONTSCAN (local protocol) ──────────────────────────────── /** * Scan a LOCAL FILE over the clamd unix socket via `zCONTSCAN`. * Never throws — every failure degrades to a structured ScanReport. */ export function scanFileViaSocket( socketPath: string, filePath: string, timeoutMs: number = DEFAULT_TIMEOUT_MS ): Promise { return new Promise((resolve) => { const socket: Socket = connect({ path: socketPath }); let buf = Buffer.alloc(0); let settled = false; const finish = (report: ScanReport): void => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); resolve(report); }; const timer = setTimeout(() => { finish({ engine: "clamav", scanned: false, infected: false, error: "clamav scan timeout" }); }, timeoutMs); socket.on("error", (err) => { log.warn({ err: err.message, socketPath }, "clamav unix socket error"); finish({ engine: "clamav", scanned: false, infected: false, error: `clamav unreachable: ${err.message}` }); }); // The reply is a single null-terminated line: ": ". socket.on("data", (chunk) => { buf = Buffer.concat([buf, chunk]); const nul = buf.indexOf(0x00); const nl = buf.indexOf(0x0a); const end = nul >= 0 && (nl < 0 || nul < nl) ? nul : nl; if (end < 0) return; const line = buf.subarray(0, end).toString("utf8").trim(); classify(line, finish); }); socket.on("close", () => { if (!settled) { const line = buf.toString("utf8").trim(); if (line) classify(line, finish); else finish({ engine: "clamav", scanned: false, infected: false, error: "clamav closed before verdict" }); } }); socket.on("connect", () => socket.write(`zCONTSCAN ${filePath}\0`)); }); } // ── TCP: zINSTREAM (remote protocol) ─────────────────────────────────────── /** * Scan an in-memory BUFFER over the clamd TCP socket via `zINSTREAM`. * Never throws — degrades to a structured ScanReport. */ export function scanStreamViaTcp( host: string, port: number, buffer: Buffer, timeoutMs: number = DEFAULT_TIMEOUT_MS ): Promise { return new Promise((resolve) => { const socket: Socket = connect({ host, port }); let state: "init" | "data" | "done" = "init"; let sent = 0; let buf = Buffer.alloc(0); let settled = false; const b64 = buffer.toString("base64"); const TEXT_CHUNK = 64 * 1024; const finish = (report: ScanReport): void => { if (settled) return; settled = true; clearTimeout(timer); socket.destroy(); resolve(report); }; const timer = setTimeout(() => { finish({ engine: "clamav", scanned: false, infected: false, error: "clamav scan timeout" }); }, timeoutMs); const sendMore = (): void => { if (state !== "data" || settled) return; if (sent >= b64.length) { socket.write("INOC\n"); state = "done"; return; } const end = Math.min(sent + TEXT_CHUNK, b64.length); const piece = b64.slice(sent, end); sent = end; socket.write(piece, () => sendMore()); }; socket.on("error", (err) => { log.warn({ err: err.message, host, port }, "clamav tcp socket error"); finish({ engine: "clamav", scanned: false, infected: false, error: `clamav unreachable: ${err.message}` }); }); socket.on("close", () => { if (!settled) { finish({ engine: "clamav", scanned: false, infected: false, error: "clamav closed before verdict" }); } }); socket.on("data", (chunk) => { buf = Buffer.concat([buf, chunk]); let idx: number; while ((idx = buf.indexOf(0x0a)) >= 0) { const line = buf.subarray(0, idx).toString("utf8").trim(); buf = buf.subarray(idx + 1); if (state === "init" && line.startsWith("INIC")) { socket.write("INOK\n"); state = "data"; sendMore(); } else if (state === "data" && line.startsWith("INIC")) { // Some builds re-announce readiness mid-stream: re-ack, keep flowing. socket.write("INOK\n"); sendMore(); } else if (state === "done") { classify(line, finish); } } }); socket.on("connect", () => socket.write("zINSTREAM\n")); }); } // ── version (best effort) ────────────────────────────────────────────────── /** * Best-effort clamd version via `zVERSION`. In some images this command * (like other INIC-handshake commands over TCP) does not respond; we keep a * short timeout and fall back to "unknown" rather than blocking the pipeline. */ export function getClamdVersion(cfg: ClamavConfig): Promise { return new Promise((resolve) => { const connectOpts = cfg.socket ? { path: cfg.socket } : { host: cfg.host ?? "", port: cfg.port ?? 3310 }; if (!cfg.socket && !cfg.host) { resolve("unknown"); return; } const socket: Socket = connect(connectOpts); let state = "z"; let settled = false; const finish = (v: string): void => { if (settled) return; settled = true; socket.destroy(); resolve(v); }; socket.setTimeout(VERSION_TIMEOUT_MS, () => finish("unknown")); socket.on("error", () => finish("unknown")); socket.on("close", () => finish("unknown")); socket.on("data", (chunk) => { const buf = chunk.toString("utf8"); // Version lines look like "ClamAV 1.5.4/28138/…"; stop after the first // non-INFO line (the server may append an OK terminator). for (const raw of buf.split("\n")) { const line = raw.trim(); if (state === "z" && line.startsWith("INIC")) { state = "inok"; socket.write("INOK\n"); continue; } if (/^ClamAV\s+[\d.]/.test(line)) { finish(line.replace(/^ClamAV\s+/, "").split("/")[0] ?? "unknown"); return; } if (state === "inok" && /^OK/.test(line)) { finish("unknown"); return; } } }); socket.on("connect", () => socket.write("zVERSION\n")); }); } // ── verdict classification ───────────────────────────────────────────────── /** Parse one clamd verdict line into a ScanReport via `finish`. */ function classify(line: string, finish: (r: ScanReport) => void): void { if (!line) return; if (line.includes("FOUND") || line.includes("INFECTED")) { const signature = line.split(":").pop()?.trim() || line; finish({ engine: "clamav", scanned: true, infected: true, signature }); } else if (/:\s*OK$/.test(line) || /^OK$/.test(line)) { finish({ engine: "clamav", scanned: true, infected: false }); } else if (line.startsWith("ERROR")) { finish({ engine: "clamav", scanned: false, infected: false, error: line }); } // Any other line (INFO chatter, mid-stream ACKs): keep waiting for verdict. }