1
38import { connect, type Socket } from "node:net";
39import { getLogger } from "../logger.js";
40
41const log = getLogger("clamav");
42
43
51export interface ScanReport {
52 engine: "clamav" | "none";
53
54 scanned: boolean;
55 infected: boolean;
56
57 signature?: string;
58
59 version?: string;
60
61 skipped?: boolean;
62
63 reason?: string;
64 error?: string;
65}
66
67export interface ClamavConfig {
68
69 socket?: string;
70
71 host?: string;
72
73 port?: number;
74
75 timeoutMs?: number;
76}
77
78const DEFAULT_TIMEOUT_MS = 120_000;
79const VERSION_TIMEOUT_MS = 3_000;
80
81
82export function isClamavConfigured(cfg: ClamavConfig): boolean {
83 return Boolean(cfg.socket || cfg.host);
84}
85
86
87
88
92export function scanFileViaSocket(
93 socketPath: string,
94 filePath: string,
95 timeoutMs: number = DEFAULT_TIMEOUT_MS
96): Promise<ScanReport> {
97 return new Promise((resolve) => {
98 const socket: Socket = connect({ path: socketPath });
99 let buf = Buffer.alloc(0);
100 let settled = false;
101
102 const finish = (report: ScanReport): void => {
103 if (settled) return;
104 settled = true;
105 clearTimeout(timer);
106 socket.destroy();
107 resolve(report);
108 };
109
110 const timer = setTimeout(() => {
111 finish({
112 engine: "clamav",
113 scanned: false,
114 infected: false,
115 error: "clamav scan timeout"
116 });
117 }, timeoutMs);
118
119 socket.on("error", (err) => {
120 log.warn({ err: err.message, socketPath }, "clamav unix socket error");
121 finish({
122 engine: "clamav",
123 scanned: false,
124 infected: false,
125 error: `clamav unreachable: ${err.message}`
126 });
127 });
128
129
130 socket.on("data", (chunk) => {
131 buf = Buffer.concat([buf, chunk]);
132 const nul = buf.indexOf(0x00);
133 const nl = buf.indexOf(0x0a);
134 const end = nul >= 0 && (nl < 0 || nul < nl) ? nul : nl;
135 if (end < 0) return;
136 const line = buf.subarray(0, end).toString("utf8").trim();
137 classify(line, finish);
138 });
139
140 socket.on("close", () => {
141 if (!settled) {
142 const line = buf.toString("utf8").trim();
143 if (line) classify(line, finish);
144 else
145 finish({
146 engine: "clamav",
147 scanned: false,
148 infected: false,
149 error: "clamav closed before verdict"
150 });
151 }
152 });
153
154 socket.on("connect", () => socket.write(`zCONTSCAN ${filePath}\0`));
155 });
156}
157
158
159
160
164export function scanStreamViaTcp(
165 host: string,
166 port: number,
167 buffer: Buffer,
168 timeoutMs: number = DEFAULT_TIMEOUT_MS
169): Promise<ScanReport> {
170 return new Promise((resolve) => {
171 const socket: Socket = connect({ host, port });
172 let state: "init" | "data" | "done" = "init";
173 let sent = 0;
174 let buf = Buffer.alloc(0);
175 let settled = false;
176
177 const b64 = buffer.toString("base64");
178 const TEXT_CHUNK = 64 * 1024;
179
180 const finish = (report: ScanReport): void => {
181 if (settled) return;
182 settled = true;
183 clearTimeout(timer);
184 socket.destroy();
185 resolve(report);
186 };
187
188 const timer = setTimeout(() => {
189 finish({
190 engine: "clamav",
191 scanned: false,
192 infected: false,
193 error: "clamav scan timeout"
194 });
195 }, timeoutMs);
196
197 const sendMore = (): void => {
198 if (state !== "data" || settled) return;
199 if (sent >= b64.length) {
200 socket.write("INOC\n");
201 state = "done";
202 return;
203 }
204 const end = Math.min(sent + TEXT_CHUNK, b64.length);
205 const piece = b64.slice(sent, end);
206 sent = end;
207 socket.write(piece, () => sendMore());
208 };
209
210 socket.on("error", (err) => {
211 log.warn({ err: err.message, host, port }, "clamav tcp socket error");
212 finish({
213 engine: "clamav",
214 scanned: false,
215 infected: false,
216 error: `clamav unreachable: ${err.message}`
217 });
218 });
219
220 socket.on("close", () => {
221 if (!settled) {
222 finish({
223 engine: "clamav",
224 scanned: false,
225 infected: false,
226 error: "clamav closed before verdict"
227 });
228 }
229 });
230
231 socket.on("data", (chunk) => {
232 buf = Buffer.concat([buf, chunk]);
233 let idx: number;
234 while ((idx = buf.indexOf(0x0a)) >= 0) {
235 const line = buf.subarray(0, idx).toString("utf8").trim();
236 buf = buf.subarray(idx + 1);
237 if (state === "init" && line.startsWith("INIC")) {
238 socket.write("INOK\n");
239 state = "data";
240 sendMore();
241 } else if (state === "data" && line.startsWith("INIC")) {
242
243 socket.write("INOK\n");
244 sendMore();
245 } else if (state === "done") {
246 classify(line, finish);
247 }
248 }
249 });
250
251 socket.on("connect", () => socket.write("zINSTREAM\n"));
252 });
253}
254
255
256
257
262export function getClamdVersion(cfg: ClamavConfig): Promise<string> {
263 return new Promise((resolve) => {
264 const connectOpts = cfg.socket
265 ? { path: cfg.socket }
266 : { host: cfg.host ?? "", port: cfg.port ?? 3310 };
267 if (!cfg.socket && !cfg.host) {
268 resolve("unknown");
269 return;
270 }
271 const socket: Socket = connect(connectOpts);
272 let state = "z";
273 let settled = false;
274
275 const finish = (v: string): void => {
276 if (settled) return;
277 settled = true;
278 socket.destroy();
279 resolve(v);
280 };
281
282 socket.setTimeout(VERSION_TIMEOUT_MS, () => finish("unknown"));
283 socket.on("error", () => finish("unknown"));
284 socket.on("close", () => finish("unknown"));
285 socket.on("data", (chunk) => {
286 const buf = chunk.toString("utf8");
287
288
289 for (const raw of buf.split("\n")) {
290 const line = raw.trim();
291 if (state === "z" && line.startsWith("INIC")) {
292 state = "inok";
293 socket.write("INOK\n");
294 continue;
295 }
296 if (/^ClamAV\s+[\d.]/.test(line)) {
297 finish(line.replace(/^ClamAV\s+/, "").split("/")[0] ?? "unknown");
298 return;
299 }
300 if (state === "inok" && /^OK/.test(line)) {
301 finish("unknown");
302 return;
303 }
304 }
305 });
306 socket.on("connect", () => socket.write("zVERSION\n"));
307 });
308}
309
310
311
312
313function classify(line: string, finish: (r: ScanReport) => void): void {
314 if (!line) return;
315 if (line.includes("FOUND") || line.includes("INFECTED")) {
316 const signature = line.split(":").pop()?.trim() || line;
317 finish({
318 engine: "clamav",
319 scanned: true,
320 infected: true,
321 signature
322 });
323 } else if (/:\s*OK$/.test(line) || /^OK$/.test(line)) {
324 finish({ engine: "clamav", scanned: true, infected: false });
325 } else if (line.startsWith("ERROR")) {
326 finish({
327 engine: "clamav",
328 scanned: false,
329 infected: false,
330 error: line
331 });
332 }
333
334}
335