pompelmi
Fast file‑upload malware scanning for Node.js — optional YARA integration, ZIP deep‑inspection, and drop‑in adapters for Express, Koa, and Next.js. Private by design. Typed. Tiny.
Coverage badge reflects core library (src/**
); adapters are measured separately.
Documentation · Install · Quick‑start · GitHub Action · Adapters · Diagrams · Config · Production checklist · Quick test · Security · FAQ
Overview
pompelmi scans untrusted file uploads before they hit disk. A tiny, TypeScript-first toolkit for Node.js with composable scanners, deep ZIP inspection, and optional signature engines.
- Private by design — no outbound calls; bytes never leave your process
- Composable scanners — mix heuristics + signatures; set
stopOn
and timeouts - ZIP hardening — traversal/bomb guards, polyglot & macro hints
- Drop-in adapters — Express, Koa, Fastify, Next.js
- Typed & tiny — modern TS, minimal surface
Highlights
- Block risky uploads early — classify uploads as clean, suspicious, or malicious and stop them at the edge.
- Real guards — extension allow‑list, server‑side MIME sniff (magic bytes), per‑file size caps, and deep ZIP traversal with anti‑bomb limits.
- Built‑in scanners — drop‑in CommonHeuristicsScanner (PDF risky actions, Office macros, PE header) and Zip‑bomb Guard; add your own or YARA via a tiny
{ scan(bytes) }
contract. - Compose scanning — run multiple scanners in parallel or sequentially with timeouts and short‑circuiting via
composeScanners()
. - Zero cloud — scans run in‑process. Keep bytes private.
- DX first — TypeScript types, ESM/CJS builds, tiny API, adapters for popular web frameworks.
Keywords: file upload security, malware scanning, YARA, Node.js, Express, Koa, Next.js, ZIP scanning, ZIP bomb, PDF JavaScript, Office macros
Installation
# core library
npm i pompelmi
# or
pnpm add pompelmi
# or
yarn add pompelmi
Optional dev deps used in the examples:
npm i -D tsx express multer @koa/router @koa/multer koa next
Quick‑start
At a glance (policy + scanners)
// Compose built‑in scanners (no EICAR). Optionally add your own/YARA.
import { CommonHeuristicsScanner, createZipBombGuard, composeScanners } from 'pompelmi';
export const policy = {
includeExtensions: ['zip','png','jpg','jpeg','pdf'],
allowedMimeTypes: ['application/zip','image/png','image/jpeg','application/pdf','text/plain'],
maxFileSizeBytes: 20 * 1024 * 1024,
timeoutMs: 5000,
concurrency: 4,
failClosed: true,
onScanEvent: (ev: unknown) => console.log('[scan]', ev)
};
export const scanner = composeScanners(
[
['zipGuard', createZipBombGuard({ maxEntries: 512, maxTotalUncompressedBytes: 100 * 1024 * 1024, maxCompressionRatio: 12 })],
['heuristics', CommonHeuristicsScanner],
// ['yara', YourYaraScanner],
],
{ parallel: false, stopOn: 'suspicious', timeoutMsPerScanner: 1500, tagSourceName: true }
);
Express
import express from 'express';
import multer from 'multer';
import { createUploadGuard } from '@pompelmi/express-middleware';
import { policy, scanner } from './security'; // the snippet above
const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: policy.maxFileSizeBytes } });
app.post('/upload', upload.any(), createUploadGuard({ ...policy, scanner }), (req, res) => {
res.json({ ok: true, scan: (req as any).pompelmi ?? null });
});
app.listen(3000, () => console.log('http://localhost:3000'));
Koa
import Koa from 'koa';
import Router from '@koa/router';
import multer from '@koa/multer';
import { createKoaUploadGuard } from '@pompelmi/koa-middleware';
import { policy, scanner } from './security';
const app = new Koa();
const router = new Router();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: policy.maxFileSizeBytes } });
router.post('/upload', upload.any(), createKoaUploadGuard({ ...policy, scanner }), (ctx) => {
ctx.body = { ok: true, scan: (ctx as any).pompelmi ?? null };
});
app.use(router.routes()).use(router.allowedMethods());
app.listen(3003, () => console.log('http://localhost:3003'));
Next.js (App Router)
// app/api/upload/route.ts
import { createNextUploadHandler } from '@pompelmi/next-upload';
import { policy, scanner } from '@/lib/security';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const POST = createNextUploadHandler({ ...policy, scanner });
GitHub Action
Run pompelmi in CI to scan repository files or built artifacts.
Minimal usage
name: Security scan (pompelmi)
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan repository with pompelmi
uses: pompelmi/pompelmi/.github/actions/pompelmi-scan@v1
with:
path: .
deep_zip: true
fail_on_detect: true
Scan a single artifact
- uses: pompelmi/pompelmi/.github/actions/pompelmi-scan@v1
with:
artifact: build.zip
deep_zip: true
fail_on_detect: true
Inputs
| Input | Default | Description |
| --- | --- | --- |
| path
| .
| Directory to scan. |
| artifact
| ""
| Single file/archive to scan. |
| yara_rules
| ""
| Glob path to YARA rules (e.g. rules/*.yar
). |
| deep_zip
| true
| Enable deep nested-archive inspection. |
| max_depth
| 3
| Max nested-archive depth. |
| fail_on_detect
| true
| Fail the job if detections occur. |
The Action lives in this repo at
.github/actions/pompelmi-scan
. When published to the Marketplace, consumers can copy the snippets above as-is.
Adapters
Use the adapter that matches your web framework. All adapters share the same policy options and scanning contract.
Framework | Package | Status |
---|---|---|
Express | @pompelmi/express-middleware |
alpha |
Koa | @pompelmi/koa-middleware |
alpha |
Next.js (App Router) | @pompelmi/next-upload |
alpha |
Fastify | @pompelmi/fastify-plugin |
alpha |
NestJS | nestjs — planned | |
Remix | remix — planned | |
hapi | hapi plugin — planned | |
SvelteKit | sveltekit — planned |
Diagrams
Upload scanning flow
flowchart TD
A["Client uploads file(s)"] --> B["Web App Route"]
B --> C{"Pre-filters<br/>(ext, size, MIME)"}
C -- fail --> X["HTTP 4xx"]
C -- pass --> D{"Is ZIP?"}
D -- yes --> E["Iterate entries<br/>(limits & scan)"]
E --> F{"Verdict?"}
D -- no --> F{"Scan bytes"}
F -- malicious/suspicious --> Y["HTTP 422 blocked"]
F -- clean --> Z["HTTP 200 ok + results"]
mermaid
flowchart TD
A["Client uploads file(s)"] --> B["Web App Route"]
B --> C{"Pre-filters<br/>(ext, size, MIME)"}
C -- fail --> X["HTTP 4xx"]
C -- pass --> D{"Is ZIP?"}
D -- yes --> E["Iterate entries<br/>(limits & scan)"]
E --> F{"Verdict?"}
D -- no --> F{"Scan bytes"}
F -- malicious/suspicious --> Y["HTTP 422 blocked"]
F -- clean --> Z["HTTP 200 ok + results"]
Sequence (App ↔ pompelmi ↔ YARA)
sequenceDiagram
participant U as User
participant A as App Route (/upload)
participant P as pompelmi (adapter)
participant Y as YARA engine
U->>A: POST multipart/form-data
A->>P: guard(files, policies)
P->>P: MIME sniff + size + ext checks
alt ZIP archive
P->>P: unpack entries with limits
end
P->>Y: scan(bytes)
Y-->>P: matches[]
P-->>A: verdict (clean/suspicious/malicious)
A-->>U: 200 or 4xx/422 with reason
mermaid
sequenceDiagram
participant U as User
participant A as App Route (/upload)
participant P as pompelmi (adapter)
participant Y as YARA engine
U->>A: POST multipart/form-data
A->>P: guard(files, policies)
P->>P: MIME sniff + size + ext checks
alt ZIP archive
P->>P: unpack entries with limits
end
P->>Y: scan(bytes)
Y-->>P: matches[]
P-->>A: verdict (clean/suspicious/malicious)
A-->>U: 200 or 4xx/422 with reason
Components (monorepo)
flowchart LR
subgraph Repo
core["pompelmi (core)"]
express["@pompelmi/express-middleware"]
koa["@pompelmi/koa-middleware"]
next["@pompelmi/next-upload"]
fastify(("fastify-plugin · planned"))
nest(("nestjs · planned"))
remix(("remix · planned"))
hapi(("hapi-plugin · planned"))
svelte(("sveltekit · planned"))
end
core --> express
core --> koa
core --> next
core -.-> fastify
core -.-> nest
core -.-> remix
core -.-> hapi
core -.-> svelte
mermaid
flowchart LR
subgraph Repo
core["pompelmi (core)"]
express["@pompelmi/express-middleware"]
koa["@pompelmi/koa-middleware"]
next["@pompelmi/next-upload"]
fastify(("fastify-plugin · planned"))
nest(("nestjs · planned"))
remix(("remix · planned"))
hapi(("hapi-plugin · planned"))
svelte(("sveltekit · planned"))
end
core --> express
core --> koa
core --> next
core -.-> fastify
core -.-> nest
core -.-> remix
core -.-> hapi
core -.-> svelte
Configuration
All adapters accept a common set of options:
Option | Type (TS) | Purpose |
---|---|---|
scanner |
{ scan(bytes: Uint8Array): Promise<Match[]> } |
Your scanning engine. Return [] when clean; non‑empty to flag. |
includeExtensions |
string[] |
Allow‑list of file extensions. Evaluated case‑insensitively. |
allowedMimeTypes |
string[] |
Allow‑list of MIME types after magic‑byte sniffing. |
maxFileSizeBytes |
number |
Per‑file size cap. Oversize files are rejected early. |
timeoutMs |
number |
Per‑file scan timeout; guards against stuck scanners. |
concurrency |
number |
How many files to scan in parallel. |
failClosed |
boolean |
If true , errors/timeouts block the upload. |
onScanEvent |
(event: unknown) => void |
Optional telemetry hook for logging/metrics. |
Common recipes
Allow only images up to 5 MB:
includeExtensions: ['png','jpg','jpeg','webp'],
allowedMimeTypes: ['image/png','image/jpeg','image/webp'],
maxFileSizeBytes: 5 * 1024 * 1024,
failClosed: true,
Production checklist
- [ ] Limit file size aggressively (
maxFileSizeBytes
). - [ ] Restrict extensions & MIME to what your app truly needs.
- [ ] Set
failClosed: true
in production to block on timeouts/errors. - [ ] Handle ZIPs carefully (enable deep ZIP, keep nesting low, cap entry sizes).
- [ ] Compose scanners with
composeScanners()
and enablestopOn
to fail fast on early detections. - [ ] Log scan events (
onScanEvent
) and monitor for spikes. - [ ] Run scans in a separate process/container for defense‑in‑depth when possible.
- [ ] Sanitize file names and paths if you persist uploads.
- [ ] Prefer memory storage + post‑processing; avoid writing untrusted bytes before policy passes.
- [ ] Add CI scanning with the GitHub Action to catch bad files in repos/artifacts.
Quick test (no EICAR)
Use the examples above, then send a minimal PDF that contains risky tokens (this triggers the built‑in heuristics).
1) Create a tiny PDF with risky actions
Linux:
printf '%%PDF-1.7\n1 0 obj\n<< /OpenAction 1 0 R /AA << /JavaScript (alert(1)) >> >>\nendobj\n%%EOF\n' > risky.pdf
macOS:
printf '%%PDF-1.7\n1 0 obj\n<< /OpenAction 1 0 R /AA << /JavaScript (alert(1)) >> >>\nendobj\n%%EOF\n' > risky.pdf
2) Send it to your endpoint
Express (default from the Quick‑start):
curl -F "file=@risky.pdf;type=application/pdf" http://localhost:3000/upload -i
You should see an HTTP 422 Unprocessable Entity (blocked by policy). Clean files return 200 OK. Pre‑filter failures (size/ext/MIME) should return a 4xx. Adapt these conventions to your app as needed.
Security notes
- The library reads bytes; it never executes files.
- YARA detections depend on the rules you provide; expect some false positives/negatives.
- ZIP scanning applies limits (entries, per‑entry size, total uncompressed, nesting) to reduce archive‑bomb risk.
- Prefer running scans in a dedicated process/container for defense‑in‑depth.
Star history
FAQ
Do I need YARA?
No. scanner
is pluggable. The examples use a minimal scanner for clarity; you can call out to a YARA engine or any other detector you prefer.
Where do the results live?
In the examples, the guard attaches scan data to the request context (e.g. req.pompelmi
in Express, ctx.pompelmi
in Koa). In Next.js, include the results in your JSON response as you see fit.
Why 422 for blocked files?
Using 422 to signal a policy violation keeps it distinct from transport errors; it’s a common pattern. Use the codes that best match your API guidelines.
Are ZIP bombs handled?
Archives are traversed with limits to reduce archive‑bomb risk. Keep your size limits conservative and prefer failClosed: true
in production.
Tests & Coverage
Run tests locally with coverage:
pnpm vitest run --coverage --passWithNoTests
The badge tracks the core library (src/**
). Adapters and engines are reported separately for now and will be folded into global coverage as their suites grow.
If you integrate Codecov in CI, upload coverage/lcov.info
and you can use this Codecov badge:
[](https://codecov.io/gh/pompelmi/pompelmi)
Contributing
PRs and issues welcome! Start with:
pnpm -r build
pnpm -r lint
License
MIT © 2025‑present pompelmi contributors