pompelmi 1.5.0 → 1.7.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.
@@ -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.