Analyse antimalware côté serveur avec ClamAV dans Node.js
Gardez un fichier envoyé privé jusqu’à la fin de son analyse antimalware. Ce tutoriel construit un point de terminaison Node.js local qui distingue l’absence de détection, une détection et une analyse incomplète, puis supprime la copie envoyée. Vous le testerez avec du texte ordinaire et la chaîne de test antivirus EICAR, qui est inoffensive.
Déterminer ce que signifie un résultat d’analyse
Le démon clamd de ClamAV garde son moteur antivirus chargé et accepte des requêtes d’analyse
via un socket. Le paquet Node.js clamscan est un client pour ce démon. Sa méthode scanStream
envoie des octets, si bien que le démon n’a pas besoin d’accéder au répertoire d’envoi de l’application.
Une analyse terminée sans détection ne prouve pas qu’un fichier est sûr. Les formats non pris en charge, les contenus chiffrés, les nouvelles menaces et les limites d’analyse propres à chaque format restent à prendre en compte. Cet exemple rejette les erreurs signalées et les alertes de limite ; il ne prétend pas identifier toutes les raisons pour lesquelles un fichier pourrait échapper à l’inspection. Conservez la validation du type de fichier et un traitement en aval sûr comme des contrôles distincts.
Configurer ClamAV sur votre serveur
Utilisez Linux, Node.js 24.15.0, Corepack avec Yarn 4.12.0 et cURL. Le chemin d’analyse ci-dessous a
été testé avec ClamAV 1.5.4 et clamscan@2.4.0. Avant de démarrer l’application Node, vous avez besoin
d’un clamd privé en cours d’exécution, doté d’une base de signatures officielle. Suivez le
guide d’installation de ClamAV et le
guide de configuration du démon si vous n’en avez
pas encore. L’installation de la base et la gestion du service dépendent de votre distribution Linux.
Configurez votre démon de test dédié avec ces limites, puis redémarrez-le. Conservez ses chemins
DatabaseDirectory et LocalSocket existants. Donnez accès au socket et à son répertoire parent
uniquement à l’utilisateur de l’application ; n’activez pas d’écouteur TCP public.
StreamMaxLength 26M
MaxFileSize 25M
MaxScanSize 100M
MaxRecursion 16
MaxFiles 1000
AlertExceedsMax yes
Ces limites jouent des rôles différents. StreamMaxLength plafonne les octets envoyés via le socket.
MaxFileSize s’applique aux fichiers individuels, y compris aux membres d’archives décompressés.
MaxScanSize borne le travail d’analyse total par entrée, contenus décompressés compris. Avec
AlertExceedsMax, les dépassements de limite pris en charge produisent des alertes Heuristics.Limits.Exceeded. Le wrapper
ci-dessous les traite comme des analyses incomplètes, et non comme des identifications de malware.
Consultez la référence de configuration versionnée
pour connaître la portée exacte de chaque limite.
Utilisez FreshClam pour mettre à jour les signatures officielles et surveiller leur ancienneté. N’exécutez pas un second outil de mise à jour sur une base déjà gérée par un service FreshClam. Ce tutoriel utilise la base chargée par votre démon ; le paquet Node ne télécharge pas de signatures et ne modifie pas les limites du démon.
Intégrer ClamAV à Node.js
Depuis un répertoire accessible en écriture, collez ce bloc de configuration. Il crée un nouveau
projet node-clamav et refuse d’écraser un répertoire existant. La configuration Yarn explicite le
maintient séparé d’un éventuel projet englobant. Une installation en échec doit être corrigée avant
de continuer.
(
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
)
Enregistrez les trois fichiers suivants dans node-clamav. Ils utilisent des extensions CommonJS
.cjs explicites et s’exécutent directement avec Node, sans étape de compilation.
Commencez par enregistrer ClamAVScanner.cjs. Le wrapper utilise
l’API de streaming du paquet pour les fichiers comme pour
la sonde de démarrage. Il installe un écouteur d’erreurs avant de se connecter, car le fichier
d’entrée peut disparaître pendant que le client ouvre son 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
Ensuite, enregistrez scan-worker.cjs. Chaque worker possède une connexion client. Un chemin de fichier
absent demande une petite analyse de démarrage ; un véritable envoi utilise son chemin temporaire
privé.
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' })
})
Analyser les fichiers envoyés avec ClamAV
Enregistrez server.cjs. Il écoute uniquement sur l’interface loopback et accepte un seul envoi à
la fois. Multer écrit l’unique champ file dans un
nouveau répertoire privé. Les envois peuvent atteindre 25 MiB. Aucun nom de fichier envoyé ne
devient un chemin du système de fichiers, et l’application ne sert jamais ces répertoires.
Le délai d’analyse de 10 secondes inclut l’initialisation du client. Une promesse rejetée n’annule
pas à elle seule une opération sur socket ; l’application attend donc
worker.terminate()
avant de supprimer l’entrée temporaire. Cela arrête le client Node ; cela ne promet pas d’annuler
le travail déjà accepté par clamd. Les limites propres au démon s’appliquent toujours.
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
})
Depuis le répertoire parent, démarrez le serveur dans un terminal en remplaçant le chemin du socket
par la valeur LocalSocket de votre démon. Par exemple, certains paquets Ubuntu utilisent
/var/run/clamav/clamd.ctl. La sonde de démarrage doit réussir avant que l’URL indiquant que le serveur est prêt
n’apparaisse. Définissez PORT sur un autre port si le port 3000 est occupé.
(cd node-clamav && CLAMD_SOCKET=/absolute/path/to/clamd.sock node server.cjs)
Envoyer un fichier inoffensif et la chaîne de test EICAR
Dans un second terminal ouvert dans le même répertoire parent, collez ce bloc. Il crée de nouveaux fichiers de test sans remplacer les fichiers existants. EICAR est un motif de test antivirus inoffensif, et non un véritable malware ; l’antivirus de votre ordinateur peut le mettre en quarantaine. Ne désactivez pas la protection pour le conserver.
(
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 première requête devrait renvoyer HTTP 200 et {"result":"no-detection"}. La requête EICAR devrait renvoyer
HTTP 403 et {"result":"detected"}. Ces commandes omettent délibérément l’option -f de cURL afin
que vous puissiez inspecter les réponses de rejet. Si vous avez modifié PORT, mettez à jour
les deux URL. Les fichiers que vous avez créés restent dans votre projet ; seules les copies envoyées
au serveur sont supprimées.
Interpréter les échecs et vérifier le nettoyage
| Statut HTTP | Résultat | Signification |
|---|---|---|
| 200 | no-detection | L’analyse configurée n’a renvoyé aucune détection connue. |
| 403 | detected | Le scanner a renvoyé une détection. |
| 400 | invalid-upload | Champs multipart manquants, vides ou non pris en charge. |
| 413 | invalid-upload | L’envoi a dépassé 25 MiB. |
| 503 | inconclusive | Une erreur d’analyse ou du parseur d’envoi, ou une alerte de limite, a empêché tout résultat. |
| 503 | busy | Une autre requête est en cours, ou le serveur est en train de s’arrêter. |
| 500 | cleanup-failed | La suppression a échoué ; le serveur refuse tout nouvel envoi. |
Le nettoyage s’exécute avant la réponse JSON, y compris en cas de rejet de l’envoi et d’échec de l’analyse. Une défaillance du système de fichiers peut tout de même empêcher la suppression ; le serveur la signale et cesse d’accepter du travail. Ctrl+C arrête les nouvelles connexions, laisse les requêtes actives se terminer et supprime la racine temporaire du processus. Un plantage ou un arrêt forcé peut laisser des fichiers derrière lui. Il s’agit d’une démo d’analyse locale, sans conservation des envois, sans authentification et sans garantie de disponibilité en production.
Résoudre les problèmes courants
Si le démarrage échoue, vérifiez que CLAMD_SOCKET désigne un socket en écoute et que votre
utilisateur peut traverser ses répertoires parents. ENOENT signale un chemin manquant,
EACCES un problème de permissions et EADDRINUSE un port HTTP occupé. Corrigez la cause et
redémarrez ; l’application ne bascule pas silencieusement vers un autre scanner.
Si un petit fichier texte fonctionne mais qu’une archive renvoie inconclusive, recherchez une limite
d’analyse dans les journaux de votre démon privé. Un petit envoi compressé peut, une fois
décompressé, dépasser MaxFileSize ou MaxScanSize. Relever uniquement la limite d’envoi HTTP ne
modifiera aucune de ces deux limites. Si le démon cesse de répondre, l’application renvoie
inconclusive après son délai d’analyse. La réception de l’envoi dispose d’un délai d’expiration de
requête HTTP distinct de 15 secondes, qui peut fermer la connexion avant l’envoi du JSON.
Garder le périmètre d’analyse privé
Avant d’adapter ce point de terminaison à un véritable pipeline d’envoi, décidez quels formats vous
acceptez et que faire des contenus chiffrés ou impossibles à inspecter pour une autre raison.
Conservez les fichiers en attente hors du stockage servi, limitez la concurrence et surveillez à la
fois la fraîcheur des signatures et les analyses incomplètes. Si vous ajoutez un stockage persistant
des fichiers envoyés, ne déplacez ces mêmes octets analysés vers leur destination qu’une fois que
le résultat satisfait à votre politique. Évitez d’exposer le protocole non authentifié de clamd à des clients non fiables.
