pompelmi 0.15.0-dev.27 → 0.15.0-dev.28

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.
@@ -1,219 +1,221 @@
1
- async function createRemoteEngine(opts) {
2
- const { endpoint, headers = {}, rulesField = 'rules', fileField = 'file', mode = 'multipart', rulesAsBase64 = false, } = opts;
3
- const engine = {
4
- async compile(rulesSource) {
5
- return {
6
- async scan(data) {
7
- const fetchFn = globalThis.fetch;
8
- if (!fetchFn)
9
- throw new Error('[remote-yara] fetch non disponibile in questo ambiente');
10
- let res;
11
- if (mode === 'multipart') {
12
- const FormDataCtor = globalThis.FormData;
13
- const BlobCtor = globalThis.Blob;
14
- if (!FormDataCtor || !BlobCtor) {
15
- throw new Error('[remote-yara] FormData/Blob non disponibili (usa json-base64 oppure esegui in browser)');
16
- }
17
- const form = new FormDataCtor();
18
- form.set(rulesField, new BlobCtor([rulesSource], { type: 'text/plain' }), 'rules.yar');
19
- form.set(fileField, new BlobCtor([data], { type: 'application/octet-stream' }), 'sample.bin');
20
- res = await fetchFn(endpoint, { method: 'POST', body: form, headers });
21
- }
22
- else {
23
- const b64 = base64FromBytes(data);
24
- const payload = { [fileField]: b64 };
25
- if (rulesAsBase64) {
26
- payload['rulesB64'] = base64FromString(rulesSource);
27
- }
28
- else {
29
- payload[rulesField] = rulesSource;
30
- }
31
- res = await fetchFn(endpoint, {
32
- method: 'POST',
33
- headers: { 'Content-Type': 'application/json', ...headers },
34
- body: JSON.stringify(payload),
35
- });
36
- }
37
- if (!res.ok) {
38
- throw new Error(`[remote-yara] HTTP ${res.status} ${res.statusText}`);
39
- }
40
- const json = await res.json().catch(() => null);
41
- const arr = Array.isArray(json) ? json : (json?.matches ?? []);
42
- return (arr ?? []).map((m) => ({
43
- rule: m.rule ?? m.ruleIdentifier ?? 'unknown',
44
- tags: m.tags ?? [],
45
- }));
46
- },
47
- };
48
- },
49
- };
50
- return engine;
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);
51
11
  }
52
- // Helpers
53
- function base64FromBytes(bytes) {
54
- // usa btoa se disponibile (browser); altrimenti fallback manuale
55
- const btoaFn = globalThis.btoa;
56
- let bin = '';
57
- for (let i = 0; i < bytes.byteLength; i++)
58
- bin += String.fromCharCode(bytes[i]);
59
- return btoaFn ? btoaFn(bin) : Buffer.from(bin, 'binary').toString('base64');
12
+ function r32(buf, off) {
13
+ return buf.readUInt32LE(off);
60
14
  }
61
- function base64FromString(s) {
62
- const btoaFn = globalThis.btoa;
63
- return btoaFn ? btoaFn(s) : Buffer.from(s, 'utf8').toString('base64');
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;
64
18
  }
65
-
66
- // Factory: sceglie l'engine a runtime (Node o Browser)
67
- // (Per ora i moduli chiamati lanceranno "non implementato")
68
- async function createYaraEngine() {
69
- const isNode = typeof process !== 'undefined' && !!process.versions?.node;
70
- const target = isNode ? 'node' : 'browser';
71
- const mod = await import(`./${target}`);
72
- return isNode
73
- ? mod.createNodeEngine()
74
- : mod.createBrowserEngine();
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;
75
24
  }
76
-
77
- function sniffMagicBytes(bytes) {
78
- const s = (sig) => {
79
- const arr = typeof sig === 'string' ? new TextEncoder().encode(sig) : new Uint8Array(sig);
80
- if (bytes.length < arr.length)
81
- return false;
82
- for (let i = 0; i < arr.length; i++)
83
- if (bytes[i] !== arr[i])
84
- return false;
85
- return true;
86
- };
87
- if (s([0x50, 0x4B, 0x03, 0x04]) || s([0x50, 0x4B, 0x05, 0x06]) || s([0x50, 0x4B, 0x07, 0x08]))
88
- return { mime: 'application/zip', extHint: 'zip' };
89
- if (s([0x25, 0x50, 0x44, 0x46, 0x2D]))
90
- return { mime: 'application/pdf', extHint: 'pdf' };
91
- if (s([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
92
- return { mime: 'image/png', extHint: 'png' };
93
- if (s([0xFF, 0xD8, 0xFF]))
94
- return { mime: 'image/jpeg', extHint: 'jpg' };
95
- if (s('GIF87a') || s('GIF89a'))
96
- return { mime: 'image/gif', extHint: 'gif' };
97
- if (s('<?xml') || s('<svg'))
98
- return { mime: 'image/svg+xml', extHint: 'svg' };
99
- if (s([0x4D, 0x5A]))
100
- return { mime: 'application/vnd.microsoft.portable-executable', extHint: 'exe' };
101
- if (s([0x7F, 0x45, 0x4C, 0x46]))
102
- return { mime: 'application/x-elf', extHint: 'elf' };
103
- return null;
25
+ function hasTraversal(name) {
26
+ return name.includes('../') || name.includes('..\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name);
104
27
  }
105
- function hasSuspiciousJpegTrailer(bytes, maxTrailer = 1000000) {
106
- for (let i = bytes.length - 2; i >= 2; i--) {
107
- if (bytes[i] === 0xFF && bytes[i + 1] === 0xD9) {
108
- const trailer = bytes.length - (i + 2);
109
- return trailer > maxTrailer;
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
+ });
96
+ }
97
+ if (sumComp === 0 && sumUnc > 0) {
98
+ matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio: Infinity } });
99
+ }
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;
110
107
  }
111
- }
112
- return false;
108
+ };
113
109
  }
114
- function prefilterBrowser(bytes, filename, policy) {
115
- const reasons = [];
116
- const ext = (filename.split('.').pop() || '').toLowerCase();
117
- if (policy.maxFileSizeBytes && bytes.byteLength > policy.maxFileSizeBytes) {
118
- reasons.push(`size_exceeded:${bytes.byteLength}`);
119
- }
120
- if (policy.includeExtensions && policy.includeExtensions.length && !policy.includeExtensions.includes(ext)) {
121
- reasons.push(`ext_denied:${ext}`);
122
- }
123
- const s = sniffMagicBytes(bytes);
124
- if (!s)
125
- reasons.push('mime_unknown');
126
- if (s?.mime && policy.allowedMimeTypes && policy.allowedMimeTypes.length && !policy.allowedMimeTypes.includes(s.mime)) {
127
- reasons.push(`mime_denied:${s.mime}`);
128
- }
129
- if (s?.extHint && ext && s.extHint !== ext) {
130
- reasons.push(`ext_mismatch:${ext}->${s.extHint}`);
131
- }
132
- if (s?.mime === 'image/jpeg' && hasSuspiciousJpegTrailer(bytes))
133
- reasons.push('jpeg_trailer_payload');
134
- if (s?.mime === 'image/svg+xml' && policy.denyScriptableSvg !== false) {
135
- const text = new TextDecoder().decode(bytes).toLowerCase();
136
- if (text.includes('<script') || text.includes('onload=') || text.includes('href="javascript:')) {
137
- reasons.push('svg_script');
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);
138
123
  }
139
- }
140
- const severity = reasons.length ? 'suspicious' : 'clean';
141
- return { severity, reasons, mime: s?.mime };
124
+ return out;
125
+ };
142
126
  }
143
- /**
144
- * Reads an array of File objects via FileReader and returns their text.
145
- */
146
- async function scanFiles(files) {
147
- const readText = (file) => new Promise((resolve, reject) => {
148
- const reader = new FileReader();
149
- reader.onload = () => resolve(reader.result);
150
- reader.onerror = () => reject(reader.error);
151
- reader.readAsText(file);
152
- });
153
- const results = [];
154
- for (const file of files) {
155
- const content = await readText(file);
156
- results.push({ file, content });
157
- }
158
- return results;
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);
159
134
  }
160
- async function scanFilesWithYara(files, rulesSource) {
161
- // Prova a creare l'engine YARA (browser). Se non disponibile, prosegui silenziosamente.
162
- let compiled;
163
- try {
164
- const engine = await createYaraEngine(); // in browser userà l'engine WASM (prossimo step)
165
- compiled = await engine.compile(rulesSource); // compila UNA sola volta
166
- }
167
- catch (e) {
168
- console.warn('[yara] non disponibile o regole non compilate:', e);
169
- }
170
- const results = [];
171
- for (const file of files) {
172
- // 1) contenuto testuale (come nella tua scanFiles)
173
- const content = await file.text();
174
- // 2) bytes per YARA (meglio dei soli caratteri)
175
- let matches = [];
176
- if (compiled) {
177
- try {
178
- const bytes = new Uint8Array(await file.arrayBuffer());
179
- matches = await compiled.scan(bytes);
180
- }
181
- catch (e) {
182
- console.warn(`[yara] errore scansione ${file.name}:`, e);
183
- }
184
- }
185
- results.push({ file, content, yara: { matches } });
135
+
136
+ /** Mappa veloce estensione -> mime (basic) */
137
+ function guessMimeByExt(name) {
138
+ if (!name)
139
+ return;
140
+ const ext = name.toLowerCase().split('.').pop();
141
+ switch (ext) {
142
+ case 'zip': return 'application/zip';
143
+ case 'png': return 'image/png';
144
+ case 'jpg':
145
+ case 'jpeg': return 'image/jpeg';
146
+ case 'pdf': return 'application/pdf';
147
+ case 'txt': return 'text/plain';
148
+ default: return;
186
149
  }
187
- return results;
188
150
  }
189
- /**
190
- * Scan files with fast browser heuristics + optional YARA.
191
- * Returns content, prefilter verdict, and YARA matches.
192
- */
193
- async function scanFilesWithHeuristicsAndYara(files, rulesSource, policy) {
194
- let compiled;
195
- try {
196
- const engine = await createYaraEngine();
197
- compiled = await engine.compile(rulesSource);
198
- }
199
- catch (e) {
200
- console.warn('[yara] non disponibile o regole non compilate:', e);
201
- }
151
+ /** Heuristica semplice per verdetto */
152
+ function computeVerdict(matches) {
153
+ if (!matches.length)
154
+ return 'clean';
155
+ // se la regola contiene 'zip_' lo marchiamo "suspicious"
156
+ const anyHigh = matches.some(m => (m.tags ?? []).includes('critical') || (m.tags ?? []).includes('high'));
157
+ return anyHigh ? 'malicious' : 'suspicious';
158
+ }
159
+ /** Converte i Match (heuristics) in YaraMatch-like per uniformare l'output */
160
+ function toYaraMatches(ms) {
161
+ return ms.map(m => ({
162
+ rule: m.rule,
163
+ namespace: 'heuristics',
164
+ tags: ['heuristics'].concat(m.severity ? [m.severity] : []),
165
+ meta: m.meta,
166
+ }));
167
+ }
168
+ /** Scan di bytes (browser/node) usando preset (default: zip-basic) */
169
+ async function scanBytes(input, opts = {}) {
170
+ const t0 = Date.now();
171
+ const preset = opts.preset ?? 'zip-basic';
172
+ const ctx = {
173
+ ...opts.ctx,
174
+ mimeType: opts.ctx?.mimeType ?? guessMimeByExt(opts.ctx?.filename),
175
+ size: opts.ctx?.size ?? input.byteLength,
176
+ };
177
+ const scanFn = createPresetScanner(preset);
178
+ const matchesH = await scanFn(input, ctx);
179
+ const matches = toYaraMatches(matchesH);
180
+ const verdict = computeVerdict(matches);
181
+ const durationMs = Date.now() - t0;
182
+ return {
183
+ ok: verdict === 'clean',
184
+ verdict,
185
+ matches,
186
+ reasons: matches.map(m => m.rule),
187
+ file: { name: ctx.filename, mimeType: ctx.mimeType, size: ctx.size },
188
+ durationMs,
189
+ engine: 'heuristics',
190
+ truncated: false,
191
+ timedOut: false,
192
+ };
193
+ }
194
+ /** Scan di un file su disco (Node). Import dinamico per non vincolare il bundle browser. */
195
+ async function scanFile(filePath, opts = {}) {
196
+ const [{ readFile, stat }, path] = await Promise.all([
197
+ import('fs/promises'),
198
+ import('path'),
199
+ ]);
200
+ const [buf, st] = await Promise.all([readFile(filePath), stat(filePath)]);
201
+ const ctx = {
202
+ filename: path.basename(filePath),
203
+ mimeType: guessMimeByExt(filePath),
204
+ size: st.size,
205
+ };
206
+ return scanBytes(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength), { ...opts, ctx });
207
+ }
208
+ /** Scan multipli File (browser) usando scanBytes + preset di default */
209
+ async function scanFiles(files, opts = {}) {
210
+ const list = Array.from(files);
202
211
  const out = [];
203
- for (const file of files) {
204
- const [content, bytes] = await Promise.all([file.text(), file.arrayBuffer().then(b => new Uint8Array(b))]);
205
- const prefilter = prefilterBrowser(bytes, file.name, policy);
206
- let matches = [];
207
- if (compiled) {
208
- try {
209
- // Optional short-circuit: only run YARA if needed. For now, we always run it if available.
210
- matches = await compiled.scan(bytes);
211
- }
212
- catch (e) {
213
- console.warn(`[yara] errore scansione ${file.name}:`, e);
214
- }
215
- }
216
- out.push({ file, content, prefilter, yara: { matches } });
212
+ for (const f of list) {
213
+ const buf = new Uint8Array(await f.arrayBuffer());
214
+ const rep = await scanBytes(buf, {
215
+ ...opts,
216
+ ctx: { filename: f.name, mimeType: f.type || guessMimeByExt(f.name), size: f.size },
217
+ });
218
+ out.push(rep);
217
219
  }
218
220
  return out;
219
221
  }
@@ -3058,7 +3060,7 @@ function useFileScanner() {
3058
3060
  setErrors(bad);
3059
3061
  if (good.length) {
3060
3062
  const scanned = await scanFiles(good);
3061
- setResults(scanned);
3063
+ setResults(scanned.map((r, i) => ({ file: good[i], report: r })));
3062
3064
  }
3063
3065
  else {
3064
3066
  setResults([]);
@@ -3067,6 +3069,71 @@ function useFileScanner() {
3067
3069
  return { results, errors, onChange };
3068
3070
  }
3069
3071
 
3072
+ async function createRemoteEngine(opts) {
3073
+ const { endpoint, headers = {}, rulesField = 'rules', fileField = 'file', mode = 'multipart', rulesAsBase64 = false, } = opts;
3074
+ const engine = {
3075
+ async compile(rulesSource) {
3076
+ return {
3077
+ async scan(data) {
3078
+ const fetchFn = globalThis.fetch;
3079
+ if (!fetchFn)
3080
+ throw new Error('[remote-yara] fetch non disponibile in questo ambiente');
3081
+ let res;
3082
+ if (mode === 'multipart') {
3083
+ const FormDataCtor = globalThis.FormData;
3084
+ const BlobCtor = globalThis.Blob;
3085
+ if (!FormDataCtor || !BlobCtor) {
3086
+ throw new Error('[remote-yara] FormData/Blob non disponibili (usa json-base64 oppure esegui in browser)');
3087
+ }
3088
+ const form = new FormDataCtor();
3089
+ form.set(rulesField, new BlobCtor([rulesSource], { type: 'text/plain' }), 'rules.yar');
3090
+ form.set(fileField, new BlobCtor([data], { type: 'application/octet-stream' }), 'sample.bin');
3091
+ res = await fetchFn(endpoint, { method: 'POST', body: form, headers });
3092
+ }
3093
+ else {
3094
+ const b64 = base64FromBytes(data);
3095
+ const payload = { [fileField]: b64 };
3096
+ if (rulesAsBase64) {
3097
+ payload['rulesB64'] = base64FromString(rulesSource);
3098
+ }
3099
+ else {
3100
+ payload[rulesField] = rulesSource;
3101
+ }
3102
+ res = await fetchFn(endpoint, {
3103
+ method: 'POST',
3104
+ headers: { 'Content-Type': 'application/json', ...headers },
3105
+ body: JSON.stringify(payload),
3106
+ });
3107
+ }
3108
+ if (!res.ok) {
3109
+ throw new Error(`[remote-yara] HTTP ${res.status} ${res.statusText}`);
3110
+ }
3111
+ const json = await res.json().catch(() => null);
3112
+ const arr = Array.isArray(json) ? json : (json?.matches ?? []);
3113
+ return (arr ?? []).map((m) => ({
3114
+ rule: m.rule ?? m.ruleIdentifier ?? 'unknown',
3115
+ tags: m.tags ?? [],
3116
+ }));
3117
+ },
3118
+ };
3119
+ },
3120
+ };
3121
+ return engine;
3122
+ }
3123
+ // Helpers
3124
+ function base64FromBytes(bytes) {
3125
+ // usa btoa se disponibile (browser); altrimenti fallback manuale
3126
+ const btoaFn = globalThis.btoa;
3127
+ let bin = '';
3128
+ for (let i = 0; i < bytes.byteLength; i++)
3129
+ bin += String.fromCharCode(bytes[i]);
3130
+ return btoaFn ? btoaFn(bin) : Buffer.from(bin, 'binary').toString('base64');
3131
+ }
3132
+ function base64FromString(s) {
3133
+ const btoaFn = globalThis.btoa;
3134
+ return btoaFn ? btoaFn(s) : Buffer.from(s, 'utf8').toString('base64');
3135
+ }
3136
+
3070
3137
  // src/scan/remote.ts
3071
3138
  /**
3072
3139
  * Scansiona una lista di File nel browser usando il motore remoto via HTTP.
@@ -3121,7 +3188,7 @@ function isOleCfb(buf) {
3121
3188
  const sig = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
3122
3189
  return startsWith(buf, sig);
3123
3190
  }
3124
- function isZipLike$1(buf) {
3191
+ function isZipLike(buf) {
3125
3192
  // PK\x03\x04
3126
3193
  return startsWith(buf, [0x50, 0x4b, 0x03, 0x04]);
3127
3194
  }
@@ -3131,7 +3198,7 @@ function isPeExecutable(buf) {
3131
3198
  }
3132
3199
  /** OOXML macro hint via filename token in ZIP container */
3133
3200
  function hasOoxmlMacros(buf) {
3134
- if (!isZipLike$1(buf))
3201
+ if (!isZipLike(buf))
3135
3202
  return false;
3136
3203
  return hasAsciiToken(buf, 'vbaProject.bin');
3137
3204
  }
@@ -3170,116 +3237,6 @@ const CommonHeuristicsScanner = {
3170
3237
  }
3171
3238
  };
3172
3239
 
3173
- const SIG_CEN = 0x02014b50;
3174
- const DEFAULTS = {
3175
- maxEntries: 1000,
3176
- maxTotalUncompressedBytes: 500 * 1024 * 1024,
3177
- maxEntryNameLength: 255,
3178
- maxCompressionRatio: 1000,
3179
- eocdSearchWindow: 70000,
3180
- };
3181
- function r16(buf, off) {
3182
- return buf.readUInt16LE(off);
3183
- }
3184
- function r32(buf, off) {
3185
- return buf.readUInt32LE(off);
3186
- }
3187
- function isZipLike(buf) {
3188
- // local file header at start is common
3189
- return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
3190
- }
3191
- function lastIndexOfEOCD(buf, window) {
3192
- const sig = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
3193
- const start = Math.max(0, buf.length - window);
3194
- const idx = buf.lastIndexOf(sig, Math.min(buf.length - sig.length, buf.length - 1));
3195
- return idx >= start ? idx : -1;
3196
- }
3197
- function hasTraversal(name) {
3198
- return name.includes('../') || name.includes('..\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name);
3199
- }
3200
- function createZipBombGuard(opts = {}) {
3201
- const cfg = { ...DEFAULTS, ...opts };
3202
- return {
3203
- async scan(input) {
3204
- const buf = Buffer.from(input);
3205
- const matches = [];
3206
- if (!isZipLike(buf))
3207
- return matches;
3208
- // Find EOCD near the end
3209
- const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
3210
- if (eocdPos < 0 || eocdPos + 22 > buf.length) {
3211
- // ZIP but no EOCD — malformed or polyglot → suspicious
3212
- matches.push({ rule: 'zip_eocd_not_found', severity: 'medium' });
3213
- return matches;
3214
- }
3215
- const totalEntries = r16(buf, eocdPos + 10);
3216
- const cdSize = r32(buf, eocdPos + 12);
3217
- const cdOffset = r32(buf, eocdPos + 16);
3218
- // Bounds check
3219
- if (cdOffset + cdSize > buf.length) {
3220
- matches.push({ rule: 'zip_cd_out_of_bounds', severity: 'medium' });
3221
- return matches;
3222
- }
3223
- // Iterate central directory entries
3224
- let ptr = cdOffset;
3225
- let seen = 0;
3226
- let sumComp = 0;
3227
- let sumUnc = 0;
3228
- while (ptr + 46 <= cdOffset + cdSize && seen < totalEntries) {
3229
- const sig = r32(buf, ptr);
3230
- if (sig !== SIG_CEN)
3231
- break; // stop if structure breaks
3232
- const compSize = r32(buf, ptr + 20);
3233
- const uncSize = r32(buf, ptr + 24);
3234
- const fnLen = r16(buf, ptr + 28);
3235
- const exLen = r16(buf, ptr + 30);
3236
- const cmLen = r16(buf, ptr + 32);
3237
- const nameStart = ptr + 46;
3238
- const nameEnd = nameStart + fnLen;
3239
- if (nameEnd > buf.length)
3240
- break;
3241
- const name = buf.toString('utf8', nameStart, nameEnd);
3242
- sumComp += compSize;
3243
- sumUnc += uncSize;
3244
- seen++;
3245
- if (name.length > cfg.maxEntryNameLength) {
3246
- matches.push({ rule: 'zip_entry_name_too_long', severity: 'medium', meta: { name, length: name.length } });
3247
- }
3248
- if (hasTraversal(name)) {
3249
- matches.push({ rule: 'zip_path_traversal_entry', severity: 'medium', meta: { name } });
3250
- }
3251
- // move to next entry
3252
- ptr = nameEnd + exLen + cmLen;
3253
- }
3254
- if (seen !== totalEntries) {
3255
- // central dir truncated/odd, still report what we found
3256
- matches.push({ rule: 'zip_cd_truncated', severity: 'medium', meta: { seen, totalEntries } });
3257
- }
3258
- // Heuristics thresholds
3259
- if (seen > cfg.maxEntries) {
3260
- matches.push({ rule: 'zip_too_many_entries', severity: 'medium', meta: { seen, limit: cfg.maxEntries } });
3261
- }
3262
- if (sumUnc > cfg.maxTotalUncompressedBytes) {
3263
- matches.push({
3264
- rule: 'zip_total_uncompressed_too_large',
3265
- severity: 'medium',
3266
- meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes }
3267
- });
3268
- }
3269
- if (sumComp === 0 && sumUnc > 0) {
3270
- matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio: Infinity } });
3271
- }
3272
- else if (sumComp > 0) {
3273
- const ratio = sumUnc / Math.max(1, sumComp);
3274
- if (ratio >= cfg.maxCompressionRatio) {
3275
- matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio, limit: cfg.maxCompressionRatio } });
3276
- }
3277
- }
3278
- return matches;
3279
- }
3280
- };
3281
- }
3282
-
3283
3240
  const MB = 1024 * 1024;
3284
3241
  const DEFAULT_POLICY = {
3285
3242
  includeExtensions: ['zip', 'png', 'jpg', 'jpeg', 'pdf'],
@@ -3304,5 +3261,5 @@ function definePolicy(input = {}) {
3304
3261
  return p;
3305
3262
  }
3306
3263
 
3307
- export { CommonHeuristicsScanner, DEFAULT_POLICY, createZipBombGuard, definePolicy, mapMatchesToVerdict, prefilterBrowser, scanFiles, scanFilesWithHeuristicsAndYara, scanFilesWithRemoteYara, scanFilesWithYara, useFileScanner, validateFile };
3264
+ export { CommonHeuristicsScanner, DEFAULT_POLICY, composeScanners, createPresetScanner, createZipBombGuard, definePolicy, mapMatchesToVerdict, scanBytes, scanFile, scanFiles, scanFilesWithRemoteYara, useFileScanner, validateFile };
3308
3265
  //# sourceMappingURL=pompelmi.esm.js.map