pompelmi 1.5.0 → 1.7.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 +113 -195
- package/action/Dockerfile +24 -0
- package/action/entrypoint.sh +23 -0
- package/action/scanner.js +89 -0
- package/action.yml +29 -0
- package/llms.txt +22 -99
- package/package.json +1 -1
- package/pr_info.tmp +2 -0
- package/release-notes-v1.4.0.md +25 -0
- package/release-notes-v1.5.0.md +37 -0
- package/src/BufferScanner.js +20 -17
- package/src/ClamAVScanner.js +4 -4
- package/src/ClamdScanner.js +18 -15
- package/src/StreamScanner.js +20 -17
- package/wiki/api-reference.md +268 -0
- package/wiki/cli-usage.md +263 -0
- package/wiki/concurrent-scanning.md +199 -0
- package/wiki/docker-compose-production.md +190 -0
- package/wiki/docker-setup.md +178 -0
- package/wiki/error-handling.md +242 -0
- package/wiki/express-integration.md +227 -0
- package/wiki/fastify-integration.md +207 -0
- package/wiki/home.md +0 -0
- package/wiki/local-vs-tcp-mode.md +179 -0
- package/wiki/multer-memory-storage.md +166 -0
- package/wiki/nestjs-integration.md +228 -0
- package/wiki/nextjs-integration.md +209 -0
- package/wiki/performance.md +178 -0
- package/wiki/quarantine-workflow.md +260 -0
- package/wiki/rest-api-server.md +297 -0
- package/wiki/s3-integration.md +233 -0
- package/wiki/security-considerations.md +192 -0
- package/wiki/typescript-usage.md +239 -0
- package/wiki/verdicts.md +192 -0
- package/wiki/virus-definitions.md +194 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# API Reference
|
|
2
|
+
|
|
3
|
+
Complete reference for all public functions exported by pompelmi.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install pompelmi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const { scan, scanBuffer, scanStream, scanDirectory, Verdict } = require('pompelmi');
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## `scan(filePath, [options])`
|
|
20
|
+
|
|
21
|
+
Scan a file by absolute or relative path. In local mode spawns `clamscan`; in TCP mode streams the file to clamd via INSTREAM.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
scan(
|
|
25
|
+
filePath: string,
|
|
26
|
+
options?: {
|
|
27
|
+
host?: string;
|
|
28
|
+
port?: number;
|
|
29
|
+
timeout?: number;
|
|
30
|
+
}
|
|
31
|
+
): Promise<symbol>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Parameters
|
|
35
|
+
|
|
36
|
+
| Parameter | Type | Required | Description |
|
|
37
|
+
|-----------|------|----------|-------------|
|
|
38
|
+
| `filePath` | `string` | Yes | Path to the file to scan. Use `path.resolve()` for safety. |
|
|
39
|
+
| `options.host` | `string` | No | clamd hostname. Setting this enables TCP mode. |
|
|
40
|
+
| `options.port` | `number` | No | clamd port. Default: `3310`. |
|
|
41
|
+
| `options.timeout` | `number` | No | Socket idle timeout in ms (TCP mode only). Default: `15000`. |
|
|
42
|
+
|
|
43
|
+
### Returns
|
|
44
|
+
|
|
45
|
+
`Promise<symbol>` — resolves to one of the three `Verdict` Symbols:
|
|
46
|
+
|
|
47
|
+
| Verdict | Local exit code | TCP response | Meaning |
|
|
48
|
+
|---------|-----------------|--------------|---------|
|
|
49
|
+
| `Verdict.Clean` | `0` | `stream: OK` | No threats found. |
|
|
50
|
+
| `Verdict.Malicious` | `1` | `stream: <name> FOUND` | Known malware signature matched. |
|
|
51
|
+
| `Verdict.ScanError` | `2` | other response | Scan could not complete. Treat as untrusted. |
|
|
52
|
+
|
|
53
|
+
### Rejects with
|
|
54
|
+
|
|
55
|
+
| Message | Cause |
|
|
56
|
+
|---------|-------|
|
|
57
|
+
| `filePath must be a string` | First argument is not a string. |
|
|
58
|
+
| `File not found: <path>` | File does not exist at the given path. |
|
|
59
|
+
| `ENOENT` | `clamscan` binary not found in PATH (local mode). |
|
|
60
|
+
| `Unexpected exit code: N` | ClamAV exited with an undocumented code. |
|
|
61
|
+
| `Process killed by signal: <SIG>` | Process was killed (timeout, OOM, SIGTERM). |
|
|
62
|
+
| `clamd connection timed out after Nms` | TCP socket idle timeout exceeded. |
|
|
63
|
+
|
|
64
|
+
### Examples
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
// Local mode
|
|
68
|
+
const result = await scan('/uploads/report.pdf');
|
|
69
|
+
|
|
70
|
+
// TCP mode
|
|
71
|
+
const result = await scan('/uploads/report.pdf', {
|
|
72
|
+
host: '127.0.0.1',
|
|
73
|
+
port: 3310,
|
|
74
|
+
timeout: 30_000,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (result === Verdict.Clean) console.log('Safe.');
|
|
78
|
+
if (result === Verdict.Malicious) throw new Error('Malware detected.');
|
|
79
|
+
if (result === Verdict.ScanError) console.warn('Scan incomplete — treat as untrusted.');
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## `scanBuffer(buffer, [options])`
|
|
85
|
+
|
|
86
|
+
Scan an in-memory `Buffer` without writing to disk (TCP mode) or via a temp file that is deleted automatically (local mode).
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
scanBuffer(
|
|
90
|
+
buffer: Buffer,
|
|
91
|
+
options?: {
|
|
92
|
+
host?: string;
|
|
93
|
+
port?: number;
|
|
94
|
+
timeout?: number;
|
|
95
|
+
}
|
|
96
|
+
): Promise<symbol>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Parameters
|
|
100
|
+
|
|
101
|
+
| Parameter | Type | Required | Description |
|
|
102
|
+
|-----------|------|----------|-------------|
|
|
103
|
+
| `buffer` | `Buffer` | Yes | The in-memory buffer to scan. |
|
|
104
|
+
| `options` | `object` | No | Same options as `scan()`. |
|
|
105
|
+
|
|
106
|
+
### Returns
|
|
107
|
+
|
|
108
|
+
Same three `Verdict` Symbols as `scan()`.
|
|
109
|
+
|
|
110
|
+
### Rejects with
|
|
111
|
+
|
|
112
|
+
Everything `scan()` can reject with, plus:
|
|
113
|
+
|
|
114
|
+
| Message | Cause |
|
|
115
|
+
|---------|-------|
|
|
116
|
+
| `buffer must be a Buffer` | First argument is not a `Buffer` instance. |
|
|
117
|
+
| `buffer is empty` | Zero-length Buffer passed. |
|
|
118
|
+
|
|
119
|
+
### Notes
|
|
120
|
+
|
|
121
|
+
- **TCP mode:** buffer is streamed to clamd via INSTREAM — no disk I/O.
|
|
122
|
+
- **Local mode:** buffer is written to a temp file in `os.tmpdir()`, scanned, then deleted in a `finally` block.
|
|
123
|
+
|
|
124
|
+
### Example
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
// multer memoryStorage
|
|
128
|
+
const result = await scanBuffer(req.file.buffer, {
|
|
129
|
+
host: process.env.CLAMAV_HOST,
|
|
130
|
+
port: 3310,
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## `scanStream(stream, [options])`
|
|
137
|
+
|
|
138
|
+
Scan any Node.js `Readable` stream. In TCP mode the stream is piped directly to clamd — no disk I/O. In local mode it is written to a temp file that is deleted automatically.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
scanStream(
|
|
142
|
+
stream: Readable,
|
|
143
|
+
options?: {
|
|
144
|
+
host?: string;
|
|
145
|
+
port?: number;
|
|
146
|
+
timeout?: number;
|
|
147
|
+
}
|
|
148
|
+
): Promise<symbol>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Parameters
|
|
152
|
+
|
|
153
|
+
| Parameter | Type | Required | Description |
|
|
154
|
+
|-----------|------|----------|-------------|
|
|
155
|
+
| `stream` | `stream.Readable` | Yes | The readable stream to scan. |
|
|
156
|
+
| `options` | `object` | No | Same options as `scan()`. |
|
|
157
|
+
|
|
158
|
+
### Returns
|
|
159
|
+
|
|
160
|
+
Same three `Verdict` Symbols as `scan()`.
|
|
161
|
+
|
|
162
|
+
### Rejects with
|
|
163
|
+
|
|
164
|
+
Everything `scan()` can reject with, plus:
|
|
165
|
+
|
|
166
|
+
| Message | Cause |
|
|
167
|
+
|---------|-------|
|
|
168
|
+
| `stream must be a Readable` | First argument is not a `stream.Readable`. |
|
|
169
|
+
| stream error | Any error emitted by the stream is propagated as-is. |
|
|
170
|
+
|
|
171
|
+
### Example
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
// S3 getObject stream
|
|
175
|
+
const { GetObjectCommand } = require('@aws-sdk/client-s3');
|
|
176
|
+
const response = await s3.send(new GetObjectCommand({ Bucket, Key }));
|
|
177
|
+
const result = await scanStream(response.Body, { host: 'clamav', port: 3310 });
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## `scanDirectory(dirPath, [options])`
|
|
183
|
+
|
|
184
|
+
Recursively scan every file in a directory. Returns three arrays of absolute paths; per-file failures are collected rather than thrown.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
scanDirectory(
|
|
188
|
+
dirPath: string,
|
|
189
|
+
options?: {
|
|
190
|
+
host?: string;
|
|
191
|
+
port?: number;
|
|
192
|
+
timeout?: number;
|
|
193
|
+
}
|
|
194
|
+
): Promise<{
|
|
195
|
+
clean: string[];
|
|
196
|
+
malicious: string[];
|
|
197
|
+
errors: string[];
|
|
198
|
+
}>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Parameters
|
|
202
|
+
|
|
203
|
+
| Parameter | Type | Required | Description |
|
|
204
|
+
|-----------|------|----------|-------------|
|
|
205
|
+
| `dirPath` | `string` | Yes | Path to the directory to scan recursively. |
|
|
206
|
+
| `options` | `object` | No | Same options as `scan()`. |
|
|
207
|
+
|
|
208
|
+
### Returns
|
|
209
|
+
|
|
210
|
+
| Field | Type | Description |
|
|
211
|
+
|-------|------|-------------|
|
|
212
|
+
| `clean` | `string[]` | Absolute paths of files with no threats. |
|
|
213
|
+
| `malicious` | `string[]` | Absolute paths of files with matched signatures. |
|
|
214
|
+
| `errors` | `string[]` | Absolute paths of files that could not be scanned. |
|
|
215
|
+
|
|
216
|
+
### Rejects with
|
|
217
|
+
|
|
218
|
+
| Message | Cause |
|
|
219
|
+
|---------|-------|
|
|
220
|
+
| `dirPath must be a string` | First argument is not a string. |
|
|
221
|
+
| `Directory not found: <path>` | Directory does not exist. |
|
|
222
|
+
|
|
223
|
+
Individual file scan failures do **not** cause the function to reject — they appear in `errors`.
|
|
224
|
+
|
|
225
|
+
### Example
|
|
226
|
+
|
|
227
|
+
```js
|
|
228
|
+
const results = await scanDirectory('/uploads', { host: 'clamav', port: 3310 });
|
|
229
|
+
|
|
230
|
+
console.log(`${results.clean.length} clean, ${results.malicious.length} malicious`);
|
|
231
|
+
results.malicious.forEach(f => fs.unlinkSync(f));
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## `Verdict`
|
|
237
|
+
|
|
238
|
+
The `Verdict` object exported by pompelmi contains three Symbols:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
const { Verdict } = require('pompelmi');
|
|
242
|
+
|
|
243
|
+
Verdict.Clean // Symbol(Clean)
|
|
244
|
+
Verdict.Malicious // Symbol(Malicious)
|
|
245
|
+
Verdict.ScanError // Symbol(ScanError)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Each Symbol has a `.description` property for safe serialisation:
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
Verdict.Clean.description // 'Clean'
|
|
252
|
+
Verdict.Malicious.description // 'Malicious'
|
|
253
|
+
Verdict.ScanError.description // 'ScanError'
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Options reference
|
|
259
|
+
|
|
260
|
+
All four functions accept the same options object:
|
|
261
|
+
|
|
262
|
+
| Option | Type | Default | Description |
|
|
263
|
+
|--------|------|---------|-------------|
|
|
264
|
+
| `host` | `string` | — | clamd hostname. Setting this enables TCP mode. |
|
|
265
|
+
| `port` | `number` | `3310` | clamd port. |
|
|
266
|
+
| `timeout` | `number` | `15000` | Socket idle timeout in ms. TCP mode only. |
|
|
267
|
+
|
|
268
|
+
When neither `host` nor `port` is set, pompelmi uses local mode (spawns `clamscan`).
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# CLI Usage
|
|
2
|
+
|
|
3
|
+
pompelmi can be used as a command-line tool for scripting, CI pipelines, and interactive scanning. This page shows how to build a CLI scanner with pompelmi and how to use it in shell scripts.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Minimal CLI scanner
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
#!/usr/bin/env node
|
|
11
|
+
// cli-scan.js
|
|
12
|
+
|
|
13
|
+
const { scan, scanDirectory, Verdict } = require('pompelmi');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
|
|
19
|
+
if (args.length === 0) {
|
|
20
|
+
console.error('Usage: node cli-scan.js <file-or-dir> [file2] ...');
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SCAN_OPTS = {
|
|
25
|
+
host: process.env.CLAMAV_HOST,
|
|
26
|
+
port: Number(process.env.CLAMAV_PORT) || 3310,
|
|
27
|
+
timeout: Number(process.env.CLAMAV_TIMEOUT) || 30_000,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
let anyMalicious = false;
|
|
32
|
+
|
|
33
|
+
for (const target of args) {
|
|
34
|
+
const resolved = path.resolve(target);
|
|
35
|
+
|
|
36
|
+
let stat;
|
|
37
|
+
try {
|
|
38
|
+
stat = fs.statSync(resolved);
|
|
39
|
+
} catch {
|
|
40
|
+
console.error(`Not found: ${resolved}`);
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (stat.isDirectory()) {
|
|
45
|
+
const results = await scanDirectory(resolved, SCAN_OPTS);
|
|
46
|
+
|
|
47
|
+
for (const f of results.clean) console.log(`CLEAN ${f}`);
|
|
48
|
+
for (const f of results.malicious) console.log(`MALICIOUS ${f}`);
|
|
49
|
+
for (const f of results.errors) console.log(`ERROR ${f}`);
|
|
50
|
+
|
|
51
|
+
if (results.malicious.length > 0) anyMalicious = true;
|
|
52
|
+
} else {
|
|
53
|
+
const result = await scan(resolved, SCAN_OPTS);
|
|
54
|
+
const label = result.description.toUpperCase().padEnd(9);
|
|
55
|
+
console.log(`${label} ${resolved}`);
|
|
56
|
+
if (result === Verdict.Malicious) anyMalicious = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Exit code 1 if any malicious file found — useful for CI
|
|
61
|
+
process.exit(anyMalicious ? 1 : 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main().catch(err => {
|
|
65
|
+
console.error(err.message);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Make it executable:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
chmod +x cli-scan.js
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Usage examples
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Scan a single file
|
|
82
|
+
node cli-scan.js /path/to/file.pdf
|
|
83
|
+
|
|
84
|
+
# Scan multiple files
|
|
85
|
+
node cli-scan.js /uploads/a.pdf /uploads/b.zip
|
|
86
|
+
|
|
87
|
+
# Scan a directory recursively
|
|
88
|
+
node cli-scan.js /uploads/
|
|
89
|
+
|
|
90
|
+
# TCP mode (set env vars)
|
|
91
|
+
CLAMAV_HOST=127.0.0.1 node cli-scan.js /uploads/file.pdf
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Exit codes
|
|
97
|
+
|
|
98
|
+
| Exit code | Meaning |
|
|
99
|
+
|-----------|---------|
|
|
100
|
+
| `0` | All scanned files are clean |
|
|
101
|
+
| `1` | One or more malicious files found |
|
|
102
|
+
| `2` | Scan failed or argument error |
|
|
103
|
+
|
|
104
|
+
Exit code `1` for malicious is standard in shell scripting — makes it easy to use in `if` statements and CI pipelines.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Shell scripting
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
#!/bin/bash
|
|
112
|
+
set -e
|
|
113
|
+
|
|
114
|
+
FILE="$1"
|
|
115
|
+
|
|
116
|
+
if [ -z "$FILE" ]; then
|
|
117
|
+
echo "Usage: $0 <file>"
|
|
118
|
+
exit 2
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
node /usr/local/bin/cli-scan.js "$FILE"
|
|
122
|
+
STATUS=$?
|
|
123
|
+
|
|
124
|
+
if [ $STATUS -eq 1 ]; then
|
|
125
|
+
echo "Upload rejected: malware detected."
|
|
126
|
+
exit 1
|
|
127
|
+
elif [ $STATUS -eq 2 ]; then
|
|
128
|
+
echo "Scan failed."
|
|
129
|
+
exit 2
|
|
130
|
+
else
|
|
131
|
+
echo "File is clean."
|
|
132
|
+
fi
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## CI pipeline integration
|
|
138
|
+
|
|
139
|
+
### GitHub Actions
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
# .github/workflows/scan-artifacts.yml
|
|
143
|
+
name: Scan artifacts
|
|
144
|
+
|
|
145
|
+
on:
|
|
146
|
+
workflow_run:
|
|
147
|
+
workflows: ["Build"]
|
|
148
|
+
types: [completed]
|
|
149
|
+
|
|
150
|
+
jobs:
|
|
151
|
+
scan:
|
|
152
|
+
runs-on: ubuntu-latest
|
|
153
|
+
steps:
|
|
154
|
+
- uses: actions/checkout@v4
|
|
155
|
+
|
|
156
|
+
- name: Install ClamAV
|
|
157
|
+
run: |
|
|
158
|
+
sudo apt-get install -y clamav
|
|
159
|
+
sudo freshclam
|
|
160
|
+
|
|
161
|
+
- name: Download build artifacts
|
|
162
|
+
uses: actions/download-artifact@v4
|
|
163
|
+
with:
|
|
164
|
+
name: dist
|
|
165
|
+
path: dist/
|
|
166
|
+
|
|
167
|
+
- name: Scan artifacts
|
|
168
|
+
run: |
|
|
169
|
+
npm ci
|
|
170
|
+
node cli-scan.js dist/
|
|
171
|
+
# Exits 1 if malicious files found — fails the job
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Pre-commit hook
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# .git/hooks/pre-commit
|
|
178
|
+
#!/bin/bash
|
|
179
|
+
|
|
180
|
+
# Scan staged files before commit
|
|
181
|
+
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
|
|
182
|
+
|
|
183
|
+
if [ -z "$STAGED" ]; then
|
|
184
|
+
exit 0
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
echo "$STAGED" | xargs node cli-scan.js
|
|
188
|
+
|
|
189
|
+
if [ $? -ne 0 ]; then
|
|
190
|
+
echo "Commit blocked: malware detected in staged files."
|
|
191
|
+
exit 1
|
|
192
|
+
fi
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Adding a global `pompelmi` command
|
|
198
|
+
|
|
199
|
+
Add to `package.json` to expose as a package binary:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"bin": {
|
|
204
|
+
"pompelmi-scan": "./cli-scan.js"
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Install globally:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
npm install -g pompelmi
|
|
213
|
+
pompelmi-scan /path/to/file.pdf
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Scanning directories and deleting malicious files
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
#!/usr/bin/env node
|
|
222
|
+
// cli-purge.js — scan a directory and delete malicious files
|
|
223
|
+
|
|
224
|
+
const { scanDirectory, Verdict } = require('pompelmi');
|
|
225
|
+
const fs = require('fs');
|
|
226
|
+
const path = require('path');
|
|
227
|
+
|
|
228
|
+
const dir = path.resolve(process.argv[2] || '.');
|
|
229
|
+
|
|
230
|
+
const results = await scanDirectory(dir, {
|
|
231
|
+
host: process.env.CLAMAV_HOST,
|
|
232
|
+
port: 3310,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
console.log(`Scanned: ${results.clean.length + results.malicious.length + results.errors.length} files`);
|
|
236
|
+
console.log(`Clean: ${results.clean.length}`);
|
|
237
|
+
console.log(`Malicious: ${results.malicious.length}`);
|
|
238
|
+
console.log(`Errors: ${results.errors.length}`);
|
|
239
|
+
|
|
240
|
+
for (const f of results.malicious) {
|
|
241
|
+
fs.unlinkSync(f);
|
|
242
|
+
console.log(`Deleted: ${f}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
process.exit(results.malicious.length > 0 ? 1 : 0);
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## JSON output for piping to other tools
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
// cli-scan-json.js — outputs JSON for use with jq
|
|
254
|
+
const results = [];
|
|
255
|
+
// ... scan logic ...
|
|
256
|
+
|
|
257
|
+
process.stdout.write(JSON.stringify({ files: results }, null, 2));
|
|
258
|
+
process.exit(anyMalicious ? 1 : 0);
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
node cli-scan-json.js /uploads/ | jq '.files[] | select(.verdict == "Malicious") | .path'
|
|
263
|
+
```
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Concurrent Scanning
|
|
2
|
+
|
|
3
|
+
Scanning multiple files in parallel improves throughput but introduces tradeoffs around resource usage, partial failures, and connection limits. This page covers the main patterns.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## `Promise.all` — scan multiple files in parallel
|
|
8
|
+
|
|
9
|
+
`Promise.all` runs all scans concurrently and resolves when every scan completes. If any scan rejects (throws), the entire `Promise.all` rejects immediately.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const { scan, Verdict } = require('pompelmi');
|
|
13
|
+
|
|
14
|
+
const files = [
|
|
15
|
+
'/uploads/document.pdf',
|
|
16
|
+
'/uploads/photo.jpg',
|
|
17
|
+
'/uploads/archive.zip',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const results = await Promise.all(files.map(f => scan(f)));
|
|
21
|
+
|
|
22
|
+
results.forEach((result, i) => {
|
|
23
|
+
if (result === Verdict.Malicious) {
|
|
24
|
+
console.log(`${files[i]} is malicious.`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use `Promise.all` when:
|
|
30
|
+
- All files must be accepted for the request to succeed.
|
|
31
|
+
- You want to fail fast if any scan throws.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## `Promise.allSettled` — partial failures
|
|
36
|
+
|
|
37
|
+
`Promise.allSettled` waits for all scans to complete regardless of individual failures. Each result has a `status` of `'fulfilled'` or `'rejected'`.
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
const { scan, scanBuffer, Verdict } = require('pompelmi');
|
|
41
|
+
|
|
42
|
+
const files = ['/uploads/a.pdf', '/uploads/b.zip', '/uploads/c.png'];
|
|
43
|
+
|
|
44
|
+
const settled = await Promise.allSettled(
|
|
45
|
+
files.map(async (f) => ({ path: f, verdict: await scan(f) }))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const accepted = [];
|
|
49
|
+
const rejected = [];
|
|
50
|
+
|
|
51
|
+
for (const r of settled) {
|
|
52
|
+
if (r.status === 'rejected') {
|
|
53
|
+
rejected.push({ path: '?', reason: r.reason.message });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const { path, verdict } = r.value;
|
|
57
|
+
if (verdict === Verdict.Clean) {
|
|
58
|
+
accepted.push(path);
|
|
59
|
+
} else {
|
|
60
|
+
rejected.push({ path, reason: verdict.description });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log({ accepted, rejected });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use `Promise.allSettled` when:
|
|
68
|
+
- You want to process as many files as possible even if some fail.
|
|
69
|
+
- You need to report which specific files were rejected.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## `scanDirectory()` — scan an entire folder
|
|
74
|
+
|
|
75
|
+
`scanDirectory()` handles concurrent scanning of every file in a directory internally. It catches per-file errors and collects them into the `errors` array rather than throwing.
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
const fs = require('fs');
|
|
79
|
+
const { scanDirectory } = require('pompelmi');
|
|
80
|
+
|
|
81
|
+
const results = await scanDirectory('/uploads', {
|
|
82
|
+
host: process.env.CLAMAV_HOST,
|
|
83
|
+
port: 3310,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
console.log(`Clean: ${results.clean.length}`);
|
|
87
|
+
console.log(`Malicious: ${results.malicious.length}`);
|
|
88
|
+
console.log(`Errors: ${results.errors.length}`);
|
|
89
|
+
|
|
90
|
+
// Auto-delete malicious files
|
|
91
|
+
results.malicious.forEach(f => fs.unlinkSync(f));
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Use `scanDirectory()` when:
|
|
95
|
+
- You have an existing folder of files to audit.
|
|
96
|
+
- You want a single-call interface with clean/malicious/errors output.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Rate limiting concurrent scans with `p-limit`
|
|
101
|
+
|
|
102
|
+
Unbounded `Promise.all` with a large number of files can overwhelm clamd or exhaust the OS file descriptor limit. Use `p-limit` to cap concurrency.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm install p-limit
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
const pLimit = require('p-limit');
|
|
110
|
+
const { scan, Verdict } = require('pompelmi');
|
|
111
|
+
|
|
112
|
+
const files = getFilePaths(); // array of N paths
|
|
113
|
+
const limit = pLimit(5); // at most 5 concurrent scans
|
|
114
|
+
|
|
115
|
+
const results = await Promise.all(
|
|
116
|
+
files.map(f => limit(() => scan(f, { host: 'clamav', port: 3310 })))
|
|
117
|
+
);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Recommended concurrency limits:
|
|
121
|
+
|
|
122
|
+
| Mode | Suggested concurrency |
|
|
123
|
+
|------|----------------------|
|
|
124
|
+
| Local (`clamscan`) | 2–4 (CPU-bound) |
|
|
125
|
+
| TCP (single clamd) | 5–10 |
|
|
126
|
+
| TCP (multiple clamd replicas) | 20–50 |
|
|
127
|
+
|
|
128
|
+
Tune based on your hardware and observed clamd CPU usage.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Concurrently scanning buffers
|
|
133
|
+
|
|
134
|
+
```js
|
|
135
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
136
|
+
|
|
137
|
+
// req.files from multer.array()
|
|
138
|
+
const results = await Promise.allSettled(
|
|
139
|
+
req.files.map(file =>
|
|
140
|
+
scanBuffer(file.buffer, { host: 'clamav', port: 3310 })
|
|
141
|
+
.then(verdict => ({ name: file.originalname, verdict }))
|
|
142
|
+
)
|
|
143
|
+
);
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Performance considerations
|
|
149
|
+
|
|
150
|
+
### Local mode
|
|
151
|
+
|
|
152
|
+
Each `scan()` in local mode spawns a `clamscan` child process. Spawning processes is expensive — ClamAV loads its virus database into memory on each invocation. For high-throughput local scanning, consider switching to TCP mode where a persistent `clamd` daemon keeps the database in memory.
|
|
153
|
+
|
|
154
|
+
### TCP mode
|
|
155
|
+
|
|
156
|
+
In TCP mode, pompelmi opens a new TCP connection per scan call. For sustained high-throughput workloads, the connection overhead is measurable. Options:
|
|
157
|
+
|
|
158
|
+
1. **Increase concurrency gradually** — start at 5, measure clamd CPU, increase until you see degradation.
|
|
159
|
+
2. **Scale clamd horizontally** — run multiple clamd containers behind a load balancer.
|
|
160
|
+
3. **Connection pooling** — pompelmi does not pool connections. For extremely high throughput, implement a connection pool that keeps sockets open and reuses them.
|
|
161
|
+
|
|
162
|
+
### Memory
|
|
163
|
+
|
|
164
|
+
`scanBuffer()` holds the full file content in memory. For large files (>50 MB), prefer `scan()` (from disk) or `scanStream()` (streaming, no full buffering in TCP mode).
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Example: batch-scan upload queue
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
const pLimit = require('p-limit');
|
|
172
|
+
const { scan, Verdict } = require('pompelmi');
|
|
173
|
+
const fs = require('fs');
|
|
174
|
+
|
|
175
|
+
async function processBatch(filePaths) {
|
|
176
|
+
const limit = pLimit(8);
|
|
177
|
+
|
|
178
|
+
const results = await Promise.allSettled(
|
|
179
|
+
filePaths.map(filePath =>
|
|
180
|
+
limit(async () => {
|
|
181
|
+
const verdict = await scan(filePath, { host: 'clamav', port: 3310 });
|
|
182
|
+
return { filePath, verdict };
|
|
183
|
+
})
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
for (const r of results) {
|
|
188
|
+
if (r.status === 'rejected') {
|
|
189
|
+
console.error('Scan error:', r.reason.message);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const { filePath, verdict } = r.value;
|
|
193
|
+
if (verdict !== Verdict.Clean) {
|
|
194
|
+
fs.unlinkSync(filePath);
|
|
195
|
+
console.warn('Rejected:', filePath, verdict.description);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|