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 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-featured&utm_medium=badge&utm_source=badge-pompelmi" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1010722&theme=light&t=1756653468504" alt="pompelmi - free&#0044;&#0032;open&#0045;source&#0032;file&#0032;scanner | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
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&#0044;&#0032;open&#0045;source&#0032;file&#0032;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" height="280" />
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
- <video src="assets/video.mp4" width="920" autoplay loop muted playsinline controls></video>
22
- <br/>
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.</p>
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
  [![Star History Chart](https://api.star-history.com/svg?repos=pompelmi/pompelmi&type=Date)](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
  [![codecov](https://codecov.io/gh/pompelmi/pompelmi/branch/main/graph/badge.svg?flag=core)](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
- ## License
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
@@ -1,137 +1,28 @@
1
- const SIG_CEN = 0x02014b50;
2
- const DEFAULTS = {
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 hasTraversal(name) {
26
- return name.includes('../') || name.includes('..\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name);
27
- }
28
- function createZipBombGuard(opts = {}) {
29
- const cfg = { ...DEFAULTS, ...opts };
30
- return {
31
- async scan(input) {
32
- const buf = Buffer.from(input);
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
- if (sumComp === 0 && sumUnc > 0) {
98
- matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio: Infinity } });
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
- /** Risolve uno Scanner (fn o oggetto con .scan) in una funzione */
112
- function asScanFn(s) {
113
- return typeof s === 'function' ? s : s.scan;
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
- const preset = opts.preset ?? 'zip-basic';
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(preset);
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'],