import net from 'node:net' /** * Anbindung an clamd. * * Es wird **INSTREAM** benutzt, nicht SCAN mit einem Pfad: dann muss der * Scanner die Datei nicht sehen können, und die Anbindung funktioniert auch, * wenn clamd anderswo läuft. Das Protokoll ist simpel - Länge als 4 Byte, * Nutzdaten, dann eine Null-Länge als Abschluss. * * Die Adresse ist entweder `host:port` oder ein Pfad zu einem Unix-Socket. * Vorgabe ist TCP, weil das offizielle clamav-Image seinen Unix-Socket unter * `/tmp/clamd.sock` anlegt und nicht dort, wo ein geteiltes Volume ihn * sichtbar machen könnte - ein Socket-Volume blieb schlicht leer und die * Virenprüfung meldete sich dauerhaft als gestört. `TCPSocket 3310` ist in * demselben Image ab Werk offen, und im Stack ist der Port nicht nach aussen * veröffentlicht. */ /** `host:port` oder ein absoluter Pfad. Getrennt, damit es prüfbar ist. */ export type ClamavAddress = { path: string } | { host: string; port: number } export function parseClamavAddress(address: string): ClamavAddress { const sauber = address.trim() if (sauber.startsWith('/') || sauber.startsWith('.')) return { path: sauber } // Von hinten trennen, damit eine IPv6-Adresse in Klammern heil bleibt. const trenner = sauber.lastIndexOf(':') if (trenner <= 0) throw new Error(`clamd-Adresse ohne Port: ${address}`) const port = Number(sauber.slice(trenner + 1)) if (!Number.isInteger(port) || port < 1 || port > 65_535) { throw new Error(`clamd-Adresse mit ungültigem Port: ${address}`) } const host = sauber.slice(0, trenner).replace(/^\[|\]$/g, '') if (host.length === 0) throw new Error(`clamd-Adresse ohne Host: ${address}`) return { host, port } } function verbinden(address: string): net.Socket { const ziel = parseClamavAddress(address) return 'path' in ziel ? net.createConnection(ziel.path) : net.createConnection({ host: ziel.host, port: ziel.port }) } export type ScanResult = | { ok: true; clean: true } | { ok: true; clean: false; signature: string } /** Scanner nicht erreichbar, Zeitüberschreitung, unverständliche Antwort. */ | { ok: false; reason: string } const CHUNK = 64 * 1024 export function scanBuffer( address: string, content: Uint8Array, timeoutMs = 30_000, ): Promise { return new Promise((resolve) => { let settled = false let antwort = '' const socket = verbinden(address) const done = (result: ScanResult) => { if (settled) return settled = true socket.destroy() resolve(result) } socket.setTimeout(timeoutMs) socket.on('timeout', () => done({ ok: false, reason: 'Zeitüberschreitung beim Scannen' })) socket.on('error', (error) => done({ ok: false, reason: `clamd nicht erreichbar: ${error.message}` }), ) socket.on('connect', () => { socket.write('zINSTREAM\0') for (let offset = 0; offset < content.length; offset += CHUNK) { const teil = content.subarray(offset, offset + CHUNK) const laenge = Buffer.alloc(4) laenge.writeUInt32BE(teil.length, 0) socket.write(laenge) socket.write(teil) } socket.write(Buffer.from([0, 0, 0, 0])) }) socket.on('data', (chunk) => { antwort += chunk.toString('utf8') }) socket.on('close', () => { const text = antwort.replace(/\0/g, '').trim() if (text.endsWith('OK')) return done({ ok: true, clean: true }) if (text.endsWith('FOUND')) { const signature = text.replace(/^stream:\s*/, '').replace(/\s*FOUND$/, '') return done({ ok: true, clean: false, signature }) } // "size limit exceeded", "ERROR" oder gar nichts. done({ ok: false, reason: text || 'keine Antwort von clamd' }) }) }) } /** Fragt clamd, ob er da ist. Antwort auf zPING ist "PONG". */ export function pingClamav(address: string, timeoutMs = 2000): Promise { return new Promise((resolve) => { let settled = false const done = (ok: boolean) => { if (settled) return settled = true socket.destroy() resolve(ok) } const socket = verbinden(address) socket.setTimeout(timeoutMs) socket.on('connect', () => socket.write('zPING\0')) socket.on('data', (chunk) => done(chunk.toString().includes('PONG'))) socket.on('timeout', () => done(false)) socket.on('error', () => done(false)) }) }