pompelmi 1.0.0 → 1.2.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,14 @@
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)",
43
+ "Bash(npm install:*)",
44
+ "Bash(npm ls:*)"
38
45
  ]
39
46
  }
40
47
  }
package/README.md CHANGED
@@ -15,14 +15,15 @@
15
15
 
16
16
  ---
17
17
 
18
- A minimal Node.js wrapper around [ClamAV](https://www.clamav.net/) that scans any file and returns a plain string: `"Clean"`, `"Malicious"`, or `"ScanError"`. No daemons. No cloud. No native bindings.
18
+ A minimal Node.js wrapper around [ClamAV](https://www.clamav.net/) that scans any file and returns a typed `Verdict` Symbol: `Verdict.Clean`, `Verdict.Malicious`, or `Verdict.ScanError`. No daemons. No cloud. No native bindings. Zero runtime dependencies.
19
19
 
20
20
  ## Table of contents
21
21
 
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)
@@ -42,12 +43,11 @@ npm install pompelmi
42
43
  ```
43
44
 
44
45
  ```js
45
- const pompelmi = require('pompelmi');
46
+ const { scan, Verdict } = require('pompelmi');
46
47
 
47
- const result = await pompelmi.scan('/path/to/file.zip');
48
- // "Clean" | "Malicious" | "ScanError"
48
+ const result = await scan('/path/to/file.zip');
49
49
 
50
- if (result === 'Malicious') {
50
+ if (result === Verdict.Malicious) {
51
51
  throw new Error('File rejected: malware detected');
52
52
  }
53
53
  ```
@@ -62,23 +62,29 @@ No stdout parsing. No regex. No surprises.
62
62
 
63
63
  ## API
64
64
 
65
- ### `pompelmi.scan(filePath)`
65
+ ### `pompelmi.scan(filePath, [options])`
66
66
 
67
67
  ```ts
68
- pompelmi.scan(filePath: string): Promise<"Clean" | "Malicious" | "ScanError">
68
+ scan(filePath: string, options?: { host?: string; port?: number; timeout?: number }): Promise<symbol>
69
+ // resolves to one of: Verdict.Clean | Verdict.Malicious | Verdict.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.html](./docs/api.html) for the full reference. |
74
76
 
75
77
  **Resolves** to one of:
76
78
 
77
- | Result | ClamAV exit code | Meaning |
78
- |---------------|:---:|------------------------------------------------------------------------------------------------------|
79
- | `"Clean"` | 0 | No threats found. |
80
- | `"Malicious"` | 1 | A known virus or malware signature was matched. |
81
- | `"ScanError"` | 2 | The scan itself failed (I/O error, encrypted archive, permission denied). File status is unknown — treat as untrusted. |
79
+ | Result | ClamAV exit code | Meaning |
80
+ |--------------------|:---:|------------------------------------------------------------------------------------------------------|
81
+ | `Verdict.Clean` | 0 | No threats found. |
82
+ | `Verdict.Malicious` | 1 | A known virus or malware signature was matched. |
83
+ | `Verdict.ScanError` | 2 | The scan itself failed (I/O error, encrypted archive, permission denied). File status is unknown — treat as untrusted. |
84
+
85
+ > **Reading the verdict as a string** — each Verdict Symbol carries a `.description` property
86
+ > (`Verdict.Clean.description === 'Clean'`) for logging or serialisation without comparing against
87
+ > raw strings in application logic.
82
88
 
83
89
  **Rejects** with an `Error` in these cases:
84
90
 
@@ -93,20 +99,20 @@ pompelmi.scan(filePath: string): Promise<"Clean" | "Malicious" | "ScanError">
93
99
  **Example — full error handling:**
94
100
 
95
101
  ```js
96
- const pompelmi = require('pompelmi');
102
+ const { scan, Verdict } = require('pompelmi');
97
103
  const path = require('path');
98
104
 
99
105
  async function safeScan(filePath) {
100
106
  try {
101
- const result = await pompelmi.scan(path.resolve(filePath));
107
+ const result = await scan(path.resolve(filePath));
102
108
 
103
- if (result === 'ScanError') {
109
+ if (result === Verdict.ScanError) {
104
110
  // The scan could not complete — treat the file as untrusted.
105
111
  console.warn('Scan incomplete, rejecting file as precaution.');
106
112
  return null;
107
113
  }
108
114
 
109
- return result; // "Clean" or "Malicious"
115
+ return result; // Verdict.Clean or Verdict.Malicious
110
116
  } catch (err) {
111
117
  console.error('Scan failed:', err.message);
112
118
  return null;
@@ -114,6 +120,21 @@ async function safeScan(filePath) {
114
120
  }
115
121
  ```
116
122
 
123
+ ## Docker / remote scanning
124
+
125
+ If ClamAV runs in a Docker container (or anywhere on the network), pass `host` and `port` — everything else stays the same.
126
+
127
+ ```js
128
+ const result = await pompelmi.scan('/path/to/upload.zip', {
129
+ host: '127.0.0.1',
130
+ port: 3310,
131
+ });
132
+ ```
133
+
134
+ See [docs/docker.md](./docs/docker.md) for the `docker-compose.yml` snippet and first-boot notes.
135
+
136
+ ---
137
+
117
138
  ## Internal utilities
118
139
 
119
140
  These modules are not part of the public npm API but are used internally to set up the ClamAV environment on a fresh machine.
@@ -183,7 +204,7 @@ npm test
183
204
 
184
205
  The test suite has two parts:
185
206
 
186
- - **Unit tests** (`test/unit.test.js`) — run with Node's built-in test runner. Mock `cross-spawn` and platform dependencies; no ClamAV installation required.
207
+ - **Unit tests** (`test/unit.test.js`) — run with Node's built-in test runner. Mock `nativeSpawn` from `src/spawn.js` and platform dependencies via require-cache injection; no ClamAV installation required.
187
208
  - **Integration tests** (`test/scan.test.js`) — spawn real `clamscan` processes against EICAR test files. Skipped automatically if `clamscan` is not found in PATH.
188
209
 
189
210
  ## Contributing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
@@ -27,9 +27,7 @@
27
27
  "test": "node --test test/unit.test.js && node test/scan.test.js",
28
28
  "lint": "eslint src/"
29
29
  },
30
- "dependencies": {
31
- "cross-spawn": "^7.0.6"
32
- },
30
+ "dependencies": {},
33
31
  "devDependencies": {
34
32
  "@eslint/js": "^10.0.1",
35
33
  "eslint": "^10.2.0",
@@ -1,4 +1,4 @@
1
- const spawn = require("cross-spawn");
1
+ const { nativeSpawn: spawn } = require('./spawn.js');
2
2
  const fs = require("fs");
3
3
  const { getUpdaterCommand } = require('./InstallerCommand.js');
4
4
  const { PLATFORM } = require('./constants.js');
@@ -1,4 +1,4 @@
1
- const spawn = require("cross-spawn");
1
+ const { nativeSpawn: spawn } = require('./spawn.js');
2
2
  const { execSync } = require("child_process");
3
3
  const { getInstallerCommand } = require('./InstallerCommand.js');
4
4
  const { PLATFORM } = require('./constants.js')
@@ -1,6 +1,7 @@
1
- const spawn = require("cross-spawn");
1
+ const { nativeSpawn: spawn } = require('./spawn.js');
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,82 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const fs = require('fs');
5
+ const { Verdict } = require('./verdicts.js');
6
+
7
+ // ClamAV INSTREAM protocol:
8
+ // 1. Send "zINSTREAM\0"
9
+ // 2. Send N chunks, each prefixed with a 4-byte big-endian length
10
+ // 3. Terminate with four zero bytes
11
+ // 4. Read response line: "stream: OK", "stream: <name> FOUND", or an error
12
+ const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
13
+ const CHUNK_SIZE = 64 * 1024; // 64 KB — well within clamd's default StreamMaxLength
14
+
15
+ function parseClamdResponse(raw) {
16
+ const text = raw.toString('utf8').trim();
17
+ if (text === 'stream: OK') return Verdict.Clean;
18
+ if (text.endsWith(' FOUND')) return Verdict.Malicious;
19
+ return Verdict.ScanError;
20
+ }
21
+
22
+ /**
23
+ * Scan a file by streaming it to a running clamd instance over TCP.
24
+ *
25
+ * @param {string} filePath - Absolute or relative path to the file to scan.
26
+ * @param {object} [options]
27
+ * @param {string} [options.host='127.0.0.1']
28
+ * @param {number} [options.port=3310]
29
+ * @param {number} [options.timeout=15000] - Socket idle timeout in ms.
30
+ * @returns {Promise<'Clean'|'Malicious'|'ScanError'>}
31
+ */
32
+ function scanViaClamd(filePath, { host = '127.0.0.1', port = 3310, timeout = 15_000 } = {}) {
33
+ return new Promise((resolve, reject) => {
34
+ if (typeof filePath !== 'string') {
35
+ return reject(new Error('filePath must be a string'));
36
+ }
37
+ if (!fs.existsSync(filePath)) {
38
+ return reject(new Error(`File not found: ${filePath}`));
39
+ }
40
+
41
+ const socket = net.createConnection({ host, port });
42
+ const chunks = [];
43
+ let settled = false;
44
+
45
+ function settle(fn, value) {
46
+ if (settled) return;
47
+ settled = true;
48
+ socket.destroy();
49
+ fn(value);
50
+ }
51
+
52
+ socket.setTimeout(timeout);
53
+ socket.on('timeout', () =>
54
+ settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
55
+ );
56
+ socket.on('error', (err) => settle(reject, err));
57
+ socket.on('data', (chunk) => chunks.push(chunk));
58
+ socket.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
59
+
60
+ socket.on('connect', () => {
61
+ socket.write(CLAMD_INSTREAM);
62
+
63
+ const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
64
+
65
+ fileStream.on('error', (err) => settle(reject, err));
66
+
67
+ fileStream.on('data', (chunk) => {
68
+ const header = Buffer.allocUnsafe(4);
69
+ header.writeUInt32BE(chunk.length, 0);
70
+ socket.write(header);
71
+ socket.write(chunk);
72
+ });
73
+
74
+ fileStream.on('end', () => {
75
+ socket.write(Buffer.alloc(4)); // terminating zero-length chunk
76
+ socket.end();
77
+ });
78
+ });
79
+ });
80
+ }
81
+
82
+ module.exports = { scanViaClamd };
package/src/config.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { Verdict } = require('./verdicts.js');
2
+
1
3
  module.exports = Object.freeze({
2
4
  INSTALLER_COMMANDS: Object.freeze({
3
5
  win32: ['choco', ['install', 'clamav', '-y']],
@@ -15,8 +17,8 @@ module.exports = Object.freeze({
15
17
  win32: 'C:\\ProgramData\\ClamAV\\main.cvd',
16
18
  }),
17
19
  SCAN_RESULTS: Object.freeze({
18
- 0: 'Clean',
19
- 1: 'Malicious',
20
- 2: 'ScanError'
20
+ 0: Verdict.Clean,
21
+ 1: Verdict.Malicious,
22
+ 2: Verdict.ScanError,
21
23
  }),
22
24
  });
package/src/index.js CHANGED
@@ -1,5 +1,4 @@
1
- const { scan } = require('./ClamAVScanner.js');
1
+ const { scan } = require('./ClamAVScanner.js');
2
+ const { Verdict } = require('./verdicts.js');
2
3
 
3
- const pompelmi = { scan };
4
-
5
- module.exports = pompelmi;
4
+ module.exports = { scan, Verdict };
package/src/spawn.js ADDED
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+
5
+ /**
6
+ * A thin wrapper around Node's built-in child_process.spawn that handles the
7
+ * one meaningful cross-platform difference: on Windows, .cmd/.bat launchers
8
+ * (e.g. choco, npm) cannot be resolved without the shell, so shell:true is
9
+ * required. On POSIX systems the shell is never involved.
10
+ *
11
+ * All callers in this codebase pass only hardcoded, trusted arguments, so
12
+ * enabling the shell on Windows introduces no injection risk.
13
+ *
14
+ * @param {string} cmd
15
+ * @param {string[]} args
16
+ * @param {object} [options] - Passed through to child_process.spawn.
17
+ * `shell` is always overridden by this wrapper.
18
+ * @returns {import('child_process').ChildProcess}
19
+ */
20
+ function nativeSpawn(cmd, args, options) {
21
+ return spawn(cmd, args, {
22
+ ...options,
23
+ shell: process.platform === 'win32',
24
+ });
25
+ }
26
+
27
+ module.exports = { nativeSpawn };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Opaque Symbol-based scan verdicts.
5
+ *
6
+ * Using Symbols instead of strings makes comparisons typo-proof: a misspelled
7
+ * string silently produces `false`; an unknown Symbol property is `undefined`
8
+ * and fails loudly at the point of use.
9
+ *
10
+ * Usage:
11
+ * const { Verdict } = require('pompelmi');
12
+ * const result = await pompelmi.scan(filePath);
13
+ * if (result === Verdict.Clean) { /* safe *\/ }
14
+ * if (result === Verdict.Malicious) { /* quarantine or reject *\/ }
15
+ * if (result === Verdict.ScanError) { /* clamscan returned exit code 2 *\/ }
16
+ */
17
+ const Verdict = Object.freeze({
18
+ Clean: Symbol('Clean'),
19
+ Malicious: Symbol('Malicious'),
20
+ ScanError: Symbol('ScanError'),
21
+ });
22
+
23
+ module.exports = { Verdict };
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