Re_Backend/src/services/clamav/clamavScanWrapper.ts

228 lines
7.1 KiB
TypeScript

/**
* 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<any> {
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<ClamScanResult> {
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<ClamDaemonStatus> {
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',
});
});
});
}