This content originally appeared on HackerNoon and was authored by SonoTommy
File upload endpoints are a standard attack surface. The fix — scanning uploads before accepting them — is well understood in theory and largely ignored in practice.
\ The reason is friction. ClamAV, the de facto open-source antivirus engine, does not have a clean Node.js interface. If you want to scan a file against it, you write infrastructure code: manage a daemon, open a TCP socket, speak the INSTREAM protocol, parse the response, handle reconnection, wrap all of it in error handling. It works, but it's the kind of boilerplate that either never gets written or gets copy-pasted across projects without being maintained.
\ I built pompelmi to eliminate that overhead.
What the Raw Problem Looks Like
ClamAV runs in two modes. The first is clamscan, a CLI tool that scans a file and exits with code 0 (clean), 1 (infected), or 2 (error). Simple, but a new process for every scan.
\
The second is clamd, a persistent daemon that accepts connections on a TCP port or UNIX socket. You send a file over the INSTREAM protocol — chunks prefixed with a 4-byte big-endian length — and the daemon responds with either stream: OK or stream: VirusName FOUND.
\ To use this from Node.js without a wrapper:
const net = require('net')
function scanFile(filePath, host, port) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port })
const fileStream = fs.createReadStream(filePath)
socket.write('zINSTREAM\0')
fileStream.on('data', (chunk) => {
const lengthBuffer = Buffer.alloc(4)
lengthBuffer.writeUInt32BE(chunk.length, 0)
socket.write(lengthBuffer)
socket.write(chunk)
})
fileStream.on('end', () => {
const terminator = Buffer.alloc(4)
socket.write(terminator)
})
let response = ''
socket.on('data', (data) => {
response += data.toString()
})
socket.on('end', () => {
if (response.includes('FOUND')) {
resolve({ infected: true, virus: response.split(':')[1].trim() })
} else if (response.includes('OK')) {
resolve({ infected: false })
} else {
reject(new Error(`Unexpected response: ${response}`))
}
})
socket.on('error', reject)
})
}
That handles one happy-path scan of a file on disk. It does not handle: timeouts, UNIX socket connections, in-memory buffers, streams that are not file reads, reconnection, or the fact that clamd may send data across multiple data events before the socket closes. The error handling is minimal.
\ This is what every project that integrates ClamAV ends up writing, usually once, usually badly.
What pompelmi Does Instead
npm install pompelmi
const { scan, Verdict } = require('pompelmi')
const result = await scan('/path/to/file.pdf', {
host: 'localhost',
port: 3310,
timeout: 5000
})
switch (result) {
case Verdict.Clean: // proceed
case Verdict.Malicious: // reject
case Verdict.ScanError: // reject with different error
}
The return value is a Symbol, not a string. This is intentional — Verdict.Clean === Verdict.Clean is a reliable comparison, whereas string-based comparisons break when ClamAV adds whitespace or changes response format between versions.
The Four Scanning Functions
scan(filePath, options) — scans a file on disk. In local mode (no options), spawns clamscan. In remote mode (host/port or socket), streams the file to clamd via INSTREAM.
\
scanBuffer(buffer, options) — scans an in-memory Buffer. Requires clamd. The buffer is streamed directly without writing to disk first. Useful with multer's memoryStorage.
\ scanStream(stream, options) — scans any Node.js Readable. Requires clamd. Useful for S3 objects, HTTP responses, or any piped source where you don't want to materialize the full content.
\
scanDirectory(dirPath, options) — scans every file in a directory recursively. Returns { clean, malicious, errors } arrays. In remote mode, scans are parallelized.
\
All four functions accept the same options object and return the same three verdict Symbols. Switching from local clamscan to a remote clamd instance requires changing only the options — the rest of the calling code is unchanged.
Connection Modes
Local
await scan('/path/to/file.pdf')
// spawns: clamscan --no-summary /path/to/file.pdf
No daemon required. One process per scan. Acceptable for low-volume or development use.
TCP
await scan('/path/to/file.pdf', { host: '127.0.0.1', port: 3310 })
Connects to a running clamd instance. Persistent process, faster than spawning clamscan per scan.
UNIX socket
await scan('/path/to/file.pdf', { socket: '/run/clamav/clamd.sock' })
No TCP overhead. Preferred for same-host production deployments. Takes precedence over host/port when set.
Production Patterns
Express with multer
const multer = require('multer')
const { scanBuffer, Verdict } = require('pompelmi')
const upload = multer({ storage: multer.memoryStorage() })
app.post('/upload', upload.single('file'), async (req, res) => {
let result
try {
result = await scanBuffer(req.file.buffer, {
host: process.env.CLAMAV_HOST,
port: parseInt(process.env.CLAMAV_PORT),
timeout: 5000
})
} catch (err) {
return res.status(503).json({ error: 'Scanner unavailable' })
}
if (result !== Verdict.Clean) {
return res.status(422).json({ error: 'File rejected' })
}
// write file to storage
res.json({ ok: true })
})
The memoryStorage approach means the file never touches disk before scanning. If the verdict is not Clean, nothing was written.
Docker Compose
services:
app:
build: .
environment:
CLAMAV_HOST: clamav
CLAMAV_PORT: 3310
depends_on:
clamav:
condition: service_healthy
clamav:
image: clamav/clamav:stable
healthcheck:
test: ["CMD", "clamdcheck.sh"]
interval: 60s
start_period: 120s
The service_healthy condition prevents the application from starting before ClamAV has finished loading its signature database, which takes 60-90 seconds on first run.
S3 integration
const { scanS3 } = require('pompelmi')
const result = await scanS3({
bucket: 'uploads',
key: 'user-files/document.pdf',
region: 'us-east-1'
}, {
socket: '/run/clamav/clamd.sock'
})
The S3 object is streamed directly to clamd without downloading to the application host first. Requires @aws-sdk/client-s3 as a peer dependency.
Connection Pool
For applications scanning at high volume, maintaining a persistent connection pool avoids the overhead of establishing a new socket per request:
const { createPool } = require('pompelmi')
const pool = createPool({
host: 'localhost',
port: 3310,
size: 5,
timeout: 5000
})
// reuses connections across requests
const result = await pool.scanBuffer(req.file.buffer)
Auto-retry
const result = await scan('file.pdf', {
host: 'localhost',
port: 3310,
retries: 3,
retryDelay: 1000
})
Retries on connection failure with a fixed delay between attempts. Useful when clamd restarts for signature updates.
GitHub Action
- uses: actions/checkout@v4
- name: Scan repository
uses: pompelmi/pompelmi@v1.12.0
with:
path: .
fail-on-virus: true
comment-on-pr: true
The Action bundles ClamAV inside a Docker image. No setup required in the workflow. On each run, freshclam updates the signature database before scanning. If infected files are found, the step exits with code 1 and posts a summary comment on the PR.
CLI
npx pompelmi scan ./uploads --recursive
npx pompelmi scan ./uploads --json
npx pompelmi watch ./uploads
Exit codes follow Unix conventions: 0 (clean), 1 (infected), 2 (scan error), 3 (clamd unreachable). The --json flag outputs machine-readable results for use in shell pipelines.
TypeScript
Type declarations are included in the package:
import { scan, scanBuffer, Verdict } from 'pompelmi'
import type { ScanOptions, ScanResult } from 'pompelmi'
const options: ScanOptions = { host: 'localhost', port: 3310 }
const result: ScanResult = await scan('/path/to/file.pdf', options)
What It Does Not Do
pompelmi is a signature-based scanner. It detects known malware patterns from ClamAV's signature database. It does not:
- Sandbox or execute files
- Detect zero-day exploits or unknown malware
- Replace network-level filtering
- Make guarantees about encrypted archives or polyglot files
\ The appropriate framing is: pompelmi eliminates the boilerplate required to run ClamAV from Node.js. Whether that scanning is sufficient for a given threat model is a separate question.
Implementation Notes
The library has zero runtime dependencies. The INSTREAM protocol implementation handles partial data events by accumulating response chunks until the socket closes or an expected terminator is received. Timeouts are implemented via socket.setTimeout with cleanup on both the happy path and error paths to avoid listener leaks.
\ The Symbol-based verdict API was chosen over strings or numeric codes after observing that ClamAV's response format has minor variations across versions and that string comparison is the most common source of integration bugs in raw ClamAV wrappers.
The repository is at github.com/pompelmi/pompelmi. The npm package is at npmjs.com/package/pompelmi. Full documentation is at pompelmi.app.
\
This content originally appeared on HackerNoon and was authored by SonoTommy
SonoTommy | Sciencx (2026-05-12T02:34:21+00:00) I Wrapped ClamAV for Node.js So You Don’t Have To. Retrieved from https://www.scien.cx/2026/05/12/i-wrapped-clamav-for-node-js-so-you-dont-have-to/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.