pompelmi 1.0.0 → 1.1.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 +6 -1
- package/README.md +20 -3
- package/package.json +1 -1
- package/src/ClamAVScanner.js +7 -1
- package/src/ClamdScanner.js +81 -0
- package/test_out.txt +74 -0
|
@@ -34,7 +34,12 @@
|
|
|
34
34
|
"Bash(git add:*)",
|
|
35
35
|
"Bash(git commit -m ':*)",
|
|
36
36
|
"Bash(git push:*)",
|
|
37
|
-
"Bash(grep -E '\\\\.\\(png|svg|ico|webp\\)$')"
|
|
37
|
+
"Bash(grep -E '\\\\.\\(png|svg|ico|webp\\)$')",
|
|
38
|
+
"Bash(ls -d /Users/tommy/pompelmi/pompelmi/*/)",
|
|
39
|
+
"Bash(node --test test/unit.test.js)",
|
|
40
|
+
"Bash(echo \"exit:$?\")",
|
|
41
|
+
"Read(//tmp/**)",
|
|
42
|
+
"Bash(tee /Users/tommy/pompelmi/pompelmi/test_out.txt)"
|
|
38
43
|
]
|
|
39
44
|
}
|
|
40
45
|
}
|
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ A minimal Node.js wrapper around [ClamAV](https://www.clamav.net/) that scans an
|
|
|
22
22
|
- [Quickstart](#quickstart)
|
|
23
23
|
- [How it works](#how-it-works)
|
|
24
24
|
- [API](#api)
|
|
25
|
-
- [pompelmi.scan()](#pompelmiscanfilepath)
|
|
25
|
+
- [pompelmi.scan()](#pompelmiscanfilepath-options)
|
|
26
|
+
- [Docker / remote scanning](#docker--remote-scanning)
|
|
26
27
|
- [Internal utilities](#internal-utilities)
|
|
27
28
|
- [ClamAVInstaller()](#clamavinstaller)
|
|
28
29
|
- [updateClamAVDatabase()](#updateclamavdatabase)
|
|
@@ -62,15 +63,16 @@ No stdout parsing. No regex. No surprises.
|
|
|
62
63
|
|
|
63
64
|
## API
|
|
64
65
|
|
|
65
|
-
### `pompelmi.scan(filePath)`
|
|
66
|
+
### `pompelmi.scan(filePath, [options])`
|
|
66
67
|
|
|
67
68
|
```ts
|
|
68
|
-
pompelmi.scan(filePath: string): Promise<"Clean" | "Malicious" | "ScanError">
|
|
69
|
+
pompelmi.scan(filePath: string, options?: { host?: string; port?: number; timeout?: number }): Promise<"Clean" | "Malicious" | "ScanError">
|
|
69
70
|
```
|
|
70
71
|
|
|
71
72
|
| Parameter | Type | Description |
|
|
72
73
|
|------------|----------|-----------------------------------------|
|
|
73
74
|
| `filePath` | `string` | Absolute or relative path to the file. |
|
|
75
|
+
| `options` | `object` | Optional. Omit to use the local `clamscan` CLI. Pass `host` / `port` to scan via a clamd TCP socket instead. See [docs/api.md](./docs/api.md) for the full reference. |
|
|
74
76
|
|
|
75
77
|
**Resolves** to one of:
|
|
76
78
|
|
|
@@ -114,6 +116,21 @@ async function safeScan(filePath) {
|
|
|
114
116
|
}
|
|
115
117
|
```
|
|
116
118
|
|
|
119
|
+
## Docker / remote scanning
|
|
120
|
+
|
|
121
|
+
If ClamAV runs in a Docker container (or anywhere on the network), pass `host` and `port` — everything else stays the same.
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
const result = await pompelmi.scan('/path/to/upload.zip', {
|
|
125
|
+
host: '127.0.0.1',
|
|
126
|
+
port: 3310,
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
See [docs/docker.md](./docs/docker.md) for the `docker-compose.yml` snippet and first-boot notes.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
117
134
|
## Internal utilities
|
|
118
135
|
|
|
119
136
|
These modules are not part of the public npm API but are used internally to set up the ClamAV environment on a fresh machine.
|
package/package.json
CHANGED
package/src/ClamAVScanner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const spawn = require("cross-spawn");
|
|
2
2
|
const fs = require("fs");
|
|
3
3
|
const { SCAN_RESULTS } = require('./config.js');
|
|
4
|
+
const { scanViaClamd } = require('./ClamdScanner.js');
|
|
4
5
|
|
|
5
6
|
const MESSAGES = {
|
|
6
7
|
FILE_NOT_FOUND: (filePath) => `File not found: ${filePath}`,
|
|
@@ -8,7 +9,12 @@ const MESSAGES = {
|
|
|
8
9
|
PROCESS_KILLED: (signal) => `Process killed by signal: ${signal}`,
|
|
9
10
|
};
|
|
10
11
|
|
|
11
|
-
function scan(filePath) {
|
|
12
|
+
function scan(filePath, options = {}) {
|
|
13
|
+
// When a host or port is provided, delegate to the clamd TCP path.
|
|
14
|
+
if (options.host !== undefined || options.port !== undefined) {
|
|
15
|
+
return scanViaClamd(filePath, options);
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
return new Promise((resolve, reject) => {
|
|
13
19
|
if (typeof filePath !== 'string') {
|
|
14
20
|
return reject(new Error('filePath must be a string'));
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
|
|
6
|
+
// ClamAV INSTREAM protocol:
|
|
7
|
+
// 1. Send "zINSTREAM\0"
|
|
8
|
+
// 2. Send N chunks, each prefixed with a 4-byte big-endian length
|
|
9
|
+
// 3. Terminate with four zero bytes
|
|
10
|
+
// 4. Read response line: "stream: OK", "stream: <name> FOUND", or an error
|
|
11
|
+
const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
|
|
12
|
+
const CHUNK_SIZE = 64 * 1024; // 64 KB — well within clamd's default StreamMaxLength
|
|
13
|
+
|
|
14
|
+
function parseClamdResponse(raw) {
|
|
15
|
+
const text = raw.toString('utf8').trim();
|
|
16
|
+
if (text === 'stream: OK') return 'Clean';
|
|
17
|
+
if (text.endsWith(' FOUND')) return 'Malicious';
|
|
18
|
+
return 'ScanError';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Scan a file by streaming it to a running clamd instance over TCP.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} filePath - Absolute or relative path to the file to scan.
|
|
25
|
+
* @param {object} [options]
|
|
26
|
+
* @param {string} [options.host='127.0.0.1']
|
|
27
|
+
* @param {number} [options.port=3310]
|
|
28
|
+
* @param {number} [options.timeout=15000] - Socket idle timeout in ms.
|
|
29
|
+
* @returns {Promise<'Clean'|'Malicious'|'ScanError'>}
|
|
30
|
+
*/
|
|
31
|
+
function scanViaClamd(filePath, { host = '127.0.0.1', port = 3310, timeout = 15_000 } = {}) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
if (typeof filePath !== 'string') {
|
|
34
|
+
return reject(new Error('filePath must be a string'));
|
|
35
|
+
}
|
|
36
|
+
if (!fs.existsSync(filePath)) {
|
|
37
|
+
return reject(new Error(`File not found: ${filePath}`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const socket = net.createConnection({ host, port });
|
|
41
|
+
const chunks = [];
|
|
42
|
+
let settled = false;
|
|
43
|
+
|
|
44
|
+
function settle(fn, value) {
|
|
45
|
+
if (settled) return;
|
|
46
|
+
settled = true;
|
|
47
|
+
socket.destroy();
|
|
48
|
+
fn(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
socket.setTimeout(timeout);
|
|
52
|
+
socket.on('timeout', () =>
|
|
53
|
+
settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
|
|
54
|
+
);
|
|
55
|
+
socket.on('error', (err) => settle(reject, err));
|
|
56
|
+
socket.on('data', (chunk) => chunks.push(chunk));
|
|
57
|
+
socket.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
58
|
+
|
|
59
|
+
socket.on('connect', () => {
|
|
60
|
+
socket.write(CLAMD_INSTREAM);
|
|
61
|
+
|
|
62
|
+
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
|
63
|
+
|
|
64
|
+
fileStream.on('error', (err) => settle(reject, err));
|
|
65
|
+
|
|
66
|
+
fileStream.on('data', (chunk) => {
|
|
67
|
+
const header = Buffer.allocUnsafe(4);
|
|
68
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
69
|
+
socket.write(header);
|
|
70
|
+
socket.write(chunk);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
fileStream.on('end', () => {
|
|
74
|
+
socket.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
75
|
+
socket.end();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { scanViaClamd };
|
package/test_out.txt
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
ClamAV is already installed, skipping.
|
|
2
|
+
Current platform is not supported.
|
|
3
|
+
Installation completed successfully!
|
|
4
|
+
Virus database already present, skipping.
|
|
5
|
+
Current platform is not supported.
|
|
6
|
+
Downloading virus definitions...
|
|
7
|
+
Database updated successfully!
|
|
8
|
+
Downloading virus definitions...
|
|
9
|
+
Downloading virus definitions...
|
|
10
|
+
▶ InstallerCommand
|
|
11
|
+
▶ getInstallerCommand
|
|
12
|
+
✔ darwin → brew install clamav (1.172833ms)
|
|
13
|
+
✔ linux → sudo apt-get install (0.163833ms)
|
|
14
|
+
✔ win32 → choco install clamav (0.07125ms)
|
|
15
|
+
✔ unknown → [null, []] (0.054792ms)
|
|
16
|
+
✔ getInstallerCommand (1.877625ms)
|
|
17
|
+
▶ getUpdaterCommand
|
|
18
|
+
✔ darwin → freshclam (0.094625ms)
|
|
19
|
+
✔ linux → sudo freshclam (0.255ms)
|
|
20
|
+
✔ win32 → freshclam (0.055834ms)
|
|
21
|
+
✔ unknown → [null, []] (0.308416ms)
|
|
22
|
+
✔ getUpdaterCommand (0.965833ms)
|
|
23
|
+
✔ InstallerCommand (3.261333ms)
|
|
24
|
+
▶ ClamAVScanner
|
|
25
|
+
✔ rejects if filePath is not a string (10.062042ms)
|
|
26
|
+
✔ rejects if file does not exist (0.441042ms)
|
|
27
|
+
✔ exit code 0 → resolves to "Clean" (0.847959ms)
|
|
28
|
+
✔ exit code 1 → resolves to "Malicious" (0.890875ms)
|
|
29
|
+
✔ exit code 2 → resolves to "ScanError" (0.5805ms)
|
|
30
|
+
✔ exit code 99 → rejects with exit code message (0.535625ms)
|
|
31
|
+
✔ spawn error → rejects with the error (0.550542ms)
|
|
32
|
+
✔ signal kill → rejects with signal name (0.405667ms)
|
|
33
|
+
✔ ClamAVScanner (14.502167ms)
|
|
34
|
+
▶ ClamAVInstaller
|
|
35
|
+
✔ resolves if ClamAV is already installed (1.17225ms)
|
|
36
|
+
✔ resolves with platform-not-supported message (1.279459ms)
|
|
37
|
+
✔ resolves after successful installation (0.755125ms)
|
|
38
|
+
✔ rejects when installation exits non-zero (0.553375ms)
|
|
39
|
+
✔ rejects on spawn error (0.969208ms)
|
|
40
|
+
✔ ClamAVInstaller (4.845042ms)
|
|
41
|
+
▶ ClamdScanner
|
|
42
|
+
✔ rejects if filePath is not a string (0.186125ms)
|
|
43
|
+
✔ rejects if file does not exist (0.371125ms)
|
|
44
|
+
✔ "stream: OK" → "Clean" (0.388458ms)
|
|
45
|
+
✔ "stream: ... FOUND" → "Malicious" (0.163792ms)
|
|
46
|
+
✔ any other clamd response → "ScanError" (0.294458ms)
|
|
47
|
+
✔ socket error (ECONNREFUSED) → rejects with that error (0.187ms)
|
|
48
|
+
✔ socket timeout → rejects with timeout message (0.119208ms)
|
|
49
|
+
✔ file read stream error → rejects with that error (0.118084ms)
|
|
50
|
+
✔ sends zINSTREAM command, chunk header, chunk data, and terminator (0.327083ms)
|
|
51
|
+
✔ ClamdScanner (2.320125ms)
|
|
52
|
+
▶ ClamAVScanner (TCP routing)
|
|
53
|
+
✔ routes to clamd when { port } is given (2.303ms)
|
|
54
|
+
✔ routes to clamd when { host } is given (0.207792ms)
|
|
55
|
+
✔ routes to clamd when { host, port } are both given (0.27525ms)
|
|
56
|
+
✔ forwards filePath and options unchanged to scanViaClamd (0.267083ms)
|
|
57
|
+
✔ uses the CLI path when called without options (0.31125ms)
|
|
58
|
+
✔ uses the CLI path when called with an empty options object {} (0.318041ms)
|
|
59
|
+
✔ ClamAVScanner (TCP routing) (3.793958ms)
|
|
60
|
+
▶ ClamAVDatabaseUpdater
|
|
61
|
+
✔ resolves if database is already present (0.793833ms)
|
|
62
|
+
✔ resolves with platform-not-supported message (0.326166ms)
|
|
63
|
+
✔ resolves after successful update (0.37675ms)
|
|
64
|
+
✔ rejects when update exits non-zero (0.330167ms)
|
|
65
|
+
✔ rejects on spawn error (0.545833ms)
|
|
66
|
+
✔ ClamAVDatabaseUpdater (2.478542ms)
|
|
67
|
+
ℹ tests 41
|
|
68
|
+
ℹ suites 8
|
|
69
|
+
ℹ pass 41
|
|
70
|
+
ℹ fail 0
|
|
71
|
+
ℹ cancelled 0
|
|
72
|
+
ℹ skipped 0
|
|
73
|
+
ℹ todo 0
|
|
74
|
+
ℹ duration_ms 107.87375
|