pompelmi 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
  <p align="center">
12
12
  <a href="https://www.npmjs.com/package/pompelmi"><img src="https://img.shields.io/npm/v/pompelmi.svg" alt="npm version"></a>
13
13
  <a href="https://www.npmjs.com/package/pompelmi"><img src="https://img.shields.io/npm/dw/pompelmi" alt="weekly downloads"></a>
14
+ <a href="https://hub.docker.com/r/pompelmi/scanner"><img src="https://img.shields.io/docker/pulls/pompelmi/scanner" alt="Docker Pulls"></a>
14
15
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="zero dependencies">
15
16
  <img src="https://img.shields.io/badge/license-ISC-blue" alt="license">
16
17
  <a href="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml"><img src="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
@@ -73,6 +74,10 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
73
74
  ## Features
74
75
 
75
76
  - Standalone CLI — scan files from any terminal with `npx pompelmi scan`
77
+ - **Official Docker image** — `pompelmi/scanner` on Docker Hub: ClamAV + HTTP scan API in one pull ([docs](./docs/docker-image.html))
78
+ - **Security scorecard** — grade your upload security A–F with `npx pompelmi scorecard` ([docs](./docs/scorecard.html))
79
+ - **VS Code extension** — scan files directly from the IDE (scaffold in `packages/vscode/`) ([docs](./docs/vscode.html))
80
+ - **Quarantine mode** — `watch ./uploads --quarantine ./quarantine` auto-moves infected files with sidecar JSON
76
81
  - HTML security dashboard — generate beautiful scan reports with `--report` ([docs](./docs/dashboard.html))
77
82
  - SVG share card — shareable scan result card with `--share-card` ([docs](./docs/dashboard.html#share-card))
78
83
  - GitHub App — one-click installation for organizations, zero-config PR scanning ([docs](./docs/github-app.html))
@@ -89,12 +94,15 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
89
94
  - Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
90
95
  - Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
91
96
  - Built-in helpers to install ClamAV and update virus definitions programmatically
97
+ - **Native ESM support** — `import { scan } from 'pompelmi'` works out of the box (dual CJS/ESM build)
98
+ - **Deno support** — `import { scan } from 'npm:pompelmi'` — no install step required
99
+ - **Cloudflare Workers** — via `@pompelmi/cloudflare` — Web APIs only, no Node.js built-ins
92
100
  - Works with Express, Fastify, NestJS, Hono, Remix, SvelteKit, and any other Node.js HTTP framework
93
- - Works with Node.js and Bun uses `Bun.file()` for faster file reading when available
101
+ - Works with **Node.js Bun Deno Cloudflare Workers**
94
102
  - Interactive demo at [pompelmi.app/demo](https://pompelmi.app/demo.html) — try before you install
95
103
  - Zero runtime dependencies — ships nothing but source code
96
104
  - Tested with EICAR standard antivirus test files
97
- - CommonJS module; TypeScript type declarations available inline
105
+ - CommonJS + ESM module; TypeScript type declarations available inline
98
106
 
99
107
  See [how pompelmi compares](./docs/comparison.html) to other Node.js ClamAV integrations.
100
108
 
@@ -112,6 +120,7 @@ Official integration packages for popular frameworks:
112
120
  | [@pompelmi/remix](./packages/remix/) | Remix | `npm i @pompelmi/remix` |
113
121
  | [@pompelmi/sveltekit](./packages/sveltekit/) | SvelteKit | `npm i @pompelmi/sveltekit` |
114
122
  | [@pompelmi/testing](./packages/testing/) | Jest/Vitest/Node | `npm i -D @pompelmi/testing` |
123
+ | [@pompelmi/cloudflare](./packages/cloudflare/) | Cloudflare Workers | `npm i @pompelmi/cloudflare` |
115
124
 
116
125
  ### NestJS
117
126
 
@@ -197,6 +206,8 @@ export const actions: Actions = {
197
206
 
198
207
  - **Node.js** — any LTS release (no native addons, no C++ bindings)
199
208
  - **Bun** — fully supported; uses `Bun.file()` for faster file reading
209
+ - **Deno** — import from `npm:pompelmi` — no install step required
210
+ - **Cloudflare Workers** — via `@pompelmi/cloudflare` — connects to a remote clamd over TCP
200
211
  - **ClamAV** — must be installed on the host or reachable over TCP
201
212
 
202
213
  pompelmi does not bundle or automatically download ClamAV. Install it once per machine (see [Installing ClamAV](#installing-clamav)).
@@ -608,8 +619,8 @@ Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before contributing. To r
608
619
 
609
620
  ## Coming soon
610
621
 
611
- - [ ] Cloudflare Workers support — edge-native scanning via the clamd TCP protocol
612
- - [ ] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
622
+ - [x] Cloudflare Workers support — `@pompelmi/cloudflare` ships in v1.17.0
623
+ - [x] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
613
624
 
614
625
  ---
615
626
 
package/bin/pompelmi.js CHANGED
@@ -71,12 +71,14 @@ function parseArgs(argv) {
71
71
  reportOutput: null,
72
72
  shareCard: false,
73
73
  shareCardOutput: null,
74
+ quarantine: null,
75
+ scorecardConfig: null,
74
76
  };
75
77
 
76
78
  let i = 0;
77
79
  while (i < args.length) {
78
80
  const a = args[i];
79
- if (a === 'scan' || a === 'watch' || a === 'version' || a === 'help') {
81
+ if (a === 'scan' || a === 'watch' || a === 'version' || a === 'help' || a === 'scorecard') {
80
82
  opts.command = a;
81
83
  } else if (a === '--json') {
82
84
  opts.json = true;
@@ -104,6 +106,10 @@ function parseArgs(argv) {
104
106
  const next = args[++i];
105
107
  if (next && next.endsWith('.svg')) opts.shareCardOutput = next;
106
108
  else opts.reportOutput = next;
109
+ } else if (a === '--quarantine') {
110
+ opts.quarantine = args[++i];
111
+ } else if (a === '--config') {
112
+ opts.scorecardConfig = args[++i];
107
113
  } else if (!a.startsWith('-') && opts.command && !opts.target) {
108
114
  opts.target = a;
109
115
  }
@@ -333,7 +339,9 @@ async function cmdWatch(opts) {
333
339
  if (!opts.json && !opts.quiet) await printLogo();
334
340
 
335
341
  const { watch } = require('../src/Watcher.js');
336
- const scanOpts = buildScanOpts(opts);
342
+ const quarantineDir = opts.quarantine ? path.resolve(opts.quarantine) : null;
343
+ const watchOpts = { ...buildScanOpts(opts) };
344
+ if (quarantineDir) watchOpts.quarantine = quarantineDir;
337
345
 
338
346
  let scanned = 0, clean = 0, infected = 0;
339
347
 
@@ -345,7 +353,7 @@ async function cmdWatch(opts) {
345
353
 
346
354
  status();
347
355
 
348
- watch(target, scanOpts, {
356
+ watch(target, watchOpts, {
349
357
  onClean(fp) {
350
358
  scanned++; clean++;
351
359
  if (!opts.quiet) process.stdout.write(`\n\x1b[32m✅ CLEAN\x1b[0m ${fp}\n`);
@@ -354,6 +362,7 @@ async function cmdWatch(opts) {
354
362
  onMalicious(fp) {
355
363
  scanned++; infected++;
356
364
  process.stdout.write(`\n\x1b[31m🚨 INFECTED\x1b[0m ${fp}\n`);
365
+ if (quarantineDir && !opts.quiet) process.stdout.write(` Quarantined to ${quarantineDir}\n`);
357
366
  status();
358
367
  },
359
368
  onError(err) {
@@ -366,6 +375,44 @@ async function cmdWatch(opts) {
366
375
  process.on('SIGINT', () => { process.stdout.write('\n'); process.exit(0); });
367
376
  }
368
377
 
378
+ async function cmdScorecard(opts) {
379
+ let config = {};
380
+ if (opts.scorecardConfig) {
381
+ const cfgPath = path.resolve(opts.scorecardConfig);
382
+ if (!fs.existsSync(cfgPath)) {
383
+ console.error(`Error: config file not found: ${cfgPath}`);
384
+ process.exit(2);
385
+ }
386
+ config = require(cfgPath);
387
+ }
388
+
389
+ const { generateScorecard } = require('../src/Scorecard.js');
390
+ const scorecard = await generateScorecard(config);
391
+
392
+ const gradeColor = { A: '\x1b[32m', B: '\x1b[32m', C: '\x1b[33m', D: '\x1b[33m', F: '\x1b[31m' };
393
+ const color = gradeColor[scorecard.grade] || '\x1b[0m';
394
+ const reset = '\x1b[0m';
395
+
396
+ if (!opts.quiet) await printLogo();
397
+
398
+ console.log(`\n Upload Security Scorecard\n`);
399
+ console.log(` Grade: ${color}${scorecard.grade}${reset} Score: ${scorecard.score}/100\n`);
400
+
401
+ for (const f of scorecard.findings) {
402
+ const icon = f.status === 'pass' ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
403
+ console.log(` ${icon} ${f.check.padEnd(40)} (weight: ${f.weight})`);
404
+ }
405
+
406
+ if (scorecard.recommendations.length > 0) {
407
+ console.log(`\n Recommendations:`);
408
+ for (const r of scorecard.recommendations) {
409
+ console.log(` • ${r}`);
410
+ }
411
+ }
412
+
413
+ console.log('');
414
+ }
415
+
369
416
  function cmdVersion() {
370
417
  console.log(pkg.version);
371
418
  }
@@ -380,6 +427,7 @@ USAGE
380
427
  COMMANDS
381
428
  scan <file|dir> Scan a file or directory for viruses
382
429
  watch <dir> Watch a directory and auto-scan new/modified files
430
+ scorecard Grade your upload security configuration (A-F)
383
431
  version Print version number
384
432
  help Show this help message
385
433
 
@@ -400,6 +448,10 @@ SCAN OPTIONS
400
448
 
401
449
  WATCH OPTIONS
402
450
  --host, --port, --socket, --timeout (same as scan)
451
+ --quarantine <dir> Move infected files to this directory (auto-created)
452
+
453
+ SCORECARD OPTIONS
454
+ --config <file> Path to a pompelmi.config.js file with your upload config
403
455
 
404
456
  EXIT CODES
405
457
  0 All files clean
@@ -428,6 +480,12 @@ EXAMPLES
428
480
 
429
481
  # Use with npx (no install required)
430
482
  npx pompelmi scan ./uploads
483
+
484
+ # Watch a directory with quarantine
485
+ pompelmi watch ./uploads --quarantine ./quarantine
486
+
487
+ # Grade your upload security config
488
+ npx pompelmi scorecard --config ./pompelmi.config.js
431
489
  `);
432
490
  }
433
491
 
@@ -456,6 +514,11 @@ async function main() {
456
514
  await cmdWatch(opts);
457
515
  return;
458
516
  }
517
+
518
+ if (opts.command === 'scorecard') {
519
+ await cmdScorecard(opts);
520
+ return;
521
+ }
459
522
  }
460
523
 
461
524
  main().catch(err => {
package/deno.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "@pompelmi/pompelmi",
3
+ "version": "1.17.0",
4
+ "exports": "./src/index.js"
5
+ }
@@ -0,0 +1,18 @@
1
+ FROM node:20-slim
2
+
3
+ RUN apt-get update && \
4
+ apt-get install -y --no-install-recommends \
5
+ clamav clamav-freshclam curl && \
6
+ rm -rf /var/lib/apt/lists/* && \
7
+ sed -i 's/^Example/#Example/' /etc/clamav/freshclam.conf && \
8
+ mkdir -p /var/lib/clamav /run/clamav /uploads
9
+
10
+ WORKDIR /app
11
+ RUN npm install -g pompelmi
12
+
13
+ COPY docker/entrypoint.sh /entrypoint.sh
14
+ RUN chmod +x /entrypoint.sh
15
+
16
+ EXPOSE 3310 8080
17
+
18
+ ENTRYPOINT ["/entrypoint.sh"]
@@ -0,0 +1,62 @@
1
+ # pompelmi/scanner Docker Image
2
+
3
+ Official Docker image for pompelmi — ClamAV antivirus scanning for Node.js.
4
+
5
+ The image ships with:
6
+ - ClamAV + clamd pre-installed
7
+ - freshclam for automatic definition updates on startup
8
+ - A minimal HTTP API server on port 8080
9
+
10
+ ## Quick start
11
+
12
+ ```bash
13
+ docker pull pompelmi/scanner
14
+ docker run -p 8080:8080 pompelmi/scanner
15
+ ```
16
+
17
+ ## Scan a file
18
+
19
+ ```bash
20
+ curl -F "file=@./document.pdf" http://localhost:8080/scan
21
+ ```
22
+
23
+ Response:
24
+
25
+ ```json
26
+ { "verdict": "clean", "file": "document.pdf", "viruses": [] }
27
+ ```
28
+
29
+ For an infected file the verdict is `"malicious"`.
30
+
31
+ ## Endpoints
32
+
33
+ | Method | Path | Description |
34
+ |--------|-----------|---------------------------------------------------------|
35
+ | POST | /scan | Accepts `multipart/form-data` with a `file` field. Returns `{ verdict, file, viruses }` |
36
+ | GET | /health | Returns `{ status: "ok", clamd: "running" }` |
37
+ | GET | /stats | Returns scan statistics (total, clean, infected, errors, uptime) |
38
+
39
+ ## Docker Compose
40
+
41
+ ```yaml
42
+ services:
43
+ pompelmi:
44
+ image: pompelmi/scanner
45
+ ports:
46
+ - "8080:8080"
47
+ volumes:
48
+ - /uploads:/uploads
49
+ restart: unless-stopped
50
+ ```
51
+
52
+ ## Environment
53
+
54
+ The container exposes two ports:
55
+ - **8080** — HTTP scan API
56
+ - **3310** — clamd TCP socket (for direct clamd connections from other containers)
57
+
58
+ ## Building locally
59
+
60
+ ```bash
61
+ docker build -f docker/Dockerfile -t pompelmi/scanner .
62
+ ```
@@ -0,0 +1,148 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # ── Update ClamAV virus definitions ──────────────────────────────────────────
5
+ echo "[pompelmi] Updating ClamAV definitions via freshclam..."
6
+ freshclam --quiet || echo "[pompelmi] freshclam failed — continuing with existing definitions"
7
+
8
+ # ── Start clamd in background ─────────────────────────────────────────────────
9
+ echo "[pompelmi] Starting clamd..."
10
+ clamd &
11
+ CLAMD_PID=$!
12
+
13
+ # Wait for clamd socket/port to be ready
14
+ echo "[pompelmi] Waiting for clamd to be ready..."
15
+ for i in $(seq 1 30); do
16
+ if clamdscan --no-summary /dev/null 2>/dev/null; then
17
+ echo "[pompelmi] clamd is ready"
18
+ break
19
+ fi
20
+ sleep 1
21
+ done
22
+
23
+ # ── Stats counters (stored in files for simplicity) ───────────────────────────
24
+ STATS_DIR=/tmp/pompelmi-stats
25
+ mkdir -p "$STATS_DIR"
26
+ echo 0 > "$STATS_DIR/total"
27
+ echo 0 > "$STATS_DIR/clean"
28
+ echo 0 > "$STATS_DIR/infected"
29
+ echo 0 > "$STATS_DIR/errors"
30
+
31
+ # ── Minimal HTTP server ───────────────────────────────────────────────────────
32
+ # Uses Node.js (already available from the base image)
33
+ node - <<'EOF'
34
+ 'use strict';
35
+
36
+ const http = require('http');
37
+ const fs = require('fs');
38
+ const os = require('os');
39
+ const path = require('path');
40
+ const { execSync, exec } = require('child_process');
41
+ const { scanBuffer } = require('pompelmi');
42
+ const { Verdict } = require('pompelmi');
43
+
44
+ const PORT = 8080;
45
+ const STATS = { total: 0, clean: 0, infected: 0, errors: 0, startedAt: new Date().toISOString() };
46
+
47
+ function parseMultipart(req) {
48
+ return new Promise((resolve, reject) => {
49
+ const boundary = (req.headers['content-type'] || '').split('boundary=')[1];
50
+ if (!boundary) return reject(new Error('Missing multipart boundary'));
51
+
52
+ const chunks = [];
53
+ req.on('data', c => chunks.push(c));
54
+ req.on('error', reject);
55
+ req.on('end', () => {
56
+ const body = Buffer.concat(chunks);
57
+ const delim = Buffer.from('\r\n--' + boundary);
58
+ const parts = [];
59
+ let start = body.indexOf('--' + boundary);
60
+
61
+ while (start !== -1) {
62
+ const headerEnd = body.indexOf('\r\n\r\n', start);
63
+ if (headerEnd === -1) break;
64
+ const headerStr = body.slice(start, headerEnd).toString();
65
+ const nameMatch = headerStr.match(/name="([^"]+)"/);
66
+ const fileMatch = headerStr.match(/filename="([^"]+)"/);
67
+ const dataStart = headerEnd + 4;
68
+ const nextDelim = body.indexOf(delim, dataStart);
69
+ const dataEnd = nextDelim === -1 ? body.indexOf('\r\n--' + boundary + '--', dataStart) : nextDelim;
70
+ if (dataEnd === -1) break;
71
+ parts.push({
72
+ name: nameMatch ? nameMatch[1] : '',
73
+ filename: fileMatch ? fileMatch[1] : '',
74
+ data: body.slice(dataStart, dataEnd),
75
+ });
76
+ start = body.indexOf('--' + boundary, dataEnd);
77
+ if (body.slice(start, start + boundary.length + 6).toString().includes('--\r\n')) break;
78
+ }
79
+ resolve(parts);
80
+ });
81
+ });
82
+ }
83
+
84
+ function isClamdRunning() {
85
+ try { execSync('clamdscan --no-summary /dev/null 2>/dev/null'); return true; }
86
+ catch { return false; }
87
+ }
88
+
89
+ const server = http.createServer(async (req, res) => {
90
+ res.setHeader('Content-Type', 'application/json');
91
+
92
+ if (req.method === 'GET' && req.url === '/health') {
93
+ res.writeHead(200);
94
+ return res.end(JSON.stringify({ status: 'ok', clamd: isClamdRunning() ? 'running' : 'unavailable' }));
95
+ }
96
+
97
+ if (req.method === 'GET' && req.url === '/stats') {
98
+ res.writeHead(200);
99
+ return res.end(JSON.stringify({ ...STATS, uptime: process.uptime() }));
100
+ }
101
+
102
+ if (req.method === 'POST' && req.url === '/scan') {
103
+ try {
104
+ const parts = await parseMultipart(req);
105
+ const filePart = parts.find(p => p.filename);
106
+ if (!filePart) {
107
+ res.writeHead(400);
108
+ return res.end(JSON.stringify({ error: 'No file field in request' }));
109
+ }
110
+
111
+ STATS.total++;
112
+ const verdict = await scanBuffer(filePart.data);
113
+
114
+ let result;
115
+ if (verdict === Verdict.Clean) {
116
+ STATS.clean++;
117
+ result = { verdict: 'clean', file: filePart.filename, viruses: [] };
118
+ } else if (verdict === Verdict.Malicious) {
119
+ STATS.infected++;
120
+ result = { verdict: 'malicious', file: filePart.filename, viruses: [] };
121
+ } else {
122
+ STATS.errors++;
123
+ result = { verdict: 'error', file: filePart.filename, viruses: [] };
124
+ }
125
+
126
+ res.writeHead(200);
127
+ return res.end(JSON.stringify(result));
128
+ } catch (err) {
129
+ STATS.errors++;
130
+ res.writeHead(500);
131
+ return res.end(JSON.stringify({ error: err.message }));
132
+ }
133
+ }
134
+
135
+ res.writeHead(404);
136
+ res.end(JSON.stringify({ error: 'Not found' }));
137
+ });
138
+
139
+ server.listen(PORT, () => {
140
+ console.log(`[pompelmi] HTTP scan server listening on port ${PORT}`);
141
+ console.log(`[pompelmi] POST /scan — scan a file`);
142
+ console.log(`[pompelmi] GET /health — health check`);
143
+ console.log(`[pompelmi] GET /stats — scan statistics`);
144
+ });
145
+
146
+ process.on('SIGTERM', () => { server.close(); process.exit(0); });
147
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
148
+ EOF
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.16.0",
3
+ "version": "1.18.0",
4
4
  "description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
5
5
  "license": "ISC",
6
6
  "author": "pompelmi contributors",
@@ -35,12 +35,19 @@
35
35
  ],
36
36
  "type": "commonjs",
37
37
  "main": "./src/index.js",
38
+ "module": "./src/index.mjs",
38
39
  "types": "./types/index.d.ts",
40
+ "exports": {
41
+ ".": {
42
+ "import": "./src/index.mjs",
43
+ "require": "./src/index.js"
44
+ }
45
+ },
39
46
  "bin": {
40
47
  "pompelmi": "./bin/pompelmi.js"
41
48
  },
42
49
  "scripts": {
43
- "test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node --test packages/remix/test/index.test.js && node --test packages/sveltekit/test/index.test.js && node test/scan.test.js",
50
+ "test": "node --test test/unit.test.js && node --test test/esm.test.mjs && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node --test packages/remix/test/index.test.js && node --test packages/sveltekit/test/index.test.js && node --test packages/cloudflare/test/index.test.mjs && node test/scan.test.js",
44
51
  "lint": "eslint src/"
45
52
  },
46
53
  "publishConfig": {
@@ -0,0 +1,94 @@
1
+ # @pompelmi/cloudflare
2
+
3
+ Scan file uploads for malware inside **Cloudflare Workers** using a remote [ClamAV](https://www.clamav.net/) (clamd) instance.
4
+
5
+ Uses Web APIs only (`fetch`, `connect` from `cloudflare:sockets`) — no Node.js built-ins, fully compatible with the Workers Runtime.
6
+
7
+ ## Requirements
8
+
9
+ Cloudflare Workers cannot run clamd locally. You need a **publicly reachable** clamd instance. Options:
10
+
11
+ - A VPS running clamd with port 3310 open (add appropriate firewall rules).
12
+ - A Cloudflare Tunnel ([cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)) pointing to a private clamd instance.
13
+
14
+ > **Security note:** Restrict clamd access to your Worker's outbound IPs or use a shared secret at the application layer. Never expose clamd directly to the public internet without access controls.
15
+
16
+ ## Installation
17
+
18
+ ```
19
+ npm i @pompelmi/cloudflare
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```js
25
+ import { scanBuffer, scanRequest } from '@pompelmi/cloudflare';
26
+
27
+ export default {
28
+ async fetch(request, env) {
29
+ // Option A: scan the whole multipart form at once
30
+ const rejection = await scanRequest(request, {
31
+ host: env.CLAMAV_HOST,
32
+ port: parseInt(env.CLAMAV_PORT),
33
+ });
34
+ if (rejection) return rejection; // 422 or 500
35
+
36
+ return new Response('File accepted');
37
+ },
38
+ };
39
+ ```
40
+
41
+ Or scan an `ArrayBuffer` directly:
42
+
43
+ ```js
44
+ import { scanBuffer } from '@pompelmi/cloudflare';
45
+
46
+ export default {
47
+ async fetch(request, env) {
48
+ const formData = await request.formData();
49
+ const file = formData.get('file');
50
+ const buffer = await file.arrayBuffer();
51
+
52
+ const result = await scanBuffer(buffer, {
53
+ host: env.CLAMAV_HOST,
54
+ port: parseInt(env.CLAMAV_PORT),
55
+ });
56
+
57
+ if (result !== 'clean') {
58
+ return new Response('File rejected', { status: 422 });
59
+ }
60
+
61
+ return new Response('OK');
62
+ },
63
+ };
64
+ ```
65
+
66
+ ## API
67
+
68
+ ### `scanBuffer(buffer, options)`
69
+
70
+ | Parameter | Type | Description |
71
+ |---|---|---|
72
+ | `buffer` | `ArrayBuffer` | The file bytes to scan |
73
+ | `options.host` | `string` | clamd hostname or IP (required) |
74
+ | `options.port` | `number` | clamd port, typically `3310` (required) |
75
+ | `options.timeout` | `number` | Read timeout in ms (default: `15000`) |
76
+
77
+ Returns `Promise<'clean' | 'malicious' | 'error'>`.
78
+
79
+ ### `scanRequest(request, options)`
80
+
81
+ Reads the multipart form field (default: `file`), scans it, and returns:
82
+ - `null` — file is clean, proceed normally.
83
+ - `Response(422)` — malicious file detected.
84
+ - `Response(500)` — scan error (clamd unreachable, timeout, etc.).
85
+
86
+ Additional option: `options.field` — form field name (default: `'file'`).
87
+
88
+ ## Wrangler configuration
89
+
90
+ Copy [`wrangler.toml.example`](./wrangler.toml.example) to `wrangler.toml` and fill in your clamd host details.
91
+
92
+ ## License
93
+
94
+ ISC
@@ -0,0 +1,50 @@
1
+ /** Result returned by scanBuffer */
2
+ export type ScanResult = 'clean' | 'malicious' | 'error';
3
+
4
+ /** Options for connecting to a remote clamd instance */
5
+ export interface CloudflareScanOptions {
6
+ /** Hostname or IP of the remote clamd instance */
7
+ host: string;
8
+ /** Port the remote clamd listens on (typically 3310) */
9
+ port: number;
10
+ /** Socket read timeout in milliseconds (default: 15000) */
11
+ timeout?: number;
12
+ /** Form field name to scan when using scanRequest (default: 'file') */
13
+ field?: string;
14
+ }
15
+
16
+ /**
17
+ * Scan an ArrayBuffer with a remote clamd instance using INSTREAM protocol.
18
+ * Uses Cloudflare's `connect()` socket API — no Node.js built-ins required.
19
+ *
20
+ * @example
21
+ * const result = await scanBuffer(await file.arrayBuffer(), {
22
+ * host: env.CLAMAV_HOST,
23
+ * port: parseInt(env.CLAMAV_PORT),
24
+ * });
25
+ */
26
+ export declare function scanBuffer(
27
+ buffer: ArrayBuffer,
28
+ options: CloudflareScanOptions
29
+ ): Promise<ScanResult>;
30
+
31
+ /**
32
+ * Scan a multipart form field from a Cloudflare Worker Request.
33
+ * Returns null if the file is clean, or an HTTP Response (422/500) otherwise.
34
+ *
35
+ * @example
36
+ * export default {
37
+ * async fetch(request, env) {
38
+ * const rejection = await scanRequest(request, {
39
+ * host: env.CLAMAV_HOST,
40
+ * port: parseInt(env.CLAMAV_PORT),
41
+ * });
42
+ * if (rejection) return rejection;
43
+ * return new Response('OK');
44
+ * }
45
+ * }
46
+ */
47
+ export declare function scanRequest(
48
+ request: Request,
49
+ options: CloudflareScanOptions
50
+ ): Promise<Response | null>;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @pompelmi/cloudflare
3
+ *
4
+ * Connects to a remote clamd instance over TCP using Cloudflare's socket API.
5
+ * Uses Web APIs only — no Node.js built-ins.
6
+ *
7
+ * Requires `connect` from `cloudflare:sockets` (available in Workers Runtime ≥ 2023-03-01).
8
+ */
9
+
10
+ const INSTREAM_CHUNK = 4096; // bytes per INSTREAM chunk
11
+ const CLEAN_RESPONSE = /^stream: OK/;
12
+ const MALICIOUS_RESPONSE = /^stream: (.+) FOUND/;
13
+
14
+ /**
15
+ * Send a Buffer or ArrayBuffer to clamd via INSTREAM and return the raw
16
+ * response string.
17
+ *
18
+ * @param {ArrayBuffer} data
19
+ * @param {{ host: string, port: number, timeout?: number }} options
20
+ * @returns {Promise<string>}
21
+ */
22
+ async function clamdInstream(data, { host, port, timeout = 15000 }) {
23
+ // eslint-disable-next-line no-undef
24
+ const { connect } = await import('cloudflare:sockets');
25
+ const socket = connect({ hostname: host, port });
26
+
27
+ const writer = socket.writable.getWriter();
28
+ const reader = socket.readable.getReader();
29
+
30
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
31
+
32
+ // Write INSTREAM command
33
+ const cmd = new TextEncoder().encode('zINSTREAM\0');
34
+ await writer.write(cmd);
35
+
36
+ // Stream data in chunks with 4-byte big-endian length prefix
37
+ let offset = 0;
38
+ while (offset < bytes.byteLength) {
39
+ const end = Math.min(offset + INSTREAM_CHUNK, bytes.byteLength);
40
+ const chunk = bytes.subarray(offset, end);
41
+ const lenBuf = new ArrayBuffer(4);
42
+ new DataView(lenBuf).setUint32(0, chunk.byteLength, false);
43
+ await writer.write(new Uint8Array(lenBuf));
44
+ await writer.write(chunk);
45
+ offset = end;
46
+ }
47
+
48
+ // Write zero-length chunk to signal end of stream
49
+ const terminator = new Uint8Array([0, 0, 0, 0]);
50
+ await writer.write(terminator);
51
+ writer.releaseLock();
52
+
53
+ // Read response with timeout
54
+ const timeoutId = setTimeout(() => reader.cancel(), timeout);
55
+ let response = '';
56
+ const decoder = new TextDecoder();
57
+ try {
58
+ while (true) {
59
+ const { value, done } = await reader.read();
60
+ if (done) break;
61
+ response += decoder.decode(value, { stream: true });
62
+ if (response.includes('\n') || response.includes('\0')) break;
63
+ }
64
+ } finally {
65
+ clearTimeout(timeoutId);
66
+ reader.releaseLock();
67
+ }
68
+
69
+ await socket.close();
70
+ return response.trim().replace(/\0/g, '');
71
+ }
72
+
73
+ /**
74
+ * Scan an ArrayBuffer with a remote clamd instance.
75
+ *
76
+ * @param {ArrayBuffer} buffer
77
+ * @param {{ host: string, port: number, timeout?: number }} options
78
+ * @returns {Promise<'clean' | 'malicious' | 'error'>}
79
+ */
80
+ export async function scanBuffer(buffer, options) {
81
+ if (!options || !options.host || !options.port) {
82
+ throw new Error('@pompelmi/cloudflare: options.host and options.port are required');
83
+ }
84
+ try {
85
+ const response = await clamdInstream(buffer, options);
86
+ if (CLEAN_RESPONSE.test(response)) return 'clean';
87
+ if (MALICIOUS_RESPONSE.test(response)) return 'malicious';
88
+ return 'error';
89
+ } catch {
90
+ return 'error';
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Convenience Cloudflare Worker handler that reads a multipart form field,
96
+ * scans it, and returns HTTP 422 on malicious files.
97
+ *
98
+ * @param {Request} request
99
+ * @param {{ host: string, port: number, field?: string, timeout?: number }} options
100
+ * @returns {Promise<Response | null>} null if clean, Response if malicious/error
101
+ */
102
+ export async function scanRequest(request, options) {
103
+ const field = options.field || 'file';
104
+ const formData = await request.formData();
105
+ const file = formData.get(field);
106
+ if (!file) return null;
107
+ const buffer = await file.arrayBuffer();
108
+ const result = await scanBuffer(buffer, options);
109
+ if (result === 'malicious') {
110
+ return new Response('File rejected: malicious content detected', { status: 422 });
111
+ }
112
+ if (result === 'error') {
113
+ return new Response('Scan error', { status: 500 });
114
+ }
115
+ return null;
116
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@pompelmi/cloudflare",
3
+ "version": "1.0.0",
4
+ "description": "pompelmi adapter for Cloudflare Workers — scan file uploads via a remote clamd instance using Web APIs only.",
5
+ "license": "ISC",
6
+ "author": "pompelmi contributors",
7
+ "homepage": "https://pompelmi.app",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/pompelmi/pompelmi.git",
11
+ "directory": "packages/cloudflare"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/pompelmi/pompelmi/issues"
15
+ },
16
+ "keywords": [
17
+ "clamav",
18
+ "antivirus",
19
+ "virus-scan",
20
+ "malware",
21
+ "cloudflare",
22
+ "cloudflare-workers",
23
+ "file-upload",
24
+ "security"
25
+ ],
26
+ "type": "module",
27
+ "main": "./index.js",
28
+ "types": "./index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "import": "./index.js"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "registry": "https://registry.npmjs.org/",
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,13 @@
1
+ name = "my-worker"
2
+ main = "src/worker.js"
3
+ compatibility_date = "2024-01-01"
4
+
5
+ [vars]
6
+ CLAMAV_HOST = "your-clamd-host.example.com"
7
+ CLAMAV_PORT = "3310"
8
+
9
+ # Cloudflare Workers require a publicly reachable clamd instance.
10
+ # Options:
11
+ # 1. Run clamd on a VPS and expose port 3310 (add firewall rules).
12
+ # 2. Use a Cloudflare Tunnel (cloudflared) to expose a private clamd instance.
13
+ # See: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
@@ -0,0 +1,61 @@
1
+ # pompelmi — VS Code Extension
2
+
3
+ Scan files for viruses directly from VS Code using pompelmi and ClamAV.
4
+
5
+ ## Requirements
6
+
7
+ - [ClamAV](https://www.clamav.net/) installed (or clamd running via Docker)
8
+ - Node.js and npm available in your PATH
9
+
10
+ ## Installation
11
+
12
+ Install from the VS Code Marketplace:
13
+
14
+ 1. Open VS Code
15
+ 2. Press `Ctrl+P` (or `Cmd+P` on macOS)
16
+ 3. Run: `ext install pompelmi.pompelmi`
17
+
18
+ ## Usage
19
+
20
+ ### Scan a single file
21
+
22
+ Right-click any file in the Explorer sidebar and select **"Scan with pompelmi"**.
23
+
24
+ Results appear as VS Code notifications:
25
+
26
+ - `✅ file.pdf — Clean`
27
+ - `🚨 malware.exe — INFECTED (Win.Malware.Agent)`
28
+
29
+ ### Scan the workspace
30
+
31
+ Open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and run:
32
+
33
+ ```
34
+ pompelmi: Scan Workspace
35
+ ```
36
+
37
+ ### Configure
38
+
39
+ Run `pompelmi: Configure` from the Command Palette to open settings.
40
+
41
+ ## Settings
42
+
43
+ | Setting | Default | Description |
44
+ |-------------------|-------------|------------------------------------------|
45
+ | `pompelmi.host` | `localhost` | clamd host |
46
+ | `pompelmi.port` | `3310` | clamd port |
47
+ | `pompelmi.socket` | *(empty)* | clamd UNIX socket path (takes precedence over host/port) |
48
+
49
+ ## Using with Docker
50
+
51
+ Start clamd via the official pompelmi Docker image:
52
+
53
+ ```bash
54
+ docker run -p 3310:3310 -p 8080:8080 pompelmi/scanner
55
+ ```
56
+
57
+ Then set `pompelmi.host` to `localhost` and `pompelmi.port` to `3310`.
58
+
59
+ ## License
60
+
61
+ ISC © pompelmi contributors
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "pompelmi",
3
+ "displayName": "pompelmi — Antivirus File Scanner",
4
+ "description": "Scan files for viruses directly from VS Code using pompelmi and ClamAV.",
5
+ "version": "0.1.0",
6
+ "publisher": "pompelmi",
7
+ "license": "ISC",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/pompelmi/pompelmi.git"
11
+ },
12
+ "engines": {
13
+ "vscode": "^1.85.0"
14
+ },
15
+ "categories": ["Other", "Linters"],
16
+ "keywords": ["antivirus", "clamav", "security", "malware", "virus"],
17
+ "icon": "icon.png",
18
+ "activationEvents": [
19
+ "onCommand:pompelmi.scanFile"
20
+ ],
21
+ "main": "./src/extension.js",
22
+ "contributes": {
23
+ "commands": [
24
+ {
25
+ "command": "pompelmi.scanFile",
26
+ "title": "Scan with pompelmi",
27
+ "category": "pompelmi"
28
+ },
29
+ {
30
+ "command": "pompelmi.scanWorkspace",
31
+ "title": "Scan Workspace",
32
+ "category": "pompelmi"
33
+ },
34
+ {
35
+ "command": "pompelmi.configure",
36
+ "title": "Configure pompelmi",
37
+ "category": "pompelmi"
38
+ }
39
+ ],
40
+ "menus": {
41
+ "explorer/context": [
42
+ {
43
+ "command": "pompelmi.scanFile",
44
+ "when": "resourceScheme == 'file'",
45
+ "group": "7_modification"
46
+ }
47
+ ]
48
+ },
49
+ "configuration": {
50
+ "title": "pompelmi",
51
+ "properties": {
52
+ "pompelmi.host": {
53
+ "type": "string",
54
+ "default": "localhost",
55
+ "description": "clamd host"
56
+ },
57
+ "pompelmi.port": {
58
+ "type": "number",
59
+ "default": 3310,
60
+ "description": "clamd port"
61
+ },
62
+ "pompelmi.socket": {
63
+ "type": "string",
64
+ "default": "",
65
+ "description": "clamd UNIX socket path (e.g. /run/clamav/clamd.sock)"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const { execFile } = require('child_process');
4
+ const path = require('path');
5
+
6
+ function getScanOptions(vscode) {
7
+ const cfg = vscode.workspace.getConfiguration('pompelmi');
8
+ const host = cfg.get('host', 'localhost');
9
+ const port = cfg.get('port', 3310);
10
+ const socket = cfg.get('socket', '');
11
+ const args = ['scan'];
12
+ if (socket) { args.push('--socket', socket); }
13
+ else { args.push('--host', host, '--port', String(port)); }
14
+ args.push('--json');
15
+ return args;
16
+ }
17
+
18
+ function runScan(filePath, args, callback) {
19
+ const allArgs = [...args, filePath];
20
+ execFile('npx', ['pompelmi', ...allArgs], { timeout: 30000 }, (err, stdout) => {
21
+ if (err && !stdout) return callback(err, null);
22
+ try {
23
+ const result = JSON.parse(stdout);
24
+ callback(null, result);
25
+ } catch {
26
+ callback(new Error('Failed to parse pompelmi output'), null);
27
+ }
28
+ });
29
+ }
30
+
31
+ function activate(context) {
32
+ const vscode = require('vscode');
33
+
34
+ const scanFileCmd = vscode.commands.registerCommand('pompelmi.scanFile', async (uri) => {
35
+ const filePath = uri ? uri.fsPath : vscode.window.activeTextEditor?.document.uri.fsPath;
36
+ if (!filePath) {
37
+ vscode.window.showErrorMessage('pompelmi: No file selected.');
38
+ return;
39
+ }
40
+
41
+ const args = getScanOptions(vscode);
42
+ vscode.window.withProgress(
43
+ { location: vscode.ProgressLocation.Notification, title: `pompelmi: scanning ${path.basename(filePath)}...` },
44
+ () => new Promise(resolve => {
45
+ runScan(filePath, args, (err, result) => {
46
+ resolve();
47
+ if (err) {
48
+ vscode.window.showErrorMessage(`pompelmi error: ${err.message}`);
49
+ return;
50
+ }
51
+ const r = result?.results?.[0];
52
+ if (!r) {
53
+ vscode.window.showErrorMessage('pompelmi: unexpected response');
54
+ return;
55
+ }
56
+ if (r.verdict === 'clean') {
57
+ vscode.window.showInformationMessage(`✅ ${path.basename(filePath)} — Clean`);
58
+ } else if (r.verdict === 'infected') {
59
+ const virus = r.viruses?.[0] ? ` (${r.viruses[0]})` : '';
60
+ vscode.window.showErrorMessage(`🚨 ${path.basename(filePath)} — INFECTED${virus}`);
61
+ } else {
62
+ vscode.window.showWarningMessage(`⚠️ ${path.basename(filePath)} — Scan error`);
63
+ }
64
+ });
65
+ })
66
+ );
67
+ });
68
+
69
+ const scanWorkspaceCmd = vscode.commands.registerCommand('pompelmi.scanWorkspace', async () => {
70
+ const folders = vscode.workspace.workspaceFolders;
71
+ if (!folders || folders.length === 0) {
72
+ vscode.window.showErrorMessage('pompelmi: No workspace folder open.');
73
+ return;
74
+ }
75
+
76
+ const args = getScanOptions(vscode);
77
+ const dirPath = folders[0].uri.fsPath;
78
+
79
+ vscode.window.withProgress(
80
+ { location: vscode.ProgressLocation.Notification, title: 'pompelmi: scanning workspace...' },
81
+ () => new Promise(resolve => {
82
+ runScan(dirPath, args, (err, result) => {
83
+ resolve();
84
+ if (err) {
85
+ vscode.window.showErrorMessage(`pompelmi error: ${err.message}`);
86
+ return;
87
+ }
88
+ const infected = result?.infected ?? 0;
89
+ const scanned = result?.scanned ?? 0;
90
+ if (infected === 0) {
91
+ vscode.window.showInformationMessage(`✅ Workspace scan complete — ${scanned} files scanned, all clean.`);
92
+ } else {
93
+ vscode.window.showErrorMessage(`🚨 Workspace scan — ${infected} infected file(s) found out of ${scanned} scanned.`);
94
+ }
95
+ });
96
+ })
97
+ );
98
+ });
99
+
100
+ const configureCmd = vscode.commands.registerCommand('pompelmi.configure', () => {
101
+ vscode.commands.executeCommand('workbench.action.openSettings', 'pompelmi');
102
+ });
103
+
104
+ context.subscriptions.push(scanFileCmd, scanWorkspaceCmd, configureCmd);
105
+ }
106
+
107
+ function deactivate() {}
108
+
109
+ module.exports = { activate, deactivate };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ const CHECKS = [
4
+ {
5
+ key: 'scanEnabled',
6
+ check: 'Virus scanning enabled',
7
+ weight: 30,
8
+ pass: c => c.scanEnabled === true,
9
+ recommendation: 'Enable virus scanning by integrating pompelmi into your upload handler.',
10
+ },
11
+ {
12
+ key: 'mimeTypeAllowlist',
13
+ check: 'MIME type allowlist',
14
+ weight: 20,
15
+ pass: c => Array.isArray(c.mimeTypeAllowlist) && c.mimeTypeAllowlist.length > 0,
16
+ recommendation: 'Define an explicit MIME type allowlist to reject unexpected file types.',
17
+ },
18
+ {
19
+ key: 'fileSizeLimit',
20
+ check: 'File size limit',
21
+ weight: 10,
22
+ pass: c => typeof c.fileSizeLimit === 'number' && c.fileSizeLimit > 0,
23
+ recommendation: 'Set a maximum file size limit to prevent resource exhaustion.',
24
+ },
25
+ {
26
+ key: 'diskWriteBeforeScan',
27
+ check: 'No disk write before scan',
28
+ weight: 15,
29
+ pass: c => c.diskWriteBeforeScan === false,
30
+ recommendation: 'Scan files in-memory before writing to disk to avoid storing malware even temporarily.',
31
+ },
32
+ {
33
+ key: 'scanErrorBehavior',
34
+ check: 'Scan error behavior is reject',
35
+ weight: 10,
36
+ pass: c => c.scanErrorBehavior === 'reject',
37
+ recommendation: 'Set scanErrorBehavior to "reject" — failing open on scan errors is a security risk.',
38
+ },
39
+ {
40
+ key: 'clamdUnavailableBehavior',
41
+ check: 'clamd unavailable behavior is reject',
42
+ weight: 10,
43
+ pass: c => c.clamdUnavailableBehavior === 'reject',
44
+ recommendation: 'Set clamdUnavailableBehavior to "reject" — if clamd is down, deny uploads rather than allowing them through.',
45
+ },
46
+ {
47
+ key: 'tlsEnabled',
48
+ check: 'TLS enabled on upload endpoint',
49
+ weight: 5,
50
+ pass: c => c.tlsEnabled === true,
51
+ recommendation: 'Enable TLS (HTTPS) on your upload endpoint to protect files in transit.',
52
+ },
53
+ ];
54
+
55
+ function scoreToGrade(score) {
56
+ if (score >= 90) return 'A';
57
+ if (score >= 75) return 'B';
58
+ if (score >= 60) return 'C';
59
+ if (score >= 45) return 'D';
60
+ return 'F';
61
+ }
62
+
63
+ async function generateScorecard(config = {}) {
64
+ const maxScore = CHECKS.reduce((s, c) => s + c.weight, 0);
65
+ let earned = 0;
66
+ const findings = [];
67
+ const recommendations = [];
68
+
69
+ for (const def of CHECKS) {
70
+ const passed = def.pass(config);
71
+ findings.push({ check: def.check, status: passed ? 'pass' : 'fail', weight: def.weight });
72
+ if (passed) {
73
+ earned += def.weight;
74
+ } else {
75
+ recommendations.push(def.recommendation);
76
+ }
77
+ }
78
+
79
+ const score = Math.round((earned / maxScore) * 100);
80
+ const grade = scoreToGrade(score);
81
+
82
+ return { grade, score, findings, recommendations };
83
+ }
84
+
85
+ module.exports = { generateScorecard };
package/src/Watcher.js CHANGED
@@ -7,44 +7,79 @@ const { Verdict } = require('./verdicts.js');
7
7
 
8
8
  const DEBOUNCE_MS = 300;
9
9
 
10
+ function quarantineFile(filePath, quarantineDir, virus) {
11
+ try {
12
+ fs.mkdirSync(quarantineDir, { recursive: true });
13
+ const basename = path.basename(filePath);
14
+ const destBase = path.join(quarantineDir, basename + '.quarantined');
15
+ const destJson = destBase + '.json';
16
+ const sha256 = (() => { try { return require('crypto').createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); } catch { return ''; } })();
17
+
18
+ fs.renameSync(filePath, destBase);
19
+
20
+ const sidecar = {
21
+ originalPath: filePath,
22
+ virus: virus || '',
23
+ timestamp: new Date().toISOString(),
24
+ sha256,
25
+ };
26
+ fs.writeFileSync(destJson, JSON.stringify(sidecar, null, 2));
27
+
28
+ return destBase;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
10
34
  /**
11
35
  * Watch a directory for new/modified files and scan each one automatically.
12
36
  * Uses fs.watch (no dependencies) with a 300 ms debounce.
13
37
  *
14
38
  * @param {string} dirPath
15
- * @param {object} [options] - Passed to scan() (host, port, socket, timeout, retries, retryDelay)
39
+ * @param {object} [options] - Passed to scan() (host, port, socket, timeout, retries, retryDelay).
40
+ * Also accepts `quarantine` (string path) to enable auto-quarantine of infected files.
16
41
  * @param {{ onClean?: Function, onMalicious?: Function, onError?: Function }} [callbacks]
17
42
  * @returns {import('fs').FSWatcher}
18
43
  */
19
44
  function watch(dirPath, options = {}, { onClean, onMalicious, onError } = {}) {
20
- const timers = new Map();
45
+ const { quarantine: quarantineDir, ...scanOptions } = options;
46
+ const timers = new Map();
47
+
48
+ if (quarantineDir) {
49
+ fs.mkdirSync(quarantineDir, { recursive: true });
50
+ }
21
51
 
22
- return fs.watch(dirPath, { recursive: true }, (_eventType, filename) => {
23
- if (!filename) return;
52
+ return fs.watch(dirPath, { recursive: true }, (_eventType, filename) => {
53
+ if (!filename) return;
24
54
 
25
- const fullPath = path.join(dirPath, filename);
55
+ const fullPath = path.join(dirPath, filename);
26
56
 
27
- if (timers.has(fullPath)) clearTimeout(timers.get(fullPath));
57
+ if (timers.has(fullPath)) clearTimeout(timers.get(fullPath));
28
58
 
29
- timers.set(fullPath, setTimeout(async () => {
30
- timers.delete(fullPath);
59
+ timers.set(fullPath, setTimeout(async () => {
60
+ timers.delete(fullPath);
31
61
 
32
- if (!fs.existsSync(fullPath)) return;
62
+ if (!fs.existsSync(fullPath)) return;
33
63
 
34
- let stat;
35
- try { stat = fs.statSync(fullPath); } catch { return; }
36
- if (!stat.isFile()) return;
64
+ let stat;
65
+ try { stat = fs.statSync(fullPath); } catch { return; }
66
+ if (!stat.isFile()) return;
37
67
 
38
- try {
39
- const verdict = await scan(fullPath, options);
40
- if (verdict === Verdict.Clean) onClean && onClean(fullPath);
41
- else if (verdict === Verdict.Malicious) onMalicious && onMalicious(fullPath);
42
- else onError && onError(new Error(`ScanError for ${fullPath}`), fullPath);
43
- } catch (err) {
44
- onError && onError(err, fullPath);
45
- }
46
- }, DEBOUNCE_MS));
47
- });
68
+ try {
69
+ const verdict = await scan(fullPath, scanOptions);
70
+ if (verdict === Verdict.Clean) {
71
+ onClean && onClean(fullPath);
72
+ } else if (verdict === Verdict.Malicious) {
73
+ if (quarantineDir) quarantineFile(fullPath, quarantineDir, '');
74
+ onMalicious && onMalicious(fullPath);
75
+ } else {
76
+ onError && onError(new Error(`ScanError for ${fullPath}`), fullPath);
77
+ }
78
+ } catch (err) {
79
+ onError && onError(err, fullPath);
80
+ }
81
+ }, DEBOUNCE_MS));
82
+ });
48
83
  }
49
84
 
50
- module.exports = { watch };
85
+ module.exports = { watch, quarantineFile };
package/src/index.js CHANGED
@@ -8,5 +8,6 @@ const { notify } = require('./WebhookNotifi
8
8
  const { createScanner } = require('./ScanEmitter.js');
9
9
  const { generateDashboard } = require('./Dashboard.js');
10
10
  const { generateShareCard } = require('./ShareCard.js');
11
+ const { generateScorecard } = require('./Scorecard.js');
11
12
 
12
- module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner, generateDashboard, generateShareCard };
13
+ module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner, generateDashboard, generateShareCard, generateScorecard };
package/src/index.mjs ADDED
@@ -0,0 +1,19 @@
1
+ import { createRequire } from 'module';
2
+ const require = createRequire(import.meta.url);
3
+ const pompelmi = require('./index.js');
4
+ export const {
5
+ scan,
6
+ scanBuffer,
7
+ scanStream,
8
+ scanDirectory,
9
+ scanS3,
10
+ createPool,
11
+ watch,
12
+ middleware,
13
+ createScanner,
14
+ generateDashboard,
15
+ generateShareCard,
16
+ notify,
17
+ Verdict,
18
+ } = pompelmi;
19
+ export default pompelmi;
package/types/index.d.ts CHANGED
@@ -142,14 +142,22 @@ export interface WatchCallbacks {
142
142
  onError?: (err: Error, filePath?: string) => void;
143
143
  }
144
144
 
145
+ /** Options for watch() — extends ScanOptions with quarantine support */
146
+ export interface WatchOptions extends ScanOptions {
147
+ /** Path to a quarantine directory. Infected files are moved here automatically. */
148
+ quarantine?: string;
149
+ }
150
+
145
151
  /**
146
152
  * Watch a directory for new/modified files and scan each automatically.
147
153
  * Uses fs.watch with a 300 ms debounce. No dependencies.
154
+ * When `options.quarantine` is set, infected files are moved to that directory
155
+ * with a `.quarantined` extension and a sidecar JSON file.
148
156
  * Returns an FSWatcher; call .close() to stop watching.
149
157
  */
150
158
  export declare function watch(
151
159
  dirPath: string,
152
- options?: ScanOptions,
160
+ options?: WatchOptions,
153
161
  callbacks?: WatchCallbacks
154
162
  ): FSWatcher;
155
163
 
@@ -266,3 +274,57 @@ export declare function generateShareCard(
266
274
  scanResults: ScanRow[] | DirectoryScanResult,
267
275
  options?: ShareCardOptions
268
276
  ): string;
277
+
278
+ /** Input configuration for generateScorecard */
279
+ export interface ScorecardConfig {
280
+ /** Whether virus scanning is enabled */
281
+ scanEnabled?: boolean;
282
+ /** Array of allowed MIME types (non-empty = pass) */
283
+ mimeTypeAllowlist?: string[];
284
+ /** Maximum file size in bytes (positive = pass) */
285
+ fileSizeLimit?: number;
286
+ /** Whether files are written to disk before scanning (false = pass) */
287
+ diskWriteBeforeScan?: boolean;
288
+ /** What to do on scan error: 'reject' = pass, anything else = fail */
289
+ scanErrorBehavior?: 'reject' | string;
290
+ /** What to do when clamd is unavailable: 'reject' = pass, anything else = fail */
291
+ clamdUnavailableBehavior?: 'reject' | string;
292
+ /** Whether TLS is enabled on the upload endpoint */
293
+ tlsEnabled?: boolean;
294
+ }
295
+
296
+ /** A single check result in a scorecard */
297
+ export interface ScorecardFinding {
298
+ check: string;
299
+ status: 'pass' | 'fail';
300
+ weight: number;
301
+ }
302
+
303
+ /** Result returned by generateScorecard */
304
+ export interface ScorecardResult {
305
+ /** Letter grade A–F */
306
+ grade: 'A' | 'B' | 'C' | 'D' | 'F';
307
+ /** Numeric score 0–100 */
308
+ score: number;
309
+ /** Per-check results */
310
+ findings: ScorecardFinding[];
311
+ /** Actionable recommendations for failed checks */
312
+ recommendations: string[];
313
+ }
314
+
315
+ /**
316
+ * Analyse a project's upload security configuration and return a grade A–F.
317
+ *
318
+ * @example
319
+ * const scorecard = await generateScorecard({
320
+ * scanEnabled: true,
321
+ * mimeTypeAllowlist: ['image/jpeg', 'image/png'],
322
+ * fileSizeLimit: 10 * 1024 * 1024,
323
+ * diskWriteBeforeScan: false,
324
+ * scanErrorBehavior: 'reject',
325
+ * clamdUnavailableBehavior: 'reject',
326
+ * tlsEnabled: true,
327
+ * });
328
+ * console.log(scorecard.grade); // 'A'
329
+ */
330
+ export declare function generateScorecard(config?: ScorecardConfig): Promise<ScorecardResult>;