pompelmi 1.17.0 → 1.19.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 +11 -0
- package/bin/pompelmi.js +66 -3
- package/docker/Dockerfile +18 -0
- package/docker/README.md +62 -0
- package/docker/entrypoint.sh +148 -0
- package/package.json +1 -1
- package/packages/vscode/README.md +61 -0
- package/packages/vscode/package.json +70 -0
- package/packages/vscode/src/extension.js +109 -0
- package/src/ClamAVScanner.js +40 -0
- package/src/MultiEngine.js +190 -0
- package/src/Policy.js +154 -0
- package/src/ScanCache.js +136 -0
- package/src/Scorecard.js +85 -0
- package/src/Watcher.js +58 -23
- package/src/index.js +5 -1
- package/src/index.mjs +4 -0
- package/types/index.d.ts +254 -2
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/justsouichi/pompelmi-scanner"><img src="https://img.shields.io/docker/pulls/justsouichi/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** — `justsouichi/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))
|
|
@@ -85,6 +90,10 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
85
90
|
- `watch(dirPath, [options], callbacks)` — watch a directory and auto-scan new/modified files (300 ms debounce)
|
|
86
91
|
- `notify(webhookUrl, scanResult, [options])` — send a POST webhook notification when a virus is detected; optional HMAC-SHA256 signing via `X-Pompelmi-Signature`; zero extra dependencies
|
|
87
92
|
- `createScanner([options])` — EventEmitter-based scanner; call `.scan(filePath)` or `.scanDirectory(dirPath)` and listen to `'clean'`, `'malicious'`, `'scanError'`, and `'error'` events
|
|
93
|
+
- **SHA256 scan cache** — `createCache([options])` — skip rescanning known-clean files; LRU eviction, configurable TTL, optional file-backed persistence; zero extra dependencies ([docs](./docs/cache.html))
|
|
94
|
+
- **Scan policies** — `createPolicy(rules)` — unified size, MIME type, extension, and virus rules in one object; Express middleware and NestJS guard included ([docs](./docs/policy.html))
|
|
95
|
+
- **Multi-engine scanning** — `createMultiEngine(options)` — combine ClamAV and VirusTotal with `any`/`all`/`majority` consensus; per-engine verdict breakdown; zero extra dependencies ([docs](./docs/multi-engine.html))
|
|
96
|
+
- **Directory streaming** — `scanDirectory.stream(dirPath)` — async-iterable progress events (`progress` / `result` / `complete`) for real-time UI feedback
|
|
88
97
|
- Auto-retry on connection error — `retries` and `retryDelay` options on every scan function
|
|
89
98
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
90
99
|
- Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
|
|
@@ -617,6 +626,8 @@ Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before contributing. To r
|
|
|
617
626
|
- [x] Cloudflare Workers support — `@pompelmi/cloudflare` ships in v1.17.0
|
|
618
627
|
- [x] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
|
|
619
628
|
|
|
629
|
+
**Pompelmi Cloud** is on the horizon — a hosted REST API for file scanning with zero infrastructure to manage. Drop-in HTTP endpoint, no ClamAV to maintain, no daemon to run. [Join the waitlist](https://pompelmi.app/cloud.html) to be notified when it launches.
|
|
630
|
+
|
|
620
631
|
---
|
|
621
632
|
|
|
622
633
|
## License
|
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
|
|
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,
|
|
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"]
|
package/docker/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# justsouichi/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 justsouichi/pompelmi-scanner
|
|
14
|
+
docker run -p 8080:8080 justsouichi/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: justsouichi/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 justsouichi/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
|
@@ -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 justsouichi/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 };
|