pompelmi 1.10.0 → 1.12.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/.mailmap +3 -0
- package/README.md +19 -0
- package/bin/pompelmi.js +403 -0
- package/package.json +7 -2
- package/src/ScanEmitter.js +81 -0
- package/src/WebhookNotifier.js +94 -0
- package/src/index.js +3 -1
- package/types/index.d.ts +63 -0
package/.mailmap
ADDED
package/README.md
CHANGED
|
@@ -21,12 +21,28 @@
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Scan a file
|
|
28
|
+
npx pompelmi scan ./uploads/file.pdf
|
|
29
|
+
|
|
30
|
+
# Scan a directory
|
|
31
|
+
npx pompelmi scan ./uploads --recursive
|
|
32
|
+
|
|
33
|
+
# Output as JSON
|
|
34
|
+
npx pompelmi scan ./uploads --json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
24
39
|
## Documentation
|
|
25
40
|
|
|
26
41
|
| Guide | Description |
|
|
27
42
|
|-------|-------------|
|
|
28
43
|
| [Getting Started](./docs/getting-started.md) | Installation, prerequisites, quickstart examples |
|
|
29
44
|
| [API Reference](./docs/api.md) | Full function signatures, options, verdicts, error conditions |
|
|
45
|
+
| [CLI Reference](./docs/cli.html) | Terminal commands, options, examples |
|
|
30
46
|
| [S3 Integration](./docs/s3.md) | Scan S3 objects directly, IAM setup, Lambda pattern |
|
|
31
47
|
| [Docker / Remote Scanning](./docs/docker.md) | TCP sidecar, UNIX socket mount, docker-compose patterns |
|
|
32
48
|
| [GitHub Action](./docs/github-action.md) | CI scanning, inputs/outputs, caching, example workflows |
|
|
@@ -56,6 +72,7 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
56
72
|
|
|
57
73
|
## Features
|
|
58
74
|
|
|
75
|
+
- Standalone CLI — scan files from any terminal with `npx pompelmi scan`
|
|
59
76
|
- Single `scan(filePath, [options])` function — works locally or against a remote clamd instance
|
|
60
77
|
- `scanBuffer(buffer, [options])` — scan in-memory Buffers directly, no temp file required in TCP mode
|
|
61
78
|
- `scanStream(stream, [options])` — scan a Readable stream directly. In TCP mode, streamed to clamd with no disk I/O.
|
|
@@ -63,6 +80,8 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
63
80
|
- `scanS3(params, [options])` — scan S3 objects by streaming directly from AWS S3, no disk I/O
|
|
64
81
|
- `createPool([options])` — persistent connection pool for high-throughput clamd scanning
|
|
65
82
|
- `watch(dirPath, [options], callbacks)` — watch a directory and auto-scan new/modified files (300 ms debounce)
|
|
83
|
+
- `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
|
|
84
|
+
- `createScanner([options])` — EventEmitter-based scanner; call `.scan(filePath)` or `.scanDirectory(dirPath)` and listen to `'clean'`, `'malicious'`, `'scanError'`, and `'error'` events
|
|
66
85
|
- Auto-retry on connection error — `retries` and `retryDelay` options on every scan function
|
|
67
86
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
68
87
|
- Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
|
package/bin/pompelmi.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
|
|
8
|
+
const pkg = require('../package.json');
|
|
9
|
+
|
|
10
|
+
// ── Logo ──────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
async function printLogo() {
|
|
13
|
+
try {
|
|
14
|
+
const terminalImage = (await import('terminal-image')).default;
|
|
15
|
+
const imgPath = path.join(__dirname, '../src/grapefruit.png');
|
|
16
|
+
if (fs.existsSync(imgPath)) {
|
|
17
|
+
const image = await terminalImage.file(imgPath, {
|
|
18
|
+
width: '20%',
|
|
19
|
+
height: '20%',
|
|
20
|
+
preserveAspectRatio: true,
|
|
21
|
+
});
|
|
22
|
+
process.stdout.write(image);
|
|
23
|
+
}
|
|
24
|
+
} catch (_) {}
|
|
25
|
+
console.log('\x1b[33m pompelmi\x1b[0m — ClamAV Antivirus Scanning for Node.js');
|
|
26
|
+
console.log('\x1b[90m v' + pkg.version + ' • Zero dependencies • TCP • UNIX socket • GitHub Action\x1b[0m\n');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Argument parsing ──────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const args = argv.slice(2);
|
|
33
|
+
const opts = {
|
|
34
|
+
command: null,
|
|
35
|
+
target: null,
|
|
36
|
+
host: undefined,
|
|
37
|
+
port: undefined,
|
|
38
|
+
socket: undefined,
|
|
39
|
+
timeout: 15000,
|
|
40
|
+
retries: 0,
|
|
41
|
+
json: false,
|
|
42
|
+
quiet: false,
|
|
43
|
+
delete: false,
|
|
44
|
+
recursive: true,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < args.length) {
|
|
49
|
+
const a = args[i];
|
|
50
|
+
if (a === 'scan' || a === 'watch' || a === 'version' || a === 'help') {
|
|
51
|
+
opts.command = a;
|
|
52
|
+
} else if (a === '--json') {
|
|
53
|
+
opts.json = true;
|
|
54
|
+
} else if (a === '--quiet' || a === '-q') {
|
|
55
|
+
opts.quiet = true;
|
|
56
|
+
} else if (a === '--recursive' || a === '-r') {
|
|
57
|
+
opts.recursive = true;
|
|
58
|
+
} else if (a === '--delete') {
|
|
59
|
+
opts.delete = true;
|
|
60
|
+
} else if (a === '--host') {
|
|
61
|
+
opts.host = args[++i];
|
|
62
|
+
} else if (a === '--port') {
|
|
63
|
+
opts.port = parseInt(args[++i], 10);
|
|
64
|
+
} else if (a === '--socket') {
|
|
65
|
+
opts.socket = args[++i];
|
|
66
|
+
} else if (a === '--timeout') {
|
|
67
|
+
opts.timeout = parseInt(args[++i], 10);
|
|
68
|
+
} else if (a === '--retries') {
|
|
69
|
+
opts.retries = parseInt(args[++i], 10);
|
|
70
|
+
} else if (!a.startsWith('-') && opts.command && !opts.target) {
|
|
71
|
+
opts.target = a;
|
|
72
|
+
}
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return opts;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Scan options builder ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function buildScanOpts(opts) {
|
|
82
|
+
const o = { timeout: opts.timeout, retries: opts.retries };
|
|
83
|
+
if (opts.host !== undefined) o.host = opts.host;
|
|
84
|
+
if (opts.port !== undefined) o.port = opts.port;
|
|
85
|
+
if (opts.socket !== undefined) o.socket = opts.socket;
|
|
86
|
+
return o;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Progress bar ──────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function progressBar(done, total, infected) {
|
|
92
|
+
const pct = total === 0 ? 0 : Math.floor((done / total) * 100);
|
|
93
|
+
const filled = Math.floor(pct / 5);
|
|
94
|
+
const empty = 20 - filled;
|
|
95
|
+
const bar = '█'.repeat(filled) + '░'.repeat(empty);
|
|
96
|
+
return ` Scanning... [${bar}] ${pct}% • ${done}/${total} files • ${infected} infected`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Box drawing output ────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
function boxLine(content, width) {
|
|
102
|
+
const padded = content.padEnd(width - 4);
|
|
103
|
+
return `│ ${padded} │`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function printResults(results, elapsed, opts) {
|
|
107
|
+
if (opts.json) {
|
|
108
|
+
const out = {
|
|
109
|
+
scanned: results.length,
|
|
110
|
+
infected: results.filter(r => r.verdict === 'infected').length,
|
|
111
|
+
errors: results.filter(r => r.verdict === 'error').length,
|
|
112
|
+
time: Math.round(elapsed / 100) / 10,
|
|
113
|
+
results: results.map(r => {
|
|
114
|
+
const o = { file: r.file, verdict: r.verdict };
|
|
115
|
+
if (r.viruses) o.viruses = r.viruses;
|
|
116
|
+
return o;
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
119
|
+
console.log(JSON.stringify(out, null, 2));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const W = 46;
|
|
124
|
+
const top = '┌' + '─'.repeat(W) + '┐';
|
|
125
|
+
const sep = '├' + '─'.repeat(W) + '┤';
|
|
126
|
+
const bottom = '└' + '─'.repeat(W) + '┘';
|
|
127
|
+
|
|
128
|
+
const infected = results.filter(r => r.verdict === 'infected').length;
|
|
129
|
+
const secs = (elapsed / 1000).toFixed(1);
|
|
130
|
+
|
|
131
|
+
console.log(top);
|
|
132
|
+
console.log(boxLine('🍊 pompelmi scan results', W));
|
|
133
|
+
console.log(sep);
|
|
134
|
+
|
|
135
|
+
for (const r of results) {
|
|
136
|
+
if (opts.quiet && r.verdict === 'clean') continue;
|
|
137
|
+
const short = r.file.length > 30 ? '...' + r.file.slice(-27) : r.file;
|
|
138
|
+
if (r.verdict === 'clean') {
|
|
139
|
+
console.log(boxLine(`\x1b[32m✅ CLEAN\x1b[0m ${short}`, W + 9));
|
|
140
|
+
} else if (r.verdict === 'infected') {
|
|
141
|
+
console.log(boxLine(`\x1b[31m🚨 INFECTED\x1b[0m ${short}`, W + 9));
|
|
142
|
+
if (r.viruses && r.viruses.length) {
|
|
143
|
+
const vname = r.viruses[0] || '';
|
|
144
|
+
console.log(boxLine(`\x1b[90m └─ ${vname}\x1b[0m`, W + 8));
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
console.log(boxLine(`\x1b[33m⚠️ ERROR\x1b[0m ${short}`, W + 12));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(sep);
|
|
152
|
+
const summary = `Scanned: ${results.length} • Infected: ${infected} • Time: ${secs}s`;
|
|
153
|
+
console.log(boxLine(summary, W));
|
|
154
|
+
console.log(bottom);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Scan a single file ────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
async function scanOne(filePath, scanOpts) {
|
|
160
|
+
const { scan } = require('../src/ClamAVScanner.js');
|
|
161
|
+
const { Verdict } = require('../src/verdicts.js');
|
|
162
|
+
try {
|
|
163
|
+
const v = await scan(filePath, scanOpts);
|
|
164
|
+
if (v === Verdict.Clean) return { file: filePath, verdict: 'clean' };
|
|
165
|
+
if (v === Verdict.Malicious) return { file: filePath, verdict: 'infected', viruses: [] };
|
|
166
|
+
return { file: filePath, verdict: 'error' };
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|unreachable/i.test(err.message)) {
|
|
169
|
+
return { file: filePath, verdict: 'error', _clamdUnreachable: true };
|
|
170
|
+
}
|
|
171
|
+
return { file: filePath, verdict: 'error' };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Collect files from path ───────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
function collectFiles(target) {
|
|
178
|
+
const stat = fs.statSync(target);
|
|
179
|
+
if (stat.isFile()) return [target];
|
|
180
|
+
const entries = fs.readdirSync(target, { recursive: true });
|
|
181
|
+
return entries
|
|
182
|
+
.map(e => path.join(target, e))
|
|
183
|
+
.filter(f => { try { return fs.statSync(f).isFile(); } catch { return false; } });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Ask confirmation ──────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function confirm(question) {
|
|
189
|
+
return new Promise(resolve => {
|
|
190
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
191
|
+
rl.question(question, ans => {
|
|
192
|
+
rl.close();
|
|
193
|
+
resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async function cmdScan(opts) {
|
|
201
|
+
if (!opts.target) {
|
|
202
|
+
console.error('Usage: pompelmi scan <file|dir> [options]');
|
|
203
|
+
process.exit(2);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const target = path.resolve(opts.target);
|
|
207
|
+
if (!fs.existsSync(target)) {
|
|
208
|
+
console.error(`Error: path not found: ${target}`);
|
|
209
|
+
process.exit(2);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!opts.json && !opts.quiet) await printLogo();
|
|
213
|
+
|
|
214
|
+
const files = collectFiles(target);
|
|
215
|
+
const scanOpts = buildScanOpts(opts);
|
|
216
|
+
const results = [];
|
|
217
|
+
const start = Date.now();
|
|
218
|
+
let infectedCount = 0;
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < files.length; i++) {
|
|
221
|
+
if (!opts.json && !opts.quiet && files.length > 1) {
|
|
222
|
+
process.stdout.write('\r' + progressBar(i, files.length, infectedCount));
|
|
223
|
+
}
|
|
224
|
+
const r = await scanOne(files[i], scanOpts);
|
|
225
|
+
if (r.verdict === 'infected') infectedCount++;
|
|
226
|
+
results.push(r);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!opts.json && !opts.quiet && files.length > 1) {
|
|
230
|
+
process.stdout.write('\r' + progressBar(files.length, files.length, infectedCount) + '\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const elapsed = Date.now() - start;
|
|
234
|
+
|
|
235
|
+
if (opts.delete) {
|
|
236
|
+
const infected = results.filter(r => r.verdict === 'infected');
|
|
237
|
+
if (infected.length > 0) {
|
|
238
|
+
if (!opts.json) {
|
|
239
|
+
console.log(`\nFound ${infected.length} infected file(s):`);
|
|
240
|
+
for (const r of infected) console.log(` ${r.file}`);
|
|
241
|
+
}
|
|
242
|
+
const ok = await confirm('Delete these files? [y/N] ');
|
|
243
|
+
if (ok) {
|
|
244
|
+
for (const r of infected) {
|
|
245
|
+
try { fs.unlinkSync(r.file); if (!opts.json) console.log(` Deleted: ${r.file}`); }
|
|
246
|
+
catch (e) { if (!opts.json) console.log(` Failed to delete: ${r.file}: ${e.message}`); }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
printResults(results, elapsed, opts);
|
|
253
|
+
|
|
254
|
+
const hasInfected = results.some(r => r.verdict === 'infected');
|
|
255
|
+
const hasError = results.some(r => r.verdict === 'error');
|
|
256
|
+
const clamdDown = results.some(r => r._clamdUnreachable);
|
|
257
|
+
|
|
258
|
+
if (clamdDown) process.exit(3);
|
|
259
|
+
if (hasInfected) process.exit(1);
|
|
260
|
+
if (hasError) process.exit(2);
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function cmdWatch(opts) {
|
|
265
|
+
if (!opts.target) {
|
|
266
|
+
console.error('Usage: pompelmi watch <dir> [options]');
|
|
267
|
+
process.exit(2);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const target = path.resolve(opts.target);
|
|
271
|
+
if (!fs.existsSync(target)) {
|
|
272
|
+
console.error(`Error: directory not found: ${target}`);
|
|
273
|
+
process.exit(2);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!opts.json && !opts.quiet) await printLogo();
|
|
277
|
+
|
|
278
|
+
const { watch } = require('../src/Watcher.js');
|
|
279
|
+
const scanOpts = buildScanOpts(opts);
|
|
280
|
+
|
|
281
|
+
let scanned = 0, clean = 0, infected = 0;
|
|
282
|
+
|
|
283
|
+
function status() {
|
|
284
|
+
process.stdout.write(
|
|
285
|
+
`\rWatching ${target} • Scanned: ${scanned} • Clean: ${clean} • Infected: ${infected} `
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
status();
|
|
290
|
+
|
|
291
|
+
watch(target, scanOpts, {
|
|
292
|
+
onClean(fp) {
|
|
293
|
+
scanned++; clean++;
|
|
294
|
+
if (!opts.quiet) process.stdout.write(`\n\x1b[32m✅ CLEAN\x1b[0m ${fp}\n`);
|
|
295
|
+
status();
|
|
296
|
+
},
|
|
297
|
+
onMalicious(fp) {
|
|
298
|
+
scanned++; infected++;
|
|
299
|
+
process.stdout.write(`\n\x1b[31m🚨 INFECTED\x1b[0m ${fp}\n`);
|
|
300
|
+
status();
|
|
301
|
+
},
|
|
302
|
+
onError(err) {
|
|
303
|
+
scanned++;
|
|
304
|
+
if (!opts.quiet) process.stdout.write(`\n\x1b[33m⚠️ ERROR\x1b[0m ${err.message}\n`);
|
|
305
|
+
status();
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
process.on('SIGINT', () => { process.stdout.write('\n'); process.exit(0); });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function cmdVersion() {
|
|
313
|
+
console.log(pkg.version);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function cmdHelp() {
|
|
317
|
+
console.log(`
|
|
318
|
+
pompelmi v${pkg.version} — ClamAV Antivirus Scanning for Node.js
|
|
319
|
+
|
|
320
|
+
USAGE
|
|
321
|
+
pompelmi <command> [options]
|
|
322
|
+
|
|
323
|
+
COMMANDS
|
|
324
|
+
scan <file|dir> Scan a file or directory for viruses
|
|
325
|
+
watch <dir> Watch a directory and auto-scan new/modified files
|
|
326
|
+
version Print version number
|
|
327
|
+
help Show this help message
|
|
328
|
+
|
|
329
|
+
SCAN OPTIONS
|
|
330
|
+
--recursive, -r Scan directory recursively (default: true)
|
|
331
|
+
--host <host> clamd host (default: localhost, uses local clamscan if omitted)
|
|
332
|
+
--port <port> clamd port (default: 3310)
|
|
333
|
+
--socket <path> UNIX socket path (e.g. /run/clamav/clamd.sock)
|
|
334
|
+
--timeout <ms> Connection timeout in ms (default: 15000)
|
|
335
|
+
--retries <n> Auto-retry on connection failure (default: 0)
|
|
336
|
+
--json Output results as JSON (no logo, no colors)
|
|
337
|
+
--quiet, -q Only print infected files and summary
|
|
338
|
+
--delete Delete infected files after confirmation
|
|
339
|
+
|
|
340
|
+
WATCH OPTIONS
|
|
341
|
+
--host, --port, --socket, --timeout (same as scan)
|
|
342
|
+
|
|
343
|
+
EXIT CODES
|
|
344
|
+
0 All files clean
|
|
345
|
+
1 Virus found
|
|
346
|
+
2 Scan error
|
|
347
|
+
3 clamd unreachable
|
|
348
|
+
|
|
349
|
+
EXAMPLES
|
|
350
|
+
# Scan a single file (local clamscan)
|
|
351
|
+
pompelmi scan ./upload.pdf
|
|
352
|
+
|
|
353
|
+
# Scan a directory recursively
|
|
354
|
+
pompelmi scan ./uploads --recursive
|
|
355
|
+
|
|
356
|
+
# Scan via clamd over TCP
|
|
357
|
+
pompelmi scan ./uploads --host 127.0.0.1 --port 3310
|
|
358
|
+
|
|
359
|
+
# Scan via UNIX socket
|
|
360
|
+
pompelmi scan ./uploads --socket /run/clamav/clamd.sock
|
|
361
|
+
|
|
362
|
+
# JSON output for scripting
|
|
363
|
+
pompelmi scan ./uploads --json
|
|
364
|
+
|
|
365
|
+
# Watch a directory
|
|
366
|
+
pompelmi watch ./uploads --host 127.0.0.1 --port 3310
|
|
367
|
+
|
|
368
|
+
# Use with npx (no install required)
|
|
369
|
+
npx pompelmi scan ./uploads
|
|
370
|
+
`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
async function main() {
|
|
376
|
+
const opts = parseArgs(process.argv);
|
|
377
|
+
|
|
378
|
+
if (!opts.command || opts.command === 'help') {
|
|
379
|
+
await printLogo();
|
|
380
|
+
cmdHelp();
|
|
381
|
+
process.exit(0);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (opts.command === 'version') {
|
|
385
|
+
cmdVersion();
|
|
386
|
+
process.exit(0);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (opts.command === 'scan') {
|
|
390
|
+
await cmdScan(opts);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (opts.command === 'watch') {
|
|
395
|
+
await cmdWatch(opts);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
main().catch(err => {
|
|
401
|
+
console.error('\x1b[31mError:\x1b[0m', err.message);
|
|
402
|
+
process.exit(2);
|
|
403
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pompelmi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"author": "pompelmi contributors",
|
|
@@ -33,6 +33,9 @@
|
|
|
33
33
|
"type": "commonjs",
|
|
34
34
|
"main": "./src/index.js",
|
|
35
35
|
"types": "./types/index.d.ts",
|
|
36
|
+
"bin": {
|
|
37
|
+
"pompelmi": "./bin/pompelmi.js"
|
|
38
|
+
},
|
|
36
39
|
"scripts": {
|
|
37
40
|
"test": "node --test test/unit.test.js && node test/scan.test.js",
|
|
38
41
|
"lint": "eslint src/"
|
|
@@ -40,7 +43,9 @@
|
|
|
40
43
|
"publishConfig": {
|
|
41
44
|
"registry": "https://registry.npmjs.org/"
|
|
42
45
|
},
|
|
43
|
-
"dependencies": {
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"terminal-image": "^4.3.0"
|
|
48
|
+
},
|
|
44
49
|
"devDependencies": {
|
|
45
50
|
"@eslint/js": "^10.0.1",
|
|
46
51
|
"eslint": "^10.2.0",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const { scan } = require('./ClamAVScanner.js');
|
|
7
|
+
const { Verdict } = require('./verdicts.js');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns an EventEmitter-based scanner.
|
|
11
|
+
*
|
|
12
|
+
* Events:
|
|
13
|
+
* 'clean' (filePath) — file scanned clean
|
|
14
|
+
* 'malicious' (filePath, viruses) — virus detected (viruses is always [])
|
|
15
|
+
* 'error' (err) — unexpected error (not a scan verdict)
|
|
16
|
+
* 'scanError' (filePath) — scan returned Verdict.ScanError
|
|
17
|
+
*
|
|
18
|
+
* @param {object} [options] - ScanOptions forwarded to scan()
|
|
19
|
+
* @returns {EventEmitter & { scan(filePath): void, scanDirectory(dirPath): void }}
|
|
20
|
+
*/
|
|
21
|
+
function createScanner(options = {}) {
|
|
22
|
+
const emitter = new EventEmitter();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Scan a single file and emit the appropriate event.
|
|
26
|
+
* @param {string} filePath
|
|
27
|
+
*/
|
|
28
|
+
emitter.scan = function scanFile(filePath) {
|
|
29
|
+
scan(filePath, options)
|
|
30
|
+
.then((verdict) => {
|
|
31
|
+
if (verdict === Verdict.Clean) {
|
|
32
|
+
emitter.emit('clean', filePath);
|
|
33
|
+
} else if (verdict === Verdict.Malicious) {
|
|
34
|
+
emitter.emit('malicious', filePath, []);
|
|
35
|
+
} else {
|
|
36
|
+
emitter.emit('scanError', filePath);
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.catch((err) => {
|
|
40
|
+
emitter.emit('error', err);
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Recursively scan every file in dirPath and emit events per file.
|
|
46
|
+
* @param {string} dirPath
|
|
47
|
+
*/
|
|
48
|
+
emitter.scanDirectory = function scanDir(dirPath) {
|
|
49
|
+
if (!fs.existsSync(dirPath)) {
|
|
50
|
+
emitter.emit('error', new Error(`Directory not found: ${dirPath}`));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let files;
|
|
55
|
+
try {
|
|
56
|
+
files = fs.readdirSync(dirPath);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
emitter.emit('error', err);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const fullPath = path.join(dirPath, file);
|
|
64
|
+
let stat;
|
|
65
|
+
try {
|
|
66
|
+
stat = fs.statSync(fullPath);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (stat.isDirectory()) {
|
|
71
|
+
emitter.scanDirectory(fullPath);
|
|
72
|
+
} else {
|
|
73
|
+
emitter.scan(fullPath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return emitter;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { createScanner };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { URL } = require('url');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Send a POST webhook notification when a scan result is available.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} webhookUrl - Destination URL (http or https).
|
|
13
|
+
* @param {object} scanResult - { file, verdict, viruses }
|
|
14
|
+
* @param {object} [options] - { onlyOnMalicious, secret }
|
|
15
|
+
* @returns {Promise<void>}
|
|
16
|
+
*/
|
|
17
|
+
function notify(webhookUrl, scanResult, options = {}) {
|
|
18
|
+
const { onlyOnMalicious = true, secret } = options;
|
|
19
|
+
|
|
20
|
+
if (!webhookUrl || typeof webhookUrl !== 'string') {
|
|
21
|
+
return Promise.reject(new Error('webhookUrl must be a non-empty string'));
|
|
22
|
+
}
|
|
23
|
+
if (!scanResult || typeof scanResult !== 'object') {
|
|
24
|
+
return Promise.reject(new Error('scanResult must be an object'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const verdictDescription =
|
|
28
|
+
scanResult.verdict && typeof scanResult.verdict === 'symbol'
|
|
29
|
+
? scanResult.verdict.description
|
|
30
|
+
: String(scanResult.verdict ?? 'Unknown');
|
|
31
|
+
|
|
32
|
+
const isMalicious = verdictDescription === 'Malicious';
|
|
33
|
+
|
|
34
|
+
if (onlyOnMalicious && !isMalicious) {
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const payload = JSON.stringify({
|
|
39
|
+
file: scanResult.file ?? null,
|
|
40
|
+
verdict: verdictDescription,
|
|
41
|
+
viruses: scanResult.viruses ?? [],
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
hostname: os.hostname(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = new URL(webhookUrl);
|
|
50
|
+
} catch {
|
|
51
|
+
return reject(new Error(`Invalid webhookUrl: ${webhookUrl}`));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isHttps = parsed.protocol === 'https:';
|
|
55
|
+
const transport = isHttps ? https : http;
|
|
56
|
+
|
|
57
|
+
const headers = {
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
60
|
+
'User-Agent': 'pompelmi-webhook/1.0',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (secret) {
|
|
64
|
+
const sig = crypto
|
|
65
|
+
.createHmac('sha256', secret)
|
|
66
|
+
.update(payload)
|
|
67
|
+
.digest('hex');
|
|
68
|
+
headers['X-Pompelmi-Signature'] = `sha256=${sig}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const reqOptions = {
|
|
72
|
+
hostname: parsed.hostname,
|
|
73
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
74
|
+
path: parsed.pathname + parsed.search,
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const req = transport.request(reqOptions, (res) => {
|
|
80
|
+
res.resume(); // drain response body
|
|
81
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
82
|
+
resolve();
|
|
83
|
+
} else {
|
|
84
|
+
reject(new Error(`Webhook responded with HTTP ${res.statusCode}`));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
req.on('error', reject);
|
|
89
|
+
req.write(payload);
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { notify };
|
package/src/index.js
CHANGED
|
@@ -4,5 +4,7 @@ const { middleware } = require('./middleware.js
|
|
|
4
4
|
const { scanS3 } = require('./S3Scanner.js');
|
|
5
5
|
const { createPool } = require('./ClamdPool.js');
|
|
6
6
|
const { watch } = require('./Watcher.js');
|
|
7
|
+
const { notify } = require('./WebhookNotifier.js');
|
|
8
|
+
const { createScanner } = require('./ScanEmitter.js');
|
|
7
9
|
|
|
8
|
-
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch };
|
|
10
|
+
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner };
|
package/types/index.d.ts
CHANGED
|
@@ -152,3 +152,66 @@ export declare function watch(
|
|
|
152
152
|
options?: ScanOptions,
|
|
153
153
|
callbacks?: WatchCallbacks
|
|
154
154
|
): FSWatcher;
|
|
155
|
+
|
|
156
|
+
/** Payload sent by the webhook notifier */
|
|
157
|
+
export interface WebhookPayload {
|
|
158
|
+
file: string | null;
|
|
159
|
+
verdict: string;
|
|
160
|
+
viruses: string[];
|
|
161
|
+
timestamp: string;
|
|
162
|
+
hostname: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Options for notify() */
|
|
166
|
+
export interface NotifyOptions {
|
|
167
|
+
/** Only send a webhook when the verdict is Malicious (default: true) */
|
|
168
|
+
onlyOnMalicious?: boolean;
|
|
169
|
+
/** HMAC-SHA256 secret — adds X-Pompelmi-Signature header when set */
|
|
170
|
+
secret?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** scan result passed to notify() */
|
|
174
|
+
export interface ScanResultInput {
|
|
175
|
+
file?: string | null;
|
|
176
|
+
verdict: symbol | string;
|
|
177
|
+
viruses?: string[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Send a POST webhook notification for a scan result.
|
|
182
|
+
* Uses Node.js built-in https/http — no external dependencies.
|
|
183
|
+
* When secret is provided, adds an X-Pompelmi-Signature: sha256=<hmac> header.
|
|
184
|
+
*/
|
|
185
|
+
export declare function notify(
|
|
186
|
+
webhookUrl: string,
|
|
187
|
+
scanResult: ScanResultInput,
|
|
188
|
+
options?: NotifyOptions
|
|
189
|
+
): Promise<void>;
|
|
190
|
+
|
|
191
|
+
import { EventEmitter } from 'events';
|
|
192
|
+
|
|
193
|
+
/** EventEmitter-based scanner returned by createScanner() */
|
|
194
|
+
export interface ScanEmitter extends EventEmitter {
|
|
195
|
+
/** Scan a single file; emits 'clean', 'malicious', 'scanError', or 'error' */
|
|
196
|
+
scan(filePath: string): void;
|
|
197
|
+
/** Recursively scan every file in dirPath; emits per-file events */
|
|
198
|
+
scanDirectory(dirPath: string): void;
|
|
199
|
+
|
|
200
|
+
on(event: 'clean', listener: (filePath: string) => void): this;
|
|
201
|
+
on(event: 'malicious', listener: (filePath: string, viruses: string[]) => void): this;
|
|
202
|
+
on(event: 'scanError', listener: (filePath: string) => void): this;
|
|
203
|
+
on(event: 'error', listener: (err: Error) => void): this;
|
|
204
|
+
on(event: string, listener: (...args: unknown[]) => void): this;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Create an EventEmitter-based scanner.
|
|
209
|
+
* Options are forwarded to the underlying scan() call (host, port, socket, etc.).
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* const scanner = createScanner({ host: 'localhost', port: 3310 });
|
|
213
|
+
* scanner.on('malicious', (file, viruses) => console.log('VIRUS:', file));
|
|
214
|
+
* scanner.scan('file.pdf');
|
|
215
|
+
* scanner.scanDirectory('/uploads');
|
|
216
|
+
*/
|
|
217
|
+
export declare function createScanner(options?: ScanOptions): ScanEmitter;
|