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 ADDED
@@ -0,0 +1,3 @@
1
+ JustSouichi <tommasobertocchideveloper04@gmail.com> claude <claude@anthropic.com>
2
+ JustSouichi <tommasobertocchideveloper04@gmail.com> Claude <claude@anthropic.com>
3
+ JustSouichi <tommasobertocchideveloper04@gmail.com> Claude Code <noreply@anthropic.com>
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.10.0",
3
+ "version": "1.11.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",
@@ -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;