Pompelmi — Add Antivirus Scanning to Your Node.js App in 5 Minutes
Building a file upload feature? Then you've already asked yourself this question:
"Should I be scanning these files for malware?"
Yes. You should.
And the follow-up question is usually:
"How complicated is that going to be?"
With pompelmi, the answer is: barely at all.
🍊 What Is Pompelmi?
Pompelmi is a minimal Node.js wrapper around ClamAV — the open-source antivirus engine maintained by Cisco Talos. It scans any file and returns a typed Verdict Symbol:
| Verdict | Meaning |
|---|---|
Verdict.Clean |
No threats found ✅ |
Verdict.Malicious |
A known malware signature was matched 🚨 |
Verdict.ScanError |
Scan failed — treat the file as untrusted ⚠️ |
No daemons to manage. No external API calls. No native bindings. Zero runtime dependencies.
🔧 Setup
1. Install ClamAV on the Host System
ClamAV must be present on the machine running your app. Pompelmi does not bundle or download it.
macOS:
brew install clamav && freshclam
Linux (Debian / Ubuntu):
sudo apt-get install -y clamav clamav-daemon && sudo freshclam
Windows (Chocolatey):
choco install clamav -y
freshclamdownloads the virus signature database. This step is essential — without an up-to-date database, ClamAV won't recognize anything.
2. Install Pompelmi
npm install pompelmi
Done. Zero peer dependencies, zero surprises.
🚀 Quick Start
const { scan, Verdict } = require('pompelmi');
const result = await scan('/path/to/uploaded-file.zip');
if (result === Verdict.Malicious) {
throw new Error('File rejected: malware detected');
}
console.log(result.description); // "Clean"
Three effective lines. That's all the complexity you need for the base case.
⚙️ How It Works Under the Hood
Pompelmi is deliberately transparent about what it does:
- Validate — checks that the argument is a string and that the file exists before spawning anything
-
Scan — spawns
clamscan --no-summary <filePath>as a child process and reads the exit code - Map — the exit code is converted to the corresponding Verdict. Unknown codes and spawn errors reject the Promise
No stdout parsing. No regex. No hidden magic.
🛡️ A Robust Production Pattern
In the real world you want to handle all three outcomes carefully:
const { scan, Verdict } = require('pompelmi');
const path = require('path');
async function scanUploadedFile(filePath) {
try {
const result = await scan(path.resolve(filePath));
switch (result) {
case Verdict.Clean:
console.log(`✅ File is safe: ${filePath}`);
return { safe: true };
case Verdict.Malicious:
console.warn(`🚨 Malware detected: ${filePath}`);
return { safe: false, reason: 'malware_detected' };
case Verdict.ScanError:
// Incomplete scan = untrusted file
console.warn(`⚠️ Scan failed, rejecting file: ${filePath}`);
return { safe: false, reason: 'scan_error' };
default:
return { safe: false, reason: 'unknown' };
}
} catch (err) {
console.error('Critical scan error:', err.message);
return { safe: false, reason: 'exception' };
}
}
💡 Golden rule: treat
ScanErrorexactly likeMalicious. When in doubt, reject. A false positive is far less damaging than letting malware through undetected.
🔍 Reading the Verdict as a String
Every Verdict Symbol exposes a .description property for logging and serialisation:
console.log(Verdict.Clean.description); // "Clean"
console.log(Verdict.Malicious.description); // "Malicious"
console.log(Verdict.ScanError.description); // "ScanError"
This lets you save it to a database or send it over an API without comparing raw Symbols throughout your entire codebase.
🐳 Remote Scanning via clamd (Perfect for Docker)
If you're running ClamAV as a separate microservice — a very common pattern in containerised setups — pompelmi supports scanning via a TCP socket:
const result = await scan('/path/to/file.pdf', {
host: '10.0.0.5', // ClamAV container address
port: 3310,
timeout: 5000 // ms
});
A typical docker-compose.yml setup might look like this:
services:
app:
build: .
environment:
- CLAMAV_HOST=clamav
- CLAMAV_PORT=3310
depends_on:
- clamav
clamav:
image: clamav/clamav:stable
ports:
- "3310:3310"
And in your application code:
const result = await scan(filePath, {
host: process.env.CLAMAV_HOST,
port: parseInt(process.env.CLAMAV_PORT),
});
Clean, scalable, and the signature database updates independently from your application.
🧪 Testing
Pompelmi has a well-thought-out testing approach:
-
Unit tests (
test/unit.test.js) — run with Node's built-in test runner. They mocknativeSpawnwithout requiring ClamAV to be installed. -
Integration tests (
test/scan.test.js) — spawn realclamscanprocesses against EICAR test files. They are skipped automatically ifclamscanis not in the PATH.
You can use EICAR files to test your integration locally:
# Download the EICAR test file (the standard antivirus test signature)
curl -o test-eicar.txt https://secure.eicar.org/eicar.com.txt
const result = await scan('./test-eicar.txt');
console.log(result === Verdict.Malicious); // true
The EICAR file is not actually malicious — it's a standardised test string that every compliant antivirus engine recognises as a "safe" way to confirm detection is working.
🔄 Express.js Integration Example
Here's a complete, practical example integrating pompelmi into an Express upload endpoint using multer:
const express = require('express');
const multer = require('multer');
const { scan, Verdict } = require('pompelmi');
const path = require('path');
const fs = require('fs');
const app = express();
const upload = multer({ dest: '/tmp/uploads/' });
app.post('/upload', upload.single('file'), async (req, res) => {
const filePath = req.file?.path;
if (!filePath) {
return res.status(400).json({ error: 'No file provided' });
}
try {
const result = await scan(path.resolve(filePath));
// Always clean up the temp file
fs.unlinkSync(filePath);
if (result === Verdict.Malicious) {
return res.status(422).json({
error: 'File rejected',
reason: 'Malware detected'
});
}
if (result === Verdict.ScanError) {
return res.status(422).json({
error: 'File rejected',
reason: 'Scan could not complete — file treated as untrusted'
});
}
// Verdict.Clean — proceed with your business logic
return res.status(200).json({ message: 'File accepted and is clean' });
} catch (err) {
// Clean up on unexpected error
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
return res.status(500).json({ error: 'Internal scan error' });
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
📋 When to Use Pompelmi
✅ Great fit for:
- File upload features in SaaS applications
- Validating attachments before archiving or processing
- CI/CD pipelines that analyse build artefacts
- Environments where data must never leave your own infrastructure
- Teams that want security without the overhead of a managed service
⚠️ Consider supplementing with additional tools if:
- You need protection against zero-day threats (ClamAV is signature-based, not behavioural)
- You're handling extremely high-risk files in critical security contexts
- Your threat model includes sophisticated, targeted attacks
For the vast majority of web applications that accept user uploads, pompelmi offers exactly the right level of protection for the complexity it introduces — which is close to zero.
🗺️ Pompelmi vs. Alternatives
| pompelmi | clamscan (direct) | Managed API (e.g. VirusTotal) | |
|---|---|---|---|
| Setup complexity | Very low | Medium | Low |
| Runtime dependencies | Zero | ClamAV | HTTP client |
| Data leaves server | ❌ Never | ❌ Never | ✅ Yes |
| Zero-day detection | ❌ No | ❌ No | ✅ Yes |
| Cost | Free | Free | Paid tiers |
| Node.js integration | Native | Manual | Manual |
| Remote/Docker support | ✅ Yes | ❌ No | ✅ Yes |
🔐 Security Considerations
A few things to keep in mind when running pompelmi in production:
Keep the signature database updated. ClamAV is only as good as its latest signatures. Run
freshclamon a schedule (daily is typical).Scan before storing. Always scan the file in a temporary location before moving it to permanent storage. Never store an unscanned file.
Don't rely on file extensions. A
.txtfile can contain a malicious payload. Always scan the actual binary content.Limit file size upstream. Large files slow down scans significantly. Enforce size limits at the HTTP layer before the file reaches pompelmi.
Run ClamAV as a separate user. Avoid running it with root privileges. A dedicated
clamavsystem user is the standard practice.
🔗 Resources
- 📦 pompelmi on npm
- 🐙 pompelmi on GitHub
- 📖 pompelmi API docs
- 🛡️ ClamAV official site
- 🐳 ClamAV Docker image
Wrapping Up
Security doesn't have to mean complexity. Pompelmi proves that a well-scoped, focused library can handle a serious problem — malware detection — with minimal friction for the developer.
If you're shipping a Node.js app that handles user files and you're not scanning them yet, there's no longer a good excuse not to. A single npm install and a handful of lines stands between you and a meaningful layer of protection.
Got questions or want to contribute? The project is open source and welcomes pull requests.
Found this useful? Drop a ❤️ and share it with a fellow developer building something users trust with their files.
Top comments (0)