228 lines
7.1 KiB
TypeScript
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',
|
|
});
|
|
});
|
|
});
|
|
}
|