pompelmi 1.9.0 → 1.11.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 +22 -11
- package/package.json +1 -1
- package/src/BufferScanner.js +47 -33
- package/src/ClamdPool.js +183 -0
- package/src/ClamdScanner.js +60 -46
- package/src/S3Scanner.js +38 -0
- package/src/ScanEmitter.js +81 -0
- package/src/StreamScanner.js +46 -32
- package/src/Watcher.js +50 -0
- package/src/WebhookNotifier.js +94 -0
- package/src/index.js +6 -1
- package/types/index.d.ts +135 -0
package/.mailmap
ADDED
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|-------|-------------|
|
|
28
28
|
| [Getting Started](./docs/getting-started.md) | Installation, prerequisites, quickstart examples |
|
|
29
29
|
| [API Reference](./docs/api.md) | Full function signatures, options, verdicts, error conditions |
|
|
30
|
+
| [S3 Integration](./docs/s3.md) | Scan S3 objects directly, IAM setup, Lambda pattern |
|
|
30
31
|
| [Docker / Remote Scanning](./docs/docker.md) | TCP sidecar, UNIX socket mount, docker-compose patterns |
|
|
31
32
|
| [GitHub Action](./docs/github-action.md) | CI scanning, inputs/outputs, caching, example workflows |
|
|
32
33
|
|
|
@@ -59,6 +60,12 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
59
60
|
- `scanBuffer(buffer, [options])` — scan in-memory Buffers directly, no temp file required in TCP mode
|
|
60
61
|
- `scanStream(stream, [options])` — scan a Readable stream directly. In TCP mode, streamed to clamd with no disk I/O.
|
|
61
62
|
- `scanDirectory(dirPath, [options])` — recursively scan every file in a directory, returns clean/malicious/errors arrays
|
|
63
|
+
- `scanS3(params, [options])` — scan S3 objects by streaming directly from AWS S3, no disk I/O
|
|
64
|
+
- `createPool([options])` — persistent connection pool for high-throughput clamd scanning
|
|
65
|
+
- `watch(dirPath, [options], callbacks)` — watch a directory and auto-scan new/modified files (300 ms debounce)
|
|
66
|
+
- `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
|
|
67
|
+
- `createScanner([options])` — EventEmitter-based scanner; call `.scan(filePath)` or `.scanDirectory(dirPath)` and listen to `'clean'`, `'malicious'`, `'scanError'`, and `'error'` events
|
|
68
|
+
- Auto-retry on connection error — `retries` and `retryDelay` options on every scan function
|
|
62
69
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
63
70
|
- Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
|
|
64
71
|
- Built-in helpers to install ClamAV and update virus definitions programmatically
|
|
@@ -296,12 +303,14 @@ See **[docs/docker.md](./docs/docker.md)** for Docker Compose examples, UNIX soc
|
|
|
296
303
|
|
|
297
304
|
pompelmi has no configuration file or environment variables. All options are passed directly to `scan()`.
|
|
298
305
|
|
|
299
|
-
| Option
|
|
300
|
-
|
|
301
|
-
| `socket`
|
|
302
|
-
| `host`
|
|
303
|
-
| `port`
|
|
304
|
-
| `timeout`
|
|
306
|
+
| Option | Type | Default | Description |
|
|
307
|
+
|--------------|----------|---------|----------------------------------------|
|
|
308
|
+
| `socket` | `string` | — | Path to a clamd UNIX domain socket (e.g. `/run/clamav/clamd.sock`). Takes precedence over `host`/`port` when set. |
|
|
309
|
+
| `host` | `string` | — | clamd hostname. Enables TCP mode when set. |
|
|
310
|
+
| `port` | `number` | `3310` | clamd port. |
|
|
311
|
+
| `timeout` | `number` | `15000` | Socket idle timeout in milliseconds (clamd mode only). |
|
|
312
|
+
| `retries` | `number` | `0` | Automatic retry attempts on connection error. |
|
|
313
|
+
| `retryDelay` | `number` | `1000` | Milliseconds to wait between retries. |
|
|
305
314
|
|
|
306
315
|
When none of `socket`, `host`, or `port` is provided, pompelmi spawns `clamscan --no-summary <filePath>` locally.
|
|
307
316
|
|
|
@@ -313,12 +322,15 @@ See **[docs/api.md](./docs/api.md)** for the full reference: function signatures
|
|
|
313
322
|
|
|
314
323
|
**Quick summary:**
|
|
315
324
|
|
|
316
|
-
| Function | Input |
|
|
317
|
-
|
|
318
|
-
| `scan(filePath, [options])` | File path on disk | None (streamed) |
|
|
325
|
+
| Function | Input | Disk I/O |
|
|
326
|
+
|----------|-------|----------|
|
|
327
|
+
| `scan(filePath, [options])` | File path on disk | None in clamd mode (streamed) |
|
|
319
328
|
| `scanBuffer(buffer, [options])` | `Buffer` | None (streamed) |
|
|
320
329
|
| `scanStream(stream, [options])` | Node.js `Readable` | None (streamed) |
|
|
321
|
-
| `scanDirectory(dirPath, [options])` | Directory path | None
|
|
330
|
+
| `scanDirectory(dirPath, [options])` | Directory path | None in clamd mode |
|
|
331
|
+
| `scanS3(params, [options])` | S3 bucket + key | None (streamed from S3) |
|
|
332
|
+
| `createPool([options])` | — | Returns a `ClamdPool` |
|
|
333
|
+
| `watch(dirPath, [options], callbacks)` | Directory path | None in clamd mode |
|
|
322
334
|
|
|
323
335
|
All four functions accept the same `options` object and resolve to the same three verdict Symbols:
|
|
324
336
|
|
|
@@ -473,7 +485,6 @@ Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before contributing. To r
|
|
|
473
485
|
|
|
474
486
|
## Coming soon
|
|
475
487
|
|
|
476
|
-
- [ ] AWS S3 integration — scan objects directly from S3 without downloading
|
|
477
488
|
- [ ] Cloudflare Workers support — edge-native scanning via the clamd TCP protocol
|
|
478
489
|
- [ ] NestJS official module — `PompelmiModule.forRoot()` with injectable `PompelmiService`
|
|
479
490
|
|
package/package.json
CHANGED
package/src/BufferScanner.js
CHANGED
|
@@ -26,45 +26,59 @@ function parseClamdResponse(raw) {
|
|
|
26
26
|
* @param {number} [options.timeout=15000]
|
|
27
27
|
* @returns {Promise<symbol>}
|
|
28
28
|
*/
|
|
29
|
-
function scanBufferViaClamd(buffer,
|
|
30
|
-
|
|
31
|
-
const connOpts = socketPath ? { path: socketPath } : { host, port };
|
|
32
|
-
const conn = net.createConnection(connOpts);
|
|
33
|
-
const chunks = [];
|
|
34
|
-
let settled = false;
|
|
29
|
+
function scanBufferViaClamd(buffer, options = {}) {
|
|
30
|
+
const { retries = 0, retryDelay = 1000, host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = options;
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
conn.
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
function attempt() {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const connOpts = socketPath ? { path: socketPath } : { host, port };
|
|
35
|
+
const conn = net.createConnection(connOpts);
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let settled = false;
|
|
42
38
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
39
|
+
function settle(fn, value) {
|
|
40
|
+
if (settled) return;
|
|
41
|
+
settled = true;
|
|
42
|
+
conn.destroy();
|
|
43
|
+
fn(value);
|
|
44
|
+
}
|
|
50
45
|
|
|
51
|
-
|
|
52
|
-
conn.
|
|
46
|
+
conn.setTimeout(timeout);
|
|
47
|
+
conn.on('timeout', () =>
|
|
48
|
+
settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
|
|
49
|
+
);
|
|
50
|
+
conn.on('error', (err) => settle(reject, err));
|
|
51
|
+
conn.on('data', (chunk) => chunks.push(chunk));
|
|
52
|
+
conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
conn.on('connect', () => {
|
|
55
|
+
conn.write(CLAMD_INSTREAM);
|
|
56
|
+
|
|
57
|
+
let offset = 0;
|
|
58
|
+
while (offset < buffer.length) {
|
|
59
|
+
const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
|
|
60
|
+
const header = Buffer.allocUnsafe(4);
|
|
61
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
62
|
+
conn.write(header);
|
|
63
|
+
conn.write(chunk);
|
|
64
|
+
offset += chunk.length;
|
|
65
|
+
}
|
|
63
66
|
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
conn.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
68
|
+
conn.end();
|
|
69
|
+
});
|
|
66
70
|
});
|
|
67
|
-
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function run(left) {
|
|
74
|
+
return attempt().catch(async (err) => {
|
|
75
|
+
if (left <= 0) throw err;
|
|
76
|
+
await new Promise(r => setTimeout(r, retryDelay));
|
|
77
|
+
return run(left - 1);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return run(retries);
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
module.exports = { scanBufferViaClamd };
|
package/src/ClamdPool.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { Verdict } = require('./verdicts.js');
|
|
6
|
+
|
|
7
|
+
const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
|
|
8
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
function parseClamdResponse(raw) {
|
|
11
|
+
const text = raw.toString('utf8').replace(/\0/g, '').trim();
|
|
12
|
+
if (text === 'stream: OK') return Verdict.Clean;
|
|
13
|
+
if (text.endsWith(' FOUND')) return Verdict.Malicious;
|
|
14
|
+
return Verdict.ScanError;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// One INSTREAM scan on a persistent socket. Does NOT call sock.end() — connection stays open.
|
|
18
|
+
// Detects end-of-response via the null terminator sent by clamd (z-prefix protocol).
|
|
19
|
+
function scanOnSocket(sock, sendPayload) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
let done = false;
|
|
23
|
+
|
|
24
|
+
function settle(fn, val) {
|
|
25
|
+
if (done) return;
|
|
26
|
+
done = true;
|
|
27
|
+
sock.removeListener('data', onData);
|
|
28
|
+
sock.removeListener('end', onEnd);
|
|
29
|
+
sock.removeListener('error', onError);
|
|
30
|
+
fn(val);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function onData(chunk) {
|
|
34
|
+
chunks.push(chunk);
|
|
35
|
+
const buf = Buffer.concat(chunks);
|
|
36
|
+
if (buf.includes(0)) settle(resolve, parseClamdResponse(buf));
|
|
37
|
+
}
|
|
38
|
+
function onEnd() { settle(resolve, parseClamdResponse(Buffer.concat(chunks))); }
|
|
39
|
+
function onError(err) { settle(reject, err); }
|
|
40
|
+
|
|
41
|
+
sock.on('data', onData);
|
|
42
|
+
sock.on('end', onEnd);
|
|
43
|
+
sock.on('error', onError);
|
|
44
|
+
|
|
45
|
+
sock.write(CLAMD_INSTREAM);
|
|
46
|
+
sendPayload(sock, (err) => settle(reject, err));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function openConnection(connOpts, timeout) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const sock = net.createConnection(connOpts);
|
|
53
|
+
let done = false;
|
|
54
|
+
function settle(err) {
|
|
55
|
+
if (done) return;
|
|
56
|
+
done = true;
|
|
57
|
+
if (err) { sock.destroy(); reject(err); }
|
|
58
|
+
else resolve(sock);
|
|
59
|
+
}
|
|
60
|
+
sock.setTimeout(timeout);
|
|
61
|
+
sock.on('timeout', () => settle(new Error(`clamd connect timed out after ${timeout}ms`)));
|
|
62
|
+
sock.once('error', (err) => settle(err));
|
|
63
|
+
sock.once('connect', () => { sock.setTimeout(0); settle(null); });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a pool of `size` persistent clamd connections.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} [options]
|
|
71
|
+
* @param {string} [options.host='127.0.0.1']
|
|
72
|
+
* @param {number} [options.port=3310]
|
|
73
|
+
* @param {string} [options.socket] - UNIX socket path (takes precedence over host/port)
|
|
74
|
+
* @param {number} [options.size=5] - Maximum number of concurrent connections
|
|
75
|
+
* @param {number} [options.timeout=15000]
|
|
76
|
+
* @returns {{ scan, scanBuffer, scanStream, destroy }}
|
|
77
|
+
*/
|
|
78
|
+
function createPool({ host = '127.0.0.1', port = 3310, socket: socketPath, size = 5, timeout = 15_000 } = {}) {
|
|
79
|
+
const connOpts = socketPath ? { path: socketPath } : { host, port };
|
|
80
|
+
const slots = Array.from({ length: size }, () => ({ socket: null, busy: false }));
|
|
81
|
+
const queue = [];
|
|
82
|
+
|
|
83
|
+
function dequeue(slot) {
|
|
84
|
+
slot.busy = false;
|
|
85
|
+
if (queue.length > 0) {
|
|
86
|
+
const { fn, resolve, reject } = queue.shift();
|
|
87
|
+
runOnSlot(slot, fn).then(resolve, reject);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function ensureConnected(slot) {
|
|
92
|
+
if (slot.socket && !slot.socket.destroyed && slot.socket.readyState === 'open') return;
|
|
93
|
+
if (slot.socket && !slot.socket.destroyed) slot.socket.destroy();
|
|
94
|
+
slot.socket = await openConnection(connOpts, timeout);
|
|
95
|
+
const sock = slot.socket;
|
|
96
|
+
sock.on('error', () => { if (slot.socket === sock) slot.socket = null; });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runOnSlot(slot, fn) {
|
|
100
|
+
slot.busy = true;
|
|
101
|
+
try {
|
|
102
|
+
await ensureConnected(slot);
|
|
103
|
+
return await fn(slot.socket);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (slot.socket) { slot.socket.destroy(); slot.socket = null; }
|
|
106
|
+
throw err;
|
|
107
|
+
} finally {
|
|
108
|
+
dequeue(slot);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function enqueue(fn) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const free = slots.find(s => !s.busy);
|
|
115
|
+
if (free) {
|
|
116
|
+
runOnSlot(free, fn).then(resolve, reject);
|
|
117
|
+
} else {
|
|
118
|
+
queue.push({ fn, resolve, reject });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
scan(filePath) {
|
|
125
|
+
if (typeof filePath !== 'string')
|
|
126
|
+
return Promise.reject(new Error('filePath must be a string'));
|
|
127
|
+
if (!fs.existsSync(filePath))
|
|
128
|
+
return Promise.reject(new Error(`File not found: ${filePath}`));
|
|
129
|
+
return enqueue((sock) => scanOnSocket(sock, (s, onErr) => {
|
|
130
|
+
const stream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
|
131
|
+
stream.on('error', onErr);
|
|
132
|
+
stream.on('data', (chunk) => {
|
|
133
|
+
const hdr = Buffer.allocUnsafe(4);
|
|
134
|
+
hdr.writeUInt32BE(chunk.length, 0);
|
|
135
|
+
s.write(hdr);
|
|
136
|
+
s.write(chunk);
|
|
137
|
+
});
|
|
138
|
+
stream.on('end', () => s.write(Buffer.alloc(4)));
|
|
139
|
+
}));
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
scanBuffer(buffer) {
|
|
143
|
+
return enqueue((sock) => scanOnSocket(sock, (s) => {
|
|
144
|
+
let offset = 0;
|
|
145
|
+
while (offset < buffer.length) {
|
|
146
|
+
const chunk = buffer.subarray(offset, offset + CHUNK_SIZE);
|
|
147
|
+
const hdr = Buffer.allocUnsafe(4);
|
|
148
|
+
hdr.writeUInt32BE(chunk.length, 0);
|
|
149
|
+
s.write(hdr);
|
|
150
|
+
s.write(chunk);
|
|
151
|
+
offset += chunk.length;
|
|
152
|
+
}
|
|
153
|
+
s.write(Buffer.alloc(4));
|
|
154
|
+
}));
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
scanStream(stream) {
|
|
158
|
+
return enqueue((sock) => scanOnSocket(sock, (s, onErr) => {
|
|
159
|
+
stream.on('error', onErr);
|
|
160
|
+
stream.on('data', (chunk) => {
|
|
161
|
+
const hdr = Buffer.allocUnsafe(4);
|
|
162
|
+
hdr.writeUInt32BE(chunk.length, 0);
|
|
163
|
+
s.write(hdr);
|
|
164
|
+
s.write(chunk);
|
|
165
|
+
});
|
|
166
|
+
stream.on('end', () => s.write(Buffer.alloc(4)));
|
|
167
|
+
}));
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
destroy() {
|
|
171
|
+
for (const slot of slots) {
|
|
172
|
+
if (slot.socket) { slot.socket.destroy(); slot.socket = null; }
|
|
173
|
+
slot.busy = false;
|
|
174
|
+
}
|
|
175
|
+
while (queue.length > 0) {
|
|
176
|
+
const { reject } = queue.shift();
|
|
177
|
+
reject(new Error('Pool destroyed'));
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { createPool };
|
package/src/ClamdScanner.js
CHANGED
|
@@ -31,55 +31,69 @@ function parseClamdResponse(raw) {
|
|
|
31
31
|
* @param {number} [options.timeout=15000] - Socket idle timeout in ms.
|
|
32
32
|
* @returns {Promise<'Clean'|'Malicious'|'ScanError'>}
|
|
33
33
|
*/
|
|
34
|
-
function scanViaClamd(filePath,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
conn.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
fileStream.on('error', (err) => settle(reject, err));
|
|
69
|
-
|
|
70
|
-
fileStream.on('data', (chunk) => {
|
|
71
|
-
const header = Buffer.allocUnsafe(4);
|
|
72
|
-
header.writeUInt32BE(chunk.length, 0);
|
|
73
|
-
conn.write(header);
|
|
74
|
-
conn.write(chunk);
|
|
75
|
-
});
|
|
34
|
+
function scanViaClamd(filePath, options = {}) {
|
|
35
|
+
const { retries = 0, retryDelay = 1000, host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = options;
|
|
36
|
+
|
|
37
|
+
function attempt() {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
if (typeof filePath !== 'string') {
|
|
40
|
+
return reject(new Error('filePath must be a string'));
|
|
41
|
+
}
|
|
42
|
+
if (!fs.existsSync(filePath)) {
|
|
43
|
+
return reject(new Error(`File not found: ${filePath}`));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const connOpts = socketPath ? { path: socketPath } : { host, port };
|
|
47
|
+
const conn = net.createConnection(connOpts);
|
|
48
|
+
const chunks = [];
|
|
49
|
+
let settled = false;
|
|
50
|
+
|
|
51
|
+
function settle(fn, value) {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
settled = true;
|
|
54
|
+
conn.destroy();
|
|
55
|
+
fn(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
conn.setTimeout(timeout);
|
|
59
|
+
conn.on('timeout', () =>
|
|
60
|
+
settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
|
|
61
|
+
);
|
|
62
|
+
conn.on('error', (err) => settle(reject, err));
|
|
63
|
+
conn.on('data', (chunk) => chunks.push(chunk));
|
|
64
|
+
conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
65
|
+
|
|
66
|
+
conn.on('connect', () => {
|
|
67
|
+
conn.write(CLAMD_INSTREAM);
|
|
76
68
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
69
|
+
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
|
70
|
+
|
|
71
|
+
fileStream.on('error', (err) => settle(reject, err));
|
|
72
|
+
|
|
73
|
+
fileStream.on('data', (chunk) => {
|
|
74
|
+
const header = Buffer.allocUnsafe(4);
|
|
75
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
76
|
+
conn.write(header);
|
|
77
|
+
conn.write(chunk);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
fileStream.on('end', () => {
|
|
81
|
+
conn.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
82
|
+
conn.end();
|
|
83
|
+
});
|
|
80
84
|
});
|
|
81
85
|
});
|
|
82
|
-
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function run(left) {
|
|
89
|
+
return attempt().catch(async (err) => {
|
|
90
|
+
if (left <= 0) throw err;
|
|
91
|
+
await new Promise(r => setTimeout(r, retryDelay));
|
|
92
|
+
return run(left - 1);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return run(retries);
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
module.exports = { scanViaClamd };
|
package/src/S3Scanner.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanStream } = require('./ClamAVScanner.js');
|
|
4
|
+
const { Readable } = require('stream');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scan an S3 object by streaming it directly to clamd — no disk I/O.
|
|
8
|
+
*
|
|
9
|
+
* Requires @aws-sdk/client-s3: npm install @aws-sdk/client-s3
|
|
10
|
+
*
|
|
11
|
+
* @param {{ bucket: string, key: string, region?: string, credentials?: object }} s3Params
|
|
12
|
+
* @param {object} [options] - Same options as scanStream (host, port, socket, timeout, retries, retryDelay)
|
|
13
|
+
* @returns {Promise<symbol>}
|
|
14
|
+
*/
|
|
15
|
+
async function scanS3({ bucket, key, region, credentials } = {}, options = {}) {
|
|
16
|
+
let S3Client, GetObjectCommand;
|
|
17
|
+
try {
|
|
18
|
+
({ S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'));
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error('Install AWS SDK: npm install @aws-sdk/client-s3');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const clientOpts = {};
|
|
24
|
+
if (region) clientOpts.region = region;
|
|
25
|
+
if (credentials) clientOpts.credentials = credentials;
|
|
26
|
+
|
|
27
|
+
const client = new S3Client(clientOpts);
|
|
28
|
+
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
29
|
+
|
|
30
|
+
const body = response.Body;
|
|
31
|
+
const stream = body instanceof Readable
|
|
32
|
+
? body
|
|
33
|
+
: (Readable.fromWeb ? Readable.fromWeb(body) : body);
|
|
34
|
+
|
|
35
|
+
return scanStream(stream, options);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { scanS3 };
|
|
@@ -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 };
|
package/src/StreamScanner.js
CHANGED
|
@@ -25,46 +25,60 @@ function parseClamdResponse(raw) {
|
|
|
25
25
|
* @param {number} [options.timeout=15000]
|
|
26
26
|
* @returns {Promise<symbol>}
|
|
27
27
|
*/
|
|
28
|
-
function scanStreamViaClamd(stream,
|
|
29
|
-
|
|
30
|
-
const connOpts = socketPath ? { path: socketPath } : { host, port };
|
|
31
|
-
const conn = net.createConnection(connOpts);
|
|
32
|
-
const chunks = [];
|
|
33
|
-
let settled = false;
|
|
28
|
+
function scanStreamViaClamd(stream, options = {}) {
|
|
29
|
+
const { retries = 0, retryDelay = 1000, host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = options;
|
|
34
30
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
conn.
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
function attempt() {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const connOpts = socketPath ? { path: socketPath } : { host, port };
|
|
34
|
+
const conn = net.createConnection(connOpts);
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let settled = false;
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
38
|
+
function settle(fn, value) {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
settled = true;
|
|
41
|
+
conn.destroy();
|
|
42
|
+
fn(value);
|
|
43
|
+
}
|
|
49
44
|
|
|
50
|
-
|
|
51
|
-
conn.
|
|
45
|
+
conn.setTimeout(timeout);
|
|
46
|
+
conn.on('timeout', () =>
|
|
47
|
+
settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
|
|
48
|
+
);
|
|
49
|
+
conn.on('error', (err) => settle(reject, err));
|
|
50
|
+
conn.on('data', (chunk) => chunks.push(chunk));
|
|
51
|
+
conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
conn.on('connect', () => {
|
|
54
|
+
conn.write(CLAMD_INSTREAM);
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
stream.on('error', (err) => settle(reject, err));
|
|
57
|
+
|
|
58
|
+
stream.on('data', (chunk) => {
|
|
59
|
+
const header = Buffer.allocUnsafe(4);
|
|
60
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
61
|
+
conn.write(header);
|
|
62
|
+
conn.write(chunk);
|
|
63
|
+
});
|
|
61
64
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
stream.on('end', () => {
|
|
66
|
+
conn.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
67
|
+
conn.end();
|
|
68
|
+
});
|
|
65
69
|
});
|
|
66
70
|
});
|
|
67
|
-
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function run(left) {
|
|
74
|
+
return attempt().catch(async (err) => {
|
|
75
|
+
if (left <= 0) throw err;
|
|
76
|
+
await new Promise(r => setTimeout(r, retryDelay));
|
|
77
|
+
return run(left - 1);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return run(retries);
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
module.exports = { scanStreamViaClamd };
|
package/src/Watcher.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { scan } = require('./ClamAVScanner.js');
|
|
6
|
+
const { Verdict } = require('./verdicts.js');
|
|
7
|
+
|
|
8
|
+
const DEBOUNCE_MS = 300;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Watch a directory for new/modified files and scan each one automatically.
|
|
12
|
+
* Uses fs.watch (no dependencies) with a 300 ms debounce.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} dirPath
|
|
15
|
+
* @param {object} [options] - Passed to scan() (host, port, socket, timeout, retries, retryDelay)
|
|
16
|
+
* @param {{ onClean?: Function, onMalicious?: Function, onError?: Function }} [callbacks]
|
|
17
|
+
* @returns {import('fs').FSWatcher}
|
|
18
|
+
*/
|
|
19
|
+
function watch(dirPath, options = {}, { onClean, onMalicious, onError } = {}) {
|
|
20
|
+
const timers = new Map();
|
|
21
|
+
|
|
22
|
+
return fs.watch(dirPath, { recursive: true }, (_eventType, filename) => {
|
|
23
|
+
if (!filename) return;
|
|
24
|
+
|
|
25
|
+
const fullPath = path.join(dirPath, filename);
|
|
26
|
+
|
|
27
|
+
if (timers.has(fullPath)) clearTimeout(timers.get(fullPath));
|
|
28
|
+
|
|
29
|
+
timers.set(fullPath, setTimeout(async () => {
|
|
30
|
+
timers.delete(fullPath);
|
|
31
|
+
|
|
32
|
+
if (!fs.existsSync(fullPath)) return;
|
|
33
|
+
|
|
34
|
+
let stat;
|
|
35
|
+
try { stat = fs.statSync(fullPath); } catch { return; }
|
|
36
|
+
if (!stat.isFile()) return;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const verdict = await scan(fullPath, options);
|
|
40
|
+
if (verdict === Verdict.Clean) onClean && onClean(fullPath);
|
|
41
|
+
else if (verdict === Verdict.Malicious) onMalicious && onMalicious(fullPath);
|
|
42
|
+
else onError && onError(new Error(`ScanError for ${fullPath}`), fullPath);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
onError && onError(err, fullPath);
|
|
45
|
+
}
|
|
46
|
+
}, DEBOUNCE_MS));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { watch };
|
|
@@ -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
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
const { scan, scanBuffer, scanStream, scanDirectory } = require('./ClamAVScanner.js');
|
|
2
2
|
const { Verdict } = require('./verdicts.js');
|
|
3
3
|
const { middleware } = require('./middleware.js');
|
|
4
|
+
const { scanS3 } = require('./S3Scanner.js');
|
|
5
|
+
const { createPool } = require('./ClamdPool.js');
|
|
6
|
+
const { watch } = require('./Watcher.js');
|
|
7
|
+
const { notify } = require('./WebhookNotifier.js');
|
|
8
|
+
const { createScanner } = require('./ScanEmitter.js');
|
|
4
9
|
|
|
5
|
-
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware };
|
|
10
|
+
module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict, middleware, scanS3, createPool, watch, notify, createScanner };
|
package/types/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Readable } from 'stream';
|
|
2
|
+
import { FSWatcher } from 'fs';
|
|
2
3
|
import { IncomingMessage, ServerResponse } from 'http';
|
|
3
4
|
|
|
4
5
|
/** Options passed to any scan function */
|
|
@@ -11,6 +12,10 @@ export interface ScanOptions {
|
|
|
11
12
|
socket?: string;
|
|
12
13
|
/** Socket idle timeout in milliseconds, clamd mode only (default: 15000) */
|
|
13
14
|
timeout?: number;
|
|
15
|
+
/** Number of retry attempts on connection error (default: 0) */
|
|
16
|
+
retries?: number;
|
|
17
|
+
/** Delay in milliseconds between retries (default: 1000) */
|
|
18
|
+
retryDelay?: number;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
/** Options for the Express/Fastify middleware */
|
|
@@ -80,3 +85,133 @@ export declare function scanDirectory(
|
|
|
80
85
|
* Call after multer, before your route handler.
|
|
81
86
|
*/
|
|
82
87
|
export declare function middleware(options?: MiddlewareOptions): RequestHandler;
|
|
88
|
+
|
|
89
|
+
/** Parameters for scanS3 */
|
|
90
|
+
export interface S3ScanParams {
|
|
91
|
+
/** S3 bucket name */
|
|
92
|
+
bucket: string;
|
|
93
|
+
/** S3 object key */
|
|
94
|
+
key: string;
|
|
95
|
+
/** AWS region */
|
|
96
|
+
region?: string;
|
|
97
|
+
/** AWS credentials object */
|
|
98
|
+
credentials?: object;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Scan an S3 object by streaming it directly via GetObjectCommand — no disk I/O.
|
|
103
|
+
* Requires @aws-sdk/client-s3 to be installed separately.
|
|
104
|
+
*/
|
|
105
|
+
export declare function scanS3(params: S3ScanParams, options?: ScanOptions): Promise<VerdictValue>;
|
|
106
|
+
|
|
107
|
+
/** Options for createPool */
|
|
108
|
+
export interface PoolOptions {
|
|
109
|
+
host?: string;
|
|
110
|
+
port?: number;
|
|
111
|
+
socket?: string;
|
|
112
|
+
/** Number of persistent connections to maintain (default: 5) */
|
|
113
|
+
size?: number;
|
|
114
|
+
timeout?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A pool of persistent clamd connections */
|
|
118
|
+
export interface ClamdPool {
|
|
119
|
+
/** Scan a file by path using a pooled connection */
|
|
120
|
+
scan(filePath: string): Promise<VerdictValue>;
|
|
121
|
+
/** Scan an in-memory Buffer using a pooled connection */
|
|
122
|
+
scanBuffer(buffer: Buffer): Promise<VerdictValue>;
|
|
123
|
+
/** Scan a Readable stream using a pooled connection */
|
|
124
|
+
scanStream(stream: Readable): Promise<VerdictValue>;
|
|
125
|
+
/** Destroy all pooled connections and reject any queued requests */
|
|
126
|
+
destroy(): void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Create a pool of persistent clamd connections for high-throughput scanning.
|
|
131
|
+
* Queues requests when all connections are busy.
|
|
132
|
+
*/
|
|
133
|
+
export declare function createPool(options?: PoolOptions): ClamdPool;
|
|
134
|
+
|
|
135
|
+
/** Callbacks for the watch() function */
|
|
136
|
+
export interface WatchCallbacks {
|
|
137
|
+
/** Called when a scanned file is clean */
|
|
138
|
+
onClean?: (filePath: string) => void;
|
|
139
|
+
/** Called when a scanned file is malicious */
|
|
140
|
+
onMalicious?: (filePath: string) => void;
|
|
141
|
+
/** Called on scan error or infrastructure failure */
|
|
142
|
+
onError?: (err: Error, filePath?: string) => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Watch a directory for new/modified files and scan each automatically.
|
|
147
|
+
* Uses fs.watch with a 300 ms debounce. No dependencies.
|
|
148
|
+
* Returns an FSWatcher; call .close() to stop watching.
|
|
149
|
+
*/
|
|
150
|
+
export declare function watch(
|
|
151
|
+
dirPath: string,
|
|
152
|
+
options?: ScanOptions,
|
|
153
|
+
callbacks?: WatchCallbacks
|
|
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;
|