DEV Community

Cover image for I Spent 3 Hours Adding Antivirus to My Express App. Then I Reduced It to 3 Lines.
Tommaso Bertocchi
Tommaso Bertocchi

Posted on • Edited on

I Spent 3 Hours Adding Antivirus to My Express App. Then I Reduced It to 3 Lines.

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 clamd daemon.
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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' });
  }
});
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

How It Works (The Boring Way)

pompelmi doesn't do anything clever:

  1. Check that the file exists
  2. Spawn clamscan --no-summary <path> using Node's native child_process
  3. Read the exit code
  4. 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
Enter fullscreen mode Exit fullscreen mode

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') { ... }
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Upgrading from v1.1.x? Replace any result === 'Clean' checks with result === Verdict.Clean (and same for Malicious and ScanError). Don't forget to import Verdict alongside scan.

"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
Enter fullscreen mode Exit fullscreen mode

GitHub · npm


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)

Collapse
 
sklieren profile image
Ben K.

Have you given clam in docker a chance already? Tbh, this is something I haven't thought about yet... 😁

Collapse
 
sonotommy profile image
Tommaso Bertocchi

That's a great suggestion! I'm currently working on it, and you'll definitely see it included in the 1.1.x release. 😁