Varredura de malware no lado do servidor com ClamAV em Node.js
Mantenha um upload privado até que a varredura de malware termine. Este passo a passo cria um endpoint local em Node.js que distingue ausência de detecção, uma detecção e uma varredura incompleta e, em seguida, exclui a cópia enviada. Você vai testá-lo com texto comum e com a string de teste antivírus EICAR, que é inofensiva.
Decida o que significa um resultado de varredura
O daemon clamd do ClamAV mantém seu mecanismo antivírus carregado e aceita
requisições de varredura por um socket. O pacote Node.js clamscan é um cliente
desse daemon. Seu método scanStream envia bytes, então o daemon não precisa de
acesso ao diretório de uploads da aplicação.
Uma varredura concluída sem detecção não prova que um arquivo é seguro. Formatos não suportados, conteúdo criptografado, ameaças novas e limites de varredura específicos de cada formato continuam importando. Este exemplo rejeita erros relatados e alertas de limite; ele não pretende identificar todos os motivos pelos quais um arquivo pode escapar da inspeção. Mantenha a validação de tipo de arquivo e o processamento seguro nas etapas seguintes como controles separados.
Configurando o ClamAV no seu servidor
Use Linux, Node.js 24.15.0, Corepack com Yarn 4.12.0 e cURL. O caminho de varredura abaixo foi
testado com ClamAV 1.5.4 e clamscan@2.4.0. Antes de iniciar a aplicação Node, você
precisa de um clamd privado em execução, com um banco de dados de
assinaturas oficial. Siga o guia de instalação do ClamAV e o
guia de configuração do daemon se ainda não tiver um. A instalação
do banco de dados e o gerenciamento do serviço dependem da sua distribuição Linux.
Configure seu daemon de teste dedicado com estes limites e depois reinicie-o. Mantenha os caminhos
DatabaseDirectory e LocalSocket já existentes. Dê acesso ao socket e
ao diretório pai dele somente ao usuário da aplicação; não habilite um listener TCP público.
StreamMaxLength 26M
MaxFileSize 25M
MaxScanSize 100M
MaxRecursion 16
MaxFiles 1000
AlertExceedsMax yes
Esses limites têm funções diferentes. StreamMaxLength limita os bytes enviados pelo
socket. MaxFileSize se aplica a arquivos individuais, incluindo os itens
extraídos de arquivos compactados. MaxScanSize restringe o trabalho total de
varredura por entrada, incluindo o conteúdo expandido. Com AlertExceedsMax, violações
de limite suportadas geram alertas Heuristics.Limits.Exceeded. O wrapper abaixo trata esses
alertas como varreduras incompletas, não como identificações de malware. Consulte a
referência de configuração versionada
para ver o escopo exato de cada limite.
Use o FreshClam para atualizar as assinaturas oficiais e monitorar a idade delas. Não execute um segundo atualizador em um banco de dados que já é gerenciado por um serviço do FreshClam. Este tutorial usa o banco de dados carregado pelo seu daemon; o pacote Node não baixa assinaturas nem altera os limites do daemon.
Integrando o ClamAV ao Node.js
A partir de um diretório com permissão de escrita, cole este bloco de configuração. Ele cria um novo
projeto node-clamav e se recusa a sobrescrever um diretório existente. A
configuração explícita do Yarn o mantém separado de um projeto que o contenha. Uma instalação que
falhar precisa ser resolvida 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
)
Salve os três arquivos a seguir dentro de node-clamav. Eles usam extensões
CommonJS .cjs explícitas e rodam diretamente com o Node, sem etapa de
build.
Primeiro, salve ClamAVScanner.cjs. O wrapper usa a
API de streaming do pacote tanto para os arquivos quanto para a
sondagem de inicialização. Ele instala um listener de erro antes de se conectar, porque o arquivo de
entrada pode desaparecer enquanto o cliente abre seu 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
Em seguida, salve scan-worker.cjs. Cada worker é dono de uma conexão de cliente. Um
caminho de arquivo ausente solicita uma pequena varredura de inicialização; um upload real usa seu
caminho temporário privado.
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' })
})
Fazendo a varredura de arquivos enviados com o ClamAV
Salve server.cjs. Ele escuta apenas no loopback e aceita um upload por vez. O
Multer grava o único campo file em um
diretório privado novo. Os uploads podem ter até 25 MiB. Nenhum nome de arquivo enviado se torna um
caminho no sistema de arquivos, e a aplicação nunca serve esses diretórios.
O prazo de 10 segundos da varredura inclui a inicialização do cliente. Uma promise rejeitada, por
si só, não cancela uma operação de socket, então a aplicação aguarda
worker.terminate()
antes de excluir a entrada temporária. Isso interrompe o cliente Node; não garante o cancelamento
de trabalho já aceito pelo clamd. Os limites do próprio daemon continuam
valendo.
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
})
A partir do diretório pai, inicie o servidor em um terminal, substituindo o caminho do socket pelo
valor de LocalSocket do seu daemon. Por exemplo, alguns pacotes do Ubuntu usam
/var/run/clamav/clamd.ctl. A sondagem de inicialização precisa ter sucesso antes que o servidor
exiba a URL em que está escutando. Defina PORT com outra porta se a 3000
estiver ocupada.
(cd node-clamav && CLAMD_SOCKET=/absolute/path/to/clamd.sock node server.cjs)
Envie um arquivo benigno e a string de teste EICAR
Em um segundo terminal, no mesmo diretório pai, cole este bloco. Ele cria novos arquivos de teste sem substituir arquivos existentes. O EICAR é um padrão de teste antivírus inofensivo, não um malware real; o antivírus do seu computador pode colocá-lo em quarentena. Não desative a proteção para mantê-lo.
(
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
)
A primeira requisição deve retornar HTTP 200 e {"result":"no-detection"}. A requisição EICAR
deve retornar HTTP 403 e {"result":"detected"}. Estes comandos omitem de propósito a opção
-f do cURL para que você possa inspecionar as respostas de rejeição. Se
você alterou PORT, atualize as duas URLs.
Os arquivos que você criou ficam no seu projeto; apenas as cópias enviadas ao servidor são
excluídas.
Interprete falhas e verifique a limpeza
| Status HTTP | Resultado | Significado |
|---|---|---|
| 200 | no-detection | A varredura configurada não retornou nenhuma detecção conhecida. |
| 403 | detected | O scanner retornou uma detecção. |
| 400 | invalid-upload | Campos multipart ausentes, vazios ou não suportados. |
| 413 | invalid-upload | O upload excedeu 25 MiB. |
| 503 | inconclusive | Um erro de varredura, um erro do parser de upload ou um alerta de limite impediu um resultado. |
| 503 | busy | Outra requisição está ativa ou o servidor está sendo encerrado. |
| 500 | cleanup-failed | A exclusão falhou; o servidor recusa novos uploads. |
A limpeza é executada antes da resposta JSON, inclusive em caso de rejeição do upload e de falha na varredura. Uma falha no sistema de arquivos ainda pode impedir a exclusão; o servidor informa isso e para de aceitar trabalho. Ctrl+C interrompe novas conexões, deixa as requisições ativas terminarem e remove a raiz temporária do processo. Uma falha grave ou um encerramento forçado pode deixar arquivos para trás. Esta é uma demonstração local de varredura, sem retenção de uploads, autenticação ou garantia de disponibilidade para produção.
Solução de problemas comuns
Se a inicialização falhar, verifique se CLAMD_SOCKET aponta para um socket que
está escutando e se seu usuário pode percorrer os diretórios pai dele. ENOENT
indica um caminho ausente, EACCES um problema de permissões e
EADDRINUSE uma porta HTTP ocupada. Corrija a causa e reinicie; a aplicação não
troca silenciosamente para outro scanner.
Se um arquivo de texto pequeno funcionar, mas um arquivo compactado retornar
inconclusive, confira nos logs do seu daemon privado se houve um limite de
varredura. Um upload compactado pequeno pode se expandir além de MaxFileSize ou
MaxScanSize. Aumentar apenas o limite de upload HTTP não altera nenhum desses
limites. Se o daemon parar de responder, a aplicação retorna inconclusive após o
prazo da varredura. O recebimento do upload tem um tempo limite de requisição HTTP separado, de 15
segundos, que pode fechar a conexão antes de o JSON ser enviado.
Mantenha privada a fronteira de varredura
Antes de adaptar este endpoint para um pipeline de upload real, decida quais formatos você aceita e
o que fazer com conteúdo criptografado ou que, por outro motivo, não possa ser inspecionado. Mantenha
os arquivos pendentes fora do armazenamento servido, limite a concorrência e monitore tanto a
atualização das assinaturas quanto as varreduras incompletas. Se você adicionar retenção, mova os
mesmos bytes verificados para o destino somente depois que o resultado atender à sua política. Evite
expor o protocolo sem autenticação do clamd a clientes não confiáveis.
