/** * ClamAV Scan Wrapper * Low-level ClamAV communication using TCP mode via clamscan npm package. * Scans files, extracts virus signatures, and pings daemon health. * * IMPORTANT: Do not cache the scanner instance — each scan creates a fresh * connection to avoid stale TCP socket issues. */ import net from 'net'; import { getToggleStatus } from './clamavToggleManager'; import { logSecurityEvent, SecurityEventType } from '../logging/securityEventLogger'; // ── Types ── export interface ClamScanResult { isInfected: boolean; virusNames: string[]; scanned: boolean; skipped: boolean; scanDuration: number; rawOutput: string; error?: string; } export interface ClamDaemonStatus { available: boolean; host: string; port: number; responseTime: number; error?: string; } // ── Configuration ── const CLAMD_HOST = process.env.CLAMD_HOST || 'localhost'; const CLAMD_PORT = parseInt(process.env.CLAMD_PORT || '3310', 10); const CLAMD_TIMEOUT = parseInt(process.env.CLAMD_TIMEOUT_MS || '30000', 10); // ── Scanner Factory (fresh instance per scan) ── async function createScanner(): Promise { const NodeClamscan = (await import('clamscan')).default; const clamscan = await new NodeClamscan().init({ removeInfected: false, debugMode: false, scanRecursively: false, clamdscan: { host: CLAMD_HOST, port: CLAMD_PORT, timeout: CLAMD_TIMEOUT, localFallback: false, multiscan: false, active: true, }, preference: 'clamdscan', }); return clamscan; } // ── Core Functions ── /** * Scan a file for malware using ClamAV daemon. * Creates a fresh scanner connection for each scan to avoid stale sockets. */ export async function scanFile(filePath: string): Promise { const startTime = Date.now(); // Check if scanning is enabled via admin toggle const toggleStatus = getToggleStatus(); if (!toggleStatus.enabled) { console.log('[ClamAV] Scanning disabled via toggle — skipping scan'); return { isInfected: false, virusNames: [], scanned: false, skipped: true, scanDuration: 0, rawOutput: 'Scanning disabled by administrator', }; } // Check if ClamAV is enabled via env if (process.env.ENABLE_CLAMAV === 'false') { console.log('[ClamAV] Disabled via ENABLE_CLAMAV=false — skipping scan'); return { isInfected: false, virusNames: [], scanned: false, skipped: true, scanDuration: 0, rawOutput: 'ClamAV disabled via ENABLE_CLAMAV env', }; } try { console.log(`[ClamAV] Scanning file: ${filePath}`); const scanner = await createScanner(); const scanResult = await scanner.isInfected(filePath); const scanDuration = Date.now() - startTime; console.log(`[ClamAV] Raw scan result:`, JSON.stringify({ isInfected: scanResult.isInfected, file: scanResult.file, viruses: scanResult.viruses, })); // IMPORTANT: clamscan can return null for isInfected when the daemon // fails to respond properly. Treat null as a scan failure, not clean. if (scanResult.isInfected === null || scanResult.isInfected === undefined) { console.error('[ClamAV] Scan returned null/undefined — treating as scan failure (fail-secure)'); logSecurityEvent(SecurityEventType.MALWARE_SCAN_ERROR, { filePath, error: 'ClamAV returned null result — possible daemon connection issue', scanDuration, }); return { isInfected: false, virusNames: [], scanned: false, skipped: false, scanDuration, rawOutput: 'ClamAV returned null/undefined isInfected', error: 'Scan result was inconclusive — ClamAV daemon may be unreachable', }; } const result: ClamScanResult = { isInfected: scanResult.isInfected === true, virusNames: scanResult.viruses || [], scanned: true, skipped: false, scanDuration, rawOutput: `File: ${scanResult.file}, Infected: ${scanResult.isInfected}, Viruses: ${(scanResult.viruses || []).join(', ')}`, }; // Log the scan event if (result.isInfected) { console.log(`[ClamAV] ⛔ MALWARE DETECTED in ${filePath}: ${result.virusNames.join(', ')}`); logSecurityEvent(SecurityEventType.MALWARE_DETECTED, { filePath, virusNames: result.virusNames, scanDuration, }); } else { console.log(`[ClamAV] ✅ File clean: ${filePath} (${scanDuration}ms)`); logSecurityEvent(SecurityEventType.MALWARE_SCAN_CLEAN, { filePath, scanDuration, }); } return result; } catch (error: any) { const scanDuration = Date.now() - startTime; console.error(`[ClamAV] ❌ Scan error for ${filePath}:`, error.message); logSecurityEvent(SecurityEventType.MALWARE_SCAN_ERROR, { filePath, error: error.message, scanDuration, }); return { isInfected: false, virusNames: [], scanned: false, skipped: false, scanDuration, rawOutput: '', error: error.message, }; } } /** * Ping ClamAV daemon via TCP socket to check availability */ export async function pingDaemon(): Promise { const startTime = Date.now(); return new Promise((resolve) => { const socket = new net.Socket(); const timeout = 5000; socket.setTimeout(timeout); socket.connect(CLAMD_PORT, CLAMD_HOST, () => { // Send PING command socket.write('zPING\0'); }); socket.on('data', (data) => { const response = data.toString().trim(); socket.destroy(); resolve({ available: response === 'PONG', host: CLAMD_HOST, port: CLAMD_PORT, responseTime: Date.now() - startTime, }); }); socket.on('error', (error: any) => { socket.destroy(); resolve({ available: false, host: CLAMD_HOST, port: CLAMD_PORT, responseTime: Date.now() - startTime, error: error.message, }); }); socket.on('timeout', () => { socket.destroy(); resolve({ available: false, host: CLAMD_HOST, port: CLAMD_PORT, responseTime: Date.now() - startTime, error: 'Connection timed out', }); }); }); }