pompelmi
Fast file-upload malware scanning for Node.js.
pompelmi — ClamAV Antivirus Scanning for Node.js
ClamAV antivirus scanning for Node.js — clean, typed, zero dependencies.
Documentation
| Guide | Description |
|---|---|
| Getting Started | Installation, prerequisites, quickstart examples |
| API Reference | Full function signatures, options, verdicts, error conditions |
| Docker / Remote Scanning | TCP sidecar, UNIX socket mount, docker-compose patterns |
| GitHub Action | CI scanning, inputs/outputs, caching, example workflows |
Overview
pompelmi is a minimal Node.js wrapper around ClamAV that exposes a single async function — scan() — and returns one of three typed verdict Symbols: Verdict.Clean, Verdict.Malicious, or Verdict.ScanError. Full documentation at pompelmi.app.
It supports two scanning modes:
- Local — spawns
clamscanas a child process and maps its exit code to a verdict. No stdout parsing, no regex. - Remote / Docker / UNIX socket — streams the file to a running
clamddaemon over TCP or a UNIX domain socket using the ClamAVINSTREAMprotocol.
No cloud. No daemon required for local mode. No native bindings. Zero runtime dependencies.
Why pompelmi
If you need to scan file uploads for viruses in Node.js, integrate ClamAV with Express or Fastify, or add antivirus scanning to any upload pipeline, pompelmi is the simplest path.
Most integrations require parsing ClamAV's stdout with regex, managing a clamd daemon, or working around unmaintained packages. pompelmi does none of that: one function call, exit-code-mapped verdicts, zero dependencies.
Features
- Single
scan(filePath, [options])function — works locally or against a remote clamd instance scanBuffer(buffer, [options])— scan in-memory Buffers directly, no temp file required in TCP modescanStream(stream, [options])— scan a Readable stream directly. In TCP mode, streamed to clamd with no disk I/O.scanDirectory(dirPath, [options])— recursively scan every file in a directory, returns clean/malicious/errors arrays- Symbol-based verdicts (
Verdict.Clean/Verdict.Malicious/Verdict.ScanError) — typo-proof comparisons - Full clamd support via the INSTREAM protocol — TCP (
host/port) or UNIX socket (socket) with configurable timeout - Built-in helpers to install ClamAV and update virus definitions programmatically
- Works with Express, Fastify, and any other Node.js HTTP framework
- Zero runtime dependencies — ships nothing but source code
- Tested with EICAR standard antivirus test files
- CommonJS module; TypeScript type declarations available inline
Requirements
- Node.js — any LTS release (no native addons, no C++ bindings)
- ClamAV — must be installed on the host or reachable over TCP
pompelmi does not bundle or automatically download ClamAV. Install it once per machine (see Installing ClamAV).
Installation
See pompelmi.app for the full getting-started guide.
# npm
npm install pompelmi
# yarn
yarn add pompelmi
# pnpm
pnpm add pompelmi
Docker
Run ClamAV as a sidecar and point pompelmi at it — no local install needed on the application host.
# docker-compose.yml
services:
clamav:
image: clamav/clamav:stable
ports:
- "3310:3310"
const result = await scan('/path/to/upload.zip', {
host: '127.0.0.1',
port: 3310,
});
See Docker / remote scanning for details.
Usage
Basic scan
const { scan, Verdict } = require('pompelmi');
const result = await scan('/path/to/file.pdf');
if (result === Verdict.Clean) console.log('File is safe.');
if (result === Verdict.Malicious) throw new Error('Malware detected — file rejected.');
if (result === Verdict.ScanError) console.warn('Scan incomplete — treat file as untrusted.');
Express file upload
const express = require('express');
const multer = require('multer');
const fs = require('fs');
const { scan, Verdict } = require('pompelmi');
const upload = multer({ dest: './uploads' });
const app = express();
app.post('/upload', upload.single('file'), async (req, res) => {
const filePath = req.file.path;
try {
const result = await scan(filePath);
if (result === Verdict.Malicious) {
fs.unlinkSync(filePath);
return res.status(422).json({ error: 'Malicious file rejected.' });
}
if (result === Verdict.ScanError) {
fs.unlinkSync(filePath);
return res.status(422).json({ error: 'Scan incomplete — file rejected as precaution.' });
}
return res.json({ ok: true, file: req.file.filename });
} catch (err) {
fs.unlink(filePath, () => {});
return res.status(500).json({ error: `Scan failed: ${err.message}` });
}
});
app.listen(3000);
Fastify file upload
const Fastify = require('fastify');
const { pipeline } = require('stream/promises');
const fs = require('fs');
const path = require('path');
const { scan, Verdict } = require('pompelmi');
const app = Fastify({ logger: true });
app.register(require('@fastify/multipart'));
app.post('/upload', async (req, reply) => {
const data = await req.file();
const filePath = path.join('./uploads', `${Date.now()}-${data.filename}`);
await pipeline(data.file, fs.createWriteStream(filePath));
const result = await scan(filePath);
if (result !== Verdict.Clean) {
fs.unlinkSync(filePath);
return reply.code(422).send({ error: result.description });
}
return reply.send({ ok: true });
});
Full error handling
const { scan, Verdict } = require('pompelmi');
const path = require('path');
async function safeScan(filePath) {
try {
const result = await scan(path.resolve(filePath));
if (result === Verdict.ScanError) {
// clamscan exited with code 2 — I/O error, encrypted archive, etc.
console.warn('Scan could not complete — rejecting file as precaution.');
return null;
}
return result; // Verdict.Clean or Verdict.Malicious
} catch (err) {
// filePath not a string, file not found, clamscan not in PATH, etc.
console.error('Scan failed:', err.message);
return null;
}
}
Scan multiple files concurrently
const { scan } = require('pompelmi');
const files = ['/uploads/a.pdf', '/uploads/b.zip', '/uploads/c.png'];
const results = await Promise.all(files.map((f) => scan(f)));
Scan a Directory
const fs = require('fs');
const { scanDirectory } = require('pompelmi');
const results = await scanDirectory('/uploads');
console.log('Clean:', results.clean);
console.log('Malicious:', results.malicious);
console.log('Errors:', results.errors);
// Delete all malicious files
results.malicious.forEach(f => fs.unlinkSync(f));
Scan a Buffer
const { scanBuffer, Verdict } = require('pompelmi');
// Useful with multer memoryStorage or any in-memory upload
const result = await scanBuffer(req.file.buffer);
if (result === Verdict.Malicious) throw new Error('Malware detected.');
if (result === Verdict.ScanError) console.warn('Scan incomplete.');
Scan a Stream
const { scanStream, Verdict } = require('pompelmi');
const { Readable } = require('stream');
// Useful for S3 getObject, HTTP downloads, or any piped source
const stream = s3.getObject({ Bucket, Key }).createReadStream();
const result = await scanStream(stream);
if (result === Verdict.Malicious) throw new Error('Malware detected.');
if (result === Verdict.ScanError) console.warn('Scan incomplete.');
Docker / Remote Scanning
Pass host and port (or socket) to switch from the local clamscan CLI to the clamd daemon. Everything else — the returned verdicts, error types — is identical.
TCP:
const result = await scan('/path/to/file.zip', { host: '127.0.0.1', port: 3310 });
UNIX socket:
const result = await scan('/path/to/file.zip', { socket: '/run/clamav/clamd.sock' });
See docs/docker.md for Docker Compose examples, UNIX socket volume mounts, scanBuffer / scanStream in clamd mode, and connection retry patterns.
Configuration
pompelmi has no configuration file or environment variables. All options are passed directly to scan().
| Option | Type | Default | Description |
|---|---|---|---|
socket |
string |
— | Path to a clamd UNIX domain socket (e.g. /run/clamav/clamd.sock). Takes precedence over host/port when set. |
host |
string |
— | clamd hostname. Enables TCP mode when set. |
port |
number |
3310 |
clamd port. |
timeout |
number |
15000 |
Socket idle timeout in milliseconds (clamd mode only). |
When none of socket, host, or port is provided, pompelmi spawns clamscan --no-summary <filePath> locally.
API Reference
See docs/api.md for the full reference: function signatures, options table, verdict Symbols, error conditions, and error handling patterns.
Quick summary:
| Function | Input | clamd mode disk I/O |
|---|---|---|
scan(filePath, [options]) |
File path on disk | None (streamed) |
scanBuffer(buffer, [options]) |
Buffer |
None (streamed) |
scanStream(stream, [options]) |
Node.js Readable |
None (streamed) |
scanDirectory(dirPath, [options]) |
Directory path | None (streamed) |
All four functions accept the same options object and resolve to the same three verdict Symbols:
| Symbol | Meaning |
|---|---|
Verdict.Clean |
No threats found |
Verdict.Malicious |
Known signature matched |
Verdict.ScanError |
Scan could not complete — treat as untrusted |
Installing ClamAV
# macOS
brew install clamav && freshclam
# Linux (Debian / Ubuntu)
sudo apt-get install -y clamav clamav-daemon && sudo freshclam
# Windows (Chocolatey)
choco install clamav -y
Examples
The examples/ directory contains standalone runnable scripts and framework-specific starters.
Framework starters
| Directory | Description |
|---|---|
examples/express/ |
Full Express app with multer + pompelmi middleware |
examples/nextjs/ |
Next.js API route that scans raw upload bytes |
examples/nestjs/ |
NestJS guard wrapping pompelmi for route-level protection |
Standalone scripts
Each can be run with node examples/<name>.js.
| File | Description |
|---|---|
basic-scan.js |
Scan a single file and log the verdict |
scan-on-upload-express.js |
Express route: scan before saving |
scan-on-upload-fastify.js |
Fastify route: same pattern |
scan-with-options.js |
Remote clamd with custom host, port, timeout |
handle-scan-error.js |
Handle every verdict including hard rejections |
delete-on-malicious.js |
Auto-delete file if malicious |
quarantine-on-malicious.js |
Move infected file to a quarantine folder |
scan-multiple-files.js |
Concurrent scans with Promise.all |
scan-directory.js |
Recursively scan every file in a directory |
scan-buffer.js |
Scan an in-memory Buffer (multer memoryStorage) |
scan-stream.js |
Scan a Readable stream (S3, HTTP, pipes) |
rest-api-server.js |
Minimal HTTP server exposing POST /scan |
s3-scan-before-upload.js |
Scan locally, then upload to S3 only if clean |
cli-scan.js |
CLI tool: scan file paths, exit non-zero on threats |
scan-with-timeout.js |
Timeout patterns for local and remote scanning |
scan-pdf.js |
PDF upload with extension validation |
scan-image.js |
Image upload with extension validation |
scan-zip.js |
ZIP archive scan (ClamAV recurses automatically) |
install-clamav.js |
Programmatic ClamAV installation |
update-virus-database.js |
Programmatic virus DB update |
typescript-usage.ts |
TypeScript example with full type declarations |
GitHub Action
Scan any repository for viruses on every push or pull request — ClamAV is bundled inside a Docker container, virus definitions are auto-updated at runtime, and no external services are required.
Minimal usage
- uses: actions/checkout@v4
- name: Virus scan
uses: pompelmi/pompelmi@v1.7.0
Full example
- uses: actions/checkout@v4
- name: Virus scan
id: scan
uses: pompelmi/pompelmi@v1.7.0
with:
path: 'uploads/' # scan a subdirectory instead of the whole workspace
fail-on-virus: 'true' # fail the workflow step on detection (default)
- name: Print infected files
if: always()
run: echo "${{ steps.scan.outputs.infected-files }}"
Inputs
| Input | Description | Default |
|---|---|---|
path |
Directory or file to scan | . (full workspace) |
fail-on-virus |
Fail the workflow step when infected files are found | true |
comment-on-pr |
Post a PR comment listing infected files (requires GITHUB_TOKEN) |
true |
Outputs
| Output | Description |
|---|---|
infected-files |
Newline-separated list of infected file paths (empty when clean) |
status |
"clean" or "infected" |
A ready-to-copy workflow is available at .github/workflows/action-example.yml. Full reference — inputs, outputs, layer caching, and more examples — in docs/github-action.md.
Contributing
Full documentation and guides are available in the Wiki.
# 1. Clone and install dev dependencies
git clone https://github.com/pompelmi/pompelmi.git
cd pompelmi
npm install
# 2. Run the test suite
npm test
# 3. Lint
npm run lint
Tests
test/unit.test.js— runs with Node's built-in test runner. MocksnativeSpawnand platform dependencies; ClamAV is not required.test/scan.test.js— integration tests that spawn realclamscanagainst EICAR test files. Skipped automatically whenclamscanis not inPATH.
Submitting changes
- Fork the repository.
- Create a feature branch:
git checkout -b feat/your-change. - Make your changes and confirm
npm testpasses. - Open a pull request against
main.
Please read CODE_OF_CONDUCT.md before contributing. To report a security vulnerability, see SECURITY.md.
Coming soon
- AWS S3 integration — scan objects directly from S3 without downloading
- Cloudflare Workers support — edge-native scanning via the clamd TCP protocol
- NestJS official module —
PompelmiModule.forRoot()with injectablePompelmiService
License
ISC — © pompelmi contributors
pompelmi.app · npm · GitHub