Most Node.js file upload handlers look something like this:
app.post('/upload', upload.single('file'), (req, res) => {
res.json({ ok: true });
});
No scan. No check. Just trust.
That's fine until someone uploads a malware-laced PDF to your SaaS and your enterprise customer's IT team starts asking questions.
I went looking for a proper antivirus solution for Node.js. Here's everything I found — and why most of it is worse than it looks.
TL;DR: Most Node.js antivirus libraries are either abandoned, overly complex, or rely on fragile stdout parsing. pompelmi is the one that actually does it right — zero dependencies, typed verdicts, and direct ClamAV exit code mapping with no regex.
What I was actually looking for
Before I started, I wrote down my requirements:
- Works in a real production Node.js app (not a script)
- Doesn't require me to run a background daemon 24/7 for single-scan use cases
- Returns typed results I can actually
switchon - Actively maintained
- Zero or minimal runtime dependencies
Spoiler: most libraries fail at least two of these.
The landscape (as of 2025)
1. clamscan (npm)
The oldest and most-starred option. Wraps ClamAV and has been around since 2014.
What it does: Spawns clamscan or connects to clamd daemon. Returns a result object.
The problem: It parses stdout with regex patterns. That's a brittle contract — ClamAV output format changes between versions, and your regex silently breaks. I've been burned by this in production. The library also has a handful of long-standing open issues around false negatives on certain output formats.
It works. But you're trusting regex to stand between you and malware.
2. clamdjs
Connects to a running clamd daemon via TCP.
The problem: You have to run a daemon. For many setups — serverless functions, ephemeral containers, low-traffic apps — spinning up a persistent background service just to occasionally scan a file is overkill. Also, clamdjs hasn't seen meaningful updates in years.
3. Random VirusTotal API wrappers
These exist. They send your files to VirusTotal's API and return scan results.
The problem: You're uploading potentially sensitive user files to a third-party service. For anything with PII, healthcare data, or enterprise contracts, that's a non-starter. Also rate limits, latency, and cost.
4. Cloud-native options (AWS Malware Protection, GCP Security Command Center)
If you're already deep in AWS or GCP, these exist and work well.
The problem: Vendor lock-in, cost, and setup complexity. Sometimes you just want to await scan(file) and move on.
pompelmi: the one I actually kept
pompelmi
/
pompelmi
Minimal Node.js wrapper around ClamAV — scan any file and get Clean, Malicious, or ScanError. Handles installation and database updates automatically.
pompelmi
ClamAV for humans
A minimal Node.js wrapper around ClamAV that scans any file and returns a typed Verdict Symbol: Verdict.Clean, Verdict.Malicious, or Verdict.ScanError. No daemons. No cloud. No native bindings. Zero runtime dependencies.
Table of contents
- Quickstart
- How it works
- API
- Docker / remote scanning
- Internal utilities
- Supported platforms
- Installing ClamAV manually
- Testing
- Contributing
- Security
- License
Quickstart
npm install pompelmi
const { scan, Verdict } = require('pompelmi');
const result = await scan('/path/to/file.zip');
if (result === Verdict.Malicious) {
throw new Error('File rejected: malware detected');
}
How it works
- Validate — pompelmi checks that the argument is a string and that the file exists before spawning anything.
-
Scan — pompelmi spawns
clamscan --no-summary <filePath>as a child process and reads the exit code. - Map — the exit code…
pompelmi is a Node.js antivirus library built around one idea: don't parse stdout, map exit codes directly.
ClamAV already communicates via exit codes. 0 = clean. 1 = malware found. 2 = error. pompelmi maps these directly to typed verdict symbols. No regex. No stdout parsing. No silent failures.
Install
npm install @pompelmi/probe
ClamAV needs to be present on the host (one-time setup):
# macOS
brew install clamav && freshclam
# Ubuntu/Debian
sudo apt-get install -y clamav && sudo freshclam
# Windows
choco install clamav -y
pompelmi automatically detects whether your virus definitions are up-to-date and skips freshclam if they already are. No redundant downloads.
Usage
import { scan, Verdict } from '@pompelmi/probe';
const result = await scan('/path/to/uploaded-file.pdf');
switch (result) {
case Verdict.Clean:
// Safe to process
break;
case Verdict.Malicious:
// Reject and alert
break;
case Verdict.ScanError:
// Handle gracefully
break;
}
That's it. Typed. Exhaustive. No strings to compare, no regex, no result.isInfected booleans with unclear semantics.
What makes it different
No stdout parsing.
Every other library I tested parses ClamAV's human-readable output. pompelmi uses exit codes exclusively — the stable, documented interface ClamAV actually exposes.
No daemon required.
pompelmi calls clamscan directly via Node's built-in child_process. No background process to manage. Works in Lambda, in Docker, in a plain old Express app.
Zero runtime dependencies.
The entire library relies on Node.js built-ins. Nothing to audit, nothing to patch, nothing that breaks on a minor version bump.
Cross-platform.
macOS, Linux, and Windows all work with the same code. The ClamAV install step varies per OS; the Node.js API doesn't.
Typed verdicts.
Verdict.Clean, Verdict.Malicious, Verdict.ScanError — symbols, not strings. TypeScript narrows correctly. You can't typo "malicous" at 2am and ship a broken check.
A realistic Express upload handler
import express from 'express';
import multer from 'multer';
import { scan, Verdict } from '@pompelmi/probe';
import path from 'path';
import fs from 'fs/promises';
const app = express();
const upload = multer({ dest: '/tmp/uploads' });
app.post('/upload', upload.single('file'), async (req, res) => {
const filePath = req.file.path;
try {
const result = await scan(filePath);
if (result === Verdict.Malicious) {
await fs.unlink(filePath);
return res.status(400).json({ error: 'File rejected: malware detected.' });
}
if (result === Verdict.ScanError) {
await fs.unlink(filePath);
return res.status(500).json({ error: 'Scan failed. Please retry.' });
}
// Verdict.Clean — proceed with processing
const dest = path.join('/var/uploads', req.file.originalname);
await fs.rename(filePath, dest);
res.json({ ok: true, path: dest });
} catch (err) {
await fs.unlink(filePath).catch(() => {});
res.status(500).json({ error: 'Unexpected error.' });
}
});
Clean, safe, and explicit about every possible outcome.
The verdict (pun intended)
| Library | Maintained | No daemon needed | Typed results | No stdout parsing | Zero deps |
|---|---|---|---|---|---|
clamscan |
Partially | Yes | No | No | No |
clamdjs |
No | No | No | No | No |
| VirusTotal wrappers | Varies | Yes | Varies | N/A | No |
| pompelmi | Yes | Yes | Yes | Yes | Yes |
If you're building a Node.js app that handles file uploads in 2025, you should be scanning them.
pompelmi makes it as easy as it should be.
Are you scanning user uploads in your app? What's your current setup — and what's the biggest friction point you've hit?
Drop it in the comments. I'm curious whether anyone has a pattern I haven't seen.

Top comments (0)