Two years ago, I shipped my first production app with file uploads. A week later, my mentor asked: "Are you scanning those files for malware?"
I wasn't.
The Google Rabbit Hole
Like any developer, I Googled "node js antivirus file upload." Here's what I found:
- ClamAV — the open-source standard. Great! Let's use it.
- clamscan npm package — 47 configuration options.
-
clamav.js — requires running
clamddaemon. - Various tutorials — "First, configure your socket connection..."
I just wanted to scan a file. Why did I need to understand Unix sockets?
My First Attempt
After an hour of reading docs, I had something like this:
const NodeClam = require('clamscan');
const clamscan = new NodeClam().init({
removeInfected: false,
quarantineInfected: false,
scanLog: null,
debugMode: false,
fileList: null,
scanRecursively: true,
clamscan: {
path: '/usr/bin/clamscan',
db: null,
scanArchives: true,
active: true
},
clamdscan: {
socket: '/var/run/clamav/clamd.ctl',
host: false,
port: false,
timeout: 60000,
localFallback: true,
path: '/usr/bin/clamdscan',
configFile: null,
multiscan: true,
reloadDb: false,
active: true,
bypassTest: false,
},
preference: 'clamdscan'
});
// ... 30 more lines to actually scan a file
It didn't work. The socket path was wrong on my Mac. I spent another hour debugging.
The Daemon Problem
Most ClamAV wrappers assume you're running clamd — a background daemon that keeps virus definitions in memory for faster scanning.
This makes sense for high-throughput enterprise systems. But I was building a side project. I didn't want to manage a daemon. I didn't want to configure systemd. I didn't want to think about socket permissions.
I just wanted to answer one question: is this file safe?
What I Actually Needed
After three hours of frustration, I wrote down what I actually wanted:
const { scan, Verdict } = require('pompelmi');
const result = await scan('/path/to/upload.zip');
// Verdict.Clean | Verdict.Malicious | Verdict.ScanError — that's it
No daemon. No configuration object. No socket paths. Just a function that takes a file and returns an answer.
So I built it.
pompelmi: ClamAV for Humans
const { scan, Verdict } = require('pompelmi');
const result = await pompelmi.scan('/path/to/file.zip');
if (result === Verdict.Malicious) {
throw new Error('File rejected: malware detected');
}
That's the entire API. One function. Three possible results — typed as Symbols, so no typos, no accidental string mismatches.
Here's the same Express middleware, before and after:
Before (47 lines)
const NodeClam = require('clamscan');
const multer = require('multer');
// ... 35 lines of configuration ...
app.post('/upload', upload.single('file'), async (req, res) => {
try {
const clam = await clamscan;
const {isInfected, viruses} = await clam.isInfected(req.file.path);
if (isInfected) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Malware detected', viruses });
}
res.json({ success: true });
} catch (err) {
// Is this a scan error or a config error? Who knows!
res.status(500).json({ error: 'Scan failed' });
}
});
After (12 lines)
const { scan, Verdict } = require('pompelmi');
const multer = require('multer');
app.post('/upload', upload.single('file'), async (req, res) => {
const result = await scan(req.file.path);
if (result === Verdict.Malicious) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Malware detected' });
}
if (result === Verdict.ScanError) {
// Scan couldn't complete — treat as untrusted
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'File rejected' });
}
res.json({ success: true });
});
How It Works (The Boring Way)
pompelmi doesn't do anything clever:
- Check that the file exists
- Spawn
clamscan --no-summary <path>using Node's nativechild_process - Read the exit code
- Map it to a Symbol
No stdout parsing. No regex. No daemon. No socket. No third-party spawn library. Just a child process and an exit code.
Exit 0 → Verdict.Clean
Exit 1 → Verdict.Malicious
Exit 2 → Verdict.ScanError
ClamAV has been doing this reliably for 20 years. I just wrapped it in a function.
Why Symbols, Not Strings?
Since v1.2.0, scan results are Symbols, not plain strings. This means:
// ✅ This works
if (result === Verdict.Malicious) { ... }
// ❌ This will never match — intentionally
if (result === 'Malicious') { ... }
It prevents a whole class of bugs where you typo a string comparison ('malicious' vs 'Malicious') and your security check silently does nothing. The compiler catches it for you.
If you need the verdict as a string for logging, each Symbol has a .description property:
console.log(result.description); // "Clean", "Malicious", or "ScanError"
Upgrading from v1.1.x? Replace any
result === 'Clean'checks withresult === Verdict.Clean(and same forMaliciousandScanError). Don't forget to importVerdictalongsidescan.
"But What About Performance?"
Yes, spawning clamscan for every file is slower than using the daemon. Each scan loads the virus database from disk (~300MB).
For a side project handling 100 uploads/day? Doesn't matter.
For a startup processing 10,000 files/hour? You probably have a dedicated security engineer who knows how to configure clamd.
pompelmi is for the 99% of developers who just need something — and "slow but working" beats "fast but misconfigured."
The Point
Developer tools should meet you where you are.
When I was a junior developer, I didn't need 47 configuration options. I needed one function that worked. I needed to ship something on Friday and not think about Unix sockets.
If you're building something bigger, you'll outgrow pompelmi. That's fine. By then, you'll understand why you need the daemon, and configuring it won't feel like dark magic.
But if you're building your first app with file uploads, and you just want to scan for malware without a 3-hour detour — npm install pompelmi.
npm install pompelmi
pompelmi is Italian for "grapefruits." I named it that because I was eating one when I finally got the code working at 2am. Sometimes that's all the reason you need.
Top comments (2)
Have you given clam in docker a chance already? Tbh, this is something I haven't thought about yet... 😁
That's a great suggestion! I'm currently working on it, and you'll definitely see it included in the 1.1.x release. 😁