pompelmi 1.4.0 → 1.6.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.
@@ -20,16 +20,18 @@ function parseClamdResponse(raw) {
20
20
  }
21
21
 
22
22
  /**
23
- * Scan a file by streaming it to a running clamd instance over TCP.
23
+ * Scan a file by streaming it to a running clamd instance over TCP or a UNIX socket.
24
24
  *
25
25
  * @param {string} filePath - Absolute or relative path to the file to scan.
26
26
  * @param {object} [options]
27
+ * @param {string} [options.socket] - Path to a clamd UNIX domain socket (e.g. '/run/clamav/clamd.sock').
28
+ * When set, takes precedence over host/port.
27
29
  * @param {string} [options.host='127.0.0.1']
28
30
  * @param {number} [options.port=3310]
29
31
  * @param {number} [options.timeout=15000] - Socket idle timeout in ms.
30
32
  * @returns {Promise<'Clean'|'Malicious'|'ScanError'>}
31
33
  */
32
- function scanViaClamd(filePath, { host = '127.0.0.1', port = 3310, timeout = 15_000 } = {}) {
34
+ function scanViaClamd(filePath, { host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = {}) {
33
35
  return new Promise((resolve, reject) => {
34
36
  if (typeof filePath !== 'string') {
35
37
  return reject(new Error('filePath must be a string'));
@@ -38,27 +40,28 @@ function scanViaClamd(filePath, { host = '127.0.0.1', port = 3310, timeout = 15_
38
40
  return reject(new Error(`File not found: ${filePath}`));
39
41
  }
40
42
 
41
- const socket = net.createConnection({ host, port });
43
+ const connOpts = socketPath ? { path: socketPath } : { host, port };
44
+ const conn = net.createConnection(connOpts);
42
45
  const chunks = [];
43
46
  let settled = false;
44
47
 
45
48
  function settle(fn, value) {
46
49
  if (settled) return;
47
50
  settled = true;
48
- socket.destroy();
51
+ conn.destroy();
49
52
  fn(value);
50
53
  }
51
54
 
52
- socket.setTimeout(timeout);
53
- socket.on('timeout', () =>
55
+ conn.setTimeout(timeout);
56
+ conn.on('timeout', () =>
54
57
  settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
55
58
  );
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
+ conn.on('error', (err) => settle(reject, err));
60
+ conn.on('data', (chunk) => chunks.push(chunk));
61
+ conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
59
62
 
60
- socket.on('connect', () => {
61
- socket.write(CLAMD_INSTREAM);
63
+ conn.on('connect', () => {
64
+ conn.write(CLAMD_INSTREAM);
62
65
 
63
66
  const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
64
67
 
@@ -67,13 +70,13 @@ function scanViaClamd(filePath, { host = '127.0.0.1', port = 3310, timeout = 15_
67
70
  fileStream.on('data', (chunk) => {
68
71
  const header = Buffer.allocUnsafe(4);
69
72
  header.writeUInt32BE(chunk.length, 0);
70
- socket.write(header);
71
- socket.write(chunk);
73
+ conn.write(header);
74
+ conn.write(chunk);
72
75
  });
73
76
 
74
77
  fileStream.on('end', () => {
75
- socket.write(Buffer.alloc(4)); // terminating zero-length chunk
76
- socket.end();
78
+ conn.write(Buffer.alloc(4)); // terminating zero-length chunk
79
+ conn.end();
77
80
  });
78
81
  });
79
82
  });
@@ -13,52 +13,55 @@ function parseClamdResponse(raw) {
13
13
  }
14
14
 
15
15
  /**
16
- * Scan a Readable stream by piping it to a running clamd instance over TCP.
16
+ * Scan a Readable stream by piping it to a running clamd instance over TCP or a UNIX socket.
17
17
  * No data is written to disk.
18
18
  *
19
19
  * @param {import('stream').Readable} stream
20
20
  * @param {object} [options]
21
+ * @param {string} [options.socket] - Path to a clamd UNIX domain socket.
22
+ * When set, takes precedence over host/port.
21
23
  * @param {string} [options.host='127.0.0.1']
22
24
  * @param {number} [options.port=3310]
23
25
  * @param {number} [options.timeout=15000]
24
26
  * @returns {Promise<symbol>}
25
27
  */
26
- function scanStreamViaClamd(stream, { host = '127.0.0.1', port = 3310, timeout = 15_000 } = {}) {
28
+ function scanStreamViaClamd(stream, { host = '127.0.0.1', port = 3310, socket: socketPath, timeout = 15_000 } = {}) {
27
29
  return new Promise((resolve, reject) => {
28
- const socket = net.createConnection({ host, port });
29
- const chunks = [];
30
- let settled = false;
30
+ const connOpts = socketPath ? { path: socketPath } : { host, port };
31
+ const conn = net.createConnection(connOpts);
32
+ const chunks = [];
33
+ let settled = false;
31
34
 
32
35
  function settle(fn, value) {
33
36
  if (settled) return;
34
37
  settled = true;
35
- socket.destroy();
38
+ conn.destroy();
36
39
  fn(value);
37
40
  }
38
41
 
39
- socket.setTimeout(timeout);
40
- socket.on('timeout', () =>
42
+ conn.setTimeout(timeout);
43
+ conn.on('timeout', () =>
41
44
  settle(reject, new Error(`clamd connection timed out after ${timeout}ms`))
42
45
  );
43
- socket.on('error', (err) => settle(reject, err));
44
- socket.on('data', (chunk) => chunks.push(chunk));
45
- socket.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
46
+ conn.on('error', (err) => settle(reject, err));
47
+ conn.on('data', (chunk) => chunks.push(chunk));
48
+ conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
46
49
 
47
- socket.on('connect', () => {
48
- socket.write(CLAMD_INSTREAM);
50
+ conn.on('connect', () => {
51
+ conn.write(CLAMD_INSTREAM);
49
52
 
50
53
  stream.on('error', (err) => settle(reject, err));
51
54
 
52
55
  stream.on('data', (chunk) => {
53
56
  const header = Buffer.allocUnsafe(4);
54
57
  header.writeUInt32BE(chunk.length, 0);
55
- socket.write(header);
56
- socket.write(chunk);
58
+ conn.write(header);
59
+ conn.write(chunk);
57
60
  });
58
61
 
59
62
  stream.on('end', () => {
60
- socket.write(Buffer.alloc(4)); // terminating zero-length chunk
61
- socket.end();
63
+ conn.write(Buffer.alloc(4)); // terminating zero-length chunk
64
+ conn.end();
62
65
  });
63
66
  });
64
67
  });
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const { scan, scanBuffer, scanStream } = require('./ClamAVScanner.js');
2
- const { Verdict } = require('./verdicts.js');
1
+ const { scan, scanBuffer, scanStream, scanDirectory } = require('./ClamAVScanner.js');
2
+ const { Verdict } = require('./verdicts.js');
3
3
 
4
- module.exports = { scan, scanBuffer, scanStream, Verdict };
4
+ module.exports = { scan, scanBuffer, scanStream, scanDirectory, Verdict };
@@ -0,0 +1,268 @@
1
+ # API Reference
2
+
3
+ Complete reference for all public functions exported by pompelmi.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install pompelmi
11
+ ```
12
+
13
+ ```js
14
+ const { scan, scanBuffer, scanStream, scanDirectory, Verdict } = require('pompelmi');
15
+ ```
16
+
17
+ ---
18
+
19
+ ## `scan(filePath, [options])`
20
+
21
+ Scan a file by absolute or relative path. In local mode spawns `clamscan`; in TCP mode streams the file to clamd via INSTREAM.
22
+
23
+ ```ts
24
+ scan(
25
+ filePath: string,
26
+ options?: {
27
+ host?: string;
28
+ port?: number;
29
+ timeout?: number;
30
+ }
31
+ ): Promise<symbol>
32
+ ```
33
+
34
+ ### Parameters
35
+
36
+ | Parameter | Type | Required | Description |
37
+ |-----------|------|----------|-------------|
38
+ | `filePath` | `string` | Yes | Path to the file to scan. Use `path.resolve()` for safety. |
39
+ | `options.host` | `string` | No | clamd hostname. Setting this enables TCP mode. |
40
+ | `options.port` | `number` | No | clamd port. Default: `3310`. |
41
+ | `options.timeout` | `number` | No | Socket idle timeout in ms (TCP mode only). Default: `15000`. |
42
+
43
+ ### Returns
44
+
45
+ `Promise<symbol>` — resolves to one of the three `Verdict` Symbols:
46
+
47
+ | Verdict | Local exit code | TCP response | Meaning |
48
+ |---------|-----------------|--------------|---------|
49
+ | `Verdict.Clean` | `0` | `stream: OK` | No threats found. |
50
+ | `Verdict.Malicious` | `1` | `stream: <name> FOUND` | Known malware signature matched. |
51
+ | `Verdict.ScanError` | `2` | other response | Scan could not complete. Treat as untrusted. |
52
+
53
+ ### Rejects with
54
+
55
+ | Message | Cause |
56
+ |---------|-------|
57
+ | `filePath must be a string` | First argument is not a string. |
58
+ | `File not found: <path>` | File does not exist at the given path. |
59
+ | `ENOENT` | `clamscan` binary not found in PATH (local mode). |
60
+ | `Unexpected exit code: N` | ClamAV exited with an undocumented code. |
61
+ | `Process killed by signal: <SIG>` | Process was killed (timeout, OOM, SIGTERM). |
62
+ | `clamd connection timed out after Nms` | TCP socket idle timeout exceeded. |
63
+
64
+ ### Examples
65
+
66
+ ```js
67
+ // Local mode
68
+ const result = await scan('/uploads/report.pdf');
69
+
70
+ // TCP mode
71
+ const result = await scan('/uploads/report.pdf', {
72
+ host: '127.0.0.1',
73
+ port: 3310,
74
+ timeout: 30_000,
75
+ });
76
+
77
+ if (result === Verdict.Clean) console.log('Safe.');
78
+ if (result === Verdict.Malicious) throw new Error('Malware detected.');
79
+ if (result === Verdict.ScanError) console.warn('Scan incomplete — treat as untrusted.');
80
+ ```
81
+
82
+ ---
83
+
84
+ ## `scanBuffer(buffer, [options])`
85
+
86
+ Scan an in-memory `Buffer` without writing to disk (TCP mode) or via a temp file that is deleted automatically (local mode).
87
+
88
+ ```ts
89
+ scanBuffer(
90
+ buffer: Buffer,
91
+ options?: {
92
+ host?: string;
93
+ port?: number;
94
+ timeout?: number;
95
+ }
96
+ ): Promise<symbol>
97
+ ```
98
+
99
+ ### Parameters
100
+
101
+ | Parameter | Type | Required | Description |
102
+ |-----------|------|----------|-------------|
103
+ | `buffer` | `Buffer` | Yes | The in-memory buffer to scan. |
104
+ | `options` | `object` | No | Same options as `scan()`. |
105
+
106
+ ### Returns
107
+
108
+ Same three `Verdict` Symbols as `scan()`.
109
+
110
+ ### Rejects with
111
+
112
+ Everything `scan()` can reject with, plus:
113
+
114
+ | Message | Cause |
115
+ |---------|-------|
116
+ | `buffer must be a Buffer` | First argument is not a `Buffer` instance. |
117
+ | `buffer is empty` | Zero-length Buffer passed. |
118
+
119
+ ### Notes
120
+
121
+ - **TCP mode:** buffer is streamed to clamd via INSTREAM — no disk I/O.
122
+ - **Local mode:** buffer is written to a temp file in `os.tmpdir()`, scanned, then deleted in a `finally` block.
123
+
124
+ ### Example
125
+
126
+ ```js
127
+ // multer memoryStorage
128
+ const result = await scanBuffer(req.file.buffer, {
129
+ host: process.env.CLAMAV_HOST,
130
+ port: 3310,
131
+ });
132
+ ```
133
+
134
+ ---
135
+
136
+ ## `scanStream(stream, [options])`
137
+
138
+ Scan any Node.js `Readable` stream. In TCP mode the stream is piped directly to clamd — no disk I/O. In local mode it is written to a temp file that is deleted automatically.
139
+
140
+ ```ts
141
+ scanStream(
142
+ stream: Readable,
143
+ options?: {
144
+ host?: string;
145
+ port?: number;
146
+ timeout?: number;
147
+ }
148
+ ): Promise<symbol>
149
+ ```
150
+
151
+ ### Parameters
152
+
153
+ | Parameter | Type | Required | Description |
154
+ |-----------|------|----------|-------------|
155
+ | `stream` | `stream.Readable` | Yes | The readable stream to scan. |
156
+ | `options` | `object` | No | Same options as `scan()`. |
157
+
158
+ ### Returns
159
+
160
+ Same three `Verdict` Symbols as `scan()`.
161
+
162
+ ### Rejects with
163
+
164
+ Everything `scan()` can reject with, plus:
165
+
166
+ | Message | Cause |
167
+ |---------|-------|
168
+ | `stream must be a Readable` | First argument is not a `stream.Readable`. |
169
+ | stream error | Any error emitted by the stream is propagated as-is. |
170
+
171
+ ### Example
172
+
173
+ ```js
174
+ // S3 getObject stream
175
+ const { GetObjectCommand } = require('@aws-sdk/client-s3');
176
+ const response = await s3.send(new GetObjectCommand({ Bucket, Key }));
177
+ const result = await scanStream(response.Body, { host: 'clamav', port: 3310 });
178
+ ```
179
+
180
+ ---
181
+
182
+ ## `scanDirectory(dirPath, [options])`
183
+
184
+ Recursively scan every file in a directory. Returns three arrays of absolute paths; per-file failures are collected rather than thrown.
185
+
186
+ ```ts
187
+ scanDirectory(
188
+ dirPath: string,
189
+ options?: {
190
+ host?: string;
191
+ port?: number;
192
+ timeout?: number;
193
+ }
194
+ ): Promise<{
195
+ clean: string[];
196
+ malicious: string[];
197
+ errors: string[];
198
+ }>
199
+ ```
200
+
201
+ ### Parameters
202
+
203
+ | Parameter | Type | Required | Description |
204
+ |-----------|------|----------|-------------|
205
+ | `dirPath` | `string` | Yes | Path to the directory to scan recursively. |
206
+ | `options` | `object` | No | Same options as `scan()`. |
207
+
208
+ ### Returns
209
+
210
+ | Field | Type | Description |
211
+ |-------|------|-------------|
212
+ | `clean` | `string[]` | Absolute paths of files with no threats. |
213
+ | `malicious` | `string[]` | Absolute paths of files with matched signatures. |
214
+ | `errors` | `string[]` | Absolute paths of files that could not be scanned. |
215
+
216
+ ### Rejects with
217
+
218
+ | Message | Cause |
219
+ |---------|-------|
220
+ | `dirPath must be a string` | First argument is not a string. |
221
+ | `Directory not found: <path>` | Directory does not exist. |
222
+
223
+ Individual file scan failures do **not** cause the function to reject — they appear in `errors`.
224
+
225
+ ### Example
226
+
227
+ ```js
228
+ const results = await scanDirectory('/uploads', { host: 'clamav', port: 3310 });
229
+
230
+ console.log(`${results.clean.length} clean, ${results.malicious.length} malicious`);
231
+ results.malicious.forEach(f => fs.unlinkSync(f));
232
+ ```
233
+
234
+ ---
235
+
236
+ ## `Verdict`
237
+
238
+ The `Verdict` object exported by pompelmi contains three Symbols:
239
+
240
+ ```js
241
+ const { Verdict } = require('pompelmi');
242
+
243
+ Verdict.Clean // Symbol(Clean)
244
+ Verdict.Malicious // Symbol(Malicious)
245
+ Verdict.ScanError // Symbol(ScanError)
246
+ ```
247
+
248
+ Each Symbol has a `.description` property for safe serialisation:
249
+
250
+ ```js
251
+ Verdict.Clean.description // 'Clean'
252
+ Verdict.Malicious.description // 'Malicious'
253
+ Verdict.ScanError.description // 'ScanError'
254
+ ```
255
+
256
+ ---
257
+
258
+ ## Options reference
259
+
260
+ All four functions accept the same options object:
261
+
262
+ | Option | Type | Default | Description |
263
+ |--------|------|---------|-------------|
264
+ | `host` | `string` | — | clamd hostname. Setting this enables TCP mode. |
265
+ | `port` | `number` | `3310` | clamd port. |
266
+ | `timeout` | `number` | `15000` | Socket idle timeout in ms. TCP mode only. |
267
+
268
+ When neither `host` nor `port` is set, pompelmi uses local mode (spawns `clamscan`).
@@ -0,0 +1,263 @@
1
+ # CLI Usage
2
+
3
+ pompelmi can be used as a command-line tool for scripting, CI pipelines, and interactive scanning. This page shows how to build a CLI scanner with pompelmi and how to use it in shell scripts.
4
+
5
+ ---
6
+
7
+ ## Minimal CLI scanner
8
+
9
+ ```js
10
+ #!/usr/bin/env node
11
+ // cli-scan.js
12
+
13
+ const { scan, scanDirectory, Verdict } = require('pompelmi');
14
+ const path = require('path');
15
+ const fs = require('fs');
16
+
17
+ const args = process.argv.slice(2);
18
+
19
+ if (args.length === 0) {
20
+ console.error('Usage: node cli-scan.js <file-or-dir> [file2] ...');
21
+ process.exit(2);
22
+ }
23
+
24
+ const SCAN_OPTS = {
25
+ host: process.env.CLAMAV_HOST,
26
+ port: Number(process.env.CLAMAV_PORT) || 3310,
27
+ timeout: Number(process.env.CLAMAV_TIMEOUT) || 30_000,
28
+ };
29
+
30
+ async function main() {
31
+ let anyMalicious = false;
32
+
33
+ for (const target of args) {
34
+ const resolved = path.resolve(target);
35
+
36
+ let stat;
37
+ try {
38
+ stat = fs.statSync(resolved);
39
+ } catch {
40
+ console.error(`Not found: ${resolved}`);
41
+ process.exit(2);
42
+ }
43
+
44
+ if (stat.isDirectory()) {
45
+ const results = await scanDirectory(resolved, SCAN_OPTS);
46
+
47
+ for (const f of results.clean) console.log(`CLEAN ${f}`);
48
+ for (const f of results.malicious) console.log(`MALICIOUS ${f}`);
49
+ for (const f of results.errors) console.log(`ERROR ${f}`);
50
+
51
+ if (results.malicious.length > 0) anyMalicious = true;
52
+ } else {
53
+ const result = await scan(resolved, SCAN_OPTS);
54
+ const label = result.description.toUpperCase().padEnd(9);
55
+ console.log(`${label} ${resolved}`);
56
+ if (result === Verdict.Malicious) anyMalicious = true;
57
+ }
58
+ }
59
+
60
+ // Exit code 1 if any malicious file found — useful for CI
61
+ process.exit(anyMalicious ? 1 : 0);
62
+ }
63
+
64
+ main().catch(err => {
65
+ console.error(err.message);
66
+ process.exit(2);
67
+ });
68
+ ```
69
+
70
+ Make it executable:
71
+
72
+ ```bash
73
+ chmod +x cli-scan.js
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Usage examples
79
+
80
+ ```bash
81
+ # Scan a single file
82
+ node cli-scan.js /path/to/file.pdf
83
+
84
+ # Scan multiple files
85
+ node cli-scan.js /uploads/a.pdf /uploads/b.zip
86
+
87
+ # Scan a directory recursively
88
+ node cli-scan.js /uploads/
89
+
90
+ # TCP mode (set env vars)
91
+ CLAMAV_HOST=127.0.0.1 node cli-scan.js /uploads/file.pdf
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Exit codes
97
+
98
+ | Exit code | Meaning |
99
+ |-----------|---------|
100
+ | `0` | All scanned files are clean |
101
+ | `1` | One or more malicious files found |
102
+ | `2` | Scan failed or argument error |
103
+
104
+ Exit code `1` for malicious is standard in shell scripting — makes it easy to use in `if` statements and CI pipelines.
105
+
106
+ ---
107
+
108
+ ## Shell scripting
109
+
110
+ ```bash
111
+ #!/bin/bash
112
+ set -e
113
+
114
+ FILE="$1"
115
+
116
+ if [ -z "$FILE" ]; then
117
+ echo "Usage: $0 <file>"
118
+ exit 2
119
+ fi
120
+
121
+ node /usr/local/bin/cli-scan.js "$FILE"
122
+ STATUS=$?
123
+
124
+ if [ $STATUS -eq 1 ]; then
125
+ echo "Upload rejected: malware detected."
126
+ exit 1
127
+ elif [ $STATUS -eq 2 ]; then
128
+ echo "Scan failed."
129
+ exit 2
130
+ else
131
+ echo "File is clean."
132
+ fi
133
+ ```
134
+
135
+ ---
136
+
137
+ ## CI pipeline integration
138
+
139
+ ### GitHub Actions
140
+
141
+ ```yaml
142
+ # .github/workflows/scan-artifacts.yml
143
+ name: Scan artifacts
144
+
145
+ on:
146
+ workflow_run:
147
+ workflows: ["Build"]
148
+ types: [completed]
149
+
150
+ jobs:
151
+ scan:
152
+ runs-on: ubuntu-latest
153
+ steps:
154
+ - uses: actions/checkout@v4
155
+
156
+ - name: Install ClamAV
157
+ run: |
158
+ sudo apt-get install -y clamav
159
+ sudo freshclam
160
+
161
+ - name: Download build artifacts
162
+ uses: actions/download-artifact@v4
163
+ with:
164
+ name: dist
165
+ path: dist/
166
+
167
+ - name: Scan artifacts
168
+ run: |
169
+ npm ci
170
+ node cli-scan.js dist/
171
+ # Exits 1 if malicious files found — fails the job
172
+ ```
173
+
174
+ ### Pre-commit hook
175
+
176
+ ```bash
177
+ # .git/hooks/pre-commit
178
+ #!/bin/bash
179
+
180
+ # Scan staged files before commit
181
+ STAGED=$(git diff --cached --name-only --diff-filter=ACM)
182
+
183
+ if [ -z "$STAGED" ]; then
184
+ exit 0
185
+ fi
186
+
187
+ echo "$STAGED" | xargs node cli-scan.js
188
+
189
+ if [ $? -ne 0 ]; then
190
+ echo "Commit blocked: malware detected in staged files."
191
+ exit 1
192
+ fi
193
+ ```
194
+
195
+ ---
196
+
197
+ ## Adding a global `pompelmi` command
198
+
199
+ Add to `package.json` to expose as a package binary:
200
+
201
+ ```json
202
+ {
203
+ "bin": {
204
+ "pompelmi-scan": "./cli-scan.js"
205
+ }
206
+ }
207
+ ```
208
+
209
+ Install globally:
210
+
211
+ ```bash
212
+ npm install -g pompelmi
213
+ pompelmi-scan /path/to/file.pdf
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Scanning directories and deleting malicious files
219
+
220
+ ```js
221
+ #!/usr/bin/env node
222
+ // cli-purge.js — scan a directory and delete malicious files
223
+
224
+ const { scanDirectory, Verdict } = require('pompelmi');
225
+ const fs = require('fs');
226
+ const path = require('path');
227
+
228
+ const dir = path.resolve(process.argv[2] || '.');
229
+
230
+ const results = await scanDirectory(dir, {
231
+ host: process.env.CLAMAV_HOST,
232
+ port: 3310,
233
+ });
234
+
235
+ console.log(`Scanned: ${results.clean.length + results.malicious.length + results.errors.length} files`);
236
+ console.log(`Clean: ${results.clean.length}`);
237
+ console.log(`Malicious: ${results.malicious.length}`);
238
+ console.log(`Errors: ${results.errors.length}`);
239
+
240
+ for (const f of results.malicious) {
241
+ fs.unlinkSync(f);
242
+ console.log(`Deleted: ${f}`);
243
+ }
244
+
245
+ process.exit(results.malicious.length > 0 ? 1 : 0);
246
+ ```
247
+
248
+ ---
249
+
250
+ ## JSON output for piping to other tools
251
+
252
+ ```js
253
+ // cli-scan-json.js — outputs JSON for use with jq
254
+ const results = [];
255
+ // ... scan logic ...
256
+
257
+ process.stdout.write(JSON.stringify({ files: results }, null, 2));
258
+ process.exit(anyMalicious ? 1 : 0);
259
+ ```
260
+
261
+ ```bash
262
+ node cli-scan-json.js /uploads/ | jq '.files[] | select(.verdict == "Malicious") | .path'
263
+ ```