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.
- package/README.md +96 -40
- package/llms.txt +22 -99
- package/package.json +4 -1
- package/release-notes-v1.4.0.md +25 -0
- package/release-notes-v1.5.0.md +37 -0
- package/src/BufferScanner.js +20 -17
- package/src/ClamAVScanner.js +42 -5
- package/src/ClamdScanner.js +18 -15
- package/src/StreamScanner.js +20 -17
- package/src/index.js +3 -3
- package/wiki/api-reference.md +268 -0
- package/wiki/cli-usage.md +263 -0
- package/wiki/concurrent-scanning.md +199 -0
- package/wiki/docker-compose-production.md +190 -0
- package/wiki/docker-setup.md +178 -0
- package/wiki/error-handling.md +242 -0
- package/wiki/express-integration.md +227 -0
- package/wiki/fastify-integration.md +207 -0
- package/wiki/home.md +0 -0
- package/wiki/local-vs-tcp-mode.md +179 -0
- package/wiki/multer-memory-storage.md +166 -0
- package/wiki/nestjs-integration.md +228 -0
- package/wiki/nextjs-integration.md +209 -0
- package/wiki/performance.md +178 -0
- package/wiki/quarantine-workflow.md +260 -0
- package/wiki/rest-api-server.md +297 -0
- package/wiki/s3-integration.md +233 -0
- package/wiki/security-considerations.md +192 -0
- package/wiki/typescript-usage.md +239 -0
- package/wiki/verdicts.md +192 -0
- package/wiki/virus-definitions.md +194 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# REST API Scan Server
|
|
2
|
+
|
|
3
|
+
Build a standalone HTTP microservice that exposes a `POST /scan` endpoint. Other services send files to it and receive a JSON verdict. This pattern lets you share one clamd instance and one scan service across multiple applications.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Minimal implementation (Node.js built-ins)
|
|
8
|
+
|
|
9
|
+
No framework required — just `node:http` and `busboy` for multipart parsing:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install pompelmi busboy
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// scan-server.js
|
|
17
|
+
const http = require('http');
|
|
18
|
+
const busboy = require('busboy');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { scan, Verdict } = require('pompelmi');
|
|
23
|
+
|
|
24
|
+
const PORT = Number(process.env.PORT) || 4000;
|
|
25
|
+
|
|
26
|
+
const SCAN_OPTS = {
|
|
27
|
+
host: process.env.CLAMAV_HOST,
|
|
28
|
+
port: Number(process.env.CLAMAV_PORT) || 3310,
|
|
29
|
+
timeout: Number(process.env.CLAMAV_TIMEOUT) || 30_000,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function json(res, status, body) {
|
|
33
|
+
const payload = JSON.stringify(body);
|
|
34
|
+
res.writeHead(status, {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
37
|
+
});
|
|
38
|
+
res.end(payload);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const server = http.createServer((req, res) => {
|
|
42
|
+
if (req.method !== 'POST' || req.url !== '/scan') {
|
|
43
|
+
return json(res, 404, { error: 'Not found.' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const bb = busboy({ headers: req.headers });
|
|
47
|
+
let handled = false;
|
|
48
|
+
|
|
49
|
+
bb.on('file', async (_name, fileStream, info) => {
|
|
50
|
+
const tmpPath = path.join(os.tmpdir(), `scan-${Date.now()}-${info.filename}`);
|
|
51
|
+
const ws = fs.createWriteStream(tmpPath);
|
|
52
|
+
|
|
53
|
+
fileStream.pipe(ws);
|
|
54
|
+
|
|
55
|
+
ws.on('finish', async () => {
|
|
56
|
+
if (handled) return;
|
|
57
|
+
handled = true;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const result = await scan(tmpPath, SCAN_OPTS);
|
|
61
|
+
json(res, result === Verdict.Clean ? 200 : 422, {
|
|
62
|
+
verdict: result.description,
|
|
63
|
+
filename: info.filename,
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
json(res, 500, { error: err.message });
|
|
67
|
+
} finally {
|
|
68
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
ws.on('error', (err) => {
|
|
73
|
+
if (!handled) {
|
|
74
|
+
handled = true;
|
|
75
|
+
json(res, 500, { error: err.message });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
bb.on('error', (err) => {
|
|
81
|
+
if (!handled) {
|
|
82
|
+
handled = true;
|
|
83
|
+
json(res, 400, { error: `Multipart error: ${err.message}` });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
req.pipe(bb);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
server.listen(PORT, () => {
|
|
91
|
+
console.log(`Scan server listening on :${PORT}`);
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Express implementation
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm install pompelmi express multer
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```js
|
|
104
|
+
// scan-server-express.js
|
|
105
|
+
const express = require('express');
|
|
106
|
+
const multer = require('multer');
|
|
107
|
+
const fs = require('fs');
|
|
108
|
+
const { scan, Verdict } = require('pompelmi');
|
|
109
|
+
|
|
110
|
+
const app = express();
|
|
111
|
+
const upload = multer({
|
|
112
|
+
dest: require('os').tmpdir(),
|
|
113
|
+
limits: { fileSize: 100 * 1024 * 1024 },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const SCAN_OPTS = {
|
|
117
|
+
host: process.env.CLAMAV_HOST,
|
|
118
|
+
port: Number(process.env.CLAMAV_PORT) || 3310,
|
|
119
|
+
timeout: 30_000,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
app.post('/scan', upload.single('file'), async (req, res) => {
|
|
123
|
+
if (!req.file) {
|
|
124
|
+
return res.status(400).json({ error: 'No file uploaded. Send a multipart/form-data request with field name "file".' });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const filePath = req.file.path;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const result = await scan(filePath, SCAN_OPTS);
|
|
131
|
+
|
|
132
|
+
return res.status(result === Verdict.Clean ? 200 : 422).json({
|
|
133
|
+
verdict: result.description,
|
|
134
|
+
filename: req.file.originalname,
|
|
135
|
+
size: req.file.size,
|
|
136
|
+
});
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return res.status(500).json({ error: err.message });
|
|
139
|
+
} finally {
|
|
140
|
+
try { fs.unlinkSync(filePath); } catch {}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
app.get('/health', (_req, res) => res.json({ ok: true }));
|
|
145
|
+
|
|
146
|
+
app.listen(Number(process.env.PORT) || 4000, () => {
|
|
147
|
+
console.log('Scan server ready.');
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## JSON response format
|
|
154
|
+
|
|
155
|
+
**Clean file (200):**
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"verdict": "Clean",
|
|
160
|
+
"filename": "report.pdf",
|
|
161
|
+
"size": 245760
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Malicious file (422):**
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"verdict": "Malicious",
|
|
170
|
+
"filename": "evil.exe",
|
|
171
|
+
"size": 16384
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Scan error (422):**
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"verdict": "ScanError",
|
|
180
|
+
"filename": "protected.zip",
|
|
181
|
+
"size": 8192
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Server error (500):**
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"error": "clamd connection timed out after 30000ms"
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Calling from another service
|
|
196
|
+
|
|
197
|
+
### curl
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
curl -X POST http://scan-service:4000/scan \
|
|
201
|
+
-F "file=@/path/to/file.pdf" \
|
|
202
|
+
-w "\n%{http_code}"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Node.js (using `form-data`)
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
npm install form-data node-fetch
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
const FormData = require('form-data');
|
|
213
|
+
const fetch = require('node-fetch');
|
|
214
|
+
const fs = require('fs');
|
|
215
|
+
|
|
216
|
+
async function remoteVerdictCheck(filePath) {
|
|
217
|
+
const form = new FormData();
|
|
218
|
+
form.append('file', fs.createReadStream(filePath));
|
|
219
|
+
|
|
220
|
+
const res = await fetch('http://scan-service:4000/scan', {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
body: form,
|
|
223
|
+
headers: form.getHeaders(),
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const body = await res.json();
|
|
227
|
+
return body.verdict; // 'Clean' | 'Malicious' | 'ScanError'
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Python
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
import requests
|
|
235
|
+
|
|
236
|
+
with open('/path/to/file.pdf', 'rb') as f:
|
|
237
|
+
response = requests.post(
|
|
238
|
+
'http://scan-service:4000/scan',
|
|
239
|
+
files={'file': f},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
verdict = response.json()['verdict']
|
|
243
|
+
print(verdict) # Clean / Malicious / ScanError
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## Docker deployment
|
|
249
|
+
|
|
250
|
+
```yaml
|
|
251
|
+
# docker-compose.yml
|
|
252
|
+
services:
|
|
253
|
+
scan-service:
|
|
254
|
+
build: .
|
|
255
|
+
ports:
|
|
256
|
+
- "4000:4000"
|
|
257
|
+
environment:
|
|
258
|
+
PORT: 4000
|
|
259
|
+
CLAMAV_HOST: clamav
|
|
260
|
+
CLAMAV_PORT: 3310
|
|
261
|
+
CLAMAV_TIMEOUT: 30000
|
|
262
|
+
depends_on:
|
|
263
|
+
clamav:
|
|
264
|
+
condition: service_healthy
|
|
265
|
+
|
|
266
|
+
clamav:
|
|
267
|
+
image: clamav/clamav:stable
|
|
268
|
+
volumes:
|
|
269
|
+
- clamav_db:/var/lib/clamav
|
|
270
|
+
healthcheck:
|
|
271
|
+
test: ["CMD", "clamdcheck"]
|
|
272
|
+
interval: 30s
|
|
273
|
+
timeout: 10s
|
|
274
|
+
retries: 5
|
|
275
|
+
start_period: 120s
|
|
276
|
+
|
|
277
|
+
volumes:
|
|
278
|
+
clamav_db:
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Security considerations for the scan service
|
|
284
|
+
|
|
285
|
+
- **Network access:** Expose the scan service only within your internal network or VPC. Never expose it to the public internet.
|
|
286
|
+
- **Authentication:** Add an API key or mTLS for service-to-service authentication.
|
|
287
|
+
- **File size limits:** Set `limits.fileSize` on multer to prevent the scan service from being used as a DoS vector.
|
|
288
|
+
- **Rate limiting:** Add rate limiting per caller IP or API key.
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
const rateLimit = require('express-rate-limit');
|
|
292
|
+
|
|
293
|
+
app.use('/scan', rateLimit({
|
|
294
|
+
windowMs: 60_000,
|
|
295
|
+
max: 60, // 60 scans per minute per IP
|
|
296
|
+
}));
|
|
297
|
+
```
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# S3 Integration
|
|
2
|
+
|
|
3
|
+
Two common patterns when using pompelmi with Amazon S3: scan before uploading (local scan then putObject), and scan a file already in S3 (getObject stream then scanStream).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Pattern 1: Scan locally, then upload to S3 if clean
|
|
8
|
+
|
|
9
|
+
The file is scanned before it ever reaches S3. Malicious files are rejected and never uploaded.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
15
|
+
const { scan, Verdict } = require('pompelmi');
|
|
16
|
+
|
|
17
|
+
const s3 = new S3Client({ region: process.env.AWS_REGION });
|
|
18
|
+
|
|
19
|
+
const SCAN_OPTS = {
|
|
20
|
+
host: process.env.CLAMAV_HOST,
|
|
21
|
+
port: Number(process.env.CLAMAV_PORT) || 3310,
|
|
22
|
+
timeout: 30_000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
async function scanThenUpload(localPath, s3Key) {
|
|
26
|
+
const result = await scan(localPath, SCAN_OPTS);
|
|
27
|
+
|
|
28
|
+
if (result === Verdict.Malicious) {
|
|
29
|
+
throw new Error(`Malicious file rejected: ${localPath}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (result === Verdict.ScanError) {
|
|
33
|
+
throw new Error(`Scan incomplete — rejecting file: ${localPath}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Only reached if Verdict.Clean
|
|
37
|
+
const fileStream = fs.createReadStream(localPath);
|
|
38
|
+
await s3.send(new PutObjectCommand({
|
|
39
|
+
Bucket: process.env.S3_BUCKET,
|
|
40
|
+
Key: s3Key,
|
|
41
|
+
Body: fileStream,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
return s3Key;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### In an Express upload route
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
const express = require('express');
|
|
52
|
+
const multer = require('multer');
|
|
53
|
+
const { scan, Verdict } = require('pompelmi');
|
|
54
|
+
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
55
|
+
const fs = require('fs');
|
|
56
|
+
|
|
57
|
+
const upload = multer({ dest: '/tmp/uploads' });
|
|
58
|
+
const s3 = new S3Client({ region: process.env.AWS_REGION });
|
|
59
|
+
const app = express();
|
|
60
|
+
|
|
61
|
+
app.post('/upload', upload.single('file'), async (req, res) => {
|
|
62
|
+
if (!req.file) return res.status(400).json({ error: 'No file.' });
|
|
63
|
+
|
|
64
|
+
const filePath = req.file.path;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const result = await scan(filePath, { host: process.env.CLAMAV_HOST, port: 3310 });
|
|
68
|
+
|
|
69
|
+
if (result !== Verdict.Clean) {
|
|
70
|
+
fs.unlinkSync(filePath);
|
|
71
|
+
return res.status(422).json({ error: `Upload rejected: ${result.description}` });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const key = `uploads/${Date.now()}-${req.file.originalname}`;
|
|
75
|
+
await s3.send(new PutObjectCommand({
|
|
76
|
+
Bucket: process.env.S3_BUCKET,
|
|
77
|
+
Key: key,
|
|
78
|
+
Body: fs.createReadStream(filePath),
|
|
79
|
+
ContentType: req.file.mimetype,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
fs.unlinkSync(filePath); // clean up temp file after upload
|
|
83
|
+
return res.json({ ok: true, key });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
try { fs.unlinkSync(filePath); } catch {}
|
|
86
|
+
return res.status(500).json({ error: err.message });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Pattern 2: Scan an S3 object stream
|
|
94
|
+
|
|
95
|
+
Scan a file that already exists in S3 by streaming the `GetObjectCommand` response body through `scanStream()`. No data is written to the application host's disk.
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
|
|
99
|
+
const { scanStream, Verdict } = require('pompelmi');
|
|
100
|
+
|
|
101
|
+
const s3 = new S3Client({ region: process.env.AWS_REGION });
|
|
102
|
+
|
|
103
|
+
const SCAN_OPTS = {
|
|
104
|
+
host: process.env.CLAMAV_HOST,
|
|
105
|
+
port: 3310,
|
|
106
|
+
timeout: 60_000,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
async function scanS3Object(bucket, key) {
|
|
110
|
+
const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
111
|
+
|
|
112
|
+
// response.Body is a SdkStreamMixin (Node.js Readable-compatible)
|
|
113
|
+
const result = await scanStream(response.Body, SCAN_OPTS);
|
|
114
|
+
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Usage
|
|
119
|
+
const verdict = await scanS3Object('my-bucket', 'uploads/user-file.pdf');
|
|
120
|
+
|
|
121
|
+
if (verdict === Verdict.Malicious) {
|
|
122
|
+
// Move to quarantine bucket
|
|
123
|
+
await moveToQuarantine('my-bucket', 'uploads/user-file.pdf');
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### AWS SDK v3 stream compatibility
|
|
128
|
+
|
|
129
|
+
The AWS SDK v3 returns `response.Body` as a `SdkStreamMixin` which implements the Node.js `Readable` interface. Pass it directly to `scanStream()`:
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const response = await s3.send(new GetObjectCommand({ Bucket, Key }));
|
|
133
|
+
const result = await scanStream(response.Body, SCAN_OPTS);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
For the older AWS SDK v2:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const response = s3.getObject({ Bucket, Key });
|
|
140
|
+
const result = await scanStream(response.createReadStream(), SCAN_OPTS);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Pattern 3: Quarantine bucket
|
|
146
|
+
|
|
147
|
+
Move malicious files to a separate quarantine bucket instead of deleting them, for forensic review.
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const {
|
|
151
|
+
S3Client,
|
|
152
|
+
GetObjectCommand,
|
|
153
|
+
CopyObjectCommand,
|
|
154
|
+
DeleteObjectCommand,
|
|
155
|
+
} = require('@aws-sdk/client-s3');
|
|
156
|
+
const { scanStream, Verdict } = require('pompelmi');
|
|
157
|
+
|
|
158
|
+
const s3 = new S3Client({ region: process.env.AWS_REGION });
|
|
159
|
+
|
|
160
|
+
async function scanAndQuarantine(sourceBucket, key) {
|
|
161
|
+
const response = await s3.send(new GetObjectCommand({
|
|
162
|
+
Bucket: sourceBucket,
|
|
163
|
+
Key: key,
|
|
164
|
+
}));
|
|
165
|
+
|
|
166
|
+
const result = await scanStream(response.Body, {
|
|
167
|
+
host: process.env.CLAMAV_HOST,
|
|
168
|
+
port: 3310,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (result === Verdict.Malicious) {
|
|
172
|
+
const quarantineKey = `quarantine/${Date.now()}-${key}`;
|
|
173
|
+
|
|
174
|
+
// Copy to quarantine bucket
|
|
175
|
+
await s3.send(new CopyObjectCommand({
|
|
176
|
+
CopySource: `${sourceBucket}/${key}`,
|
|
177
|
+
Bucket: process.env.QUARANTINE_BUCKET,
|
|
178
|
+
Key: quarantineKey,
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
// Delete from source
|
|
182
|
+
await s3.send(new DeleteObjectCommand({
|
|
183
|
+
Bucket: sourceBucket,
|
|
184
|
+
Key: key,
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
console.warn({ event: 'quarantined', sourceBucket, key, quarantineKey });
|
|
188
|
+
return { quarantined: true, quarantineKey };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { quarantined: false, verdict: result.description };
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## S3 trigger pattern (Lambda or background job)
|
|
198
|
+
|
|
199
|
+
Scan every object as it arrives in an upload bucket using an S3 event trigger or a polling job:
|
|
200
|
+
|
|
201
|
+
```js
|
|
202
|
+
// Lambda handler (or background worker)
|
|
203
|
+
async function processUpload(event) {
|
|
204
|
+
const record = event.Records[0];
|
|
205
|
+
const bucket = record.s3.bucket.name;
|
|
206
|
+
const key = decodeURIComponent(record.s3.object.key);
|
|
207
|
+
|
|
208
|
+
const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
209
|
+
const result = await scanStream(response.Body, {
|
|
210
|
+
host: process.env.CLAMAV_HOST,
|
|
211
|
+
port: 3310,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (result !== Verdict.Clean) {
|
|
215
|
+
await moveToQuarantine(bucket, key);
|
|
216
|
+
await notifyAdmin(key, result.description);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
> **Note on Lambda:** ClamAV cannot run inside a standard Lambda function. Use TCP mode pointing to clamd on a persistent host (EC2, ECS, Fargate) or a dedicated scan microservice.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Environment variables
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
CLAMAV_HOST=clamav.internal
|
|
229
|
+
CLAMAV_PORT=3310
|
|
230
|
+
AWS_REGION=us-east-1
|
|
231
|
+
S3_BUCKET=uploads-bucket
|
|
232
|
+
QUARANTINE_BUCKET=quarantine-bucket
|
|
233
|
+
```
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Security Considerations
|
|
2
|
+
|
|
3
|
+
pompelmi is one layer in a secure file upload pipeline — not a complete solution on its own. This page covers what ClamAV protects against, what it does not, and how to build a genuinely secure upload endpoint.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## What ClamAV detects
|
|
8
|
+
|
|
9
|
+
ClamAV is a signature-based antivirus. It detects:
|
|
10
|
+
|
|
11
|
+
- **Known malware** — executables, scripts, and documents matching its signature database
|
|
12
|
+
- **Known malware inside archives** — ZIP, RAR, TAR, PDF, Office documents (recursive scanning)
|
|
13
|
+
- **EICAR test files** — for verifying your integration
|
|
14
|
+
- **Some heuristic patterns** — suspicious bytecode, known malware families
|
|
15
|
+
|
|
16
|
+
ClamAV does **not** detect:
|
|
17
|
+
|
|
18
|
+
- **Zero-day malware** — novel malware without a signature
|
|
19
|
+
- **Obfuscated malware** — some malware evades signature matching through packing or encryption
|
|
20
|
+
- **Logic bombs** — malicious code that only activates under specific conditions
|
|
21
|
+
- **Malicious content that is not malware** — spam, phishing text, NSFW images, copyright violations
|
|
22
|
+
|
|
23
|
+
**ClamAV is a necessary but not sufficient safeguard.** Use it as one layer in a defence-in-depth strategy.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Reject on `ScanError`
|
|
28
|
+
|
|
29
|
+
`Verdict.ScanError` means the scan did not complete. Password-protected archives, corrupt files, and oversized archives all return `ScanError`. These are common evasion techniques — always reject `ScanError` files:
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
if (result !== Verdict.Clean) {
|
|
33
|
+
fs.unlinkSync(filePath);
|
|
34
|
+
return res.status(422).json({ error: 'Upload rejected.' });
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Never serve a file whose safety status is unknown.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Validate MIME type and extension
|
|
43
|
+
|
|
44
|
+
ClamAV checks content, not metadata. But your application may have legitimate business reasons to restrict file types. Add MIME validation as a complementary check:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
const ALLOWED_MIME_TYPES = new Set([
|
|
48
|
+
'application/pdf',
|
|
49
|
+
'image/jpeg',
|
|
50
|
+
'image/png',
|
|
51
|
+
'image/gif',
|
|
52
|
+
'image/webp',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const ALLOWED_EXTENSIONS = new Set(['.pdf', '.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
56
|
+
|
|
57
|
+
function validateFile(file) {
|
|
58
|
+
const ext = path.extname(file.originalname).toLowerCase();
|
|
59
|
+
|
|
60
|
+
if (!ALLOWED_MIME_TYPES.has(file.mimetype)) {
|
|
61
|
+
throw new Error(`File type not allowed: ${file.mimetype}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
65
|
+
throw new Error(`File extension not allowed: ${ext}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Note: MIME type from `req.file.mimetype` is supplied by the client and can be spoofed. For strong MIME validation, use `file-type` to detect the real MIME type from the file's magic bytes:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm install file-type
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const { fileTypeFromBuffer } = require('file-type');
|
|
78
|
+
|
|
79
|
+
const detected = await fileTypeFromBuffer(req.file.buffer);
|
|
80
|
+
if (!ALLOWED_MIME_TYPES.has(detected?.mime)) {
|
|
81
|
+
throw new Error('File type mismatch or not allowed.');
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Set file size limits
|
|
88
|
+
|
|
89
|
+
Never let a file reach ClamAV if it exceeds your maximum allowed size. Check size before scanning:
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
// multer
|
|
93
|
+
const upload = multer({
|
|
94
|
+
dest: './uploads',
|
|
95
|
+
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Manual check
|
|
99
|
+
if (req.file.size > 10 * 1024 * 1024) {
|
|
100
|
+
fs.unlinkSync(req.file.path);
|
|
101
|
+
return res.status(413).json({ error: 'File too large.' });
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Large files slow down scans and can exhaust ClamAV's memory when unpacking archives.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Never serve files from the upload directory directly
|
|
110
|
+
|
|
111
|
+
After a file is uploaded and scanned, do not serve it directly from the upload directory as a static file. Instead:
|
|
112
|
+
|
|
113
|
+
1. Move it to a separate storage location (S3, a content-addressed store, or a named directory).
|
|
114
|
+
2. Serve files through a route handler that validates authorisation before returning the file.
|
|
115
|
+
|
|
116
|
+
Serving uploads as static files bypasses all access control and lets any user download any uploaded file if they know or guess the path.
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
// BAD — serves all uploads publicly
|
|
120
|
+
app.use('/uploads', express.static('./uploads'));
|
|
121
|
+
|
|
122
|
+
// GOOD — validate before serving
|
|
123
|
+
app.get('/files/:id', authenticate, async (req, res) => {
|
|
124
|
+
const file = await db.files.findById(req.params.id);
|
|
125
|
+
if (!file || file.userId !== req.user.id) {
|
|
126
|
+
return res.status(404).end();
|
|
127
|
+
}
|
|
128
|
+
res.sendFile(file.storagePath);
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Store files with randomised names
|
|
135
|
+
|
|
136
|
+
Never use the original filename from the upload. Sanitise or replace it entirely to prevent path traversal, null byte injection, and social engineering attacks:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
const { randomBytes } = require('crypto');
|
|
140
|
+
const path = require('path');
|
|
141
|
+
|
|
142
|
+
function safeFilename(originalname) {
|
|
143
|
+
const ext = path.extname(originalname).toLowerCase().replace(/[^a-z0-9.]/g, '');
|
|
144
|
+
return `${randomBytes(16).toString('hex')}${ext}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const storedName = safeFilename(req.file.originalname);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## OWASP file upload security checklist
|
|
153
|
+
|
|
154
|
+
| Control | How to implement with pompelmi |
|
|
155
|
+
|---------|-------------------------------|
|
|
156
|
+
| Validate file type | `file-type` for magic bytes + MIME allowlist |
|
|
157
|
+
| Validate file size | `multer limits.fileSize` + pre-scan size check |
|
|
158
|
+
| Scan for malware | `scan()` / `scanBuffer()` / `scanStream()` |
|
|
159
|
+
| Rename uploaded files | Generate random names — never use original filename |
|
|
160
|
+
| Store outside webroot | Use S3 or a non-public directory |
|
|
161
|
+
| Serve through auth-gated handler | Route handler with session/token check |
|
|
162
|
+
| Limit upload rate | Express rate limiting middleware |
|
|
163
|
+
| Log all upload attempts | Log verdict, user, IP, original filename |
|
|
164
|
+
| Reject `ScanError` | `if (result !== Verdict.Clean)` → reject |
|
|
165
|
+
| Set Content-Security-Policy | Prevent XSS from served HTML files |
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Defence in depth
|
|
170
|
+
|
|
171
|
+
pompelmi sits at layer 3 of a multi-layer defence:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
Layer 1: TLS — encrypted transport
|
|
175
|
+
Layer 2: Authentication — only authorised users can upload
|
|
176
|
+
Layer 3: Size limits — reject oversized files before processing
|
|
177
|
+
Layer 4: Extension / MIME allowlist — reject obviously wrong file types
|
|
178
|
+
Layer 5: pompelmi — ClamAV signature scan
|
|
179
|
+
Layer 6: Random storage name — no path traversal possible
|
|
180
|
+
Layer 7: Auth-gated serving — no direct URL access to upload directory
|
|
181
|
+
Layer 8: CSP headers — limit damage if a malicious file is served
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Remove any one of these layers and the others compensate. pompelmi does not replace them — it adds to them.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Privacy and data handling
|
|
189
|
+
|
|
190
|
+
pompelmi scans files locally. In local mode, files are passed to `clamscan` as a path argument. In TCP mode, file content is streamed to your own clamd instance. **No file content is sent to any third party.** This makes pompelmi suitable for GDPR, HIPAA, and other privacy-sensitive environments.
|
|
191
|
+
|
|
192
|
+
In TCP mode with `scanBuffer()` or `scanStream()`, no data is written to the application host's disk at all — the content goes directly from memory to the clamd daemon.
|