pompelmi 1.12.0 → 1.13.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 +39 -1
- package/bin/pompelmi.js +30 -5
- package/package.json +2 -2
- package/packages/fastify/README.md +115 -0
- package/packages/fastify/index.d.ts +27 -0
- package/packages/fastify/index.js +56 -0
- package/packages/fastify/package.json +40 -0
- package/packages/nestjs/README.md +150 -0
- package/packages/nestjs/index.d.ts +37 -0
- package/packages/nestjs/package.json +38 -0
- package/packages/nestjs/src/index.js +8 -0
- package/packages/nestjs/src/pompelmi.guard.js +19 -0
- package/packages/nestjs/src/pompelmi.interceptor.js +24 -0
- package/packages/nestjs/src/pompelmi.module.js +48 -0
- package/packages/nestjs/src/pompelmi.service.js +28 -0
- package/packages/nestjs/tsconfig.json +12 -0
package/README.md
CHANGED
|
@@ -86,13 +86,51 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
|
|
|
86
86
|
- Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
|
|
87
87
|
- Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
|
|
88
88
|
- Built-in helpers to install ClamAV and update virus definitions programmatically
|
|
89
|
-
- Works with Express, Fastify, and any other Node.js HTTP framework
|
|
89
|
+
- Works with Express, Fastify, NestJS, and any other Node.js HTTP framework
|
|
90
90
|
- Zero runtime dependencies — ships nothing but source code
|
|
91
91
|
- Tested with EICAR standard antivirus test files
|
|
92
92
|
- CommonJS module; TypeScript type declarations available inline
|
|
93
93
|
|
|
94
94
|
---
|
|
95
95
|
|
|
96
|
+
## Framework Integrations
|
|
97
|
+
|
|
98
|
+
Official integration packages for popular frameworks:
|
|
99
|
+
|
|
100
|
+
| Package | Framework | Install |
|
|
101
|
+
|---------|-----------|---------|
|
|
102
|
+
| [@pompelmi/nestjs](./packages/nestjs/) | NestJS | `npm i @pompelmi/nestjs` |
|
|
103
|
+
| [@pompelmi/fastify](./packages/fastify/) | Fastify | `npm i @pompelmi/fastify` |
|
|
104
|
+
|
|
105
|
+
### NestJS
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { PompelmiModule, PompelmiService } from '@pompelmi/nestjs';
|
|
109
|
+
|
|
110
|
+
// app.module.ts
|
|
111
|
+
@Module({ imports: [PompelmiModule.forRoot({ host: 'localhost', port: 3310 })] })
|
|
112
|
+
export class AppModule {}
|
|
113
|
+
|
|
114
|
+
// upload.service.ts
|
|
115
|
+
constructor(private readonly pompelmi: PompelmiService) {}
|
|
116
|
+
const result = await this.pompelmi.scanBuffer(file.buffer);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Fastify
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
const pompelmi = require('@pompelmi/fastify');
|
|
123
|
+
await fastify.register(pompelmi, { host: 'localhost', port: 3310 });
|
|
124
|
+
|
|
125
|
+
// Scan manually
|
|
126
|
+
const result = await fastify.pompelmi.scanBuffer(buffer);
|
|
127
|
+
|
|
128
|
+
// Or use the preHandler hook
|
|
129
|
+
fastify.post('/upload', { preHandler: fastify.pompelmi.preHandler({ field: 'file' }) }, handler);
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
96
134
|
## Requirements
|
|
97
135
|
|
|
98
136
|
- **Node.js** — any LTS release (no native addons, no C++ bindings)
|
package/bin/pompelmi.js
CHANGED
|
@@ -10,20 +10,45 @@ const pkg = require('../package.json');
|
|
|
10
10
|
// ── Logo ──────────────────────────────────────────────────────────────────────
|
|
11
11
|
|
|
12
12
|
async function printLogo() {
|
|
13
|
+
const titleLines = [
|
|
14
|
+
'\x1b[33mpompelmi\x1b[0m — ClamAV Antivirus Scanning for Node.js',
|
|
15
|
+
'\x1b[90mv' + pkg.version + ' • Zero dependencies • TCP • UNIX socket\x1b[0m',
|
|
16
|
+
];
|
|
17
|
+
|
|
13
18
|
try {
|
|
14
19
|
const terminalImage = (await import('terminal-image')).default;
|
|
15
20
|
const imgPath = path.join(__dirname, '../src/grapefruit.png');
|
|
16
21
|
if (fs.existsSync(imgPath)) {
|
|
17
22
|
const image = await terminalImage.file(imgPath, {
|
|
18
|
-
width:
|
|
19
|
-
height: '20%',
|
|
23
|
+
width: 24,
|
|
20
24
|
preserveAspectRatio: true,
|
|
21
25
|
});
|
|
22
|
-
|
|
26
|
+
|
|
27
|
+
const rawLines = image.split('\n');
|
|
28
|
+
// Drop trailing blank line that terminal-image appends
|
|
29
|
+
const imgLines = rawLines[rawLines.length - 1].trim() === ''
|
|
30
|
+
? rawLines.slice(0, -1)
|
|
31
|
+
: rawLines;
|
|
32
|
+
const pad = ' ';
|
|
33
|
+
const gap = ' ';
|
|
34
|
+
const maxRows = Math.max(imgLines.length, titleLines.length);
|
|
35
|
+
|
|
36
|
+
process.stdout.write('\n');
|
|
37
|
+
for (let i = 0; i < maxRows; i++) {
|
|
38
|
+
const imgPart = imgLines[i] ?? ' '.repeat(24);
|
|
39
|
+
const textPart = i === 0 ? titleLines[0]
|
|
40
|
+
: i === 1 ? titleLines[1]
|
|
41
|
+
: '';
|
|
42
|
+
process.stdout.write(pad + imgPart + gap + textPart + '\n');
|
|
43
|
+
}
|
|
44
|
+
process.stdout.write('\n');
|
|
45
|
+
return;
|
|
23
46
|
}
|
|
24
47
|
} catch (_) {}
|
|
25
|
-
|
|
26
|
-
|
|
48
|
+
|
|
49
|
+
// Fallback: text-only header
|
|
50
|
+
console.log('\n\x1b[33m pompelmi\x1b[0m — ClamAV Antivirus Scanning for Node.js');
|
|
51
|
+
console.log('\x1b[90m v' + pkg.version + ' • Zero dependencies • TCP • UNIX socket\x1b[0m\n');
|
|
27
52
|
}
|
|
28
53
|
|
|
29
54
|
// ── Argument parsing ──────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pompelmi",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"pompelmi": "./bin/pompelmi.js"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
|
-
"test": "node --test test/unit.test.js && node test/scan.test.js",
|
|
40
|
+
"test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node test/scan.test.js",
|
|
41
41
|
"lint": "eslint src/"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @pompelmi/fastify
|
|
2
|
+
|
|
3
|
+
Fastify plugin for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @pompelmi/fastify pompelmi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Plugin Setup
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const fastify = require('fastify')();
|
|
15
|
+
const pompelmi = require('@pompelmi/fastify');
|
|
16
|
+
|
|
17
|
+
await fastify.register(pompelmi, {
|
|
18
|
+
host: 'localhost',
|
|
19
|
+
port: 3310,
|
|
20
|
+
timeout: 5000,
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### UNIX socket
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
await fastify.register(pompelmi, {
|
|
28
|
+
socket: '/run/clamav/clamd.sock',
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Decorated Instance
|
|
33
|
+
|
|
34
|
+
After registration, `fastify.pompelmi` is available everywhere:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
// Scan a file path
|
|
38
|
+
const result = await fastify.pompelmi.scan('/uploads/report.pdf');
|
|
39
|
+
|
|
40
|
+
// Scan an in-memory Buffer
|
|
41
|
+
const result = await fastify.pompelmi.scanBuffer(file.buffer);
|
|
42
|
+
|
|
43
|
+
// Scan a Readable stream
|
|
44
|
+
const result = await fastify.pompelmi.scanStream(readableStream);
|
|
45
|
+
|
|
46
|
+
// Compare against Verdict symbols
|
|
47
|
+
const { Verdict } = require('pompelmi');
|
|
48
|
+
if (result === Verdict.Malicious) { /* ... */ }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## preHandler Hook
|
|
52
|
+
|
|
53
|
+
Use the built-in `preHandler` helper to scan uploaded files before your route handler runs:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const multipart = require('@fastify/multipart');
|
|
57
|
+
await fastify.register(multipart);
|
|
58
|
+
|
|
59
|
+
fastify.post('/upload', {
|
|
60
|
+
preHandler: fastify.pompelmi.preHandler({ field: 'file' }),
|
|
61
|
+
}, async (request, reply) => {
|
|
62
|
+
return { message: 'File is clean' };
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
When a malicious file is detected the preHandler automatically responds with HTTP 400:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{ "error": "Malicious file detected" }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Custom malicious handler
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
fastify.post('/upload', {
|
|
76
|
+
preHandler: fastify.pompelmi.preHandler({
|
|
77
|
+
field: 'file',
|
|
78
|
+
onMalicious: async (request, reply) => {
|
|
79
|
+
request.log.warn({ ip: request.ip }, 'malicious upload blocked');
|
|
80
|
+
reply.code(422).send({ error: 'File rejected by security scan' });
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
}, handler);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Configuration Reference
|
|
87
|
+
|
|
88
|
+
All options are forwarded to pompelmi's `ScanOptions`:
|
|
89
|
+
|
|
90
|
+
| Option | Type | Default | Description |
|
|
91
|
+
|--------|------|---------|-------------|
|
|
92
|
+
| `host` | `string` | — | clamd hostname (enables TCP mode) |
|
|
93
|
+
| `port` | `number` | `3310` | clamd port |
|
|
94
|
+
| `socket` | `string` | — | UNIX domain socket path |
|
|
95
|
+
| `timeout` | `number` | `15000` | Socket idle timeout in ms |
|
|
96
|
+
| `retries` | `number` | `0` | Number of retry attempts |
|
|
97
|
+
| `retryDelay` | `number` | `1000` | Delay between retries in ms |
|
|
98
|
+
|
|
99
|
+
## TypeScript
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import fastify from 'fastify';
|
|
103
|
+
import pompelmi from '@pompelmi/fastify';
|
|
104
|
+
import { Verdict } from 'pompelmi';
|
|
105
|
+
|
|
106
|
+
const app = fastify();
|
|
107
|
+
await app.register(pompelmi, { host: 'localhost', port: 3310 });
|
|
108
|
+
|
|
109
|
+
const result = await app.pompelmi.scanBuffer(buffer);
|
|
110
|
+
if (result === Verdict.Malicious) throw new Error('Infected');
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
ISC — see root [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { FastifyPluginCallback } from 'fastify';
|
|
2
|
+
import { ScanOptions, VerdictValue } from 'pompelmi';
|
|
3
|
+
import { Readable } from 'stream';
|
|
4
|
+
|
|
5
|
+
export interface PompelmiPreHandlerOptions {
|
|
6
|
+
/** Multer/Busboy field name to look for the uploaded file (default: 'file') */
|
|
7
|
+
field?: string;
|
|
8
|
+
/** Custom handler called instead of the default 400 response on malicious files */
|
|
9
|
+
onMalicious?: (request: any, reply: any) => void | Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PompelmiDecorator {
|
|
13
|
+
Verdict: { readonly Clean: unique symbol; readonly Malicious: unique symbol; readonly ScanError: unique symbol };
|
|
14
|
+
scan(filePath: string): Promise<VerdictValue>;
|
|
15
|
+
scanBuffer(buffer: Buffer): Promise<VerdictValue>;
|
|
16
|
+
scanStream(stream: Readable): Promise<VerdictValue>;
|
|
17
|
+
preHandler(opts?: PompelmiPreHandlerOptions): (request: any, reply: any) => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module 'fastify' {
|
|
21
|
+
interface FastifyInstance {
|
|
22
|
+
pompelmi: PompelmiDecorator;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare const pompelmiPlugin: FastifyPluginCallback<ScanOptions>;
|
|
27
|
+
export = pompelmiPlugin;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scan, scanBuffer, scanStream, Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
function buildScanOptions(options) {
|
|
6
|
+
const keys = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
|
|
7
|
+
const out = {};
|
|
8
|
+
for (const k of keys) {
|
|
9
|
+
if (options[k] !== undefined) out[k] = options[k];
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pompelmiPlugin(fastify, options, done) {
|
|
15
|
+
const scanOptions = buildScanOptions(options || {});
|
|
16
|
+
|
|
17
|
+
const pompelmi = {
|
|
18
|
+
Verdict,
|
|
19
|
+
|
|
20
|
+
scan(filePath) {
|
|
21
|
+
return scan(filePath, scanOptions);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
scanBuffer(buffer) {
|
|
25
|
+
return scanBuffer(buffer, scanOptions);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
scanStream(stream) {
|
|
29
|
+
return scanStream(stream, scanOptions);
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
preHandler(opts) {
|
|
33
|
+
const { field = 'file', onMalicious } = opts || {};
|
|
34
|
+
return async function pompelmiPreHandler(request, reply) {
|
|
35
|
+
const body = request.body;
|
|
36
|
+
if (!body) return;
|
|
37
|
+
const raw = body[field];
|
|
38
|
+
if (!raw) return;
|
|
39
|
+
const buffer = Buffer.isBuffer(raw) ? raw : (raw._buf || raw.data || null);
|
|
40
|
+
if (!buffer) return;
|
|
41
|
+
const result = await scanBuffer(buffer, scanOptions);
|
|
42
|
+
if (result === Verdict.Malicious) {
|
|
43
|
+
if (typeof onMalicious === 'function') return onMalicious(request, reply);
|
|
44
|
+
return reply.code(400).send({ error: 'Malicious file detected' });
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
fastify.decorate('pompelmi', pompelmi);
|
|
51
|
+
done();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
pompelmiPlugin[Symbol.for('skip-override')] = true;
|
|
55
|
+
|
|
56
|
+
module.exports = pompelmiPlugin;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/fastify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Fastify plugin 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/fastify"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"fastify",
|
|
15
|
+
"fastify-plugin",
|
|
16
|
+
"clamav",
|
|
17
|
+
"antivirus",
|
|
18
|
+
"virus-scan",
|
|
19
|
+
"malware",
|
|
20
|
+
"file-upload",
|
|
21
|
+
"security",
|
|
22
|
+
"pompelmi"
|
|
23
|
+
],
|
|
24
|
+
"main": "./index.js",
|
|
25
|
+
"types": "./index.d.ts",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/index.test.js"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"fastify": ">=4",
|
|
31
|
+
"pompelmi": ">=1.12.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"fastify": { "optional": false }
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"registry": "https://registry.npmjs.org/",
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# @pompelmi/nestjs
|
|
2
|
+
|
|
3
|
+
NestJS module for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @pompelmi/nestjs pompelmi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependencies (install if not already present):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @nestjs/common @nestjs/core
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Module Setup
|
|
18
|
+
|
|
19
|
+
Register the module globally in your root `AppModule`:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { Module } from '@nestjs/common';
|
|
23
|
+
import { PompelmiModule } from '@pompelmi/nestjs';
|
|
24
|
+
|
|
25
|
+
@Module({
|
|
26
|
+
imports: [
|
|
27
|
+
PompelmiModule.forRoot({
|
|
28
|
+
host: 'localhost',
|
|
29
|
+
port: 3310,
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
})
|
|
34
|
+
export class AppModule {}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Async configuration (`forRootAsync`)
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
41
|
+
import { PompelmiModule } from '@pompelmi/nestjs';
|
|
42
|
+
|
|
43
|
+
PompelmiModule.forRootAsync({
|
|
44
|
+
imports: [ConfigModule],
|
|
45
|
+
useFactory: (config: ConfigService) => ({
|
|
46
|
+
host: config.get('CLAMAV_HOST'),
|
|
47
|
+
port: config.get<number>('CLAMAV_PORT'),
|
|
48
|
+
}),
|
|
49
|
+
inject: [ConfigService],
|
|
50
|
+
})
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Service Usage
|
|
54
|
+
|
|
55
|
+
Inject `PompelmiService` into any provider:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { Injectable } from '@nestjs/common';
|
|
59
|
+
import { PompelmiService } from '@pompelmi/nestjs';
|
|
60
|
+
import { Verdict } from 'pompelmi';
|
|
61
|
+
|
|
62
|
+
@Injectable()
|
|
63
|
+
export class UploadService {
|
|
64
|
+
constructor(private readonly pompelmi: PompelmiService) {}
|
|
65
|
+
|
|
66
|
+
async processUpload(buffer: Buffer) {
|
|
67
|
+
const result = await this.pompelmi.scanBuffer(buffer);
|
|
68
|
+
if (result === Verdict.Malicious) throw new Error('Malicious file');
|
|
69
|
+
|
|
70
|
+
// Or use the convenience helper:
|
|
71
|
+
const isMalware = await this.pompelmi.isMalware(buffer);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Service API
|
|
77
|
+
|
|
78
|
+
| Method | Signature | Description |
|
|
79
|
+
|--------|-----------|-------------|
|
|
80
|
+
| `scan` | `scan(filePath: string): Promise<VerdictValue>` | Scan a file by path |
|
|
81
|
+
| `scanBuffer` | `scanBuffer(buffer: Buffer): Promise<VerdictValue>` | Scan an in-memory Buffer |
|
|
82
|
+
| `scanStream` | `scanStream(stream: Readable): Promise<VerdictValue>` | Scan a Readable stream |
|
|
83
|
+
| `isMalware` | `isMalware(buffer: Buffer): Promise<boolean>` | Returns `true` when infected |
|
|
84
|
+
|
|
85
|
+
## Guard Usage
|
|
86
|
+
|
|
87
|
+
`PompelmiGuard` implements `CanActivate`. It reads `req.file` (or `req.files[0]`) set by Multer and blocks the request (`canActivate → false`) when the file is malicious.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { Controller, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
|
91
|
+
import { FileInterceptor } from '@nestjs/platform-express';
|
|
92
|
+
import { PompelmiGuard } from '@pompelmi/nestjs';
|
|
93
|
+
|
|
94
|
+
@Controller('upload')
|
|
95
|
+
export class UploadController {
|
|
96
|
+
@Post()
|
|
97
|
+
@UseGuards(PompelmiGuard)
|
|
98
|
+
@UseInterceptors(FileInterceptor('file'))
|
|
99
|
+
uploadFile(@UploadedFile() file: Express.Multer.File) {
|
|
100
|
+
return { message: 'File is clean', size: file.size };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Register `PompelmiGuard` as a provider in your module so NestJS can inject `PompelmiService`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
@Module({
|
|
109
|
+
imports: [PompelmiModule.forRoot({ host: 'localhost', port: 3310 })],
|
|
110
|
+
providers: [PompelmiGuard],
|
|
111
|
+
controllers: [UploadController],
|
|
112
|
+
})
|
|
113
|
+
export class UploadModule {}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Interceptor Usage
|
|
117
|
+
|
|
118
|
+
`PompelmiInterceptor` is an alternative to the guard. It throws `BadRequestException` when a malicious file is detected, which NestJS maps to an HTTP 400 response.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
|
122
|
+
import { FileInterceptor } from '@nestjs/platform-express';
|
|
123
|
+
import { PompelmiInterceptor } from '@pompelmi/nestjs';
|
|
124
|
+
|
|
125
|
+
@Controller('upload')
|
|
126
|
+
export class UploadController {
|
|
127
|
+
@Post()
|
|
128
|
+
@UseInterceptors(FileInterceptor('file'), PompelmiInterceptor)
|
|
129
|
+
uploadFile(@UploadedFile() file: Express.Multer.File) {
|
|
130
|
+
return { message: 'File is clean' };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Configuration Reference
|
|
136
|
+
|
|
137
|
+
All options are forwarded to pompelmi's `ScanOptions`:
|
|
138
|
+
|
|
139
|
+
| Option | Type | Default | Description |
|
|
140
|
+
|--------|------|---------|-------------|
|
|
141
|
+
| `host` | `string` | — | clamd hostname (enables TCP mode) |
|
|
142
|
+
| `port` | `number` | `3310` | clamd port |
|
|
143
|
+
| `socket` | `string` | — | UNIX domain socket path |
|
|
144
|
+
| `timeout` | `number` | `15000` | Socket idle timeout in ms |
|
|
145
|
+
| `retries` | `number` | `0` | Number of retry attempts |
|
|
146
|
+
| `retryDelay` | `number` | `1000` | Delay between retries in ms |
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
ISC — see root [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { DynamicModule, CanActivate, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
|
2
|
+
import { ScanOptions, VerdictValue } from 'pompelmi';
|
|
3
|
+
import { Observable } from 'rxjs';
|
|
4
|
+
|
|
5
|
+
export declare const POMPELMI_OPTIONS = 'POMPELMI_OPTIONS';
|
|
6
|
+
|
|
7
|
+
export interface PompelmiModuleOptions extends ScanOptions {}
|
|
8
|
+
|
|
9
|
+
export interface PompelmiModuleAsyncOptions {
|
|
10
|
+
imports?: any[];
|
|
11
|
+
useFactory: (...args: any[]) => PompelmiModuleOptions | Promise<PompelmiModuleOptions>;
|
|
12
|
+
inject?: any[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export declare class PompelmiModule {
|
|
16
|
+
static forRoot(options?: PompelmiModuleOptions): DynamicModule;
|
|
17
|
+
static forRootAsync(options: PompelmiModuleAsyncOptions): DynamicModule;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export declare class PompelmiService {
|
|
21
|
+
readonly options: PompelmiModuleOptions;
|
|
22
|
+
constructor(options?: PompelmiModuleOptions);
|
|
23
|
+
scan(filePath: string): Promise<VerdictValue>;
|
|
24
|
+
scanBuffer(buffer: Buffer): Promise<VerdictValue>;
|
|
25
|
+
scanStream(stream: import('stream').Readable): Promise<VerdictValue>;
|
|
26
|
+
isMalware(buffer: Buffer): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export declare class PompelmiGuard implements CanActivate {
|
|
30
|
+
constructor(pompelmiService: PompelmiService);
|
|
31
|
+
canActivate(context: ExecutionContext): Promise<boolean>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export declare class PompelmiInterceptor implements NestInterceptor {
|
|
35
|
+
constructor(pompelmiService: PompelmiService);
|
|
36
|
+
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>>;
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/nestjs",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "NestJS module for pompelmi — in-process 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/nestjs"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"nestjs",
|
|
15
|
+
"nestjs-module",
|
|
16
|
+
"clamav",
|
|
17
|
+
"antivirus",
|
|
18
|
+
"virus-scan",
|
|
19
|
+
"malware",
|
|
20
|
+
"file-upload",
|
|
21
|
+
"security",
|
|
22
|
+
"pompelmi"
|
|
23
|
+
],
|
|
24
|
+
"main": "./src/index.js",
|
|
25
|
+
"types": "./index.d.ts",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/index.test.js"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@nestjs/common": ">=9",
|
|
31
|
+
"@nestjs/core": ">=9",
|
|
32
|
+
"pompelmi": ">=1.12.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"registry": "https://registry.npmjs.org/",
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PompelmiModule, POMPELMI_OPTIONS } = require('./pompelmi.module');
|
|
4
|
+
const { PompelmiService } = require('./pompelmi.service');
|
|
5
|
+
const { PompelmiGuard } = require('./pompelmi.guard');
|
|
6
|
+
const { PompelmiInterceptor } = require('./pompelmi.interceptor');
|
|
7
|
+
|
|
8
|
+
module.exports = { PompelmiModule, PompelmiService, PompelmiGuard, PompelmiInterceptor, POMPELMI_OPTIONS };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
class PompelmiGuard {
|
|
6
|
+
constructor(pompelmiService) {
|
|
7
|
+
this.pompelmiService = pompelmiService;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async canActivate(context) {
|
|
11
|
+
const request = context.switchToHttp().getRequest();
|
|
12
|
+
const file = request.file || (Array.isArray(request.files) && request.files[0]);
|
|
13
|
+
if (!file) return true;
|
|
14
|
+
const result = await this.pompelmiService.scanBuffer(file.buffer);
|
|
15
|
+
return result !== Verdict.Malicious;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { PompelmiGuard };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BadRequestException } = require('@nestjs/common');
|
|
4
|
+
const { Verdict } = require('pompelmi');
|
|
5
|
+
|
|
6
|
+
class PompelmiInterceptor {
|
|
7
|
+
constructor(pompelmiService) {
|
|
8
|
+
this.pompelmiService = pompelmiService;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async intercept(context, next) {
|
|
12
|
+
const request = context.switchToHttp().getRequest();
|
|
13
|
+
const file = request.file || (Array.isArray(request.files) && request.files[0]);
|
|
14
|
+
if (file) {
|
|
15
|
+
const result = await this.pompelmiService.scanBuffer(file.buffer);
|
|
16
|
+
if (result === Verdict.Malicious) {
|
|
17
|
+
throw new BadRequestException('Malicious file detected');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return next.handle();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { PompelmiInterceptor };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PompelmiService } = require('./pompelmi.service');
|
|
4
|
+
|
|
5
|
+
const POMPELMI_OPTIONS = 'POMPELMI_OPTIONS';
|
|
6
|
+
|
|
7
|
+
class PompelmiModule {
|
|
8
|
+
static forRoot(options = {}) {
|
|
9
|
+
return {
|
|
10
|
+
module: PompelmiModule,
|
|
11
|
+
providers: [
|
|
12
|
+
{ provide: POMPELMI_OPTIONS, useValue: options },
|
|
13
|
+
{
|
|
14
|
+
provide: PompelmiService,
|
|
15
|
+
useFactory: (opts) => new PompelmiService(opts),
|
|
16
|
+
inject: [POMPELMI_OPTIONS],
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
exports: [PompelmiService],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static forRootAsync(asyncOptions = {}) {
|
|
24
|
+
const asyncProviders = [];
|
|
25
|
+
if (asyncOptions.useFactory) {
|
|
26
|
+
asyncProviders.push({
|
|
27
|
+
provide: POMPELMI_OPTIONS,
|
|
28
|
+
useFactory: asyncOptions.useFactory,
|
|
29
|
+
inject: asyncOptions.inject || [],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
module: PompelmiModule,
|
|
34
|
+
imports: asyncOptions.imports || [],
|
|
35
|
+
providers: [
|
|
36
|
+
...asyncProviders,
|
|
37
|
+
{
|
|
38
|
+
provide: PompelmiService,
|
|
39
|
+
useFactory: (opts) => new PompelmiService(opts),
|
|
40
|
+
inject: [POMPELMI_OPTIONS],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
exports: [PompelmiService],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { PompelmiModule, POMPELMI_OPTIONS };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scan, scanBuffer, scanStream, Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
class PompelmiService {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.options = options;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
scan(filePath) {
|
|
11
|
+
return scan(filePath, this.options);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
scanBuffer(buffer) {
|
|
15
|
+
return scanBuffer(buffer, this.options);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
scanStream(stream) {
|
|
19
|
+
return scanStream(stream, this.options);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async isMalware(buffer) {
|
|
23
|
+
const result = await scanBuffer(buffer, this.options);
|
|
24
|
+
return result === Verdict.Malicious;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { PompelmiService };
|