pompelmi 1.17.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))
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 => {
@@ -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.17.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",
@@ -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/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>;