pompelmi 0.35.4 → 0.35.5

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,3 +1,33 @@
1
+ <!-- ════════════════════════════════════════════════════════════════════
2
+ PIVOT NOTICE — read before using this package
3
+ ════════════════════════════════════════════════════════════════════ -->
4
+ > [!CAUTION]
5
+ > **v0.x is an experimental prototype. Do NOT use it in production.**
6
+ >
7
+ > Pompelmi v0.x has **known Event Loop blocking issues** and makes overreaching
8
+ > "scanner" claims that it cannot keep. This version is in **soft maintenance
9
+ > only** — no new features, security patches only.
10
+ >
11
+ > ---
12
+ >
13
+ > **Pompelmi is pivoting for v1.0.**
14
+ >
15
+ > The new identity is a **dead-simple, one-line utility wrapper** for Node.js
16
+ > file uploads — not an "ultimate malware scanner". v1.0 will bundle standard,
17
+ > well-understood checks (size limits, magic bytes, basic heuristics) into a
18
+ > single convenient call that returns a traffic-light verdict:
19
+ >
20
+ > ```js
21
+ > const result = await pompelmi.scan(file);
22
+ > // returns 'green', 'suspicious', or 'malicious'
23
+ > ```
24
+ >
25
+ > No magic promises. No impossible guarantees. Just saved developer time.
26
+ >
27
+ > Follow the pivot → [GitHub Issues](https://github.com/pompelmi/pompelmi/issues) · [Discussions](https://github.com/pompelmi/pompelmi/discussions)
28
+
29
+ ---
30
+
1
31
  <div align="center">
2
32
  <img src="./assets/logo.svg" alt="Pompelmi logo" width="120" />
3
33
 
@@ -24,11 +54,18 @@
24
54
 
25
55
  <p>
26
56
  <a href="https://www.npmjs.com/package/pompelmi"><img alt="npm version" src="https://img.shields.io/npm/v/pompelmi" /></a>
57
+ <a href="https://www.npmjs.com/package/pompelmi"><img alt="npm total downloads" src="https://img.shields.io/npm/dt/pompelmi" /></a>
27
58
  <a href="https://github.com/pompelmi/pompelmi/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/pompelmi/pompelmi/ci.yml?label=ci" /></a>
28
59
  <a href="https://codecov.io/gh/pompelmi/pompelmi"><img alt="codecov" src="https://codecov.io/gh/pompelmi/pompelmi/graph/badge.svg" /></a>
29
60
  <a href="https://github.com/pompelmi/pompelmi/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/pompelmi/pompelmi?style=social" /></a>
61
+ <a href="https://github.com/pompelmi/pompelmi/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-green.svg" /></a>
62
+ <a href="https://nodejs.org"><img alt="Node.js 18+" src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" /></a>
30
63
  </p>
31
64
 
65
+ > If Pompelmi fits how you think about upload security at the route
66
+ > level, starring the repo helps other Node.js teams find it.
67
+ > [★ Star on GitHub](https://github.com/pompelmi/pompelmi/stargazers)
68
+
32
69
  <p>
33
70
  <a href="https://pompelmi.app/"><strong>Docs</strong></a>
34
71
  ·
@@ -50,12 +87,10 @@
50
87
  <a href="https://stackoverflow.blog/2026/02/23/defense-against-uploads-oss-file-scanner-pompelmi/">Stack Overflow</a>,
51
88
  <a href="https://www.helpnetsecurity.com/2026/02/02/pompelmi-open-source-secure-file-upload-scanning-node-js/">Help Net Security</a>,
52
89
  <a href="https://github.com/sorrycc/awesome-javascript">Awesome JavaScript</a>,
90
+ <a href="https://github.com/dzharii/awesome-typescript">Awesome TypeScript</a>,
91
+ <a href="https://bytes.dev/archives/429">Bytes</a>,
53
92
  and
54
- <a href="https://github.com/dzharii/awesome-typescript">Awesome TypeScript</a>
55
- </p>
56
-
57
- <p align="center">
58
- <sub>If you want upload security to start at the route boundary instead of after storage, consider starring the repo.</sub>
93
+ <a href="https://www.detectionengineering.net/p/det-eng-weekly-124">Detection Engineering Weekly</a>
59
94
  </p>
60
95
 
61
96
  > Upload endpoints are part of your attack surface. Pompelmi helps Node.js teams scan files before storage and make the decision while the route still has context: accept, quarantine, or reject.
@@ -99,6 +134,8 @@ Start with [Getting started](https://pompelmi.app/getting-started/) for a local
99
134
  - Pompelmi keeps the first trust decision inside the application path, where the route still knows the file class, trust level, storage path, and failure mode.
100
135
  - It gives Node.js teams a practical way to build secure file uploads with route-level decisions instead of bolting checks on after persistence.
101
136
 
137
+ File upload vulnerabilities are the root cause of real CVEs across web frameworks. Application-layer inspection is the earliest point where the route still has full policy context — file class, trust level, storage path, and failure mode. Waiting until after storage removes most of that context and limits what the application can decide.
138
+
102
139
  ## What it checks
103
140
 
104
141
  - MIME sniffing, magic-byte validation, and extension mismatch detection
@@ -125,6 +162,8 @@ That inspect-first, store-later shape is where Pompelmi is strongest.
125
162
  | Antivirus or YARA only | Known malicious matches and signature-style detection | Route context, spoofing checks, and before-storage handling |
126
163
  | Pompelmi at the upload route | Node.js file upload security, scan files before storage, and verdict-driven workflow decisions | It is not a full antivirus replacement on its own |
127
164
 
165
+ <!-- search: file upload security Node.js, MIME spoofing protection, archive bomb defense -->
166
+
128
167
  ## Supported frameworks and workflows
129
168
 
130
169
  | Stack or workflow | Links |
@@ -147,9 +186,7 @@ That inspect-first, store-later shape is where Pompelmi is strongest.
147
186
  - [Examples index](./examples/README.md) for framework-specific and production-oriented patterns
148
187
  - [Docs home](https://pompelmi.app/) for guides, comparisons, use cases, and tutorials
149
188
 
150
- ## Why star this repo
151
-
152
- Pompelmi is focused on a real gap in most Node.js stacks: secure file uploads that make a decision before storage, not after. If that matches how you want upload security to work, star the repo to follow the project and help more teams discover the inspect-before-storage model.
189
+ Listed in Awesome JavaScript and Awesome TypeScript, and featured by Node Weekly, Stack Overflow, Help Net Security, Bytes, and Detection Engineering Weekly.
153
190
 
154
191
  <!-- MENTIONS:START -->
155
192
 
@@ -160,6 +197,10 @@ Pompelmi is focused on a real gap in most Node.js stacks: secure file uploads th
160
197
  - [Pompelmi: Open-source secure file upload scanning for Node.js](https://www.helpnetsecurity.com/2026/02/02/pompelmi-open-source-secure-file-upload-scanning-node-js/) — Help Net Security
161
198
  - [Awesome JavaScript](https://github.com/sorrycc/awesome-javascript)
162
199
  - [Awesome TypeScript](https://github.com/dzharii/awesome-typescript)
200
+ - [Bytes #429](https://bytes.dev/archives/429) — bytes.dev (2025-10-03)
201
+ - [Det. Eng. Weekly #124](https://www.detectionengineering.net/p/det-eng-weekly-124) — detectionengineering.net (2025-08-13)
202
+ - [The Overflow #319](https://stackoverflow.blog/2026/03/04/the-overflow-319/) — stackoverflow.blog (2026-03-04)
203
+ - [Hottest OSS tools Feb 2026](https://www.helpnetsecurity.com/2026/02/26/hottest-oss-tools-feb-2026/) — helpnetsecurity.com (2026-02-26)
163
204
  - [See all mentions](https://pompelmi.app/featured-in/)
164
205
 
165
206
  <!-- MENTIONS:END -->
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var crypto = require('crypto');
4
+ var zlib = require('zlib');
4
5
 
5
6
  const MB$1 = 1024 * 1024;
6
7
  const DEFAULT_POLICY = {
@@ -1053,12 +1054,15 @@ async function scanFiles(files, opts = {}) {
1053
1054
  return out;
1054
1055
  }
1055
1056
 
1057
+ const ARCHIVE_BOMB_DETECTED = "ARCHIVE_BOMB_DETECTED";
1058
+ const SIG_LFH = 0x04034b50;
1056
1059
  const SIG_CEN = 0x02014b50;
1057
1060
  const DEFAULTS = {
1058
1061
  maxEntries: 1000,
1059
1062
  maxTotalUncompressedBytes: 500 * 1024 * 1024,
1063
+ maxPerEntryUncompressedBytes: 100 * 1024 * 1024,
1060
1064
  maxEntryNameLength: 255,
1061
- maxCompressionRatio: 1000,
1065
+ maxCompressionRatio: 100,
1062
1066
  eocdSearchWindow: 70000,
1063
1067
  };
1064
1068
  function r16(buf, off) {
@@ -1068,7 +1072,6 @@ function r32(buf, off) {
1068
1072
  return buf.readUInt32LE(off);
1069
1073
  }
1070
1074
  function isZipLike(buf) {
1071
- // local file header at start is common
1072
1075
  return (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
1073
1076
  }
1074
1077
  function lastIndexOfEOCD(buf, window) {
@@ -1080,6 +1083,47 @@ function lastIndexOfEOCD(buf, window) {
1080
1083
  function hasTraversal(name) {
1081
1084
  return (name.includes("../") || name.includes("..\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name));
1082
1085
  }
1086
+ function makeBombError() {
1087
+ return Object.assign(new Error("Archive bomb detected: decompression limits exceeded"), {
1088
+ code: ARCHIVE_BOMB_DETECTED,
1089
+ });
1090
+ }
1091
+ /**
1092
+ * Feeds `compressed` into a raw DEFLATE inflate stream and counts the actual
1093
+ * output bytes. Resolves with bombed=true and aborts early if any limit fires:
1094
+ * - decompressed bytes > maxPerEntry
1095
+ * - totalSoFar + decompressed > maxTotal
1096
+ * - decompressed / compressed > maxRatio (ratio measured on real bytes, not headers)
1097
+ *
1098
+ * Malformed DEFLATE is treated as safe (bombed=false, decompressed=0).
1099
+ */
1100
+ function streamInflate(compressed, maxPerEntry, maxTotal, alreadySeen, maxRatio) {
1101
+ return new Promise((resolve) => {
1102
+ const inf = zlib.createInflateRaw();
1103
+ let out = 0;
1104
+ const compBytes = compressed.length;
1105
+ let done = false;
1106
+ const finish = (bombed) => {
1107
+ if (done)
1108
+ return;
1109
+ done = true;
1110
+ inf.destroy();
1111
+ resolve({ decompressed: out, bombed });
1112
+ };
1113
+ inf.on("data", (chunk) => {
1114
+ out += chunk.length;
1115
+ if (out > maxPerEntry ||
1116
+ alreadySeen + out > maxTotal ||
1117
+ (compBytes > 0 && out / compBytes > maxRatio)) {
1118
+ finish(true);
1119
+ }
1120
+ });
1121
+ inf.on("end", () => finish(false));
1122
+ // Malformed DEFLATE stream → not a bomb, just corrupt
1123
+ inf.on("error", () => finish(false));
1124
+ inf.end(compressed);
1125
+ });
1126
+ }
1083
1127
  function createZipBombGuard(opts = {}) {
1084
1128
  const cfg = { ...DEFAULTS, ...opts };
1085
1129
  return {
@@ -1088,43 +1132,36 @@ function createZipBombGuard(opts = {}) {
1088
1132
  const matches = [];
1089
1133
  if (!isZipLike(buf))
1090
1134
  return matches;
1091
- // Find EOCD near the end
1135
+ // ── 1. Locate EOCD ──────────────────────────────────────────────────────
1092
1136
  const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
1093
1137
  if (eocdPos < 0 || eocdPos + 22 > buf.length) {
1094
- // ZIP but no EOCD — malformed or polyglot → suspicious
1095
1138
  matches.push({ rule: "zip_eocd_not_found", severity: "medium" });
1096
1139
  return matches;
1097
1140
  }
1098
1141
  const totalEntries = r16(buf, eocdPos + 10);
1099
1142
  const cdSize = r32(buf, eocdPos + 12);
1100
1143
  const cdOffset = r32(buf, eocdPos + 16);
1101
- // Bounds check
1102
1144
  if (cdOffset + cdSize > buf.length) {
1103
1145
  matches.push({ rule: "zip_cd_out_of_bounds", severity: "medium" });
1104
1146
  return matches;
1105
1147
  }
1106
- // Iterate central directory entries
1148
+ const lfhIndex = [];
1107
1149
  let ptr = cdOffset;
1108
1150
  let seen = 0;
1109
- let sumComp = 0;
1110
- let sumUnc = 0;
1111
1151
  while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
1112
- const sig = r32(buf, ptr);
1113
- if (sig !== SIG_CEN)
1114
- break; // stop if structure breaks
1115
- const compSize = r32(buf, ptr + 20);
1116
- const uncSize = r32(buf, ptr + 24);
1152
+ if (r32(buf, ptr) !== SIG_CEN)
1153
+ break;
1154
+ const cdCompSize = r32(buf, ptr + 20);
1117
1155
  const fnLen = r16(buf, ptr + 28);
1118
1156
  const exLen = r16(buf, ptr + 30);
1119
1157
  const cmLen = r16(buf, ptr + 32);
1120
- const nameStart = ptr + 46;
1121
- const nameEnd = nameStart + fnLen;
1158
+ const lfhOffset = r32(buf, ptr + 42);
1159
+ const nameEnd = ptr + 46 + fnLen;
1122
1160
  if (nameEnd > buf.length)
1123
1161
  break;
1124
- const name = buf.toString("utf8", nameStart, nameEnd);
1125
- sumComp += compSize;
1126
- sumUnc += uncSize;
1162
+ const name = buf.toString("utf8", ptr + 46, nameEnd);
1127
1163
  seen++;
1164
+ lfhIndex.push({ lfhOffset, cdCompSize });
1128
1165
  if (name.length > cfg.maxEntryNameLength) {
1129
1166
  matches.push({
1130
1167
  rule: "zip_entry_name_too_long",
@@ -1135,48 +1172,67 @@ function createZipBombGuard(opts = {}) {
1135
1172
  if (hasTraversal(name)) {
1136
1173
  matches.push({ rule: "zip_path_traversal_entry", severity: "medium", meta: { name } });
1137
1174
  }
1138
- // move to next entry
1139
1175
  ptr = nameEnd + exLen + cmLen;
1140
1176
  }
1141
1177
  if (seen !== totalEntries) {
1142
- // central dir truncated/odd, still report what we found
1143
1178
  matches.push({
1144
1179
  rule: "zip_cd_truncated",
1145
1180
  severity: "medium",
1146
1181
  meta: { seen, totalEntries },
1147
1182
  });
1148
1183
  }
1149
- // Heuristics thresholds
1150
1184
  if (seen > cfg.maxEntries) {
1151
1185
  matches.push({
1152
1186
  rule: "zip_too_many_entries",
1153
1187
  severity: "medium",
1154
1188
  meta: { seen, limit: cfg.maxEntries },
1155
1189
  });
1190
+ // Return early — decompressing thousands of entries would be a DoS vector
1191
+ return matches;
1156
1192
  }
1157
- if (sumUnc > cfg.maxTotalUncompressedBytes) {
1158
- matches.push({
1159
- rule: "zip_total_uncompressed_too_large",
1160
- severity: "medium",
1161
- meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes },
1162
- });
1163
- }
1164
- if (sumComp === 0 && sumUnc > 0) {
1165
- matches.push({
1166
- rule: "zip_suspicious_ratio",
1167
- severity: "medium",
1168
- meta: { ratio: Infinity },
1169
- });
1170
- }
1171
- else if (sumComp > 0) {
1172
- const ratio = sumUnc / Math.max(1, sumComp);
1173
- if (ratio >= cfg.maxCompressionRatio) {
1174
- matches.push({
1175
- rule: "zip_suspicious_ratio",
1176
- severity: "medium",
1177
- meta: { ratio, limit: cfg.maxCompressionRatio },
1178
- });
1193
+ // ── 3. True streaming decompression — archive bomb detection ────────────
1194
+ // For every DEFLATE entry (method=8) we feed the raw compressed bytes into
1195
+ // zlib.createInflateRaw() and count the bytes that come OUT. We abort the
1196
+ // moment any limit fires; we NEVER trust the header-reported uncompressed
1197
+ // size for the ratio decision.
1198
+ //
1199
+ // For STORED entries (method=0) compressed == uncompressed by spec, so the
1200
+ // byte count is immediate.
1201
+ let totalDecompressed = 0;
1202
+ for (const { lfhOffset, cdCompSize } of lfhIndex) {
1203
+ if (lfhOffset + 30 > buf.length)
1204
+ continue;
1205
+ if (r32(buf, lfhOffset) !== SIG_LFH)
1206
+ continue;
1207
+ const gpbf = r16(buf, lfhOffset + 6);
1208
+ const method = r16(buf, lfhOffset + 8);
1209
+ let lfhCompSz = r32(buf, lfhOffset + 18);
1210
+ const fnLen = r16(buf, lfhOffset + 26);
1211
+ const exLen = r16(buf, lfhOffset + 28);
1212
+ const dataOff = lfhOffset + 30 + fnLen + exLen;
1213
+ // If the data-descriptor flag is set (GPBF bit 3), the LFH sizes are 0.
1214
+ // Fall back to the CD size purely for navigation — not for bomb detection.
1215
+ if ((gpbf & 0x08) !== 0 && lfhCompSz === 0) {
1216
+ lfhCompSz = cdCompSize;
1217
+ }
1218
+ if (dataOff + lfhCompSz > buf.length)
1219
+ continue; // truncated entry — skip
1220
+ if (method === 8 /* DEFLATE */) {
1221
+ const compressed = buf.slice(dataOff, dataOff + lfhCompSz);
1222
+ const { decompressed, bombed } = await streamInflate(compressed, cfg.maxPerEntryUncompressedBytes, cfg.maxTotalUncompressedBytes, totalDecompressed, cfg.maxCompressionRatio);
1223
+ if (bombed)
1224
+ throw makeBombError();
1225
+ totalDecompressed += decompressed;
1226
+ }
1227
+ else if (method === 0 /* STORED */) {
1228
+ // Compressed == uncompressed for stored entries
1229
+ if (lfhCompSz > cfg.maxPerEntryUncompressedBytes)
1230
+ throw makeBombError();
1231
+ totalDecompressed += lfhCompSz;
1232
+ if (totalDecompressed > cfg.maxTotalUncompressedBytes)
1233
+ throw makeBombError();
1179
1234
  }
1235
+ // Other methods (bzip2=12, lzma=14, zstd=93, …) — skip; no built-in support
1180
1236
  }
1181
1237
  return matches;
1182
1238
  },