DEV Community

Tommaso Bertocchi
Tommaso Bertocchi

Posted on

Pompelmi — Add Antivirus Scanning to Your Node.js App in 5 Minutes

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

Linux (Debian / Ubuntu):

sudo apt-get install -y clamav clamav-daemon && sudo freshclam
Enter fullscreen mode Exit fullscreen mode

Windows (Chocolatey):

choco install clamav -y
Enter fullscreen mode Exit fullscreen mode

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

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

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:

  1. Validate — checks that the argument is a string and that the file exists before spawning anything
  2. Scan — spawns clamscan --no-summary <filePath> as a child process and reads the exit code
  3. 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' };
  }
}
Enter fullscreen mode Exit fullscreen mode

💡 Golden rule: treat ScanError exactly like Malicious. 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"
Enter fullscreen mode Exit fullscreen mode

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

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

And in your application code:

const result = await scan(filePath, {
  host: process.env.CLAMAV_HOST,
  port: parseInt(process.env.CLAMAV_PORT),
});
Enter fullscreen mode Exit fullscreen mode

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 mock nativeSpawn without requiring ClamAV to be installed.
  • Integration tests (test/scan.test.js) — spawn real clamscan processes against EICAR test files. They are skipped automatically if clamscan is 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
Enter fullscreen mode Exit fullscreen mode
const result = await scan('./test-eicar.txt');
console.log(result === Verdict.Malicious); // true
Enter fullscreen mode Exit fullscreen mode

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

📋 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:

  1. Keep the signature database updated. ClamAV is only as good as its latest signatures. Run freshclam on a schedule (daily is typical).

  2. Scan before storing. Always scan the file in a temporary location before moving it to permanent storage. Never store an unscanned file.

  3. Don't rely on file extensions. A .txt file can contain a malicious payload. Always scan the actual binary content.

  4. Limit file size upstream. Large files slow down scans significantly. Enforce size limits at the HTTP layer before the file reaches pompelmi.

  5. Run ClamAV as a separate user. Avoid running it with root privileges. A dedicated clamav system user is the standard practice.


🔗 Resources


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)