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

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 result = await scanFile('/path/to/upload.zip');
// "Clean" or "Malicious" — 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 pompelmi = require('pompelmi');

const result = await pompelmi.scan('/path/to/file.zip');
// "Clean" | "Malicious" | "ScanError"
Enter fullscreen mode Exit fullscreen mode

That's the entire API. One function. Three possible results.

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 pompelmi = require('pompelmi');
const multer = require('multer');

app.post('/upload', upload.single('file'), async (req, res) => {
  const result = await pompelmi.scan(req.file.path);

  if (result === 'Malicious') {
    fs.unlinkSync(req.file.path);
    return res.status(400).json({ error: 'Malware detected' });
  }

  if (result === '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>
  3. Read the exit code
  4. Map it to a string

No stdout parsing. No regex. No daemon. No socket. Just a child process and an exit code.

Exit 0 → "Clean"
Exit 1 → "Malicious"  
Exit 2 → "ScanError"
Enter fullscreen mode Exit fullscreen mode

ClamAV has been doing this reliably for 20 years. I just wrapped it in a function.

"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. 😁