pompelmi 1.2.3 → 1.3.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/.claude/settings.local.json +3 -1
- package/README.md +57 -2
- package/package.json +10 -9
- package/src/BufferScanner.js +67 -0
- package/src/ClamAVScanner.js +32 -4
- package/src/index.js +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img src="./src/grapefruit.png" width="88" alt="pompelmi logo">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
|
-
<h1 align="center">pompelmi</h1>
|
|
5
|
+
<h1 align="center">pompelmi — ClamAV Antivirus Scanning for Node.js</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center"><strong>ClamAV antivirus scanning for Node.js — clean, typed, zero dependencies.</strong></p>
|
|
8
8
|
|
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
<img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker available">
|
|
14
14
|
<img src="https://img.shields.io/badge/license-ISC-blue.svg" alt="license">
|
|
15
15
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="zero dependencies">
|
|
16
|
+
<img src="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml/badge.svg" alt="Node.js CI">
|
|
16
17
|
</p>
|
|
17
18
|
|
|
18
19
|
---
|
|
19
20
|
|
|
20
21
|
## Overview
|
|
21
22
|
|
|
22
|
-
pompelmi is a minimal Node.js wrapper around [ClamAV](https://www.clamav.net/) that exposes a single async function — `scan()` — and returns one of three typed verdict Symbols: `Verdict.Clean`, `Verdict.Malicious`, or `Verdict.ScanError`.
|
|
23
|
+
pompelmi is a minimal Node.js wrapper around [ClamAV](https://www.clamav.net/) that exposes a single async function — `scan()` — and returns one of three typed verdict Symbols: `Verdict.Clean`, `Verdict.Malicious`, or `Verdict.ScanError`. Full documentation at [pompelmi.app](https://pompelmi.app).
|
|
23
24
|
|
|
24
25
|
It supports two scanning modes:
|
|
25
26
|
|
|
@@ -30,9 +31,18 @@ No cloud. No daemon required for local mode. No native bindings. Zero runtime de
|
|
|
30
31
|
|
|
31
32
|
---
|
|
32
33
|
|
|
34
|
+
## Why pompelmi
|
|
35
|
+
|
|
36
|
+
If you need to **scan file uploads for viruses in Node.js**, integrate **ClamAV with Express or Fastify**, or add **antivirus scanning to any upload pipeline**, pompelmi is the simplest path.
|
|
37
|
+
|
|
38
|
+
Most integrations require parsing ClamAV's stdout with regex, managing a clamd daemon, or working around unmaintained packages. pompelmi does none of that: one function call, exit-code-mapped verdicts, zero dependencies.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
33
42
|
## Features
|
|
34
43
|
|
|
35
44
|
- Single `scan(filePath, [options])` function — works locally or against a remote clamd instance
|
|
45
|
+
- `scanBuffer(buffer, [options])` — scan in-memory Buffers directly, no temp file required in TCP mode
|
|
36
46
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
37
47
|
- Full TCP/clamd support via the INSTREAM protocol with configurable host, port, and timeout
|
|
38
48
|
- Built-in helpers to install ClamAV and update virus definitions programmatically
|
|
@@ -54,6 +64,8 @@ pompelmi does not bundle or automatically download ClamAV. Install it once per m
|
|
|
54
64
|
|
|
55
65
|
## Installation
|
|
56
66
|
|
|
67
|
+
See [pompelmi.app](https://pompelmi.app) for the full getting-started guide.
|
|
68
|
+
|
|
57
69
|
```bash
|
|
58
70
|
# npm
|
|
59
71
|
npm install pompelmi
|
|
@@ -202,6 +214,18 @@ const files = ['/uploads/a.pdf', '/uploads/b.zip', '/uploads/c.png'];
|
|
|
202
214
|
const results = await Promise.all(files.map((f) => scan(f)));
|
|
203
215
|
```
|
|
204
216
|
|
|
217
|
+
### Scan a Buffer
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
221
|
+
|
|
222
|
+
// Useful with multer memoryStorage or any in-memory upload
|
|
223
|
+
const result = await scanBuffer(req.file.buffer);
|
|
224
|
+
|
|
225
|
+
if (result === Verdict.Malicious) throw new Error('Malware detected.');
|
|
226
|
+
if (result === Verdict.ScanError) console.warn('Scan incomplete.');
|
|
227
|
+
```
|
|
228
|
+
|
|
205
229
|
---
|
|
206
230
|
|
|
207
231
|
## Docker / Remote Scanning
|
|
@@ -274,6 +298,33 @@ Verdict.ScanError.description // 'ScanError'
|
|
|
274
298
|
|
|
275
299
|
---
|
|
276
300
|
|
|
301
|
+
### `scanBuffer(buffer, [options])`
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
scanBuffer(
|
|
305
|
+
buffer: Buffer,
|
|
306
|
+
options?: { host?: string; port?: number; timeout?: number }
|
|
307
|
+
): Promise<symbol>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
| Parameter | Type | Description |
|
|
311
|
+
|---|---|---|
|
|
312
|
+
| `buffer` | `Buffer` | The in-memory buffer to scan |
|
|
313
|
+
| `options` | `object` | Same options as `scan()` — host, port, timeout |
|
|
314
|
+
|
|
315
|
+
**Returns** the same three Symbol verdicts as `scan()`: `Verdict.Clean`, `Verdict.Malicious`, `Verdict.ScanError`.
|
|
316
|
+
|
|
317
|
+
**Rejects** with the same error types as `scan()` where applicable, plus:
|
|
318
|
+
|
|
319
|
+
| Condition | Error message |
|
|
320
|
+
|---|---|
|
|
321
|
+
| `buffer` is not a Buffer | `buffer must be a Buffer` |
|
|
322
|
+
| `buffer` is empty | `buffer is empty` |
|
|
323
|
+
|
|
324
|
+
In TCP mode (`host` or `port` provided), the buffer is streamed directly to clamd via the INSTREAM protocol — no data is written to disk. In local mode, a temp file is written to `os.tmpdir()` and deleted automatically in a `finally` block regardless of outcome.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
277
328
|
### `ClamAVInstaller()` _(internal)_
|
|
278
329
|
|
|
279
330
|
Installs ClamAV using the platform's native package manager. Resolves immediately if ClamAV is already installed.
|
|
@@ -384,3 +435,7 @@ Please read [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) before contributing. To r
|
|
|
384
435
|
## License
|
|
385
436
|
|
|
386
437
|
[ISC](./LICENSE) — © pompelmi contributors
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
441
|
+
[pompelmi.app](https://pompelmi.app) · [npm](https://www.npmjs.com/package/pompelmi) · [GitHub](https://github.com/pompelmi/pompelmi)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pompelmi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -15,18 +15,19 @@
|
|
|
15
15
|
"keywords": [
|
|
16
16
|
"clamav",
|
|
17
17
|
"antivirus",
|
|
18
|
-
"malware",
|
|
19
18
|
"virus-scan",
|
|
20
|
-
"
|
|
19
|
+
"malware",
|
|
20
|
+
"file-upload",
|
|
21
21
|
"security",
|
|
22
|
+
"scan",
|
|
22
23
|
"clamscan",
|
|
23
|
-
"malware-detection",
|
|
24
|
-
"virus-detection",
|
|
25
|
-
"file-upload-security",
|
|
26
|
-
"upload-scan",
|
|
27
|
-
"nodejs-security",
|
|
28
24
|
"clamd",
|
|
29
|
-
"
|
|
25
|
+
"instream",
|
|
26
|
+
"nodejs",
|
|
27
|
+
"express",
|
|
28
|
+
"fastify",
|
|
29
|
+
"nestjs",
|
|
30
|
+
"multer",
|
|
30
31
|
"zero-dependencies"
|
|
31
32
|
],
|
|
32
33
|
"type": "commonjs",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const { Verdict } = require('./verdicts.js');
|
|
5
|
+
|
|
6
|
+
const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
|
|
7
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
function parseClamdResponse(raw) {
|
|
10
|
+
const text = raw.toString('utf8').trim();
|
|
11
|
+
if (text === 'stream: OK') return Verdict.Clean;
|
|
12
|
+
if (text.endsWith(' FOUND')) return Verdict.Malicious;
|
|
13
|
+
return Verdict.ScanError;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scan an in-memory Buffer by streaming it to a running clamd instance over TCP.
|
|
18
|
+
* No data is written to disk.
|
|
19
|
+
*
|
|
20
|
+
* @param {Buffer} buffer
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {string} [options.host='127.0.0.1']
|
|
23
|
+
* @param {number} [options.port=3310]
|
|
24
|
+
* @param {number} [options.timeout=15000]
|
|
25
|
+
* @returns {Promise<symbol>}
|
|
26
|
+
*/
|
|
27
|
+
function scanBufferViaClamd(buffer, { host = '127.0.0.1', port = 3310, timeout = 15_000 } = {}) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const socket = net.createConnection({ host, port });
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let settled = false;
|
|
32
|
+
|
|
33
|
+
function settle(fn, value) {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
socket.destroy();
|
|
37
|
+
fn(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
socket.setTimeout(timeout);
|
|
41
|
+
socket.on('timeout', () =>
|
|
42
|
+
settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
|
|
43
|
+
);
|
|
44
|
+
socket.on('error', (err) => settle(reject, err));
|
|
45
|
+
socket.on('data', (chunk) => chunks.push(chunk));
|
|
46
|
+
socket.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
47
|
+
|
|
48
|
+
socket.on('connect', () => {
|
|
49
|
+
socket.write(CLAMD_INSTREAM);
|
|
50
|
+
|
|
51
|
+
let offset = 0;
|
|
52
|
+
while (offset < buffer.length) {
|
|
53
|
+
const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
|
|
54
|
+
const header = Buffer.allocUnsafe(4);
|
|
55
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
56
|
+
socket.write(header);
|
|
57
|
+
socket.write(chunk);
|
|
58
|
+
offset += chunk.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
socket.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
62
|
+
socket.end();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { scanBufferViaClamd };
|
package/src/ClamAVScanner.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const { nativeSpawn: spawn } = require('./spawn.js');
|
|
2
|
-
const fs
|
|
3
|
-
const
|
|
4
|
-
const
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { SCAN_RESULTS } = require('./config.js');
|
|
6
|
+
const { scanViaClamd } = require('./ClamdScanner.js');
|
|
7
|
+
const { scanBufferViaClamd } = require('./BufferScanner.js');
|
|
5
8
|
|
|
6
9
|
const MESSAGES = {
|
|
7
10
|
FILE_NOT_FOUND: (filePath) => `File not found: ${filePath}`,
|
|
@@ -34,4 +37,29 @@ function scan(filePath, options = {}) {
|
|
|
34
37
|
});
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
async function scanBuffer(buffer, options = {}) {
|
|
41
|
+
if (!Buffer.isBuffer(buffer)) {
|
|
42
|
+
throw new Error('buffer must be a Buffer');
|
|
43
|
+
}
|
|
44
|
+
if (buffer.length === 0) {
|
|
45
|
+
throw new Error('buffer is empty');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (options.host !== undefined || options.port !== undefined) {
|
|
49
|
+
return scanBufferViaClamd(buffer, options);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tmpPath = path.join(
|
|
53
|
+
os.tmpdir(),
|
|
54
|
+
`pompelmi-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
fs.writeFileSync(tmpPath, buffer);
|
|
58
|
+
try {
|
|
59
|
+
return await scan(tmpPath);
|
|
60
|
+
} finally {
|
|
61
|
+
fs.unlink(tmpPath, () => {});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { scan, scanBuffer };
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { scan }
|
|
2
|
-
const { Verdict }
|
|
1
|
+
const { scan, scanBuffer } = require('./ClamAVScanner.js');
|
|
2
|
+
const { Verdict } = require('./verdicts.js');
|
|
3
3
|
|
|
4
|
-
module.exports = { scan, Verdict };
|
|
4
|
+
module.exports = { scan, scanBuffer, Verdict };
|