pompelmi 0.15.0 → 0.15.1

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
@@ -18,9 +18,14 @@
18
18
 
19
19
 
20
20
  <p align="center">
21
- <video src="assets/video.mp4" width="920" autoplay loop muted playsinline controls></video>
21
+ <img src="assets/video.gif" alt="pompelmi demo" width="920" />
22
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>
23
+ <br/>
24
+ <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.
25
+ </p>
26
+
27
+ assets/video.mp4
28
+
24
29
 
25
30
  <p align="center">
26
31
  <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>
@@ -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'],