pompelmi 1.18.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 +8 -2
- package/docker/README.md +5 -5
- package/package.json +1 -1
- package/packages/vscode/README.md +1 -1
- 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/index.js +4 -1
- package/src/index.mjs +4 -0
- package/types/index.d.ts +191 -1
package/README.md
CHANGED
|
@@ -11,7 +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
|
|
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>
|
|
15
15
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="zero dependencies">
|
|
16
16
|
<img src="https://img.shields.io/badge/license-ISC-blue" alt="license">
|
|
17
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>
|
|
@@ -74,7 +74,7 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
74
74
|
## Features
|
|
75
75
|
|
|
76
76
|
- Standalone CLI — scan files from any terminal with `npx pompelmi scan`
|
|
77
|
-
- **Official Docker image** — `pompelmi
|
|
77
|
+
- **Official Docker image** — `justsouichi/pompelmi-scanner` on Docker Hub: ClamAV + HTTP scan API in one pull ([docs](./docs/docker-image.html))
|
|
78
78
|
- **Security scorecard** — grade your upload security A–F with `npx pompelmi scorecard` ([docs](./docs/scorecard.html))
|
|
79
79
|
- **VS Code extension** — scan files directly from the IDE (scaffold in `packages/vscode/`) ([docs](./docs/vscode.html))
|
|
80
80
|
- **Quarantine mode** — `watch ./uploads --quarantine ./quarantine` auto-moves infected files with sidecar JSON
|
|
@@ -90,6 +90,10 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
90
90
|
- `watch(dirPath, [options], callbacks)` — watch a directory and auto-scan new/modified files (300 ms debounce)
|
|
91
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
|
|
92
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
|
|
93
97
|
- Auto-retry on connection error — `retries` and `retryDelay` options on every scan function
|
|
94
98
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
95
99
|
- Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
|
|
@@ -622,6 +626,8 @@ Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before contributing. To r
|
|
|
622
626
|
- [x] Cloudflare Workers support — `@pompelmi/cloudflare` ships in v1.17.0
|
|
623
627
|
- [x] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
|
|
624
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
|
+
|
|
625
631
|
---
|
|
626
632
|
|
|
627
633
|
## License
|
package/docker/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# pompelmi
|
|
1
|
+
# justsouichi/pompelmi-scanner Docker Image
|
|
2
2
|
|
|
3
3
|
Official Docker image for pompelmi — ClamAV antivirus scanning for Node.js.
|
|
4
4
|
|
|
@@ -10,8 +10,8 @@ The image ships with:
|
|
|
10
10
|
## Quick start
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
docker pull pompelmi
|
|
14
|
-
docker run -p 8080:8080 pompelmi
|
|
13
|
+
docker pull justsouichi/pompelmi-scanner
|
|
14
|
+
docker run -p 8080:8080 justsouichi/pompelmi-scanner
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
## Scan a file
|
|
@@ -41,7 +41,7 @@ For an infected file the verdict is `"malicious"`.
|
|
|
41
41
|
```yaml
|
|
42
42
|
services:
|
|
43
43
|
pompelmi:
|
|
44
|
-
image: pompelmi
|
|
44
|
+
image: justsouichi/pompelmi-scanner
|
|
45
45
|
ports:
|
|
46
46
|
- "8080:8080"
|
|
47
47
|
volumes:
|
|
@@ -58,5 +58,5 @@ The container exposes two ports:
|
|
|
58
58
|
## Building locally
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
|
-
docker build -f docker/Dockerfile -t pompelmi
|
|
61
|
+
docker build -f docker/Dockerfile -t justsouichi/pompelmi-scanner .
|
|
62
62
|
```
|
package/package.json
CHANGED
|
@@ -51,7 +51,7 @@ Run `pompelmi: Configure` from the Command Palette to open settings.
|
|
|
51
51
|
Start clamd via the official pompelmi Docker image:
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
docker run -p 3310:3310 -p 8080:8080 pompelmi
|
|
54
|
+
docker run -p 3310:3310 -p 8080:8080 justsouichi/pompelmi-scanner
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Then set `pompelmi.host` to `localhost` and `pompelmi.port` to `3310`.
|
package/src/ClamAVScanner.js
CHANGED
|
@@ -137,4 +137,44 @@ async function scanDirectory(dirPath, options = {}) {
|
|
|
137
137
|
return { clean, malicious, errors };
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
async function* streamDirectory(dirPath, options = {}) {
|
|
141
|
+
if (typeof dirPath !== 'string') {
|
|
142
|
+
throw new Error('dirPath must be a string');
|
|
143
|
+
}
|
|
144
|
+
if (!fs.existsSync(dirPath)) {
|
|
145
|
+
throw new Error(`Directory not found: ${dirPath}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const entries = fs.readdirSync(dirPath, { recursive: true });
|
|
149
|
+
const files = entries
|
|
150
|
+
.map(entry => path.join(dirPath, entry))
|
|
151
|
+
.filter(fullPath => fs.statSync(fullPath).isFile());
|
|
152
|
+
|
|
153
|
+
const total = files.length;
|
|
154
|
+
let scanned = 0;
|
|
155
|
+
const summary = { clean: [], malicious: [], errors: [] };
|
|
156
|
+
|
|
157
|
+
for (const filePath of files) {
|
|
158
|
+
let verdict;
|
|
159
|
+
try {
|
|
160
|
+
verdict = await scan(filePath, options);
|
|
161
|
+
} catch {
|
|
162
|
+
verdict = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
scanned++;
|
|
166
|
+
|
|
167
|
+
if (verdict === Verdict.Clean) summary.clean.push(filePath);
|
|
168
|
+
else if (verdict === Verdict.Malicious) summary.malicious.push(filePath);
|
|
169
|
+
else summary.errors.push(filePath);
|
|
170
|
+
|
|
171
|
+
yield { type: 'progress', scanned, total, file: filePath };
|
|
172
|
+
yield { type: 'result', file: filePath, verdict };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
yield { type: 'complete', summary };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
scanDirectory.stream = streamDirectory;
|
|
179
|
+
|
|
140
180
|
module.exports = { scan, scanBuffer, scanStream, scanDirectory };
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { scan, scanBuffer } = require('./ClamAVScanner.js');
|
|
8
|
+
const { Verdict } = require('./verdicts.js');
|
|
9
|
+
|
|
10
|
+
// ── VirusTotal helpers ──────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function vtRequest(method, urlStr, headers, body) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const url = new URL(urlStr);
|
|
15
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
16
|
+
const opts = {
|
|
17
|
+
method,
|
|
18
|
+
hostname: url.hostname,
|
|
19
|
+
path: url.pathname + url.search,
|
|
20
|
+
headers,
|
|
21
|
+
};
|
|
22
|
+
const req = mod.request(opts, (res) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
res.on('data', c => chunks.push(c));
|
|
25
|
+
res.on('end', () => {
|
|
26
|
+
try {
|
|
27
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
28
|
+
} catch {
|
|
29
|
+
resolve({});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
req.on('error', reject);
|
|
34
|
+
if (body) req.write(body);
|
|
35
|
+
req.end();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Multipart form-data upload for VirusTotal /files endpoint
|
|
40
|
+
function vtUpload(apiKey, buffer) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const boundary = crypto.randomBytes(16).toString('hex');
|
|
43
|
+
const CRLF = '\r\n';
|
|
44
|
+
const pre = Buffer.from(
|
|
45
|
+
`--${boundary}${CRLF}` +
|
|
46
|
+
`Content-Disposition: form-data; name="file"; filename="upload"${CRLF}` +
|
|
47
|
+
`Content-Type: application/octet-stream${CRLF}${CRLF}`
|
|
48
|
+
);
|
|
49
|
+
const post = Buffer.from(`${CRLF}--${boundary}--${CRLF}`);
|
|
50
|
+
const body = Buffer.concat([pre, buffer, post]);
|
|
51
|
+
|
|
52
|
+
const opts = {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
hostname: 'www.virustotal.com',
|
|
55
|
+
path: '/api/v3/files',
|
|
56
|
+
headers: {
|
|
57
|
+
'x-apikey': apiKey,
|
|
58
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
59
|
+
'Content-Length': body.length,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const req = https.request(opts, (res) => {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
res.on('data', c => chunks.push(c));
|
|
66
|
+
res.on('end', () => {
|
|
67
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
|
68
|
+
catch { resolve({}); }
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
req.on('error', reject);
|
|
72
|
+
req.write(body);
|
|
73
|
+
req.end();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function vtScanBuffer(apiKey, buffer, threshold = 1) {
|
|
78
|
+
try {
|
|
79
|
+
// Upload file
|
|
80
|
+
const upload = await vtUpload(apiKey, buffer);
|
|
81
|
+
const id = upload && upload.data && upload.data.id;
|
|
82
|
+
if (!id) return { name: 'virustotal', verdict: Verdict.ScanError, detections: 0, error: 'no analysis id' };
|
|
83
|
+
|
|
84
|
+
// Poll for result (up to 60s)
|
|
85
|
+
for (let i = 0; i < 12; i++) {
|
|
86
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
87
|
+
const report = await vtRequest('GET',
|
|
88
|
+
`https://www.virustotal.com/api/v3/analyses/${id}`,
|
|
89
|
+
{ 'x-apikey': apiKey }, null
|
|
90
|
+
);
|
|
91
|
+
const status = report && report.data && report.data.attributes && report.data.attributes.status;
|
|
92
|
+
if (status === 'completed') {
|
|
93
|
+
const stats = report.data.attributes.stats || {};
|
|
94
|
+
const detections = (stats.malicious || 0) + (stats.suspicious || 0);
|
|
95
|
+
const verdict = detections >= threshold ? Verdict.Malicious : Verdict.Clean;
|
|
96
|
+
return { name: 'virustotal', verdict, detections };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { name: 'virustotal', verdict: Verdict.ScanError, detections: 0, error: 'timeout' };
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { name: 'virustotal', verdict: Verdict.ScanError, detections: 0, error: err.message };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── ClamAV engine wrapper ───────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async function clamavScanFile(config, filePath) {
|
|
108
|
+
try {
|
|
109
|
+
const verdict = await scan(filePath, config);
|
|
110
|
+
return { name: 'clamav', verdict };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { name: 'clamav', verdict: Verdict.ScanError, error: err.message };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function clamavScanBuffer(config, buffer) {
|
|
117
|
+
try {
|
|
118
|
+
const verdict = await scanBuffer(buffer, config);
|
|
119
|
+
return { name: 'clamav', verdict };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { name: 'clamav', verdict: Verdict.ScanError, error: err.message };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Consensus logic ─────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function applyConsensus(results, consensus) {
|
|
128
|
+
const verdicts = results.map(r => r.verdict);
|
|
129
|
+
const malCount = verdicts.filter(v => v === Verdict.Malicious).length;
|
|
130
|
+
const total = verdicts.length;
|
|
131
|
+
|
|
132
|
+
let final;
|
|
133
|
+
if (consensus === 'all') {
|
|
134
|
+
final = malCount === total ? Verdict.Malicious : Verdict.Clean;
|
|
135
|
+
} else if (consensus === 'majority') {
|
|
136
|
+
final = malCount > total / 2 ? Verdict.Malicious : Verdict.Clean;
|
|
137
|
+
} else {
|
|
138
|
+
// 'any' (default)
|
|
139
|
+
final = malCount > 0 ? Verdict.Malicious : Verdict.Clean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// If no conclusive result but errors exist, propagate ScanError
|
|
143
|
+
if (final === Verdict.Clean && verdicts.every(v => v === Verdict.ScanError)) {
|
|
144
|
+
final = Verdict.ScanError;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return final;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Public factory ──────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function createMultiEngine(config = {}) {
|
|
153
|
+
const { engines = [], consensus = 'any' } = config;
|
|
154
|
+
|
|
155
|
+
async function _runEnginesOnBuffer(buffer) {
|
|
156
|
+
return Promise.all(engines.map(async (engine) => {
|
|
157
|
+
if (engine.type === 'virustotal') {
|
|
158
|
+
if (!engine.apiKey) return { name: 'virustotal', verdict: Verdict.ScanError, error: 'no apiKey' };
|
|
159
|
+
return vtScanBuffer(engine.apiKey, buffer, engine.threshold ?? 1);
|
|
160
|
+
}
|
|
161
|
+
// clamav
|
|
162
|
+
return clamavScanBuffer(engine, buffer);
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function scanMultiBuffer(buffer) {
|
|
167
|
+
if (!Buffer.isBuffer(buffer)) throw new Error('buffer must be a Buffer');
|
|
168
|
+
const engineResults = await _runEnginesOnBuffer(buffer);
|
|
169
|
+
const verdict = applyConsensus(engineResults, consensus);
|
|
170
|
+
return { verdict, consensus, engines: engineResults };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function scanMultiFile(filePath) {
|
|
174
|
+
if (typeof filePath !== 'string') throw new Error('filePath must be a string');
|
|
175
|
+
const buffer = fs.readFileSync(filePath);
|
|
176
|
+
const engineResults = await Promise.all(engines.map(async (engine) => {
|
|
177
|
+
if (engine.type === 'virustotal') {
|
|
178
|
+
if (!engine.apiKey) return { name: 'virustotal', verdict: Verdict.ScanError, error: 'no apiKey' };
|
|
179
|
+
return vtScanBuffer(engine.apiKey, buffer, engine.threshold ?? 1);
|
|
180
|
+
}
|
|
181
|
+
return clamavScanFile(engine, filePath);
|
|
182
|
+
}));
|
|
183
|
+
const verdict = applyConsensus(engineResults, consensus);
|
|
184
|
+
return { verdict, consensus, engines: engineResults };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { scan: scanMultiFile, scanBuffer: scanMultiBuffer };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { createMultiEngine };
|
package/src/Policy.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { scanBuffer } = require('./ClamAVScanner.js');
|
|
5
|
+
const { Verdict } = require('./verdicts.js');
|
|
6
|
+
|
|
7
|
+
const REASONS = {
|
|
8
|
+
FILE_TOO_LARGE: 'file_too_large',
|
|
9
|
+
MIME_NOT_ALLOWED: 'mime_not_allowed',
|
|
10
|
+
EXT_NOT_ALLOWED: 'extension_not_allowed',
|
|
11
|
+
ENCRYPTED: 'encrypted_archive',
|
|
12
|
+
VIRUS_DETECTED: 'virus_detected',
|
|
13
|
+
SCANNER_UNAVAILABLE: 'scanner_unavailable',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Minimal magic-byte check for encrypted ZIP (central directory encrypted flag)
|
|
17
|
+
function looksEncrypted(buffer) {
|
|
18
|
+
// ZIP local file header signature: 50 4B 03 04
|
|
19
|
+
// General-purpose bit flag word at offset 6; bit 0 = encryption
|
|
20
|
+
if (buffer.length < 8) return false;
|
|
21
|
+
if (buffer[0] === 0x50 && buffer[1] === 0x4B &&
|
|
22
|
+
buffer[2] === 0x03 && buffer[3] === 0x04) {
|
|
23
|
+
const flags = buffer.readUInt16LE(6);
|
|
24
|
+
return (flags & 0x01) !== 0;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createPolicy(rules = {}) {
|
|
30
|
+
const {
|
|
31
|
+
scan: scanOpts = undefined,
|
|
32
|
+
maxSize = null,
|
|
33
|
+
allowedMimeTypes = null,
|
|
34
|
+
allowedExtensions = null,
|
|
35
|
+
rejectEncrypted = false,
|
|
36
|
+
onScannerUnavailable = 'reject',
|
|
37
|
+
} = rules;
|
|
38
|
+
|
|
39
|
+
async function check(buffer, meta = {}) {
|
|
40
|
+
const { filename = null, mimeType = null, size = buffer.length } = meta;
|
|
41
|
+
const details = { size, mimeType, extension: null, virusName: null };
|
|
42
|
+
|
|
43
|
+
if (filename) {
|
|
44
|
+
details.extension = path.extname(filename).toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 1. Size check
|
|
48
|
+
if (maxSize !== null && size > maxSize) {
|
|
49
|
+
return { allowed: false, reason: REASONS.FILE_TOO_LARGE, verdict: Verdict.ScanError, details };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. MIME type check
|
|
53
|
+
if (allowedMimeTypes && mimeType) {
|
|
54
|
+
if (!allowedMimeTypes.includes(mimeType)) {
|
|
55
|
+
return { allowed: false, reason: REASONS.MIME_NOT_ALLOWED, verdict: Verdict.ScanError, details };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 3. Extension check
|
|
60
|
+
if (allowedExtensions && details.extension) {
|
|
61
|
+
if (!allowedExtensions.includes(details.extension)) {
|
|
62
|
+
return { allowed: false, reason: REASONS.EXT_NOT_ALLOWED, verdict: Verdict.ScanError, details };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 4. Encrypted archive check
|
|
67
|
+
if (rejectEncrypted && looksEncrypted(buffer)) {
|
|
68
|
+
return { allowed: false, reason: REASONS.ENCRYPTED, verdict: Verdict.ScanError, details };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 5. Virus scan
|
|
72
|
+
if (scanOpts !== undefined) {
|
|
73
|
+
let verdict;
|
|
74
|
+
try {
|
|
75
|
+
verdict = await scanBuffer(buffer, scanOpts);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (onScannerUnavailable === 'allow') {
|
|
78
|
+
return { allowed: true, reason: null, verdict: Verdict.ScanError, details };
|
|
79
|
+
}
|
|
80
|
+
if (onScannerUnavailable === 'throw') {
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
// 'reject'
|
|
84
|
+
return { allowed: false, reason: REASONS.SCANNER_UNAVAILABLE, verdict: Verdict.ScanError, details };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (verdict === Verdict.Malicious) {
|
|
88
|
+
return { allowed: false, reason: REASONS.VIRUS_DETECTED, verdict: Verdict.Malicious, details };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { allowed: true, reason: null, verdict, details };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { allowed: true, reason: null, verdict: Verdict.Clean, details };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function middleware() {
|
|
98
|
+
return async function policyMiddleware(req, res, next) {
|
|
99
|
+
let file = null;
|
|
100
|
+
if (req.file) {
|
|
101
|
+
file = req.file;
|
|
102
|
+
} else if (req.files) {
|
|
103
|
+
const files = Array.isArray(req.files)
|
|
104
|
+
? req.files
|
|
105
|
+
: Object.values(req.files).flat();
|
|
106
|
+
file = files[0] || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!file) return next();
|
|
110
|
+
|
|
111
|
+
const buffer = file.buffer;
|
|
112
|
+
const filename = file.originalname || null;
|
|
113
|
+
const mimeType = file.mimetype || null;
|
|
114
|
+
const size = buffer ? buffer.length : (file.size || 0);
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const result = await check(buffer || Buffer.alloc(0), { filename, mimeType, size });
|
|
118
|
+
if (!result.allowed) {
|
|
119
|
+
return res.status(403).json({ error: result.reason });
|
|
120
|
+
}
|
|
121
|
+
next();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
next(err);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function nestGuard() {
|
|
129
|
+
return {
|
|
130
|
+
canActivate: async (context) => {
|
|
131
|
+
const req = context.switchToHttp().getRequest();
|
|
132
|
+
const res = context.switchToHttp().getResponse();
|
|
133
|
+
const file = req.file || (req.files && (Array.isArray(req.files) ? req.files[0] : Object.values(req.files).flat()[0]));
|
|
134
|
+
if (!file) return true;
|
|
135
|
+
|
|
136
|
+
const buffer = file.buffer;
|
|
137
|
+
const filename = file.originalname || null;
|
|
138
|
+
const mimeType = file.mimetype || null;
|
|
139
|
+
const size = buffer ? buffer.length : (file.size || 0);
|
|
140
|
+
|
|
141
|
+
const result = await check(buffer || Buffer.alloc(0), { filename, mimeType, size });
|
|
142
|
+
if (!result.allowed) {
|
|
143
|
+
res.status(403).json({ error: result.reason });
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { check, middleware, nestGuard };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { createPolicy };
|
package/src/ScanCache.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { scan, scanBuffer } = require('./ClamAVScanner.js');
|
|
6
|
+
|
|
7
|
+
function sha256ofBuffer(buf) {
|
|
8
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sha256ofFile(filePath) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const hash = crypto.createHash('sha256');
|
|
14
|
+
const stream = fs.createReadStream(filePath);
|
|
15
|
+
stream.on('error', reject);
|
|
16
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
17
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createCache(options = {}) {
|
|
22
|
+
const ttl = options.ttl ?? 3600000;
|
|
23
|
+
const maxSize = options.maxSize ?? 1000;
|
|
24
|
+
const storage = options.storage || 'memory';
|
|
25
|
+
const filePath = options.filePath || './.pompelmi-cache.json';
|
|
26
|
+
|
|
27
|
+
// LRU map: key = sha256, value = { verdict, expiresAt }
|
|
28
|
+
// Insertion order tracks LRU; on hit we re-insert to move to back.
|
|
29
|
+
let store = new Map();
|
|
30
|
+
let hits = 0;
|
|
31
|
+
let misses = 0;
|
|
32
|
+
let writeTimer = null;
|
|
33
|
+
|
|
34
|
+
// ── file storage bootstrap ──────────────────────────────────────────────────
|
|
35
|
+
if (storage === 'file') {
|
|
36
|
+
try {
|
|
37
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
40
|
+
if (v.expiresAt > now) store.set(k, v);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// file absent or invalid — start with empty cache
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _persist() {
|
|
48
|
+
if (storage !== 'file') return;
|
|
49
|
+
if (writeTimer) return;
|
|
50
|
+
writeTimer = setTimeout(() => {
|
|
51
|
+
writeTimer = null;
|
|
52
|
+
const obj = {};
|
|
53
|
+
for (const [k, v] of store) obj[k] = v;
|
|
54
|
+
const tmp = filePath + '.tmp';
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
57
|
+
fs.renameSync(tmp, filePath);
|
|
58
|
+
} catch { /* non-fatal */ }
|
|
59
|
+
}, 500);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _get(sha) {
|
|
63
|
+
const entry = store.get(sha);
|
|
64
|
+
if (!entry) return null;
|
|
65
|
+
if (Date.now() > entry.expiresAt) {
|
|
66
|
+
store.delete(sha);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// Move to end (LRU: most-recently-used = last)
|
|
70
|
+
store.delete(sha);
|
|
71
|
+
store.set(sha, entry);
|
|
72
|
+
return entry.verdict;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _set(sha, verdict) {
|
|
76
|
+
if (store.has(sha)) store.delete(sha); // re-insert at end
|
|
77
|
+
if (store.size >= maxSize) {
|
|
78
|
+
// evict oldest (first) entry
|
|
79
|
+
store.delete(store.keys().next().value);
|
|
80
|
+
}
|
|
81
|
+
store.set(sha, { verdict, expiresAt: Date.now() + ttl });
|
|
82
|
+
_persist();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function scanWithCache(filePath, opts = {}) {
|
|
86
|
+
const sha = await sha256ofFile(filePath);
|
|
87
|
+
const cached = _get(sha);
|
|
88
|
+
if (cached !== null) { hits++; return cached; }
|
|
89
|
+
misses++;
|
|
90
|
+
const verdict = await scan(filePath, opts);
|
|
91
|
+
_set(sha, verdict);
|
|
92
|
+
return verdict;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function scanBufferWithCache(buffer, opts = {}) {
|
|
96
|
+
const sha = sha256ofBuffer(buffer);
|
|
97
|
+
const cached = _get(sha);
|
|
98
|
+
if (cached !== null) { hits++; return cached; }
|
|
99
|
+
misses++;
|
|
100
|
+
const verdict = await scanBuffer(buffer, opts);
|
|
101
|
+
_set(sha, verdict);
|
|
102
|
+
return verdict;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stats() {
|
|
106
|
+
const total = hits + misses;
|
|
107
|
+
return {
|
|
108
|
+
hits,
|
|
109
|
+
misses,
|
|
110
|
+
size: store.size,
|
|
111
|
+
hitRate: total === 0 ? 0 : hits / total,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function clear() {
|
|
116
|
+
store = new Map();
|
|
117
|
+
hits = 0;
|
|
118
|
+
misses = 0;
|
|
119
|
+
_persist();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function del(sha) {
|
|
123
|
+
store.delete(sha);
|
|
124
|
+
_persist();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
scan: scanWithCache,
|
|
129
|
+
scanBuffer: scanBufferWithCache,
|
|
130
|
+
stats,
|
|
131
|
+
clear,
|
|
132
|
+
delete: del,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { createCache };
|
package/src/index.js
CHANGED
|
@@ -9,5 +9,8 @@ const { createScanner } = require('./ScanEmitter.j
|
|
|
9
9
|
const { generateDashboard } = require('./Dashboard.js');
|
|
10
10
|
const { generateShareCard } = require('./ShareCard.js');
|
|
11
11
|
const { generateScorecard } = require('./Scorecard.js');
|
|
12
|
+
const { createCache } = require('./ScanCache.js');
|
|
13
|
+
const { createPolicy } = require('./Policy.js');
|
|
14
|
+
const { createMultiEngine } = require('./MultiEngine.js');
|
|
12
15
|
|
|
13
|
-
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner, generateDashboard, generateShareCard, generateScorecard };
|
|
16
|
+
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner, generateDashboard, generateShareCard, generateScorecard, createCache, createPolicy, createMultiEngine };
|
package/src/index.mjs
CHANGED
package/types/index.d.ts
CHANGED
|
@@ -73,11 +73,14 @@ export declare function scanStream(stream: Readable, options?: ScanOptions): Pro
|
|
|
73
73
|
/**
|
|
74
74
|
* Recursively scan every file under dirPath.
|
|
75
75
|
* Per-file errors are caught and collected without aborting the full scan.
|
|
76
|
+
* Use scanDirectory.stream() for async-iterable progress events.
|
|
76
77
|
*/
|
|
77
78
|
export declare function scanDirectory(
|
|
78
79
|
dirPath: string,
|
|
79
80
|
options?: ScanOptions
|
|
80
|
-
): Promise<DirectoryScanResult
|
|
81
|
+
): Promise<DirectoryScanResult> & {
|
|
82
|
+
stream(dirPath: string, options?: ScanOptions): AsyncIterable<DirectoryScanEvent>;
|
|
83
|
+
};
|
|
81
84
|
|
|
82
85
|
/**
|
|
83
86
|
* Express / Fastify middleware that scans multer-uploaded files
|
|
@@ -328,3 +331,190 @@ export interface ScorecardResult {
|
|
|
328
331
|
* console.log(scorecard.grade); // 'A'
|
|
329
332
|
*/
|
|
330
333
|
export declare function generateScorecard(config?: ScorecardConfig): Promise<ScorecardResult>;
|
|
334
|
+
|
|
335
|
+
// ─── ScanCache ────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/** Options for createCache() */
|
|
338
|
+
export interface CacheOptions {
|
|
339
|
+
/** Entry TTL in milliseconds (default: 3600000 = 1 hour) */
|
|
340
|
+
ttl?: number;
|
|
341
|
+
/** Maximum number of entries before LRU eviction (default: 1000) */
|
|
342
|
+
maxSize?: number;
|
|
343
|
+
/** Storage backend: 'memory' or 'file' (default: 'memory') */
|
|
344
|
+
storage?: 'memory' | 'file';
|
|
345
|
+
/** Path for file-backed storage (default: './.pompelmi-cache.json') */
|
|
346
|
+
filePath?: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Cache hit/miss statistics */
|
|
350
|
+
export interface CacheStats {
|
|
351
|
+
hits: number;
|
|
352
|
+
misses: number;
|
|
353
|
+
size: number;
|
|
354
|
+
hitRate: number;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** SHA256-based scan result cache */
|
|
358
|
+
export interface ScanCache {
|
|
359
|
+
/** Scan a file by path, returning a cached result if available */
|
|
360
|
+
scan(filePath: string, options?: ScanOptions): Promise<VerdictValue>;
|
|
361
|
+
/** Scan an in-memory Buffer, returning a cached result if available */
|
|
362
|
+
scanBuffer(buffer: Buffer, options?: ScanOptions): Promise<VerdictValue>;
|
|
363
|
+
/** Return cache statistics */
|
|
364
|
+
stats(): CacheStats;
|
|
365
|
+
/** Clear all cache entries */
|
|
366
|
+
clear(): void;
|
|
367
|
+
/** Remove a specific entry by its SHA256 hash */
|
|
368
|
+
delete(sha256: string): void;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Create a SHA256-based scan result cache.
|
|
373
|
+
* Skips rescanning files with known-clean or known-malicious hashes.
|
|
374
|
+
* Supports LRU eviction and optional file-backed persistence.
|
|
375
|
+
*/
|
|
376
|
+
export declare function createCache(options?: CacheOptions): ScanCache;
|
|
377
|
+
|
|
378
|
+
// ─── Policy ───────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
/** Options for createPolicy() */
|
|
381
|
+
export interface PolicyRules {
|
|
382
|
+
/** ClamAV connection options for virus scanning */
|
|
383
|
+
scan?: ScanOptions;
|
|
384
|
+
/** Maximum allowed file size in bytes */
|
|
385
|
+
maxSize?: number;
|
|
386
|
+
/** Allowlist of MIME types (e.g. ['image/jpeg', 'application/pdf']) */
|
|
387
|
+
allowedMimeTypes?: string[];
|
|
388
|
+
/** Allowlist of file extensions (e.g. ['.jpg', '.pdf']) */
|
|
389
|
+
allowedExtensions?: string[];
|
|
390
|
+
/** Reject encrypted archives (default: false) */
|
|
391
|
+
rejectEncrypted?: boolean;
|
|
392
|
+
/** Behaviour when clamd is unavailable: 'reject' | 'allow' | 'throw' (default: 'reject') */
|
|
393
|
+
onScannerUnavailable?: 'reject' | 'allow' | 'throw';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Metadata passed to policy.check() */
|
|
397
|
+
export interface FileMeta {
|
|
398
|
+
filename?: string | null;
|
|
399
|
+
mimeType?: string | null;
|
|
400
|
+
size?: number;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Details about the file that was checked */
|
|
404
|
+
export interface PolicyDetails {
|
|
405
|
+
size: number;
|
|
406
|
+
mimeType: string | null;
|
|
407
|
+
extension: string | null;
|
|
408
|
+
virusName: string | null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Result returned by policy.check() */
|
|
412
|
+
export interface PolicyResult {
|
|
413
|
+
allowed: boolean;
|
|
414
|
+
reason:
|
|
415
|
+
| null
|
|
416
|
+
| 'file_too_large'
|
|
417
|
+
| 'mime_not_allowed'
|
|
418
|
+
| 'extension_not_allowed'
|
|
419
|
+
| 'encrypted_archive'
|
|
420
|
+
| 'virus_detected'
|
|
421
|
+
| 'scanner_unavailable';
|
|
422
|
+
verdict: VerdictValue;
|
|
423
|
+
details: PolicyDetails;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A compiled scan policy */
|
|
427
|
+
export interface ScanPolicy {
|
|
428
|
+
/** Apply all policy rules to a buffer */
|
|
429
|
+
check(buffer: Buffer, meta?: FileMeta): Promise<PolicyResult>;
|
|
430
|
+
/** Express/Fastify middleware — rejects with HTTP 403 on policy violation */
|
|
431
|
+
middleware(): RequestHandler;
|
|
432
|
+
/** NestJS CanActivate guard */
|
|
433
|
+
nestGuard(): { canActivate(context: unknown): Promise<boolean> };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Create a unified scan policy combining size, MIME, extension, and virus checks.
|
|
438
|
+
*/
|
|
439
|
+
export declare function createPolicy(rules?: PolicyRules): ScanPolicy;
|
|
440
|
+
|
|
441
|
+
// ─── MultiEngine ─────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
/** ClamAV engine configuration */
|
|
444
|
+
export interface ClamAVEngineConfig {
|
|
445
|
+
type: 'clamav';
|
|
446
|
+
host?: string;
|
|
447
|
+
port?: number;
|
|
448
|
+
socket?: string;
|
|
449
|
+
timeout?: number;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** VirusTotal engine configuration */
|
|
453
|
+
export interface VirusTotalEngineConfig {
|
|
454
|
+
type: 'virustotal';
|
|
455
|
+
apiKey: string;
|
|
456
|
+
/** Minimum number of engine detections to flag as malicious (default: 1) */
|
|
457
|
+
threshold?: number;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export type EngineConfig = ClamAVEngineConfig | VirusTotalEngineConfig;
|
|
461
|
+
|
|
462
|
+
/** Per-engine result */
|
|
463
|
+
export interface EngineResult {
|
|
464
|
+
name: string;
|
|
465
|
+
verdict: VerdictValue;
|
|
466
|
+
detections?: number;
|
|
467
|
+
virus?: string;
|
|
468
|
+
error?: string;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Result returned by multi-engine scan */
|
|
472
|
+
export interface MultiEngineResult {
|
|
473
|
+
verdict: VerdictValue;
|
|
474
|
+
consensus: 'any' | 'all' | 'majority';
|
|
475
|
+
engines: EngineResult[];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Options for createMultiEngine() */
|
|
479
|
+
export interface MultiEngineOptions {
|
|
480
|
+
engines: EngineConfig[];
|
|
481
|
+
/** How to combine engine verdicts (default: 'any') */
|
|
482
|
+
consensus?: 'any' | 'all' | 'majority';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Multi-engine scanner */
|
|
486
|
+
export interface MultiEngine {
|
|
487
|
+
scan(filePath: string): Promise<MultiEngineResult>;
|
|
488
|
+
scanBuffer(buffer: Buffer): Promise<MultiEngineResult>;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Create a multi-engine scanner combining ClamAV and/or VirusTotal.
|
|
493
|
+
* The consensus mode controls how engine verdicts are combined.
|
|
494
|
+
*/
|
|
495
|
+
export declare function createMultiEngine(options?: MultiEngineOptions): MultiEngine;
|
|
496
|
+
|
|
497
|
+
// ─── scanDirectory.stream ─────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
/** Progress event emitted by scanDirectory.stream() */
|
|
500
|
+
export interface ScanProgressEvent {
|
|
501
|
+
type: 'progress';
|
|
502
|
+
scanned: number;
|
|
503
|
+
total: number;
|
|
504
|
+
file: string;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Per-file result event emitted by scanDirectory.stream() */
|
|
508
|
+
export interface ScanResultEvent {
|
|
509
|
+
type: 'result';
|
|
510
|
+
file: string;
|
|
511
|
+
verdict: VerdictValue | null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Final summary event emitted by scanDirectory.stream() */
|
|
515
|
+
export interface ScanCompleteEvent {
|
|
516
|
+
type: 'complete';
|
|
517
|
+
summary: DirectoryScanResult;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export type DirectoryScanEvent = ScanProgressEvent | ScanResultEvent | ScanCompleteEvent;
|