pompelmi 0.15.0 → 0.15.2
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 +30 -23
- package/dist/pompelmi.esm.js +132 -131
- package/dist/pompelmi.esm.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
<!-- HERO START -->
|
|
1
2
|
|
|
2
3
|
<p align="center">
|
|
3
4
|
|
|
4
5
|
<br/>
|
|
5
|
-
<a href="https://www.producthunt.com/products/pompelmi?embed=true&utm_source=badge-
|
|
6
|
+
<a href="https://www.producthunt.com/products/pompelmi?embed=true&utm_source=badge-pompelmi&utm_medium=badge" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1010722&theme=light&t=1756653468504" alt="pompelmi - free, open-source file scanner | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
|
6
7
|
<br/>
|
|
7
8
|
<a href="https://github.com/pompelmi/pompelmi" target="_blank" rel="noopener noreferrer">
|
|
8
|
-
<img src="https://raw.githubusercontent.com/pompelmi/pompelmi/refs/heads/main/assets/logo.svg" alt="pompelmi logo" width="360"
|
|
9
|
+
<img src="https://raw.githubusercontent.com/pompelmi/pompelmi/refs/heads/main/assets/logo.svg" alt="pompelmi logo" width="360" />
|
|
9
10
|
</a>
|
|
10
11
|
<br/>
|
|
11
12
|
<a href="https://www.detectionengineering.net/p/det-eng-weekly-issue-124-the-defcon"><img alt="Featured in Detection Engineering Weekly #124" src="https://img.shields.io/badge/featured-Detection%20Engineering%20Weekly-0A84FF?logo=substack"></a>
|
|
@@ -18,9 +19,11 @@
|
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
<p align="center">
|
|
21
|
-
|
|
22
|
-
<
|
|
23
|
-
|
|
22
|
+
|
|
23
|
+
<strong>Fast file‑upload malware scanning for Node.js</strong> — optional <strong>YARA</strong> integration, ZIP deep‑inspection, and drop‑in adapters for <em>Express</em>, <em>Koa</em>, and <em>Next.js</em>. Private by design. Typed. Tiny.
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
<p align="center">
|
|
26
29
|
<a href="https://www.npmjs.com/package/pompelmi"><img alt="npm version" src="https://img.shields.io/npm/v/pompelmi?label=pompelmi&color=0a7ea4"></a>
|
|
@@ -46,14 +49,16 @@
|
|
|
46
49
|
<a href="#configuration">Config</a> ·
|
|
47
50
|
<a href="#production-checklist">Production checklist</a> ·
|
|
48
51
|
<a href="#yara-getting-started">YARA</a> ·
|
|
49
|
-
<a href="#quick-test-eicar">Quick test</a> ·
|
|
52
|
+
<a href="#quick-test-no-eicar">Quick test</a> ·
|
|
50
53
|
<a href="#security-notes">Security</a> ·
|
|
51
54
|
<a href="#faq">FAQ</a>
|
|
52
55
|
</p>
|
|
53
56
|
|
|
57
|
+
<!-- HERO END -->
|
|
58
|
+
|
|
54
59
|
---
|
|
55
60
|
|
|
56
|
-
## Overview
|
|
61
|
+
## 🚀 Overview
|
|
57
62
|
|
|
58
63
|
**pompelmi** scans untrusted file uploads **before** they hit disk. A tiny, TypeScript-first toolkit for Node.js with composable scanners, deep ZIP inspection, and optional signature engines.
|
|
59
64
|
|
|
@@ -63,7 +68,7 @@
|
|
|
63
68
|
- **Drop-in adapters** — Express, Koa, Fastify, Next.js
|
|
64
69
|
- **Typed & tiny** — modern TS, minimal surface
|
|
65
70
|
|
|
66
|
-
## Highlights
|
|
71
|
+
## ✨ Highlights
|
|
67
72
|
|
|
68
73
|
- **Block risky uploads early** — classify uploads as _clean_, _suspicious_, or _malicious_ and stop them at the edge.
|
|
69
74
|
- **Real guards** — extension allow‑list, server‑side MIME sniff (magic bytes), per‑file size caps, and **deep ZIP** traversal with anti‑bomb limits.
|
|
@@ -76,7 +81,7 @@
|
|
|
76
81
|
|
|
77
82
|
---
|
|
78
83
|
|
|
79
|
-
## Installation
|
|
84
|
+
## 🔧 Installation
|
|
80
85
|
|
|
81
86
|
```bash
|
|
82
87
|
# core library
|
|
@@ -95,7 +100,7 @@ yarn add pompelmi
|
|
|
95
100
|
|
|
96
101
|
---
|
|
97
102
|
|
|
98
|
-
## Quick‑start
|
|
103
|
+
## ⚡ Quick‑start
|
|
99
104
|
|
|
100
105
|
**At a glance (policy + scanners)**
|
|
101
106
|
|
|
@@ -177,7 +182,7 @@ export const POST = createNextUploadHandler({ ...policy, scanner });
|
|
|
177
182
|
|
|
178
183
|
---
|
|
179
184
|
|
|
180
|
-
## GitHub Action
|
|
185
|
+
## 🤖 GitHub Action
|
|
181
186
|
|
|
182
187
|
Run **pompelmi** in CI to scan repository files or built artifacts.
|
|
183
188
|
|
|
@@ -223,7 +228,7 @@ jobs:
|
|
|
223
228
|
|
|
224
229
|
---
|
|
225
230
|
|
|
226
|
-
## Adapters
|
|
231
|
+
## 🧩 Adapters
|
|
227
232
|
|
|
228
233
|
Use the adapter that matches your web framework. All adapters share the same policy options and scanning contract.
|
|
229
234
|
|
|
@@ -240,7 +245,7 @@ Use the adapter that matches your web framework. All adapters share the same pol
|
|
|
240
245
|
|
|
241
246
|
---
|
|
242
247
|
|
|
243
|
-
## Diagrams
|
|
248
|
+
## 🗺️ Diagrams
|
|
244
249
|
|
|
245
250
|
### Upload scanning flow
|
|
246
251
|
```mermaid
|
|
@@ -366,7 +371,7 @@ flowchart LR
|
|
|
366
371
|
|
|
367
372
|
---
|
|
368
373
|
|
|
369
|
-
## Configuration
|
|
374
|
+
## ⚙️ Configuration
|
|
370
375
|
|
|
371
376
|
All adapters accept a common set of options:
|
|
372
377
|
|
|
@@ -394,7 +399,7 @@ failClosed: true,
|
|
|
394
399
|
|
|
395
400
|
---
|
|
396
401
|
|
|
397
|
-
## Production checklist
|
|
402
|
+
## ✅ Production checklist
|
|
398
403
|
|
|
399
404
|
- [ ] **Limit file size** aggressively (`maxFileSizeBytes`).
|
|
400
405
|
- [ ] **Restrict extensions & MIME** to what your app truly needs.
|
|
@@ -409,7 +414,7 @@ failClosed: true,
|
|
|
409
414
|
|
|
410
415
|
---
|
|
411
416
|
|
|
412
|
-
## YARA Getting Started
|
|
417
|
+
## 🧬 YARA Getting Started
|
|
413
418
|
|
|
414
419
|
YARA lets you detect suspicious or malicious content using pattern‑matching rules.
|
|
415
420
|
**pompelmi** treats YARA matches as signals that you can map to your own verdicts
|
|
@@ -527,7 +532,7 @@ export const scanner = composeScanners(
|
|
|
527
532
|
|
|
528
533
|
Combine YARA with MIME sniffing, ZIP safety limits, and strict size/time caps.
|
|
529
534
|
|
|
530
|
-
## Quick test (no EICAR)
|
|
535
|
+
## 🧪 Quick test (no EICAR)
|
|
531
536
|
|
|
532
537
|
Use the examples above, then send a **minimal PDF** that contains risky tokens (this triggers the built‑in heuristics).
|
|
533
538
|
|
|
@@ -554,7 +559,7 @@ You should see an HTTP **422 Unprocessable Entity** (blocked by policy). Clean f
|
|
|
554
559
|
|
|
555
560
|
---
|
|
556
561
|
|
|
557
|
-
## Security notes
|
|
562
|
+
## 🔒 Security notes
|
|
558
563
|
|
|
559
564
|
- The library **reads** bytes; it never executes files.
|
|
560
565
|
- YARA detections depend on the **rules you provide**; expect some false positives/negatives.
|
|
@@ -563,13 +568,13 @@ You should see an HTTP **422 Unprocessable Entity** (blocked by policy). Clean f
|
|
|
563
568
|
|
|
564
569
|
---
|
|
565
570
|
|
|
566
|
-
## Star history
|
|
571
|
+
## ⭐ Star history
|
|
567
572
|
|
|
568
573
|
[](https://star-history.com/#pompelmi/pompelmi&Date)
|
|
569
574
|
|
|
570
575
|
---
|
|
571
576
|
|
|
572
|
-
## FAQ
|
|
577
|
+
## 💬 FAQ
|
|
573
578
|
|
|
574
579
|
**Do I need YARA?**
|
|
575
580
|
No. `scanner` is pluggable. The examples use a minimal scanner for clarity; you can call out to a YARA engine or any other detector you prefer.
|
|
@@ -585,7 +590,7 @@ Archives are traversed with limits to reduce archive‑bomb risk. Keep your size
|
|
|
585
590
|
|
|
586
591
|
---
|
|
587
592
|
|
|
588
|
-
## Tests & Coverage
|
|
593
|
+
## 🧪 Tests & Coverage
|
|
589
594
|
|
|
590
595
|
Run tests locally with coverage:
|
|
591
596
|
|
|
@@ -601,7 +606,7 @@ If you integrate Codecov in CI, upload `coverage/lcov.info` and you can use this
|
|
|
601
606
|
[](https://codecov.io/gh/pompelmi/pompelmi)
|
|
602
607
|
```
|
|
603
608
|
|
|
604
|
-
## Contributing
|
|
609
|
+
## 🤝 Contributing
|
|
605
610
|
|
|
606
611
|
PRs and issues welcome! Start with:
|
|
607
612
|
|
|
@@ -612,6 +617,8 @@ pnpm -r lint
|
|
|
612
617
|
|
|
613
618
|
---
|
|
614
619
|
|
|
615
|
-
|
|
620
|
+
<p align="right"><a href="#pompelmi">↑ Back to top</a></p>
|
|
621
|
+
|
|
622
|
+
## 📜 License
|
|
616
623
|
|
|
617
624
|
[MIT](./LICENSE) © 2025‑present pompelmi contributors
|
package/dist/pompelmi.esm.js
CHANGED
|
@@ -1,137 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
maxEntries: 1000,
|
|
4
|
-
maxTotalUncompressedBytes: 500 * 1024 * 1024,
|
|
5
|
-
maxEntryNameLength: 255,
|
|
6
|
-
maxCompressionRatio: 1000,
|
|
7
|
-
eocdSearchWindow: 70000,
|
|
8
|
-
};
|
|
9
|
-
function r16(buf, off) {
|
|
10
|
-
return buf.readUInt16LE(off);
|
|
11
|
-
}
|
|
12
|
-
function r32(buf, off) {
|
|
13
|
-
return buf.readUInt32LE(off);
|
|
14
|
-
}
|
|
15
|
-
function isZipLike$1(buf) {
|
|
16
|
-
// local file header at start is common
|
|
17
|
-
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
|
|
18
|
-
}
|
|
19
|
-
function lastIndexOfEOCD(buf, window) {
|
|
20
|
-
const sig = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
21
|
-
const start = Math.max(0, buf.length - window);
|
|
22
|
-
const idx = buf.lastIndexOf(sig, Math.min(buf.length - sig.length, buf.length - 1));
|
|
23
|
-
return idx >= start ? idx : -1;
|
|
1
|
+
function toScanFn(s) {
|
|
2
|
+
return (typeof s === "function" ? s : s.scan);
|
|
24
3
|
}
|
|
25
|
-
function
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const matches = [];
|
|
34
|
-
if (!isZipLike$1(buf))
|
|
35
|
-
return matches;
|
|
36
|
-
// Find EOCD near the end
|
|
37
|
-
const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
|
|
38
|
-
if (eocdPos < 0 || eocdPos + 22 > buf.length) {
|
|
39
|
-
// ZIP but no EOCD — malformed or polyglot → suspicious
|
|
40
|
-
matches.push({ rule: 'zip_eocd_not_found', severity: 'medium' });
|
|
41
|
-
return matches;
|
|
42
|
-
}
|
|
43
|
-
const totalEntries = r16(buf, eocdPos + 10);
|
|
44
|
-
const cdSize = r32(buf, eocdPos + 12);
|
|
45
|
-
const cdOffset = r32(buf, eocdPos + 16);
|
|
46
|
-
// Bounds check
|
|
47
|
-
if (cdOffset + cdSize > buf.length) {
|
|
48
|
-
matches.push({ rule: 'zip_cd_out_of_bounds', severity: 'medium' });
|
|
49
|
-
return matches;
|
|
50
|
-
}
|
|
51
|
-
// Iterate central directory entries
|
|
52
|
-
let ptr = cdOffset;
|
|
53
|
-
let seen = 0;
|
|
54
|
-
let sumComp = 0;
|
|
55
|
-
let sumUnc = 0;
|
|
56
|
-
while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
|
|
57
|
-
const sig = r32(buf, ptr);
|
|
58
|
-
if (sig !== SIG_CEN)
|
|
59
|
-
break; // stop if structure breaks
|
|
60
|
-
const compSize = r32(buf, ptr + 20);
|
|
61
|
-
const uncSize = r32(buf, ptr + 24);
|
|
62
|
-
const fnLen = r16(buf, ptr + 28);
|
|
63
|
-
const exLen = r16(buf, ptr + 30);
|
|
64
|
-
const cmLen = r16(buf, ptr + 32);
|
|
65
|
-
const nameStart = ptr + 46;
|
|
66
|
-
const nameEnd = nameStart + fnLen;
|
|
67
|
-
if (nameEnd > buf.length)
|
|
68
|
-
break;
|
|
69
|
-
const name = buf.toString('utf8', nameStart, nameEnd);
|
|
70
|
-
sumComp += compSize;
|
|
71
|
-
sumUnc += uncSize;
|
|
72
|
-
seen++;
|
|
73
|
-
if (name.length > cfg.maxEntryNameLength) {
|
|
74
|
-
matches.push({ rule: 'zip_entry_name_too_long', severity: 'medium', meta: { name, length: name.length } });
|
|
75
|
-
}
|
|
76
|
-
if (hasTraversal(name)) {
|
|
77
|
-
matches.push({ rule: 'zip_path_traversal_entry', severity: 'medium', meta: { name } });
|
|
78
|
-
}
|
|
79
|
-
// move to next entry
|
|
80
|
-
ptr = nameEnd + exLen + cmLen;
|
|
81
|
-
}
|
|
82
|
-
if (seen !== totalEntries) {
|
|
83
|
-
// central dir truncated/odd, still report what we found
|
|
84
|
-
matches.push({ rule: 'zip_cd_truncated', severity: 'medium', meta: { seen, totalEntries } });
|
|
85
|
-
}
|
|
86
|
-
// Heuristics thresholds
|
|
87
|
-
if (seen > cfg.maxEntries) {
|
|
88
|
-
matches.push({ rule: 'zip_too_many_entries', severity: 'medium', meta: { seen, limit: cfg.maxEntries } });
|
|
89
|
-
}
|
|
90
|
-
if (sumUnc > cfg.maxTotalUncompressedBytes) {
|
|
91
|
-
matches.push({
|
|
92
|
-
rule: 'zip_total_uncompressed_too_large',
|
|
93
|
-
severity: 'medium',
|
|
94
|
-
meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes }
|
|
95
|
-
});
|
|
4
|
+
function composeScanners(...scanners) {
|
|
5
|
+
return async (input, ctx) => {
|
|
6
|
+
const all = [];
|
|
7
|
+
for (const s of scanners) {
|
|
8
|
+
try {
|
|
9
|
+
const out = await toScanFn(s)(input, ctx);
|
|
10
|
+
if (Array.isArray(out))
|
|
11
|
+
all.push(...out);
|
|
96
12
|
}
|
|
97
|
-
|
|
98
|
-
|
|
13
|
+
catch {
|
|
14
|
+
// ignore individual scanner failures
|
|
99
15
|
}
|
|
100
|
-
else if (sumComp > 0) {
|
|
101
|
-
const ratio = sumUnc / Math.max(1, sumComp);
|
|
102
|
-
if (ratio >= cfg.maxCompressionRatio) {
|
|
103
|
-
matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio, limit: cfg.maxCompressionRatio } });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return matches;
|
|
107
16
|
}
|
|
17
|
+
return all;
|
|
108
18
|
};
|
|
109
19
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
/** Composizione sequenziale: concatena tutti i match degli scanner */
|
|
116
|
-
function composeScanners(scanners) {
|
|
117
|
-
return async (input, ctx) => {
|
|
118
|
-
const out = [];
|
|
119
|
-
for (const s of scanners) {
|
|
120
|
-
const res = await Promise.resolve(asScanFn(s)(input, ctx));
|
|
121
|
-
if (Array.isArray(res) && res.length)
|
|
122
|
-
out.push(...res);
|
|
123
|
-
}
|
|
124
|
-
return out;
|
|
20
|
+
function createPresetScanner(_preset, _opts = {}) {
|
|
21
|
+
// TODO: wire to real preset registry
|
|
22
|
+
return async (_input, _ctx) => {
|
|
23
|
+
return [];
|
|
125
24
|
};
|
|
126
25
|
}
|
|
127
|
-
/** Ritorna uno ScanFn pronto all'uso, oggi con zip-bomb guard */
|
|
128
|
-
function createPresetScanner(_name = 'zip-basic', _opts = {}) {
|
|
129
|
-
// Al momento un solo preset "zip-basic"
|
|
130
|
-
const scanners = [
|
|
131
|
-
createZipBombGuard(), // usa i default interni
|
|
132
|
-
];
|
|
133
|
-
return composeScanners(scanners);
|
|
134
|
-
}
|
|
135
26
|
|
|
136
27
|
/** Mappa veloce estensione -> mime (basic) */
|
|
137
28
|
function guessMimeByExt(name) {
|
|
@@ -168,14 +59,14 @@ function toYaraMatches(ms) {
|
|
|
168
59
|
/** Scan di bytes (browser/node) usando preset (default: zip-basic) */
|
|
169
60
|
async function scanBytes(input, opts = {}) {
|
|
170
61
|
const t0 = Date.now();
|
|
171
|
-
|
|
62
|
+
opts.preset ?? 'zip-basic';
|
|
172
63
|
const ctx = {
|
|
173
64
|
...opts.ctx,
|
|
174
65
|
mimeType: opts.ctx?.mimeType ?? guessMimeByExt(opts.ctx?.filename),
|
|
175
66
|
size: opts.ctx?.size ?? input.byteLength,
|
|
176
67
|
};
|
|
177
|
-
const scanFn = createPresetScanner(
|
|
178
|
-
const matchesH = await scanFn(input, ctx);
|
|
68
|
+
const scanFn = createPresetScanner();
|
|
69
|
+
const matchesH = await (typeof scanFn === "function" ? scanFn : scanFn.scan)(input, ctx);
|
|
179
70
|
const matches = toYaraMatches(matchesH);
|
|
180
71
|
const verdict = computeVerdict(matches);
|
|
181
72
|
const durationMs = Date.now() - t0;
|
|
@@ -3188,7 +3079,7 @@ function isOleCfb(buf) {
|
|
|
3188
3079
|
const sig = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
|
3189
3080
|
return startsWith(buf, sig);
|
|
3190
3081
|
}
|
|
3191
|
-
function isZipLike(buf) {
|
|
3082
|
+
function isZipLike$1(buf) {
|
|
3192
3083
|
// PK\x03\x04
|
|
3193
3084
|
return startsWith(buf, [0x50, 0x4b, 0x03, 0x04]);
|
|
3194
3085
|
}
|
|
@@ -3198,7 +3089,7 @@ function isPeExecutable(buf) {
|
|
|
3198
3089
|
}
|
|
3199
3090
|
/** OOXML macro hint via filename token in ZIP container */
|
|
3200
3091
|
function hasOoxmlMacros(buf) {
|
|
3201
|
-
if (!isZipLike(buf))
|
|
3092
|
+
if (!isZipLike$1(buf))
|
|
3202
3093
|
return false;
|
|
3203
3094
|
return hasAsciiToken(buf, 'vbaProject.bin');
|
|
3204
3095
|
}
|
|
@@ -3237,6 +3128,116 @@ const CommonHeuristicsScanner = {
|
|
|
3237
3128
|
}
|
|
3238
3129
|
};
|
|
3239
3130
|
|
|
3131
|
+
const SIG_CEN = 0x02014b50;
|
|
3132
|
+
const DEFAULTS = {
|
|
3133
|
+
maxEntries: 1000,
|
|
3134
|
+
maxTotalUncompressedBytes: 500 * 1024 * 1024,
|
|
3135
|
+
maxEntryNameLength: 255,
|
|
3136
|
+
maxCompressionRatio: 1000,
|
|
3137
|
+
eocdSearchWindow: 70000,
|
|
3138
|
+
};
|
|
3139
|
+
function r16(buf, off) {
|
|
3140
|
+
return buf.readUInt16LE(off);
|
|
3141
|
+
}
|
|
3142
|
+
function r32(buf, off) {
|
|
3143
|
+
return buf.readUInt32LE(off);
|
|
3144
|
+
}
|
|
3145
|
+
function isZipLike(buf) {
|
|
3146
|
+
// local file header at start is common
|
|
3147
|
+
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
|
|
3148
|
+
}
|
|
3149
|
+
function lastIndexOfEOCD(buf, window) {
|
|
3150
|
+
const sig = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
3151
|
+
const start = Math.max(0, buf.length - window);
|
|
3152
|
+
const idx = buf.lastIndexOf(sig, Math.min(buf.length - sig.length, buf.length - 1));
|
|
3153
|
+
return idx >= start ? idx : -1;
|
|
3154
|
+
}
|
|
3155
|
+
function hasTraversal(name) {
|
|
3156
|
+
return name.includes('../') || name.includes('..\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name);
|
|
3157
|
+
}
|
|
3158
|
+
function createZipBombGuard(opts = {}) {
|
|
3159
|
+
const cfg = { ...DEFAULTS, ...opts };
|
|
3160
|
+
return {
|
|
3161
|
+
async scan(input) {
|
|
3162
|
+
const buf = Buffer.from(input);
|
|
3163
|
+
const matches = [];
|
|
3164
|
+
if (!isZipLike(buf))
|
|
3165
|
+
return matches;
|
|
3166
|
+
// Find EOCD near the end
|
|
3167
|
+
const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
|
|
3168
|
+
if (eocdPos < 0 || eocdPos + 22 > buf.length) {
|
|
3169
|
+
// ZIP but no EOCD — malformed or polyglot → suspicious
|
|
3170
|
+
matches.push({ rule: 'zip_eocd_not_found', severity: 'medium' });
|
|
3171
|
+
return matches;
|
|
3172
|
+
}
|
|
3173
|
+
const totalEntries = r16(buf, eocdPos + 10);
|
|
3174
|
+
const cdSize = r32(buf, eocdPos + 12);
|
|
3175
|
+
const cdOffset = r32(buf, eocdPos + 16);
|
|
3176
|
+
// Bounds check
|
|
3177
|
+
if (cdOffset + cdSize > buf.length) {
|
|
3178
|
+
matches.push({ rule: 'zip_cd_out_of_bounds', severity: 'medium' });
|
|
3179
|
+
return matches;
|
|
3180
|
+
}
|
|
3181
|
+
// Iterate central directory entries
|
|
3182
|
+
let ptr = cdOffset;
|
|
3183
|
+
let seen = 0;
|
|
3184
|
+
let sumComp = 0;
|
|
3185
|
+
let sumUnc = 0;
|
|
3186
|
+
while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
|
|
3187
|
+
const sig = r32(buf, ptr);
|
|
3188
|
+
if (sig !== SIG_CEN)
|
|
3189
|
+
break; // stop if structure breaks
|
|
3190
|
+
const compSize = r32(buf, ptr + 20);
|
|
3191
|
+
const uncSize = r32(buf, ptr + 24);
|
|
3192
|
+
const fnLen = r16(buf, ptr + 28);
|
|
3193
|
+
const exLen = r16(buf, ptr + 30);
|
|
3194
|
+
const cmLen = r16(buf, ptr + 32);
|
|
3195
|
+
const nameStart = ptr + 46;
|
|
3196
|
+
const nameEnd = nameStart + fnLen;
|
|
3197
|
+
if (nameEnd > buf.length)
|
|
3198
|
+
break;
|
|
3199
|
+
const name = buf.toString('utf8', nameStart, nameEnd);
|
|
3200
|
+
sumComp += compSize;
|
|
3201
|
+
sumUnc += uncSize;
|
|
3202
|
+
seen++;
|
|
3203
|
+
if (name.length > cfg.maxEntryNameLength) {
|
|
3204
|
+
matches.push({ rule: 'zip_entry_name_too_long', severity: 'medium', meta: { name, length: name.length } });
|
|
3205
|
+
}
|
|
3206
|
+
if (hasTraversal(name)) {
|
|
3207
|
+
matches.push({ rule: 'zip_path_traversal_entry', severity: 'medium', meta: { name } });
|
|
3208
|
+
}
|
|
3209
|
+
// move to next entry
|
|
3210
|
+
ptr = nameEnd + exLen + cmLen;
|
|
3211
|
+
}
|
|
3212
|
+
if (seen !== totalEntries) {
|
|
3213
|
+
// central dir truncated/odd, still report what we found
|
|
3214
|
+
matches.push({ rule: 'zip_cd_truncated', severity: 'medium', meta: { seen, totalEntries } });
|
|
3215
|
+
}
|
|
3216
|
+
// Heuristics thresholds
|
|
3217
|
+
if (seen > cfg.maxEntries) {
|
|
3218
|
+
matches.push({ rule: 'zip_too_many_entries', severity: 'medium', meta: { seen, limit: cfg.maxEntries } });
|
|
3219
|
+
}
|
|
3220
|
+
if (sumUnc > cfg.maxTotalUncompressedBytes) {
|
|
3221
|
+
matches.push({
|
|
3222
|
+
rule: 'zip_total_uncompressed_too_large',
|
|
3223
|
+
severity: 'medium',
|
|
3224
|
+
meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes }
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
if (sumComp === 0 && sumUnc > 0) {
|
|
3228
|
+
matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio: Infinity } });
|
|
3229
|
+
}
|
|
3230
|
+
else if (sumComp > 0) {
|
|
3231
|
+
const ratio = sumUnc / Math.max(1, sumComp);
|
|
3232
|
+
if (ratio >= cfg.maxCompressionRatio) {
|
|
3233
|
+
matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio, limit: cfg.maxCompressionRatio } });
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
return matches;
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3240
3241
|
const MB = 1024 * 1024;
|
|
3241
3242
|
const DEFAULT_POLICY = {
|
|
3242
3243
|
includeExtensions: ['zip', 'png', 'jpg', 'jpeg', 'pdf'],
|