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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -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