Implementar análisis de malware en servidor con ClamAV y Node.js
Mantén privada cada subida hasta que termine su análisis de malware. Esta guía crea un endpoint local en Node.js que distingue entre ausencia de detecciones, una detección y un análisis incompleto, y luego elimina la copia subida. Lo probarás con texto común y con la cadena de prueba antivirus inofensiva de EICAR.
Decide qué significa el resultado de un análisis
El demonio clamd de ClamAV mantiene cargado su motor antivirus y acepta
solicitudes de análisis a través de un socket. El paquete de Node.js
clamscan es un cliente para ese demonio. Su método
scanStream envía bytes, por lo que el demonio no necesita acceso al directorio
de subidas de la aplicación.
Un análisis completado sin detecciones no demuestra que un archivo sea seguro. Los formatos no compatibles, el contenido cifrado, las amenazas nuevas y los límites de análisis específicos de cada formato siguen siendo relevantes. Este ejemplo rechaza los errores notificados y las alertas de límites; no pretende identificar todos los motivos por los que un archivo podría eludir la inspección. Mantén la validación del tipo de archivo y el procesamiento posterior seguro como controles independientes.
Configura ClamAV en tu servidor
Usa Linux, Node.js 24.15.0, Corepack con Yarn 4.12.0 y cURL. El procedimiento de análisis que se
muestra a continuación se probó con ClamAV 1.5.4 y clamscan@2.4.0. Antes de iniciar
la aplicación de Node, necesitas un clamd privado en ejecución con una
base de datos de firmas oficial. Sigue la
guía de instalación de ClamAV y la
guía de configuración del demonio si aún no tienes uno.
La instalación de la base de datos y la gestión del servicio dependen de tu distribución de Linux.
Configura tu demonio dedicado a pruebas con estos límites y luego reinícialo. Conserva sus rutas
DatabaseDirectory y LocalSocket existentes. Concede acceso al socket
y a su directorio padre solo al usuario de la aplicación; no habilites un listener TCP público.
StreamMaxLength 26M
MaxFileSize 25M
MaxScanSize 100M
MaxRecursion 16
MaxFiles 1000
AlertExceedsMax yes
Estos límites cumplen funciones diferentes. StreamMaxLength limita los bytes
que se envían por el socket. MaxFileSize se aplica a archivos individuales,
incluidos los miembros extraídos de archivos contenedores. MaxScanSize limita
el trabajo total de análisis por entrada, incluido el contenido expandido. Con
AlertExceedsMax, las infracciones de límites compatibles generan alertas
Heuristics.Limits.Exceeded. El wrapper que aparece a continuación las trata como análisis
incompletos, no como identificaciones de malware. Consulta la
referencia de configuración de esta versión
para conocer el alcance exacto de cada límite.
Usa FreshClam para actualizar las firmas oficiales y supervisar su antigüedad. No ejecutes un segundo actualizador sobre una base de datos que ya gestione un servicio FreshClam. Este tutorial usa la base de datos cargada por tu demonio; el paquete de Node no descarga firmas ni cambia los límites del demonio.
Integra ClamAV con Node.js
Desde un directorio con permiso de escritura, pega este bloque de configuración. Crea un proyecto
node-clamav nuevo y se niega a sobrescribir un directorio existente. La
configuración explícita de Yarn lo mantiene separado de cualquier proyecto que lo contenga.
Si la instalación falla, debes resolver el problema antes de continuar.
(
set -eu
mkdir -m 700 node-clamav
cd node-clamav
printf '{"private":true,"type":"commonjs","packageManager":"yarn@4.12.0"}\n' > package.json
printf 'nodeLinker: node-modules\n' > .yarnrc.yml
touch yarn.lock
corepack yarn add --exact clamscan@2.4.0 express@5.2.1 multer@2.4.0
)
Guarda los siguientes tres archivos dentro de node-clamav. Usan extensiones
CommonJS explícitas .cjs y se ejecutan directamente con Node, sin una
etapa de compilación.
Primero, guarda ClamAVScanner.cjs. El wrapper usa la
API de streaming del paquete tanto para los archivos como para la
prueba de inicio. Registra un listener de errores antes de conectarse, porque el archivo de
entrada puede desaparecer mientras el cliente abre su socket.
const { createReadStream } = require('node:fs')
const { stat } = require('node:fs/promises')
const { Readable } = require('node:stream')
const ClamScan = require('clamscan')
const MAX_FILE_BYTES = 25 * 1024 * 1024
class ClamAVScanner {
async initialize(socket) {
this.clamscan = await new ClamScan().init({
clamscan: { active: false },
preference: 'clamdscan',
clamdscan: { socket, timeout: 10000, localFallback: false },
})
this.isInitialized = true
}
async scanFile(filePath) {
const info = await stat(filePath)
if (!info.isFile() || info.size === 0 || info.size > MAX_FILE_BYTES) {
throw new Error('File is empty, invalid, or exceeds the scan limit')
}
return this.scanStream(createReadStream(filePath))
}
async scanBuffer(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0 || buffer.length > MAX_FILE_BYTES) {
throw new Error('Buffer is empty, invalid, or exceeds the scan limit')
}
return this.scanStream(Readable.from([buffer]))
}
async scanStream(input) {
if (!this.isInitialized) {
input.destroy()
throw new Error('ClamAV scanner not initialized')
}
const inputError = new Promise((_, reject) => input.once('error', reject))
try {
const result = await Promise.race([inputError, this.clamscan.scanStream(input)])
if (
!result || result.timeout === true ||
(result.isInfected !== true && result.isInfected !== false) ||
!Array.isArray(result.viruses) ||
!result.viruses.every((name) => typeof name === 'string')
) {
throw new Error('Scan result is inconclusive')
}
const { isInfected, viruses } = result
if (
viruses.some((name) => name.startsWith('Heuristics.Limits.Exceeded')) ||
isInfected !== (viruses.length > 0)
) {
throw new Error('Scan limit or inconsistent result; scan is inconclusive')
}
return { isInfected, viruses }
} finally {
input.destroy()
}
}
}
module.exports = ClamAVScanner
A continuación, guarda scan-worker.cjs. Cada worker tiene su propia conexión de
cliente. La ausencia de una ruta de archivo solicita un pequeño análisis de inicio; una subida
real usa su ruta temporal privada.
const { parentPort, workerData } = require('node:worker_threads')
const ClamAVScanner = require('./ClamAVScanner.cjs')
async function main() {
const scanner = new ClamAVScanner()
await scanner.initialize(workerData.socket)
const result = workerData.filePath === null
? await scanner.scanBuffer(Buffer.from('scanner startup probe'))
: await scanner.scanFile(workerData.filePath)
parentPort.postMessage({ result })
}
main().catch((error) => {
parentPort.postMessage({ errorCode: typeof error?.code === 'string' ? error.code : 'SCAN_FAILED' })
})
Analiza los archivos subidos con ClamAV
Guarda server.cjs. Escucha solo en la interfaz de loopback y acepta una subida
a la vez. Multer escribe el único campo
file en un directorio privado nuevo. Las subidas pueden tener hasta
25 MiB. Ningún nombre de archivo subido se convierte en una ruta del sistema de archivos, y la
aplicación nunca sirve estos directorios.
El plazo de 10 segundos para el análisis incluye la inicialización del cliente. Una promesa
rechazada por sí sola no cancela una operación de socket, por lo que la aplicación espera a
worker.terminate()
antes de eliminar la entrada temporal. Eso detiene al cliente de Node; no garantiza que se cancele
el trabajo ya aceptado por clamd. Los límites propios del demonio siguen
aplicándose.
const { mkdtemp, rm } = require('node:fs/promises')
const { createServer } = require('node:http')
const { tmpdir } = require('node:os')
const { join, resolve } = require('node:path')
const { Worker } = require('node:worker_threads')
const express = require('express')
const multer = require('multer')
const app = express()
let uploadRoot
let busy = false
let stopping = false
let socket
function diagnosticCode(error) {
return ['ENOENT', 'EACCES', 'EEXIST', 'EADDRINUSE', 'ECONNREFUSED', 'ETIMEDOUT'].includes(error?.code)
? error.code
: 'SCAN_FAILED'
}
async function scanFile(filePath) {
const worker = new Worker(join(__dirname, 'scan-worker.cjs'), {
workerData: { socket, filePath },
stdout: true,
stderr: true,
})
// The client can print raw errors even with debugMode disabled.
worker.stdout.resume()
worker.stderr.resume()
let timer
try {
return await new Promise((resolveScan, reject) => {
timer = setTimeout(() => {
reject(Object.assign(new Error('Scan deadline exceeded'), { code: 'ETIMEDOUT' }))
}, 10000)
worker.once('message', (message) => {
if (message.errorCode) {
reject(Object.assign(new Error('Scan failed'), { code: message.errorCode }))
} else {
resolveScan(message.result)
}
})
worker.once('error', reject)
worker.once('exit', () => reject(new Error('Scanner exited without a result')))
})
} finally {
clearTimeout(timer)
await worker.terminate()
}
}
app.post('/upload', async (req, res) => {
if (busy || stopping) return res.status(503).json({ result: 'busy' })
busy = true
let directory
let status = 503
let result = 'inconclusive'
try {
directory = await mkdtemp(join(uploadRoot, 'request-'))
const upload = multer({
dest: directory,
limits: { fileSize: 25 * 1024 * 1024, files: 1, fields: 0, parts: 2 },
}).single('file')
await new Promise((resolveUpload, reject) => {
upload(req, res, (error) => error ? reject(error) : resolveUpload())
})
if (!req.file || req.file.size === 0) {
status = 400
result = 'invalid-upload'
} else {
const scan = await scanFile(req.file.path)
status = scan.isInfected ? 403 : 200
result = scan.isInfected ? 'detected' : 'no-detection'
}
} catch (error) {
if (error instanceof multer.MulterError) {
status = error.code === 'LIMIT_FILE_SIZE' ? 413 : 400
result = 'invalid-upload'
} else {
console.error('File scan could not be completed', { code: diagnosticCode(error) })
}
} finally {
if (directory) {
try {
await rm(directory, { recursive: true, force: true })
} catch (error) {
status = 500
result = 'cleanup-failed'
stopping = true
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
}
}
busy = false
}
if (!res.destroyed) res.status(status).json({ result })
})
async function main() {
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 0 || port > 65535 || !process.env.CLAMD_SOCKET) {
throw new Error('Set CLAMD_SOCKET and a valid PORT')
}
socket = resolve(process.env.CLAMD_SOCKET)
const probe = await scanFile(null)
if (probe.isInfected) throw new Error('Startup probe triggered a detection')
uploadRoot = await mkdtemp(join(tmpdir(), 'node-clamav-'))
const server = createServer({ requestTimeout: 15000, connectionsCheckingInterval: 1000 }, app)
await new Promise((resolveListen, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', resolveListen)
})
console.log(`Listening on http://127.0.0.1:${server.address().port}`)
const stop = () => {
if (stopping && !server.listening) return
stopping = true
server.close(() => {
rm(uploadRoot, { recursive: true, force: true }).catch((error) => {
console.error('Temporary file cleanup failed', { code: diagnosticCode(error) })
process.exitCode = 1
})
})
}
process.on('SIGINT', stop)
process.on('SIGTERM', stop)
}
main().catch(async (error) => {
console.error('Server startup failed; check CLAMD_SOCKET, PORT, and daemon status', {
code: diagnosticCode(error),
})
if (uploadRoot) {
await rm(uploadRoot, { recursive: true, force: true }).catch(() => {
console.error('Temporary file cleanup failed')
})
}
process.exitCode = 1
})
Desde el directorio padre, inicia el servidor en una terminal y sustituye la ruta del socket por
el valor de LocalSocket de tu demonio. Por ejemplo, algunos paquetes de Ubuntu
usan /var/run/clamav/clamd.ctl. La prueba de inicio debe completarse correctamente antes de
que aparezca la URL que indica que está listo. Configura PORT con otro
puerto si el 3000 está ocupado.
(cd node-clamav && CLAMD_SOCKET=/absolute/path/to/clamd.sock node server.cjs)
Envía un archivo benigno y la cadena de prueba de EICAR
En una segunda terminal, desde el mismo directorio padre, pega este bloque. Crea archivos de prueba nuevos sin reemplazar los existentes. EICAR es un patrón de prueba antivirus inofensivo, no malware real; el antivirus de tu equipo podría ponerlo en cuarentena. No desactives la protección para conservarlo.
(
set -eu
cd node-clamav
node <<'JS'
const { writeFileSync } = require('node:fs')
writeFileSync('hello.txt', 'ordinary upload\n', { flag: 'wx' })
writeFileSync('eicar.txt', 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*', { flag: 'wx' })
JS
curl -sS -i --max-time 20 -F 'file=@hello.txt' http://127.0.0.1:3000/upload
curl -sS -i --max-time 20 -F 'file=@eicar.txt' http://127.0.0.1:3000/upload
)
La primera solicitud debería devolver HTTP 200 y {"result":"no-detection"}. La solicitud de
EICAR debería devolver HTTP 403 y {"result":"detected"}. Estos comandos omiten
intencionalmente la opción -f de cURL para que puedas inspeccionar
las respuestas de rechazo. Si cambiaste PORT, actualiza ambas URL.
Los archivos que creaste permanecen en tu proyecto; solo se eliminan las copias subidas al servidor.
Interpreta los fallos y comprueba la limpieza
| Estado HTTP | Resultado | Significado |
|---|---|---|
| 200 | no-detection | El análisis configurado no devolvió ninguna detección conocida. |
| 403 | detected | El escáner devolvió una detección. |
| 400 | invalid-upload | Faltan campos multipart, están vacíos o no son compatibles. |
| 413 | invalid-upload | La subida superó los 25 MiB. |
| 503 | inconclusive | Un error de análisis de malware o del parser de subidas, o una alerta de límite, impidió obtener un resultado. |
| 503 | busy | Hay otra solicitud activa o el servidor se está deteniendo. |
| 500 | cleanup-failed | La eliminación falló; el servidor rechaza nuevas subidas. |
La limpieza se ejecuta antes de la respuesta JSON, incluso si se rechaza la subida o falla el análisis. Un fallo del sistema de archivos aún puede impedir la eliminación; el servidor lo notifica y deja de aceptar trabajo. Ctrl+C detiene las conexiones nuevas, permite que terminen las solicitudes activas y elimina el directorio raíz temporal del proceso. Un cierre inesperado o forzado puede dejar archivos sin eliminar. Esta es una demostración local de análisis sin retención de subidas, autenticación ni garantía de disponibilidad en producción.
Soluciona problemas comunes
Si el inicio falla, comprueba que CLAMD_SOCKET identifique un socket a la escucha
y que tu usuario pueda recorrer sus directorios padre. ENOENT indica que
falta una ruta, EACCES indica un problema de permisos y
EADDRINUSE indica que un puerto HTTP está ocupado. Corrige la causa y reinicia;
la aplicación no cambia silenciosamente a otro escáner.
Si un archivo de texto pequeño funciona, pero un archivo contenedor devuelve
inconclusive, inspecciona los registros de tu demonio privado para detectar si
se alcanzó un límite de análisis. Una subida comprimida pequeña puede expandirse hasta superar
MaxFileSize o MaxScanSize. Aumentar solo el límite de subida
HTTP no cambiará ninguno de esos límites. Si el demonio deja de responder, la aplicación devuelve
inconclusive al vencer su plazo de análisis. La recepción de la subida tiene un
tiempo de espera independiente de 15 segundos para la solicitud HTTP, que puede cerrar la
conexión antes de que se envíe el JSON.
Mantén privado el entorno de análisis
Antes de adaptar este endpoint a un pipeline de subidas real, decide qué formatos aceptas y qué
hacer con el contenido cifrado o que no se pueda inspeccionar por otros motivos. Mantén los
archivos pendientes fuera del almacenamiento desde el que se sirve contenido, limita la
concurrencia y supervisa tanto la vigencia de las firmas como los análisis incompletos. Si añades
retención, mueve los mismos bytes analizados a su destino solo después de que el resultado
cumpla tu política. Evita exponer el protocolo sin autenticación de
clamd a clientes no confiables.
