pompelmi 1.13.0 → 1.15.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/README.md +33 -1
- package/bin/pompelmi.js +36 -0
- package/package.json +2 -2
- package/packages/hono/README.md +160 -0
- package/packages/hono/index.d.ts +29 -0
- package/packages/hono/index.js +74 -0
- package/packages/hono/package.json +40 -0
- package/packages/nextjs/README.md +83 -0
- package/packages/nextjs/index.d.ts +45 -0
- package/packages/nextjs/index.js +130 -0
- package/packages/nextjs/package.json +39 -0
- package/packages/testing/README.md +126 -0
- package/packages/testing/index.d.ts +44 -0
- package/packages/testing/index.js +82 -0
- package/packages/testing/package.json +35 -0
- package/src/BufferScanner.js +3 -0
- package/src/ClamdScanner.js +38 -14
- package/src/Dashboard.js +370 -0
- package/src/ShareCard.js +124 -0
- package/src/StreamScanner.js +3 -0
- package/src/index.js +3 -1
- package/types/index.d.ts +51 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build a pompelmi scan options object from the plugin options,
|
|
7
|
+
* excluding non-scan keys.
|
|
8
|
+
*/
|
|
9
|
+
function buildScanOpts(options) {
|
|
10
|
+
const keys = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const k of keys) {
|
|
13
|
+
if (options[k] !== undefined) out[k] = options[k];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read a Request body as a Buffer.
|
|
20
|
+
* Supports Web API Request (App Router) and Node.js IncomingMessage (Pages Router).
|
|
21
|
+
*/
|
|
22
|
+
async function bodyBuffer(req) {
|
|
23
|
+
if (typeof req.arrayBuffer === 'function') {
|
|
24
|
+
// Web API Request (Next.js App Router)
|
|
25
|
+
const ab = await req.arrayBuffer();
|
|
26
|
+
return Buffer.from(ab);
|
|
27
|
+
}
|
|
28
|
+
// Node.js IncomingMessage (Pages Router)
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const chunks = [];
|
|
31
|
+
req.on('data', c => chunks.push(c));
|
|
32
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
33
|
+
req.on('error', reject);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* App Router wrapper (Next.js 13+).
|
|
39
|
+
*
|
|
40
|
+
* Wraps a route handler so that the raw request body is scanned before the
|
|
41
|
+
* handler runs. req.pompelmiVerdict is set to the scan verdict symbol.
|
|
42
|
+
* If the body is malicious a 400 JSON response is returned immediately.
|
|
43
|
+
*
|
|
44
|
+
* @param {Function} handler - async (req, ctx) => Response
|
|
45
|
+
* @param {object} options - ScanOptions (host, port, socket, …)
|
|
46
|
+
* @returns {Function} Next.js App Router handler
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* // app/api/upload/route.js
|
|
50
|
+
* import { withPompelmi } from '@pompelmi/nextjs'
|
|
51
|
+
*
|
|
52
|
+
* export const POST = withPompelmi(async (req) => {
|
|
53
|
+
* return Response.json({ ok: true })
|
|
54
|
+
* }, { host: 'localhost', port: 3310 })
|
|
55
|
+
*/
|
|
56
|
+
function withPompelmi(handler, options = {}) {
|
|
57
|
+
const scanOpts = buildScanOpts(options);
|
|
58
|
+
|
|
59
|
+
return async function pompelmiHandler(req, ctx) {
|
|
60
|
+
let buf;
|
|
61
|
+
try {
|
|
62
|
+
buf = await bodyBuffer(req);
|
|
63
|
+
} catch (_) {
|
|
64
|
+
return new Response(JSON.stringify({ error: 'Failed to read request body' }), {
|
|
65
|
+
status: 400,
|
|
66
|
+
headers: { 'Content-Type': 'application/json' },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const verdict = await scanBuffer(buf, scanOpts);
|
|
71
|
+
|
|
72
|
+
if (verdict === Verdict.Malicious) {
|
|
73
|
+
return new Response(JSON.stringify({ error: 'Malicious file detected' }), {
|
|
74
|
+
status: 400,
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Attach verdict so the handler can inspect it if needed
|
|
80
|
+
req.pompelmiVerdict = verdict;
|
|
81
|
+
return handler(req, ctx);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pages Router wrapper (Next.js ≤ 12 / Pages API routes).
|
|
87
|
+
*
|
|
88
|
+
* Wraps a Next.js API handler so that the raw request body is scanned before
|
|
89
|
+
* the handler runs. req.pompelmiVerdict is set to the scan verdict symbol.
|
|
90
|
+
* If the body is malicious a 400 JSON response is sent immediately.
|
|
91
|
+
*
|
|
92
|
+
* @param {Function} handler - (req, res) => void | Promise<void>
|
|
93
|
+
* @param {object} options - ScanOptions (host, port, socket, …)
|
|
94
|
+
* @returns {Function} Next.js Pages API handler
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* // pages/api/upload.js
|
|
98
|
+
* import { withPompelmiHandler } from '@pompelmi/nextjs'
|
|
99
|
+
*
|
|
100
|
+
* async function handler(req, res) {
|
|
101
|
+
* res.json({ ok: true })
|
|
102
|
+
* }
|
|
103
|
+
*
|
|
104
|
+
* export default withPompelmiHandler(handler, { host: 'localhost', port: 3310 })
|
|
105
|
+
*/
|
|
106
|
+
function withPompelmiHandler(handler, options = {}) {
|
|
107
|
+
const scanOpts = buildScanOpts(options);
|
|
108
|
+
|
|
109
|
+
return async function pompelmiPagesHandler(req, res) {
|
|
110
|
+
let buf;
|
|
111
|
+
try {
|
|
112
|
+
buf = await bodyBuffer(req);
|
|
113
|
+
} catch (_) {
|
|
114
|
+
res.status(400).json({ error: 'Failed to read request body' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const verdict = await scanBuffer(buf, scanOpts);
|
|
119
|
+
|
|
120
|
+
if (verdict === Verdict.Malicious) {
|
|
121
|
+
res.status(400).json({ error: 'Malicious file detected' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
req.pompelmiVerdict = verdict;
|
|
126
|
+
return handler(req, res);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { withPompelmi, withPompelmiHandler, Verdict };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/nextjs",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Next.js middleware for pompelmi — in-process ClamAV virus scanning with zero extra dependencies",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "pompelmi contributors",
|
|
7
|
+
"homepage": "https://pompelmi.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/pompelmi/pompelmi.git",
|
|
11
|
+
"directory": "packages/nextjs"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"nextjs",
|
|
15
|
+
"next",
|
|
16
|
+
"clamav",
|
|
17
|
+
"antivirus",
|
|
18
|
+
"virus-scan",
|
|
19
|
+
"malware",
|
|
20
|
+
"file-upload",
|
|
21
|
+
"security",
|
|
22
|
+
"pompelmi",
|
|
23
|
+
"app-router",
|
|
24
|
+
"pages-router"
|
|
25
|
+
],
|
|
26
|
+
"main": "./index.js",
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/index.test.js"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"next": ">=13",
|
|
33
|
+
"pompelmi": ">=1.13.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"registry": "https://registry.npmjs.org/",
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# @pompelmi/testing
|
|
2
|
+
|
|
3
|
+
Mock utilities for unit-testing applications that use [pompelmi](https://pompelmi.app).
|
|
4
|
+
|
|
5
|
+
Compatible with **Jest**, **Vitest**, and the **Node.js built-in test runner**.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --save-dev @pompelmi/testing pompelmi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### `mockClean()` / `mockInfected()` / `mockScanError()`
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const { mockClean, mockInfected, mockScanError, Verdict } = require('@pompelmi/testing')
|
|
19
|
+
|
|
20
|
+
it('passes clean files through', async () => {
|
|
21
|
+
const scanner = mockClean()
|
|
22
|
+
const result = await scanner.scanBuffer(Buffer.from('hello'))
|
|
23
|
+
assert.equal(result, Verdict.Clean)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('rejects infected files', async () => {
|
|
27
|
+
const scanner = mockInfected('Win.Malware.Test')
|
|
28
|
+
const result = await scanner.scanBuffer(Buffer.from('eicar'))
|
|
29
|
+
assert.equal(result, Verdict.Malicious)
|
|
30
|
+
assert.equal(scanner.virusName, 'Win.Malware.Test')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles scan errors', async () => {
|
|
34
|
+
const scanner = mockScanError()
|
|
35
|
+
const result = await scanner.scanBuffer(Buffer.from('data'))
|
|
36
|
+
assert.equal(result, Verdict.ScanError)
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### `createMockScanner(defaultVerdict)`
|
|
41
|
+
|
|
42
|
+
Creates a scanner that resolves every scan call to the given verdict.
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
const { createMockScanner, Verdict } = require('@pompelmi/testing')
|
|
46
|
+
|
|
47
|
+
const scanner = createMockScanner(Verdict.Malicious)
|
|
48
|
+
// scanner.scan(), scanner.scanBuffer(), scanner.scanStream() all → Verdict.Malicious
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### `withMockedPompelmi(verdict, fn)`
|
|
52
|
+
|
|
53
|
+
Convenience wrapper that creates the mock and passes it to your test function.
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const { withMockedPompelmi, Verdict } = require('@pompelmi/testing')
|
|
57
|
+
|
|
58
|
+
it('rejects infected files', () =>
|
|
59
|
+
withMockedPompelmi(Verdict.Malicious, async (scanner) => {
|
|
60
|
+
const result = await scanner.scanBuffer(Buffer.from('eicar'))
|
|
61
|
+
assert.equal(result, Verdict.Malicious)
|
|
62
|
+
})
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Jest Example
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
import { mockInfected, Verdict } from '@pompelmi/testing'
|
|
70
|
+
|
|
71
|
+
// Manually inject the mock scanner into a module under test
|
|
72
|
+
jest.mock('pompelmi', () => mockInfected('Eicar'))
|
|
73
|
+
|
|
74
|
+
test('upload handler rejects infected files', async () => {
|
|
75
|
+
const { handleUpload } = await import('./upload-handler')
|
|
76
|
+
const response = await handleUpload(evilBuffer)
|
|
77
|
+
expect(response.status).toBe(422)
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Vitest Example
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { mockClean, Verdict } from '@pompelmi/testing'
|
|
85
|
+
import { vi, it, expect } from 'vitest'
|
|
86
|
+
|
|
87
|
+
vi.mock('pompelmi', () => mockClean())
|
|
88
|
+
|
|
89
|
+
it('allows clean files', async () => {
|
|
90
|
+
const { handleUpload } = await import('./upload-handler')
|
|
91
|
+
const res = await handleUpload(cleanBuffer)
|
|
92
|
+
expect(res.status).toBe(200)
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Node.js built-in test runner
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const { describe, it } = require('node:test')
|
|
100
|
+
const assert = require('node:assert/strict')
|
|
101
|
+
const { mockInfected, Verdict } = require('@pompelmi/testing')
|
|
102
|
+
|
|
103
|
+
describe('upload handler', () => {
|
|
104
|
+
it('rejects infected files', async () => {
|
|
105
|
+
const scanner = mockInfected('Win.Malware.Agent')
|
|
106
|
+
const result = await scanner.scanBuffer(Buffer.from('eicar'))
|
|
107
|
+
assert.equal(result, Verdict.Malicious)
|
|
108
|
+
assert.equal(scanner.virusName, 'Win.Malware.Agent')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## API
|
|
114
|
+
|
|
115
|
+
| Export | Description |
|
|
116
|
+
|--------|-------------|
|
|
117
|
+
| `createMockScanner(verdict)` | Returns a mock scanner that resolves to `verdict` |
|
|
118
|
+
| `mockClean()` | Shorthand for `createMockScanner(Verdict.Clean)` |
|
|
119
|
+
| `mockInfected(virusName?)` | Shorthand for `createMockScanner(Verdict.Malicious)` |
|
|
120
|
+
| `mockScanError()` | Shorthand for `createMockScanner(Verdict.ScanError)` |
|
|
121
|
+
| `withMockedPompelmi(verdict, fn)` | Runs `fn` with a mock scanner, returns a Promise |
|
|
122
|
+
| `Verdict` | Re-exported from `pompelmi` |
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
ISC — see root [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
|
|
2
|
+
export interface MockScanner {
|
|
3
|
+
Verdict: { readonly Clean: symbol; readonly Malicious: symbol; readonly ScanError: symbol };
|
|
4
|
+
scan(filePath: string): Promise<symbol>;
|
|
5
|
+
scanBuffer(buffer: Buffer | Uint8Array): Promise<symbol>;
|
|
6
|
+
scanStream(stream: NodeJS.ReadableStream): Promise<symbol>;
|
|
7
|
+
scanS3(opts: Record<string, unknown>): Promise<symbol>;
|
|
8
|
+
/** The verdict this scanner always resolves to */
|
|
9
|
+
_verdict: symbol;
|
|
10
|
+
/** Virus name label — only present on mockInfected() scanners */
|
|
11
|
+
virusName?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creates a mock pompelmi scanner where every scan method resolves to
|
|
16
|
+
* `defaultVerdict`.
|
|
17
|
+
*/
|
|
18
|
+
export function createMockScanner(defaultVerdict: symbol): MockScanner;
|
|
19
|
+
|
|
20
|
+
/** Returns a mock scanner that always resolves to Verdict.Clean */
|
|
21
|
+
export function mockClean(): MockScanner;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns a mock scanner that always resolves to Verdict.Malicious.
|
|
25
|
+
* The `virusName` is stored as `scanner.virusName` for test assertions.
|
|
26
|
+
*/
|
|
27
|
+
export function mockInfected(virusName?: string): MockScanner;
|
|
28
|
+
|
|
29
|
+
/** Returns a mock scanner that always resolves to Verdict.ScanError */
|
|
30
|
+
export function mockScanError(): MockScanner;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wraps a test function with a mock scanner.
|
|
34
|
+
* The scanner is passed as the first argument to `fn`.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* await withMockedPompelmi(Verdict.Malicious, async (scanner) => {
|
|
38
|
+
* const result = await scanner.scanBuffer(buffer)
|
|
39
|
+
* expect(result).toBe(Verdict.Malicious)
|
|
40
|
+
* })
|
|
41
|
+
*/
|
|
42
|
+
export function withMockedPompelmi(verdict: symbol, fn: (scanner: MockScanner) => unknown): Promise<void>;
|
|
43
|
+
|
|
44
|
+
export { Verdict } from 'pompelmi';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a mock pompelmi scanner where all scan methods resolve to
|
|
7
|
+
* the given verdict.
|
|
8
|
+
*
|
|
9
|
+
* @param {symbol} defaultVerdict - One of Verdict.Clean, Verdict.Malicious, or Verdict.ScanError.
|
|
10
|
+
* @returns {{ scan, scanBuffer, scanStream, Verdict }}
|
|
11
|
+
*/
|
|
12
|
+
function createMockScanner(defaultVerdict) {
|
|
13
|
+
if (defaultVerdict !== Verdict.Clean &&
|
|
14
|
+
defaultVerdict !== Verdict.Malicious &&
|
|
15
|
+
defaultVerdict !== Verdict.ScanError) {
|
|
16
|
+
throw new TypeError('defaultVerdict must be one of Verdict.Clean, Verdict.Malicious, or Verdict.ScanError');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
Verdict,
|
|
21
|
+
scan: () => Promise.resolve(defaultVerdict),
|
|
22
|
+
scanBuffer: () => Promise.resolve(defaultVerdict),
|
|
23
|
+
scanStream: () => Promise.resolve(defaultVerdict),
|
|
24
|
+
scanS3: () => Promise.resolve(defaultVerdict),
|
|
25
|
+
_verdict: defaultVerdict,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Shorthand: returns a mock scanner that always resolves to Verdict.Clean.
|
|
31
|
+
*/
|
|
32
|
+
function mockClean() {
|
|
33
|
+
return createMockScanner(Verdict.Clean);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Shorthand: returns a mock scanner that always resolves to Verdict.Malicious.
|
|
38
|
+
* The virusName parameter is stored on the scanner for inspection in tests.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} [virusName='Win.Malware.Test'] - Virus name label (stored as scanner.virusName).
|
|
41
|
+
*/
|
|
42
|
+
function mockInfected(virusName) {
|
|
43
|
+
const scanner = createMockScanner(Verdict.Malicious);
|
|
44
|
+
scanner.virusName = virusName || 'Win.Malware.Test';
|
|
45
|
+
return scanner;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Shorthand: returns a mock scanner that always resolves to Verdict.ScanError.
|
|
50
|
+
*/
|
|
51
|
+
function mockScanError() {
|
|
52
|
+
return createMockScanner(Verdict.ScanError);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Wraps a test function with a mocked pompelmi scanner.
|
|
57
|
+
* The scanner is passed as the first argument to fn.
|
|
58
|
+
*
|
|
59
|
+
* This is a lightweight helper — for full module-level mocking in Jest/Vitest,
|
|
60
|
+
* use jest.mock() / vi.mock() directly.
|
|
61
|
+
*
|
|
62
|
+
* @param {symbol} verdict - Verdict to mock.
|
|
63
|
+
* @param {Function} fn - Test function that receives the mock scanner.
|
|
64
|
+
* @returns {Promise}
|
|
65
|
+
*/
|
|
66
|
+
function withMockedPompelmi(verdict, fn) {
|
|
67
|
+
const scanner = createMockScanner(verdict);
|
|
68
|
+
try {
|
|
69
|
+
return Promise.resolve(fn(scanner));
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return Promise.reject(err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
createMockScanner,
|
|
77
|
+
mockClean,
|
|
78
|
+
mockInfected,
|
|
79
|
+
mockScanError,
|
|
80
|
+
withMockedPompelmi,
|
|
81
|
+
Verdict,
|
|
82
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/testing",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Mock utilities for unit-testing applications that use pompelmi — Jest, Vitest, and Node test runner",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "pompelmi contributors",
|
|
7
|
+
"homepage": "https://pompelmi.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/pompelmi/pompelmi.git",
|
|
11
|
+
"directory": "packages/testing"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pompelmi",
|
|
15
|
+
"testing",
|
|
16
|
+
"mock",
|
|
17
|
+
"jest",
|
|
18
|
+
"vitest",
|
|
19
|
+
"clamav",
|
|
20
|
+
"antivirus",
|
|
21
|
+
"unit-test"
|
|
22
|
+
],
|
|
23
|
+
"main": "./index.js",
|
|
24
|
+
"types": "./index.d.ts",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test test/index.test.js"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"pompelmi": ">=1.14.0"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"registry": "https://registry.npmjs.org/",
|
|
33
|
+
"access": "public"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/BufferScanner.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
const net = require('net');
|
|
4
4
|
const { Verdict } = require('./verdicts.js');
|
|
5
5
|
|
|
6
|
+
// net.createConnection is polyfilled by Bun — no code changes needed
|
|
7
|
+
const isBun = typeof Bun !== 'undefined'; // eslint-disable-line no-unused-vars
|
|
8
|
+
|
|
6
9
|
const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
|
|
7
10
|
const CHUNK_SIZE = 64 * 1024;
|
|
8
11
|
|
package/src/ClamdScanner.js
CHANGED
|
@@ -4,6 +4,8 @@ const net = require('net');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const { Verdict } = require('./verdicts.js');
|
|
6
6
|
|
|
7
|
+
const isBun = typeof Bun !== 'undefined';
|
|
8
|
+
|
|
7
9
|
// ClamAV INSTREAM protocol:
|
|
8
10
|
// 1. Send "zINSTREAM\0"
|
|
9
11
|
// 2. Send N chunks, each prefixed with a 4-byte big-endian length
|
|
@@ -63,24 +65,46 @@ function scanViaClamd(filePath, options = {}) {
|
|
|
63
65
|
conn.on('data', (chunk) => chunks.push(chunk));
|
|
64
66
|
conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
|
|
65
67
|
|
|
66
|
-
conn.on('connect', () => {
|
|
68
|
+
conn.on('connect', async () => {
|
|
67
69
|
conn.write(CLAMD_INSTREAM);
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
if (isBun) {
|
|
72
|
+
// Bun.file() is faster than fs.createReadStream on Bun
|
|
73
|
+
let fileData;
|
|
74
|
+
try {
|
|
75
|
+
fileData = await Bun.file(filePath).bytes();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return settle(reject, err);
|
|
78
|
+
}
|
|
79
|
+
const buf = Buffer.from(fileData);
|
|
80
|
+
let offset = 0;
|
|
81
|
+
while (offset < buf.length) {
|
|
82
|
+
const chunk = buf.slice(offset, offset + CHUNK_SIZE);
|
|
83
|
+
const header = Buffer.allocUnsafe(4);
|
|
84
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
85
|
+
conn.write(header);
|
|
86
|
+
conn.write(chunk);
|
|
87
|
+
offset += chunk.length;
|
|
88
|
+
}
|
|
81
89
|
conn.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
82
90
|
conn.end();
|
|
83
|
-
}
|
|
91
|
+
} else {
|
|
92
|
+
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
|
93
|
+
|
|
94
|
+
fileStream.on('error', (err) => settle(reject, err));
|
|
95
|
+
|
|
96
|
+
fileStream.on('data', (chunk) => {
|
|
97
|
+
const header = Buffer.allocUnsafe(4);
|
|
98
|
+
header.writeUInt32BE(chunk.length, 0);
|
|
99
|
+
conn.write(header);
|
|
100
|
+
conn.write(chunk);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
fileStream.on('end', () => {
|
|
104
|
+
conn.write(Buffer.alloc(4)); // terminating zero-length chunk
|
|
105
|
+
conn.end();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
84
108
|
});
|
|
85
109
|
});
|
|
86
110
|
}
|