pompelmi 1.10.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 +2 -0
- package/package.json +1 -1
- 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
|
@@ -63,6 +63,8 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
63
63
|
- `scanS3(params, [options])` — scan S3 objects by streaming directly from AWS S3, no disk I/O
|
|
64
64
|
- `createPool([options])` — persistent connection pool for high-throughput clamd scanning
|
|
65
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
|
|
66
68
|
- Auto-retry on connection error — `retries` and `retryDelay` options on every scan function
|
|
67
69
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
68
70
|
- Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
|
package/package.json
CHANGED
|
@@ -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;
|