pompelmi 0.34.10 → 0.35.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.
Files changed (112) hide show
  1. package/README.md +26 -15
  2. package/dist/pompelmi.audit.cjs +13 -15
  3. package/dist/pompelmi.audit.cjs.map +1 -1
  4. package/dist/pompelmi.audit.esm.js +13 -15
  5. package/dist/pompelmi.audit.esm.js.map +1 -1
  6. package/dist/pompelmi.browser.cjs +595 -557
  7. package/dist/pompelmi.browser.cjs.map +1 -1
  8. package/dist/pompelmi.browser.esm.js +595 -557
  9. package/dist/pompelmi.browser.esm.js.map +1 -1
  10. package/dist/pompelmi.cjs +2056 -2015
  11. package/dist/pompelmi.cjs.map +1 -1
  12. package/dist/pompelmi.esm.js +2056 -2015
  13. package/dist/pompelmi.esm.js.map +1 -1
  14. package/dist/pompelmi.hooks.cjs +2 -2
  15. package/dist/pompelmi.hooks.cjs.map +1 -1
  16. package/dist/pompelmi.hooks.esm.js +2 -2
  17. package/dist/pompelmi.hooks.esm.js.map +1 -1
  18. package/dist/pompelmi.policy-packs.cjs +74 -73
  19. package/dist/pompelmi.policy-packs.cjs.map +1 -1
  20. package/dist/pompelmi.policy-packs.esm.js +74 -73
  21. package/dist/pompelmi.policy-packs.esm.js.map +1 -1
  22. package/dist/pompelmi.quarantine.cjs +135 -133
  23. package/dist/pompelmi.quarantine.cjs.map +1 -1
  24. package/dist/pompelmi.quarantine.esm.js +135 -133
  25. package/dist/pompelmi.quarantine.esm.js.map +1 -1
  26. package/dist/pompelmi.react.cjs +595 -557
  27. package/dist/pompelmi.react.cjs.map +1 -1
  28. package/dist/pompelmi.react.esm.js +595 -557
  29. package/dist/pompelmi.react.esm.js.map +1 -1
  30. package/dist/types/audit.d.ts +12 -12
  31. package/dist/types/browser-index.d.ts +12 -12
  32. package/dist/types/config.d.ts +4 -4
  33. package/dist/types/engines/dynamic-taint.d.ts +1 -1
  34. package/dist/types/engines/hybrid-orchestrator.d.ts +1 -1
  35. package/dist/types/engines/hybrid-taint-integration.d.ts +6 -6
  36. package/dist/types/engines/taint-policies.d.ts +4 -4
  37. package/dist/types/hipaa-compliance.d.ts +2 -2
  38. package/dist/types/hooks.d.ts +2 -2
  39. package/dist/types/index.d.ts +20 -20
  40. package/dist/types/node/scanDir.d.ts +5 -5
  41. package/dist/types/policy-packs.d.ts +2 -2
  42. package/dist/types/presets.d.ts +3 -3
  43. package/dist/types/quarantine/index.d.ts +3 -3
  44. package/dist/types/quarantine/storage.d.ts +1 -1
  45. package/dist/types/quarantine/types.d.ts +3 -3
  46. package/dist/types/quarantine/workflow.d.ts +4 -4
  47. package/dist/types/react-index.d.ts +2 -2
  48. package/dist/types/risk.d.ts +1 -1
  49. package/dist/types/scan/remote.d.ts +2 -2
  50. package/dist/types/scan.d.ts +5 -5
  51. package/dist/types/scanners/common-heuristics.d.ts +1 -1
  52. package/dist/types/scanners/zip-bomb-guard.d.ts +1 -1
  53. package/dist/types/src/audit.d.ts +84 -0
  54. package/dist/types/src/browser-index.d.ts +29 -0
  55. package/dist/types/src/config.d.ts +143 -0
  56. package/dist/types/src/engines/dynamic-taint.d.ts +102 -0
  57. package/dist/types/src/engines/hybrid-orchestrator.d.ts +65 -0
  58. package/dist/types/src/engines/hybrid-taint-integration.d.ts +129 -0
  59. package/dist/types/src/engines/taint-policies.d.ts +84 -0
  60. package/dist/types/src/hipaa-compliance.d.ts +110 -0
  61. package/dist/types/src/hooks.d.ts +89 -0
  62. package/dist/types/src/index.d.ts +29 -0
  63. package/dist/types/src/magic.d.ts +7 -0
  64. package/dist/types/src/node/scanDir.d.ts +30 -0
  65. package/dist/types/src/policy-packs.d.ts +98 -0
  66. package/dist/types/src/policy.d.ts +12 -0
  67. package/dist/types/src/presets.d.ts +72 -0
  68. package/dist/types/src/quarantine/index.d.ts +18 -0
  69. package/dist/types/src/quarantine/storage.d.ts +77 -0
  70. package/dist/types/src/quarantine/types.d.ts +78 -0
  71. package/dist/types/src/quarantine/workflow.d.ts +97 -0
  72. package/dist/types/src/react-index.d.ts +13 -0
  73. package/dist/types/src/risk.d.ts +18 -0
  74. package/dist/types/src/scan/remote.d.ts +12 -0
  75. package/dist/types/src/scan.d.ts +17 -0
  76. package/dist/types/src/scanners/common-heuristics.d.ts +14 -0
  77. package/dist/types/src/scanners/zip-bomb-guard.d.ts +9 -0
  78. package/dist/types/src/scanners/zipTraversalGuard.d.ts +19 -0
  79. package/dist/types/src/stream.d.ts +10 -0
  80. package/dist/types/src/types/decompilation.d.ts +96 -0
  81. package/dist/types/src/types/taint-tracking.d.ts +495 -0
  82. package/dist/types/src/types.d.ts +48 -0
  83. package/dist/types/src/useFileScanner.d.ts +15 -0
  84. package/dist/types/src/utils/advanced-detection.d.ts +21 -0
  85. package/dist/types/src/utils/batch-scanner.d.ts +62 -0
  86. package/dist/types/src/utils/cache-manager.d.ts +95 -0
  87. package/dist/types/src/utils/export.d.ts +51 -0
  88. package/dist/types/src/utils/performance-metrics.d.ts +68 -0
  89. package/dist/types/src/utils/threat-intelligence.d.ts +96 -0
  90. package/dist/types/src/validate.d.ts +7 -0
  91. package/dist/types/src/verdict.d.ts +2 -0
  92. package/dist/types/src/yara/browser.d.ts +7 -0
  93. package/dist/types/src/yara/index.d.ts +17 -0
  94. package/dist/types/src/yara/node.d.ts +2 -0
  95. package/dist/types/src/yara/remote.d.ts +10 -0
  96. package/dist/types/src/yara-bridge.d.ts +3 -0
  97. package/dist/types/src/zip.d.ts +13 -0
  98. package/dist/types/types/decompilation.d.ts +4 -4
  99. package/dist/types/types/taint-tracking.d.ts +19 -19
  100. package/dist/types/types.d.ts +3 -3
  101. package/dist/types/useFileScanner.d.ts +1 -1
  102. package/dist/types/utils/advanced-detection.d.ts +1 -1
  103. package/dist/types/utils/batch-scanner.d.ts +3 -3
  104. package/dist/types/utils/cache-manager.d.ts +1 -1
  105. package/dist/types/utils/export.d.ts +2 -2
  106. package/dist/types/utils/threat-intelligence.d.ts +4 -4
  107. package/dist/types/verdict.d.ts +1 -1
  108. package/dist/types/yara/browser.d.ts +1 -1
  109. package/dist/types/yara/index.d.ts +1 -1
  110. package/dist/types/yara/node.d.ts +1 -1
  111. package/dist/types/yara/remote.d.ts +2 -2
  112. package/package.json +7 -7
@@ -1,9 +1,239 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { useState, useCallback } from 'react';
3
3
 
4
+ const MB$1 = 1024 * 1024;
5
+ const DEFAULT_POLICY = {
6
+ includeExtensions: ["zip", "png", "jpg", "jpeg", "pdf"],
7
+ allowedMimeTypes: ["application/zip", "image/png", "image/jpeg", "application/pdf", "text/plain"],
8
+ maxFileSizeBytes: 20 * MB$1,
9
+ timeoutMs: 5000,
10
+ concurrency: 4,
11
+ failClosed: true,
12
+ };
13
+ function definePolicy(input = {}) {
14
+ const p = { ...DEFAULT_POLICY, ...input };
15
+ if (!Array.isArray(p.includeExtensions))
16
+ throw new TypeError("includeExtensions must be string[]");
17
+ if (!Array.isArray(p.allowedMimeTypes))
18
+ throw new TypeError("allowedMimeTypes must be string[]");
19
+ if (!(Number.isFinite(p.maxFileSizeBytes) && p.maxFileSizeBytes > 0))
20
+ throw new TypeError("maxFileSizeBytes must be > 0");
21
+ if (!(Number.isFinite(p.timeoutMs) && p.timeoutMs > 0))
22
+ throw new TypeError("timeoutMs must be > 0");
23
+ if (!(Number.isInteger(p.concurrency) && p.concurrency > 0))
24
+ throw new TypeError("concurrency must be > 0");
25
+ return p;
26
+ }
27
+
28
+ /**
29
+ * Policy packs for Pompelmi.
30
+ *
31
+ * Pre-configured, named policies for common upload scenarios. Each pack
32
+ * defines the file type allowlist, size limits, and timeout appropriate for
33
+ * its use case.
34
+ *
35
+ * All packs are built on `definePolicy` and are fully overridable:
36
+ *
37
+ * ```ts
38
+ * import { POLICY_PACKS } from 'pompelmi/policy-packs';
39
+ *
40
+ * // Use a pack as-is:
41
+ * const policy = POLICY_PACKS['images-only'];
42
+ *
43
+ * // Or override individual fields:
44
+ * import { definePolicy } from 'pompelmi';
45
+ * const custom = definePolicy({ ...POLICY_PACKS['documents-only'], maxFileSizeBytes: 5 * 1024 * 1024 });
46
+ * ```
47
+ *
48
+ * These packs are *deterministic* and *descriptor-based* — they do not
49
+ * depend on any external threat intelligence feed.
50
+ *
51
+ * @module policy-packs
52
+ */
53
+ const KB = 1024;
54
+ const MB = 1024 * KB;
55
+ // ── Policy packs ──────────────────────────────────────────────────────────────
56
+ /**
57
+ * Documents-only policy.
58
+ *
59
+ * Appropriate for: document management APIs, PDF/Office file upload endpoints,
60
+ * data import pipelines.
61
+ *
62
+ * Allowed: PDF, Word (.docx/.doc), Excel (.xlsx/.xls), PowerPoint (.pptx/.ppt),
63
+ * CSV, plain text, JSON, YAML, ODT/ODS/ODP (OpenDocument).
64
+ * Max size: 25 MB.
65
+ */
66
+ const DOCUMENTS_ONLY = definePolicy({
67
+ includeExtensions: [
68
+ "pdf",
69
+ "doc",
70
+ "docx",
71
+ "xls",
72
+ "xlsx",
73
+ "ppt",
74
+ "pptx",
75
+ "odt",
76
+ "ods",
77
+ "odp",
78
+ "csv",
79
+ "txt",
80
+ "json",
81
+ "yaml",
82
+ "yml",
83
+ "md",
84
+ ],
85
+ allowedMimeTypes: [
86
+ "application/pdf",
87
+ "application/msword",
88
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
89
+ "application/vnd.ms-excel",
90
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
91
+ "application/vnd.ms-powerpoint",
92
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
93
+ "application/vnd.oasis.opendocument.text",
94
+ "application/vnd.oasis.opendocument.spreadsheet",
95
+ "application/vnd.oasis.opendocument.presentation",
96
+ "text/csv",
97
+ "text/plain",
98
+ "application/json",
99
+ "text/yaml",
100
+ "text/markdown",
101
+ ],
102
+ maxFileSizeBytes: 25 * MB,
103
+ timeoutMs: 10000,
104
+ concurrency: 4,
105
+ failClosed: true,
106
+ });
107
+ /**
108
+ * Images-only policy.
109
+ *
110
+ * Appropriate for: avatar uploads, product image APIs, content platforms with
111
+ * user-generated imagery.
112
+ *
113
+ * Allowed: JPEG, PNG, GIF, WebP, AVIF, TIFF, BMP, ICO.
114
+ * Max size: 10 MB.
115
+ * Note: SVG is intentionally excluded — inline SVGs can contain scripts.
116
+ */
117
+ const IMAGES_ONLY = definePolicy({
118
+ includeExtensions: ["jpg", "jpeg", "png", "gif", "webp", "avif", "tiff", "tif", "bmp", "ico"],
119
+ allowedMimeTypes: [
120
+ "image/jpeg",
121
+ "image/png",
122
+ "image/gif",
123
+ "image/webp",
124
+ "image/avif",
125
+ "image/tiff",
126
+ "image/bmp",
127
+ "image/x-icon",
128
+ "image/vnd.microsoft.icon",
129
+ ],
130
+ maxFileSizeBytes: 10 * MB,
131
+ timeoutMs: 5000,
132
+ concurrency: 8,
133
+ failClosed: true,
134
+ });
135
+ /**
136
+ * Strict public-upload policy.
137
+ *
138
+ * Appropriate for: anonymous or low-trust upload endpoints, public APIs,
139
+ * any surface exposed to untrusted users.
140
+ *
141
+ * Aggressive size limit (5 MB), short timeout, fail-closed, narrow MIME
142
+ * allowlist. Only allows plain images and PDF.
143
+ */
144
+ const STRICT_PUBLIC_UPLOAD = definePolicy({
145
+ includeExtensions: ["jpg", "jpeg", "png", "webp", "pdf"],
146
+ allowedMimeTypes: ["image/jpeg", "image/png", "image/webp", "application/pdf"],
147
+ maxFileSizeBytes: 5 * MB,
148
+ timeoutMs: 4000,
149
+ concurrency: 2,
150
+ failClosed: true,
151
+ });
152
+ /**
153
+ * Conservative default policy.
154
+ *
155
+ * A hardened version of the built-in `DEFAULT_POLICY` suitable for
156
+ * production without further customisation. Stricter size limit and
157
+ * shorter timeout than the permissive default.
158
+ */
159
+ const CONSERVATIVE_DEFAULT = definePolicy({
160
+ includeExtensions: ["zip", "png", "jpg", "jpeg", "pdf", "txt", "csv", "docx", "xlsx"],
161
+ allowedMimeTypes: [
162
+ "application/zip",
163
+ "image/png",
164
+ "image/jpeg",
165
+ "application/pdf",
166
+ "text/plain",
167
+ "text/csv",
168
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
169
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
170
+ ],
171
+ maxFileSizeBytes: 10 * MB,
172
+ timeoutMs: 8000,
173
+ concurrency: 4,
174
+ failClosed: true,
175
+ });
176
+ /**
177
+ * Archives policy.
178
+ *
179
+ * Appropriate for: endpoints that accept ZIP, tar, or compressed archives.
180
+ * Combines a generous size allowance with a longer timeout for deep inspection.
181
+ *
182
+ * NOTE: Pair this policy with `createZipBombGuard()` to defend against
183
+ * decompression-bomb attacks:
184
+ *
185
+ * ```ts
186
+ * import { composeScanners, createZipBombGuard, CommonHeuristicsScanner } from 'pompelmi';
187
+ * const scanner = composeScanners(
188
+ * [['zipGuard', createZipBombGuard()], ['heuristics', CommonHeuristicsScanner]]
189
+ * );
190
+ * ```
191
+ */
192
+ const ARCHIVES = definePolicy({
193
+ includeExtensions: ["zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar"],
194
+ allowedMimeTypes: [
195
+ "application/zip",
196
+ "application/x-tar",
197
+ "application/gzip",
198
+ "application/x-bzip2",
199
+ "application/x-xz",
200
+ "application/x-7z-compressed",
201
+ "application/x-rar-compressed",
202
+ ],
203
+ maxFileSizeBytes: 100 * MB,
204
+ timeoutMs: 30000,
205
+ concurrency: 2,
206
+ failClosed: true,
207
+ });
208
+ /**
209
+ * Named map of all built-in policy packs.
210
+ *
211
+ * ```ts
212
+ * import { POLICY_PACKS } from 'pompelmi/policy-packs';
213
+ * const policy = POLICY_PACKS['strict-public-upload'];
214
+ * ```
215
+ */
216
+ const POLICY_PACKS = {
217
+ "documents-only": DOCUMENTS_ONLY,
218
+ "images-only": IMAGES_ONLY,
219
+ "strict-public-upload": STRICT_PUBLIC_UPLOAD,
220
+ "conservative-default": CONSERVATIVE_DEFAULT,
221
+ archives: ARCHIVES,
222
+ };
223
+ /**
224
+ * Look up a policy pack by name.
225
+ * Throws if the name is not recognised.
226
+ */
227
+ function getPolicyPack(name) {
228
+ const policy = POLICY_PACKS[name];
229
+ if (!policy)
230
+ throw new Error(`Unknown policy pack: '${name}'. Valid names: ${Object.keys(POLICY_PACKS).join(", ")}`);
231
+ return policy;
232
+ }
233
+
4
234
  function hasAsciiToken(buf, token) {
5
235
  // Use latin1 so we can safely search binary
6
- return buf.indexOf(token, 0, 'latin1') !== -1;
236
+ return buf.indexOf(token, 0, "latin1") !== -1;
7
237
  }
8
238
  function startsWith(buf, bytes) {
9
239
  if (buf.length < bytes.length)
@@ -19,7 +249,7 @@ function isPDF(buf) {
19
249
  }
20
250
  function isOleCfb(buf) {
21
251
  // D0 CF 11 E0 A1 B1 1A E1
22
- const sig = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
252
+ const sig = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
23
253
  return startsWith(buf, sig);
24
254
  }
25
255
  function isZipLike$1(buf) {
@@ -34,12 +264,12 @@ function isPeExecutable(buf) {
34
264
  function hasOoxmlMacros(buf) {
35
265
  if (!isZipLike$1(buf))
36
266
  return false;
37
- return hasAsciiToken(buf, 'vbaProject.bin');
267
+ return hasAsciiToken(buf, "vbaProject.bin");
38
268
  }
39
269
  /** PDF risky features (/JavaScript, /OpenAction, /AA, /Launch) */
40
270
  function pdfRiskTokens(buf) {
41
- const tokens = ['/JavaScript', '/OpenAction', '/AA', '/Launch'];
42
- return tokens.filter(t => hasAsciiToken(buf, t));
271
+ const tokens = ["/JavaScript", "/OpenAction", "/AA", "/Launch"];
272
+ return tokens.filter((t) => hasAsciiToken(buf, t));
43
273
  }
44
274
  const CommonHeuristicsScanner = {
45
275
  async scan(input) {
@@ -47,33 +277,37 @@ const CommonHeuristicsScanner = {
47
277
  const matches = [];
48
278
  // Office macros (OLE / OOXML)
49
279
  if (isOleCfb(buf)) {
50
- matches.push({ rule: 'office_ole_container', severity: 'suspicious' });
280
+ matches.push({ rule: "office_ole_container", severity: "suspicious" });
51
281
  }
52
282
  if (hasOoxmlMacros(buf)) {
53
- matches.push({ rule: 'office_ooxml_macros', severity: 'suspicious' });
283
+ matches.push({ rule: "office_ooxml_macros", severity: "suspicious" });
54
284
  }
55
285
  // PDF risky tokens
56
286
  if (isPDF(buf)) {
57
287
  const toks = pdfRiskTokens(buf);
58
288
  if (toks.length) {
59
289
  matches.push({
60
- rule: 'pdf_risky_actions',
61
- severity: 'suspicious',
62
- meta: { tokens: toks }
290
+ rule: "pdf_risky_actions",
291
+ severity: "suspicious",
292
+ meta: { tokens: toks },
63
293
  });
64
294
  }
65
295
  }
66
296
  // Executable header
67
297
  if (isPeExecutable(buf)) {
68
- matches.push({ rule: 'pe_executable_signature', severity: 'suspicious' });
298
+ matches.push({ rule: "pe_executable_signature", severity: "suspicious" });
69
299
  }
70
300
  // EICAR test file
71
301
  const EICAR_NEEDLE = "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!";
72
302
  if (hasAsciiToken(buf, EICAR_NEEDLE)) {
73
- matches.push({ rule: 'eicar_test_file', severity: 'high', meta: { note: 'EICAR standard antivirus test file detected' } });
303
+ matches.push({
304
+ rule: "eicar_test_file",
305
+ severity: "high",
306
+ meta: { note: "EICAR standard antivirus test file detected" },
307
+ });
74
308
  }
75
309
  return matches;
76
- }
310
+ },
77
311
  };
78
312
 
79
313
  function toScanFn(s) {
@@ -112,7 +346,13 @@ async function runWithTimeout(fn, timeoutMs) {
112
346
  return fn();
113
347
  return new Promise((resolve, reject) => {
114
348
  const timer = setTimeout(() => reject(new Error("scanner timeout")), timeoutMs);
115
- fn().then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
349
+ fn().then((v) => {
350
+ clearTimeout(timer);
351
+ resolve(v);
352
+ }, (e) => {
353
+ clearTimeout(timer);
354
+ reject(e);
355
+ });
116
356
  });
117
357
  }
118
358
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -123,7 +363,9 @@ function composeScanners(...args) {
123
363
  if (Array.isArray(first) &&
124
364
  (first.length === 0 || (Array.isArray(first[0]) && typeof first[0][0] === "string"))) {
125
365
  const entries = first;
126
- const opts = rest.length > 0 && !Array.isArray(rest[0]) && typeof rest[0] !== "function" &&
366
+ const opts = rest.length > 0 &&
367
+ !Array.isArray(rest[0]) &&
368
+ typeof rest[0] !== "function" &&
127
369
  !(typeof rest[0] === "object" && rest[0] !== null && "scan" in rest[0])
128
370
  ? rest[0]
129
371
  : {};
@@ -131,7 +373,7 @@ function composeScanners(...args) {
131
373
  const all = [];
132
374
  if (opts.parallel) {
133
375
  // Parallel execution — collect all results then return
134
- const results = await Promise.allSettled(entries.map(([name, scanner]) => runWithTimeout(() => toScanFn(scanner)(input, ctx), opts.timeoutMsPerScanner)));
376
+ const results = await Promise.allSettled(entries.map(([_name, scanner]) => runWithTimeout(() => toScanFn(scanner)(input, ctx), opts.timeoutMsPerScanner)));
135
377
  for (let i = 0; i < results.length; i++) {
136
378
  const result = results[i];
137
379
  if (result.status === "fulfilled" && Array.isArray(result.value)) {
@@ -185,152 +427,61 @@ function composeScanners(...args) {
185
427
  };
186
428
  }
187
429
  function createPresetScanner(preset, opts = {}) {
188
- const scanners = [];
189
- // Always include heuristics (EICAR, PHP webshells, JS obfuscation, PE hints, etc.)
190
- scanners.push(CommonHeuristicsScanner);
430
+ const baseScanners = [CommonHeuristicsScanner];
431
+ const dynamicScannerPromises = [];
191
432
  // Add decompilation scanners based on preset
192
- if (preset === 'decompilation-basic' || preset === 'decompilation-deep' ||
193
- preset === 'malware-analysis' || opts.enableDecompilation) {
194
- const depth = preset === 'decompilation-deep' ? 'deep' :
195
- preset === 'decompilation-basic' ? 'basic' :
196
- opts.decompilationDepth || 'basic';
197
- if (!opts.decompilationEngine || opts.decompilationEngine === 'binaryninja-hlil' || opts.decompilationEngine === 'both') {
198
- try {
199
- // Dynamic import to avoid bundling issues - using Function to bypass TypeScript type checking
200
- const importModule = new Function('specifier', 'return import(specifier)');
201
- importModule('@pompelmi/engine-binaryninja').then((mod) => {
202
- const binjaScanner = mod.createBinaryNinjaScanner({
203
- timeout: opts.decompilationTimeout || opts.timeout || 30000,
204
- depth,
205
- pythonPath: opts.pythonPath,
206
- binaryNinjaPath: opts.binaryNinjaPath
207
- });
208
- scanners.push(binjaScanner);
209
- }).catch(() => {
210
- // Binary Ninja engine not available - silently skip
211
- });
212
- }
213
- catch {
214
- // Engine not installed
215
- }
216
- }
217
- if (!opts.decompilationEngine || opts.decompilationEngine === 'ghidra-pcode' || opts.decompilationEngine === 'both') {
218
- try {
219
- // Dynamic import for Ghidra engine (when implemented) - using Function to bypass TypeScript type checking
220
- const importModule = new Function('specifier', 'return import(specifier)');
221
- importModule('@pompelmi/engine-ghidra').then((mod) => {
222
- const ghidraScanner = mod.createGhidraScanner({
223
- timeout: opts.decompilationTimeout || opts.timeout || 30000,
224
- depth,
225
- ghidraPath: opts.ghidraPath,
226
- analyzeHeadless: opts.analyzeHeadless
227
- });
228
- scanners.push(ghidraScanner);
229
- }).catch(() => {
230
- // Ghidra engine not available - silently skip
231
- });
232
- }
233
- catch {
234
- // Engine not installed
235
- }
433
+ if (preset === "decompilation-basic" ||
434
+ preset === "decompilation-deep" ||
435
+ preset === "malware-analysis" ||
436
+ opts.enableDecompilation) {
437
+ const depth = preset === "decompilation-deep" || preset === "malware-analysis"
438
+ ? "deep"
439
+ : preset === "decompilation-basic"
440
+ ? "basic"
441
+ : opts.decompilationDepth || "basic";
442
+ let importModule;
443
+ try {
444
+ // Dynamic import to avoid bundling issues - using Function to bypass TypeScript type checking
445
+ importModule = new Function("specifier", "return import(specifier)");
236
446
  }
237
- }
238
- if (scanners.length === 0) {
239
- // Fallback scanner that returns no matches
240
- return async (_input, _ctx) => {
241
- return [];
242
- };
243
- }
244
- return composeScanners(...scanners);
245
- }
246
-
247
- /**
248
- * Performance monitoring utilities for pompelmi scans
249
- * @module utils/performance-metrics
250
- */
251
- /**
252
- * Track performance metrics for a scan operation
253
- */
254
- class PerformanceTracker {
255
- constructor() {
256
- this.checkpoints = new Map();
257
- this.startTime = Date.now();
258
- }
259
- /**
260
- * Mark a checkpoint in the scan process
261
- */
262
- checkpoint(name) {
263
- this.checkpoints.set(name, Date.now());
264
- }
265
- /**
266
- * Get duration since start or since a specific checkpoint
267
- */
268
- getDuration(since) {
269
- const now = Date.now();
270
- if (since && this.checkpoints.has(since)) {
271
- return now - (this.checkpoints.get(since) ?? now);
447
+ catch {
448
+ importModule = undefined;
272
449
  }
273
- return now - this.startTime;
274
- }
275
- /**
276
- * Generate final metrics report
277
- */
278
- getMetrics(bytesScanned) {
279
- const totalDuration = this.getDuration();
280
- const throughput = totalDuration > 0 ? (bytesScanned / totalDuration) * 1000 : 0;
281
- return {
282
- totalDurationMs: totalDuration,
283
- heuristicsDurationMs: this.checkpoints.has('heuristics_end')
284
- ? (this.checkpoints.get('heuristics_end') ?? 0) - (this.checkpoints.get('heuristics_start') ?? 0)
285
- : undefined,
286
- yaraDurationMs: this.checkpoints.has('yara_end')
287
- ? (this.checkpoints.get('yara_end') ?? 0) - (this.checkpoints.get('yara_start') ?? 0)
288
- : undefined,
289
- prepDurationMs: this.checkpoints.has('prep_end')
290
- ? (this.checkpoints.get('prep_end') ?? 0) - this.startTime
291
- : undefined,
292
- throughputBps: throughput,
293
- bytesScanned,
294
- startedAt: this.startTime,
295
- completedAt: Date.now(),
296
- };
297
- }
298
- }
299
- /**
300
- * Aggregate statistics from multiple scan reports
301
- */
302
- function aggregateScanStats(reports) {
303
- let cleanCount = 0;
304
- let suspiciousCount = 0;
305
- let maliciousCount = 0;
306
- let totalDuration = 0;
307
- let totalBytes = 0;
308
- let validDurationCount = 0;
309
- for (const report of reports) {
310
- if (report.verdict === 'clean')
311
- cleanCount++;
312
- else if (report.verdict === 'suspicious')
313
- suspiciousCount++;
314
- else if (report.verdict === 'malicious')
315
- maliciousCount++;
316
- if (report.durationMs !== undefined) {
317
- totalDuration += report.durationMs;
318
- validDurationCount++;
450
+ if (importModule &&
451
+ (!opts.decompilationEngine ||
452
+ opts.decompilationEngine === "binaryninja-hlil" ||
453
+ opts.decompilationEngine === "both")) {
454
+ dynamicScannerPromises.push(importModule("@pompelmi/engine-binaryninja")
455
+ .then((mod) => mod.createBinaryNinjaScanner({
456
+ timeout: opts.decompilationTimeout || opts.timeout || 30000,
457
+ depth,
458
+ pythonPath: opts.pythonPath,
459
+ binaryNinjaPath: opts.binaryNinjaPath,
460
+ }))
461
+ .catch(() => null));
319
462
  }
320
- if (report.file?.size !== undefined) {
321
- totalBytes += report.file.size;
463
+ if (importModule &&
464
+ (!opts.decompilationEngine ||
465
+ opts.decompilationEngine === "ghidra-pcode" ||
466
+ opts.decompilationEngine === "both")) {
467
+ dynamicScannerPromises.push(importModule("@pompelmi/engine-ghidra")
468
+ .then((mod) => mod.createGhidraScanner({
469
+ timeout: opts.decompilationTimeout || opts.timeout || 30000,
470
+ depth,
471
+ ghidraPath: opts.ghidraPath,
472
+ analyzeHeadless: opts.analyzeHeadless,
473
+ }))
474
+ .catch(() => null));
322
475
  }
323
476
  }
324
- const avgDuration = validDurationCount > 0 ? totalDuration / validDurationCount : 0;
325
- const avgThroughput = totalDuration > 0 ? (totalBytes / totalDuration) * 1000 : 0;
326
- return {
327
- totalScans: reports.length,
328
- cleanCount,
329
- suspiciousCount,
330
- maliciousCount,
331
- avgDurationMs: avgDuration,
332
- avgThroughputBps: avgThroughput,
333
- totalBytesScanned: totalBytes,
477
+ let composedScannerPromise;
478
+ const getComposedScanner = async () => {
479
+ composedScannerPromise ?? (composedScannerPromise = Promise.all(dynamicScannerPromises).then((dynamicScanners) => composeScanners(...baseScanners, ...dynamicScanners.filter((scanner) => scanner !== null))));
480
+ return composedScannerPromise;
481
+ };
482
+ return async (input, ctx) => {
483
+ const scanner = await getComposedScanner();
484
+ return scanner(input, ctx);
334
485
  };
335
486
  }
336
487
 
@@ -347,25 +498,25 @@ function detectPolyglot(bytes) {
347
498
  // Check for PDF/ZIP polyglot
348
499
  if (isPDFZipPolyglot(bytes)) {
349
500
  matches.push({
350
- rule: 'polyglot_pdf_zip',
351
- severity: 'high',
352
- meta: { description: 'File can be interpreted as both PDF and ZIP' },
501
+ rule: "polyglot_pdf_zip",
502
+ severity: "high",
503
+ meta: { description: "File can be interpreted as both PDF and ZIP" },
353
504
  });
354
505
  }
355
506
  // Check for image/script polyglot
356
507
  if (isImageScriptPolyglot(bytes)) {
357
508
  matches.push({
358
- rule: 'polyglot_image_script',
359
- severity: 'high',
360
- meta: { description: 'Image file contains executable script content' },
509
+ rule: "polyglot_image_script",
510
+ severity: "high",
511
+ meta: { description: "Image file contains executable script content" },
361
512
  });
362
513
  }
363
514
  // Check for GIFAR (GIF/JAR polyglot)
364
515
  if (isGIFAR(bytes)) {
365
516
  matches.push({
366
- rule: 'polyglot_gifar',
367
- severity: 'critical',
368
- meta: { description: 'GIF file contains Java archive' },
517
+ rule: "polyglot_gifar",
518
+ severity: "critical",
519
+ meta: { description: "GIF file contains Java archive" },
369
520
  });
370
521
  }
371
522
  return matches;
@@ -375,7 +526,7 @@ function detectPolyglot(bytes) {
375
526
  */
376
527
  function detectObfuscatedScripts(bytes) {
377
528
  const matches = [];
378
- const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes.slice(0, Math.min(64 * 1024, bytes.length)));
529
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, Math.min(64 * 1024, bytes.length)));
379
530
  // Check for common obfuscation patterns
380
531
  const obfuscationPatterns = [
381
532
  /eval\s*\(\s*unescape\s*\(/gi,
@@ -387,10 +538,10 @@ function detectObfuscatedScripts(bytes) {
387
538
  for (const pattern of obfuscationPatterns) {
388
539
  if (pattern.test(text)) {
389
540
  matches.push({
390
- rule: 'obfuscated_script',
391
- severity: 'medium',
541
+ rule: "obfuscated_script",
542
+ severity: "medium",
392
543
  meta: {
393
- description: 'Detected obfuscated script content',
544
+ description: "Detected obfuscated script content",
394
545
  pattern: pattern.source,
395
546
  },
396
547
  });
@@ -430,7 +581,10 @@ function isPDFZipPolyglot(bytes) {
430
581
  // Check for ZIP signature anywhere in the file
431
582
  let hasZIP = false;
432
583
  for (let i = 0; i < Math.min(bytes.length - 4, 1024); i++) {
433
- if (bytes[i] === 0x50 && bytes[i + 1] === 0x4B && bytes[i + 2] === 0x03 && bytes[i + 3] === 0x04) {
584
+ if (bytes[i] === 0x50 &&
585
+ bytes[i + 1] === 0x4b &&
586
+ bytes[i + 2] === 0x03 &&
587
+ bytes[i + 3] === 0x04) {
434
588
  hasZIP = true;
435
589
  break;
436
590
  }
@@ -441,14 +595,13 @@ function isImageScriptPolyglot(bytes) {
441
595
  if (bytes.length < 100)
442
596
  return false;
443
597
  // Check for image signatures
444
- const isImage = ((bytes[0] === 0xFF && bytes[1] === 0xD8) || // JPEG
445
- (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) || // PNG
446
- (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) // GIF
447
- );
598
+ const isImage = (bytes[0] === 0xff && bytes[1] === 0xd8) || // JPEG
599
+ (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) || // PNG
600
+ (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46); // GIF
448
601
  if (!isImage)
449
602
  return false;
450
603
  // Check for script content
451
- const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
604
+ const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
452
605
  return /<script|javascript:|eval\(|function\s*\(/i.test(text);
453
606
  }
454
607
  function isGIFAR(bytes) {
@@ -459,7 +612,10 @@ function isGIFAR(bytes) {
459
612
  // Check for ZIP/JAR signature
460
613
  let hasZIP = false;
461
614
  for (let i = 0; i < Math.min(bytes.length - 4, 1024); i++) {
462
- if (bytes[i] === 0x50 && bytes[i + 1] === 0x4B && bytes[i + 2] === 0x03 && bytes[i + 3] === 0x04) {
615
+ if (bytes[i] === 0x50 &&
616
+ bytes[i + 1] === 0x4b &&
617
+ bytes[i + 2] === 0x03 &&
618
+ bytes[i + 3] === 0x04) {
463
619
  hasZIP = true;
464
620
  break;
465
621
  }
@@ -471,13 +627,13 @@ function isArchive(bytes) {
471
627
  return false;
472
628
  return (
473
629
  // ZIP
474
- (bytes[0] === 0x50 && bytes[1] === 0x4B && bytes[2] === 0x03 && bytes[3] === 0x04) ||
630
+ (bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04) ||
475
631
  // RAR
476
632
  (bytes[0] === 0x52 && bytes[1] === 0x61 && bytes[2] === 0x72 && bytes[3] === 0x21) ||
477
633
  // 7z
478
- (bytes[0] === 0x37 && bytes[1] === 0x7A && bytes[2] === 0xBC && bytes[3] === 0xAF) ||
634
+ (bytes[0] === 0x37 && bytes[1] === 0x7a && bytes[2] === 0xbc && bytes[3] === 0xaf) ||
479
635
  // tar.gz
480
- (bytes[0] === 0x1F && bytes[1] === 0x8B));
636
+ (bytes[0] === 0x1f && bytes[1] === 0x8b));
481
637
  }
482
638
 
483
639
  /**
@@ -505,10 +661,10 @@ class ScanCacheManager {
505
661
  * Generate cache key from file content
506
662
  */
507
663
  generateKey(content, preset) {
508
- const hash = createHash('sha256')
664
+ const hash = createHash("sha256")
509
665
  .update(content)
510
- .update(preset || 'default')
511
- .digest('hex');
666
+ .update(preset || "default")
667
+ .digest("hex");
512
668
  return hash;
513
669
  }
514
670
  /**
@@ -652,35 +808,132 @@ function getDefaultCache(options) {
652
808
  return defaultCache;
653
809
  }
654
810
 
655
- /** Mappa veloce estensione -> mime (basic) */
811
+ /**
812
+ * Performance monitoring utilities for pompelmi scans
813
+ * @module utils/performance-metrics
814
+ */
815
+ /**
816
+ * Track performance metrics for a scan operation
817
+ */
818
+ class PerformanceTracker {
819
+ constructor() {
820
+ this.checkpoints = new Map();
821
+ this.startTime = Date.now();
822
+ }
823
+ /**
824
+ * Mark a checkpoint in the scan process
825
+ */
826
+ checkpoint(name) {
827
+ this.checkpoints.set(name, Date.now());
828
+ }
829
+ /**
830
+ * Get duration since start or since a specific checkpoint
831
+ */
832
+ getDuration(since) {
833
+ const now = Date.now();
834
+ if (since && this.checkpoints.has(since)) {
835
+ return now - (this.checkpoints.get(since) ?? now);
836
+ }
837
+ return now - this.startTime;
838
+ }
839
+ /**
840
+ * Generate final metrics report
841
+ */
842
+ getMetrics(bytesScanned) {
843
+ const totalDuration = this.getDuration();
844
+ const throughput = totalDuration > 0 ? (bytesScanned / totalDuration) * 1000 : 0;
845
+ return {
846
+ totalDurationMs: totalDuration,
847
+ heuristicsDurationMs: this.checkpoints.has("heuristics_end")
848
+ ? (this.checkpoints.get("heuristics_end") ?? 0) -
849
+ (this.checkpoints.get("heuristics_start") ?? 0)
850
+ : undefined,
851
+ yaraDurationMs: this.checkpoints.has("yara_end")
852
+ ? (this.checkpoints.get("yara_end") ?? 0) - (this.checkpoints.get("yara_start") ?? 0)
853
+ : undefined,
854
+ prepDurationMs: this.checkpoints.has("prep_end")
855
+ ? (this.checkpoints.get("prep_end") ?? 0) - this.startTime
856
+ : undefined,
857
+ throughputBps: throughput,
858
+ bytesScanned,
859
+ startedAt: this.startTime,
860
+ completedAt: Date.now(),
861
+ };
862
+ }
863
+ }
864
+ /**
865
+ * Aggregate statistics from multiple scan reports
866
+ */
867
+ function aggregateScanStats(reports) {
868
+ let cleanCount = 0;
869
+ let suspiciousCount = 0;
870
+ let maliciousCount = 0;
871
+ let totalDuration = 0;
872
+ let totalBytes = 0;
873
+ let validDurationCount = 0;
874
+ for (const report of reports) {
875
+ if (report.verdict === "clean")
876
+ cleanCount++;
877
+ else if (report.verdict === "suspicious")
878
+ suspiciousCount++;
879
+ else if (report.verdict === "malicious")
880
+ maliciousCount++;
881
+ if (report.durationMs !== undefined) {
882
+ totalDuration += report.durationMs;
883
+ validDurationCount++;
884
+ }
885
+ if (report.file?.size !== undefined) {
886
+ totalBytes += report.file.size;
887
+ }
888
+ }
889
+ const avgDuration = validDurationCount > 0 ? totalDuration / validDurationCount : 0;
890
+ const avgThroughput = totalDuration > 0 ? (totalBytes / totalDuration) * 1000 : 0;
891
+ return {
892
+ totalScans: reports.length,
893
+ cleanCount,
894
+ suspiciousCount,
895
+ maliciousCount,
896
+ avgDurationMs: avgDuration,
897
+ avgThroughputBps: avgThroughput,
898
+ totalBytesScanned: totalBytes,
899
+ };
900
+ }
901
+
902
+ /** Mappa veloce estensione -> mime (basic) */
656
903
  function guessMimeByExt(name) {
657
904
  if (!name)
658
905
  return;
659
- const ext = name.toLowerCase().split('.').pop();
906
+ const ext = name.toLowerCase().split(".").pop();
660
907
  switch (ext) {
661
- case 'zip': return 'application/zip';
662
- case 'png': return 'image/png';
663
- case 'jpg':
664
- case 'jpeg': return 'image/jpeg';
665
- case 'pdf': return 'application/pdf';
666
- case 'txt': return 'text/plain';
667
- default: return;
908
+ case "zip":
909
+ return "application/zip";
910
+ case "png":
911
+ return "image/png";
912
+ case "jpg":
913
+ case "jpeg":
914
+ return "image/jpeg";
915
+ case "pdf":
916
+ return "application/pdf";
917
+ case "txt":
918
+ return "text/plain";
919
+ default:
920
+ return;
668
921
  }
669
922
  }
670
923
  /** Heuristica semplice per verdetto */
671
924
  function computeVerdict(matches) {
672
925
  if (!matches.length)
673
- return 'clean';
926
+ return "clean";
674
927
  // se la regola contiene 'zip_' lo marchiamo "suspicious"
675
- const anyHigh = matches.some(m => (m.tags ?? []).includes('critical') || (m.tags ?? []).includes('high'));
676
- return anyHigh ? 'malicious' : 'suspicious';
928
+ const anyHigh = matches.some((m) => (m.tags ?? []).includes("critical") || (m.tags ?? []).includes("high"));
929
+ return anyHigh ? "malicious" : "suspicious";
677
930
  }
678
931
  /** Converte i Match (heuristics) in YaraMatch-like per uniformare l'output */
679
932
  function toYaraMatches(ms) {
680
- return ms.map(m => ({
933
+ return ms.map((m) => ({
681
934
  rule: m.rule,
682
- namespace: 'heuristics',
683
- tags: ['heuristics'].concat(m.severity ? [m.severity] : []),
935
+ namespace: "heuristics",
936
+ tags: ["heuristics"].concat(m.severity ? [m.severity] : []),
684
937
  meta: m.meta,
685
938
  }));
686
939
  }
@@ -694,26 +947,28 @@ async function scanBytes(input, opts = {}) {
694
947
  return cached;
695
948
  }
696
949
  }
697
- const perfTracker = (opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking)
950
+ const perfTracker = opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking
698
951
  ? new PerformanceTracker()
699
952
  : null;
700
- perfTracker?.checkpoint('prep_start');
701
- const preset = opts.preset ?? opts.config?.defaultPreset ?? 'zip-basic';
953
+ perfTracker?.checkpoint("prep_start");
954
+ const preset = opts.preset ?? opts.config?.defaultPreset ?? "zip-basic";
702
955
  const ctx = {
703
956
  ...opts.ctx,
704
957
  mimeType: opts.ctx?.mimeType ?? guessMimeByExt(opts.ctx?.filename),
705
958
  size: opts.ctx?.size ?? input.byteLength,
706
959
  };
707
- perfTracker?.checkpoint('prep_end');
708
- perfTracker?.checkpoint('heuristics_start');
960
+ perfTracker?.checkpoint("prep_end");
961
+ perfTracker?.checkpoint("heuristics_start");
709
962
  const scanFn = createPresetScanner(preset);
710
- const matchesH = await (typeof scanFn === "function" ? scanFn : scanFn.scan)(input, ctx);
711
- let allMatches = [...matchesH];
712
- perfTracker?.checkpoint('heuristics_end');
963
+ const matchesH = await (typeof scanFn === "function"
964
+ ? scanFn
965
+ : scanFn.scan)(input, ctx);
966
+ const allMatches = [...matchesH];
967
+ perfTracker?.checkpoint("heuristics_end");
713
968
  // Advanced detection (enabled by default, can be overridden by config)
714
969
  const advancedEnabled = opts.enableAdvancedDetection ?? opts.config?.advanced?.enablePolyglotDetection ?? true;
715
970
  if (advancedEnabled) {
716
- perfTracker?.checkpoint('advanced_start');
971
+ perfTracker?.checkpoint("advanced_start");
717
972
  // Detect polyglot files
718
973
  if (opts.config?.advanced?.enablePolyglotDetection !== false) {
719
974
  const polyglotMatches = detectPolyglot(input);
@@ -728,37 +983,38 @@ async function scanBytes(input, opts = {}) {
728
983
  if (opts.config?.advanced?.enableNestedArchiveAnalysis !== false) {
729
984
  const nestingAnalysis = analyzeNestedArchives(input);
730
985
  const maxDepth = opts.config?.advanced?.maxArchiveDepth ?? 5;
731
- if (nestingAnalysis.hasExcessiveNesting || (nestingAnalysis.depth > maxDepth)) {
986
+ if (nestingAnalysis.hasExcessiveNesting || nestingAnalysis.depth > maxDepth) {
732
987
  allMatches.push({
733
- rule: 'excessive_archive_nesting',
734
- severity: 'high',
988
+ rule: "excessive_archive_nesting",
989
+ severity: "high",
735
990
  meta: {
736
- description: 'Excessive archive nesting detected',
991
+ description: "Excessive archive nesting detected",
737
992
  depth: nestingAnalysis.depth,
738
993
  maxAllowed: maxDepth,
739
994
  },
740
995
  });
741
996
  }
742
997
  }
743
- perfTracker?.checkpoint('advanced_end');
998
+ perfTracker?.checkpoint("advanced_end");
744
999
  }
745
1000
  const matches = toYaraMatches(allMatches);
746
1001
  const verdict = computeVerdict(matches);
747
1002
  perfTracker ? perfTracker.getDuration() : Date.now();
748
1003
  const durationMs = perfTracker ? perfTracker.getDuration() : 0;
749
1004
  const report = {
750
- ok: verdict === 'clean',
1005
+ ok: verdict === "clean",
751
1006
  verdict,
752
1007
  matches,
753
- reasons: matches.map(m => m.rule),
1008
+ reasons: matches.map((m) => m.rule),
754
1009
  file: { name: ctx.filename, mimeType: ctx.mimeType, size: ctx.size },
755
1010
  durationMs,
756
- engine: 'heuristics',
1011
+ engine: "heuristics",
757
1012
  truncated: false,
758
1013
  timedOut: false,
759
1014
  };
760
1015
  // Add performance metrics if tracking enabled
761
- if (perfTracker && (opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking)) {
1016
+ if (perfTracker &&
1017
+ (opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking)) {
762
1018
  report.performanceMetrics = perfTracker.getMetrics(input.byteLength);
763
1019
  }
764
1020
  // Cache result if enabled
@@ -772,10 +1028,7 @@ async function scanBytes(input, opts = {}) {
772
1028
  }
773
1029
  /** Scan di un file su disco (Node). Import dinamico per non vincolare il bundle browser. */
774
1030
  async function scanFile(filePath, opts = {}) {
775
- const [{ readFile, stat }, path] = await Promise.all([
776
- import('fs/promises'),
777
- import('path'),
778
- ]);
1031
+ const [{ readFile, stat }, path] = await Promise.all([import('fs/promises'), import('path')]);
779
1032
  const [buf, st] = await Promise.all([readFile(filePath), stat(filePath)]);
780
1033
  const ctx = {
781
1034
  filename: path.basename(filePath),
@@ -799,21 +1052,6 @@ async function scanFiles(files, opts = {}) {
799
1052
  return out;
800
1053
  }
801
1054
 
802
- /**
803
- * Validates a File by MIME type and size (max 5 MB).
804
- */
805
- function validateFile(file) {
806
- const maxSize = 5 * 1024 * 1024;
807
- const allowedTypes = ['text/plain', 'application/json', 'text/csv'];
808
- if (!allowedTypes.includes(file.type)) {
809
- return { valid: false, error: 'Unsupported file type' };
810
- }
811
- if (file.size > maxSize) {
812
- return { valid: false, error: 'File too large (max 5 MB)' };
813
- }
814
- return { valid: true };
815
- }
816
-
817
1055
  const SIG_CEN = 0x02014b50;
818
1056
  const DEFAULTS = {
819
1057
  maxEntries: 1000,
@@ -830,7 +1068,7 @@ function r32(buf, off) {
830
1068
  }
831
1069
  function isZipLike(buf) {
832
1070
  // local file header at start is common
833
- return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
1071
+ return (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
834
1072
  }
835
1073
  function lastIndexOfEOCD(buf, window) {
836
1074
  const sig = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
@@ -839,7 +1077,7 @@ function lastIndexOfEOCD(buf, window) {
839
1077
  return idx >= start ? idx : -1;
840
1078
  }
841
1079
  function hasTraversal(name) {
842
- return name.includes('../') || name.includes('..\\') || name.startsWith('/') || /^[A-Za-z]:/.test(name);
1080
+ return (name.includes("../") || name.includes("..\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name));
843
1081
  }
844
1082
  function createZipBombGuard(opts = {}) {
845
1083
  const cfg = { ...DEFAULTS, ...opts };
@@ -853,7 +1091,7 @@ function createZipBombGuard(opts = {}) {
853
1091
  const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
854
1092
  if (eocdPos < 0 || eocdPos + 22 > buf.length) {
855
1093
  // ZIP but no EOCD — malformed or polyglot → suspicious
856
- matches.push({ rule: 'zip_eocd_not_found', severity: 'medium' });
1094
+ matches.push({ rule: "zip_eocd_not_found", severity: "medium" });
857
1095
  return matches;
858
1096
  }
859
1097
  const totalEntries = r16(buf, eocdPos + 10);
@@ -861,7 +1099,7 @@ function createZipBombGuard(opts = {}) {
861
1099
  const cdOffset = r32(buf, eocdPos + 16);
862
1100
  // Bounds check
863
1101
  if (cdOffset + cdSize > buf.length) {
864
- matches.push({ rule: 'zip_cd_out_of_bounds', severity: 'medium' });
1102
+ matches.push({ rule: "zip_cd_out_of_bounds", severity: "medium" });
865
1103
  return matches;
866
1104
  }
867
1105
  // Iterate central directory entries
@@ -882,287 +1120,68 @@ function createZipBombGuard(opts = {}) {
882
1120
  const nameEnd = nameStart + fnLen;
883
1121
  if (nameEnd > buf.length)
884
1122
  break;
885
- const name = buf.toString('utf8', nameStart, nameEnd);
1123
+ const name = buf.toString("utf8", nameStart, nameEnd);
886
1124
  sumComp += compSize;
887
1125
  sumUnc += uncSize;
888
1126
  seen++;
889
1127
  if (name.length > cfg.maxEntryNameLength) {
890
- matches.push({ rule: 'zip_entry_name_too_long', severity: 'medium', meta: { name, length: name.length } });
1128
+ matches.push({
1129
+ rule: "zip_entry_name_too_long",
1130
+ severity: "medium",
1131
+ meta: { name, length: name.length },
1132
+ });
891
1133
  }
892
1134
  if (hasTraversal(name)) {
893
- matches.push({ rule: 'zip_path_traversal_entry', severity: 'medium', meta: { name } });
1135
+ matches.push({ rule: "zip_path_traversal_entry", severity: "medium", meta: { name } });
894
1136
  }
895
1137
  // move to next entry
896
1138
  ptr = nameEnd + exLen + cmLen;
897
1139
  }
898
1140
  if (seen !== totalEntries) {
899
1141
  // central dir truncated/odd, still report what we found
900
- matches.push({ rule: 'zip_cd_truncated', severity: 'medium', meta: { seen, totalEntries } });
1142
+ matches.push({
1143
+ rule: "zip_cd_truncated",
1144
+ severity: "medium",
1145
+ meta: { seen, totalEntries },
1146
+ });
901
1147
  }
902
1148
  // Heuristics thresholds
903
1149
  if (seen > cfg.maxEntries) {
904
- matches.push({ rule: 'zip_too_many_entries', severity: 'medium', meta: { seen, limit: cfg.maxEntries } });
1150
+ matches.push({
1151
+ rule: "zip_too_many_entries",
1152
+ severity: "medium",
1153
+ meta: { seen, limit: cfg.maxEntries },
1154
+ });
905
1155
  }
906
1156
  if (sumUnc > cfg.maxTotalUncompressedBytes) {
907
1157
  matches.push({
908
- rule: 'zip_total_uncompressed_too_large',
909
- severity: 'medium',
910
- meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes }
1158
+ rule: "zip_total_uncompressed_too_large",
1159
+ severity: "medium",
1160
+ meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes },
911
1161
  });
912
1162
  }
913
1163
  if (sumComp === 0 && sumUnc > 0) {
914
- matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio: Infinity } });
1164
+ matches.push({
1165
+ rule: "zip_suspicious_ratio",
1166
+ severity: "medium",
1167
+ meta: { ratio: Infinity },
1168
+ });
915
1169
  }
916
1170
  else if (sumComp > 0) {
917
1171
  const ratio = sumUnc / Math.max(1, sumComp);
918
1172
  if (ratio >= cfg.maxCompressionRatio) {
919
- matches.push({ rule: 'zip_suspicious_ratio', severity: 'medium', meta: { ratio, limit: cfg.maxCompressionRatio } });
1173
+ matches.push({
1174
+ rule: "zip_suspicious_ratio",
1175
+ severity: "medium",
1176
+ meta: { ratio, limit: cfg.maxCompressionRatio },
1177
+ });
920
1178
  }
921
1179
  }
922
1180
  return matches;
923
- }
1181
+ },
924
1182
  };
925
1183
  }
926
1184
 
927
- const MB$1 = 1024 * 1024;
928
- const DEFAULT_POLICY = {
929
- includeExtensions: ['zip', 'png', 'jpg', 'jpeg', 'pdf'],
930
- allowedMimeTypes: ['application/zip', 'image/png', 'image/jpeg', 'application/pdf', 'text/plain'],
931
- maxFileSizeBytes: 20 * MB$1,
932
- timeoutMs: 5000,
933
- concurrency: 4,
934
- failClosed: true
935
- };
936
- function definePolicy(input = {}) {
937
- const p = { ...DEFAULT_POLICY, ...input };
938
- if (!Array.isArray(p.includeExtensions))
939
- throw new TypeError('includeExtensions must be string[]');
940
- if (!Array.isArray(p.allowedMimeTypes))
941
- throw new TypeError('allowedMimeTypes must be string[]');
942
- if (!(Number.isFinite(p.maxFileSizeBytes) && p.maxFileSizeBytes > 0))
943
- throw new TypeError('maxFileSizeBytes must be > 0');
944
- if (!(Number.isFinite(p.timeoutMs) && p.timeoutMs > 0))
945
- throw new TypeError('timeoutMs must be > 0');
946
- if (!(Number.isInteger(p.concurrency) && p.concurrency > 0))
947
- throw new TypeError('concurrency must be > 0');
948
- return p;
949
- }
950
-
951
- /**
952
- * Policy packs for Pompelmi.
953
- *
954
- * Pre-configured, named policies for common upload scenarios. Each pack
955
- * defines the file type allowlist, size limits, and timeout appropriate for
956
- * its use case.
957
- *
958
- * All packs are built on `definePolicy` and are fully overridable:
959
- *
960
- * ```ts
961
- * import { POLICY_PACKS } from 'pompelmi/policy-packs';
962
- *
963
- * // Use a pack as-is:
964
- * const policy = POLICY_PACKS['images-only'];
965
- *
966
- * // Or override individual fields:
967
- * import { definePolicy } from 'pompelmi';
968
- * const custom = definePolicy({ ...POLICY_PACKS['documents-only'], maxFileSizeBytes: 5 * 1024 * 1024 });
969
- * ```
970
- *
971
- * These packs are *deterministic* and *descriptor-based* — they do not
972
- * depend on any external threat intelligence feed.
973
- *
974
- * @module policy-packs
975
- */
976
- const KB = 1024;
977
- const MB = 1024 * KB;
978
- // ── Policy packs ──────────────────────────────────────────────────────────────
979
- /**
980
- * Documents-only policy.
981
- *
982
- * Appropriate for: document management APIs, PDF/Office file upload endpoints,
983
- * data import pipelines.
984
- *
985
- * Allowed: PDF, Word (.docx/.doc), Excel (.xlsx/.xls), PowerPoint (.pptx/.ppt),
986
- * CSV, plain text, JSON, YAML, ODT/ODS/ODP (OpenDocument).
987
- * Max size: 25 MB.
988
- */
989
- const DOCUMENTS_ONLY = definePolicy({
990
- includeExtensions: [
991
- 'pdf',
992
- 'doc', 'docx',
993
- 'xls', 'xlsx',
994
- 'ppt', 'pptx',
995
- 'odt', 'ods', 'odp',
996
- 'csv',
997
- 'txt',
998
- 'json',
999
- 'yaml', 'yml',
1000
- 'md',
1001
- ],
1002
- allowedMimeTypes: [
1003
- 'application/pdf',
1004
- 'application/msword',
1005
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1006
- 'application/vnd.ms-excel',
1007
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1008
- 'application/vnd.ms-powerpoint',
1009
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
1010
- 'application/vnd.oasis.opendocument.text',
1011
- 'application/vnd.oasis.opendocument.spreadsheet',
1012
- 'application/vnd.oasis.opendocument.presentation',
1013
- 'text/csv',
1014
- 'text/plain',
1015
- 'application/json',
1016
- 'text/yaml',
1017
- 'text/markdown',
1018
- ],
1019
- maxFileSizeBytes: 25 * MB,
1020
- timeoutMs: 10000,
1021
- concurrency: 4,
1022
- failClosed: true,
1023
- });
1024
- /**
1025
- * Images-only policy.
1026
- *
1027
- * Appropriate for: avatar uploads, product image APIs, content platforms with
1028
- * user-generated imagery.
1029
- *
1030
- * Allowed: JPEG, PNG, GIF, WebP, AVIF, TIFF, BMP, ICO.
1031
- * Max size: 10 MB.
1032
- * Note: SVG is intentionally excluded — inline SVGs can contain scripts.
1033
- */
1034
- const IMAGES_ONLY = definePolicy({
1035
- includeExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'tiff', 'tif', 'bmp', 'ico'],
1036
- allowedMimeTypes: [
1037
- 'image/jpeg',
1038
- 'image/png',
1039
- 'image/gif',
1040
- 'image/webp',
1041
- 'image/avif',
1042
- 'image/tiff',
1043
- 'image/bmp',
1044
- 'image/x-icon',
1045
- 'image/vnd.microsoft.icon',
1046
- ],
1047
- maxFileSizeBytes: 10 * MB,
1048
- timeoutMs: 5000,
1049
- concurrency: 8,
1050
- failClosed: true,
1051
- });
1052
- /**
1053
- * Strict public-upload policy.
1054
- *
1055
- * Appropriate for: anonymous or low-trust upload endpoints, public APIs,
1056
- * any surface exposed to untrusted users.
1057
- *
1058
- * Aggressive size limit (5 MB), short timeout, fail-closed, narrow MIME
1059
- * allowlist. Only allows plain images and PDF.
1060
- */
1061
- const STRICT_PUBLIC_UPLOAD = definePolicy({
1062
- includeExtensions: ['jpg', 'jpeg', 'png', 'webp', 'pdf'],
1063
- allowedMimeTypes: [
1064
- 'image/jpeg',
1065
- 'image/png',
1066
- 'image/webp',
1067
- 'application/pdf',
1068
- ],
1069
- maxFileSizeBytes: 5 * MB,
1070
- timeoutMs: 4000,
1071
- concurrency: 2,
1072
- failClosed: true,
1073
- });
1074
- /**
1075
- * Conservative default policy.
1076
- *
1077
- * A hardened version of the built-in `DEFAULT_POLICY` suitable for
1078
- * production without further customisation. Stricter size limit and
1079
- * shorter timeout than the permissive default.
1080
- */
1081
- const CONSERVATIVE_DEFAULT = definePolicy({
1082
- includeExtensions: ['zip', 'png', 'jpg', 'jpeg', 'pdf', 'txt', 'csv', 'docx', 'xlsx'],
1083
- allowedMimeTypes: [
1084
- 'application/zip',
1085
- 'image/png',
1086
- 'image/jpeg',
1087
- 'application/pdf',
1088
- 'text/plain',
1089
- 'text/csv',
1090
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1091
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1092
- ],
1093
- maxFileSizeBytes: 10 * MB,
1094
- timeoutMs: 8000,
1095
- concurrency: 4,
1096
- failClosed: true,
1097
- });
1098
- /**
1099
- * Archives policy.
1100
- *
1101
- * Appropriate for: endpoints that accept ZIP, tar, or compressed archives.
1102
- * Combines a generous size allowance with a longer timeout for deep inspection.
1103
- *
1104
- * NOTE: Pair this policy with `createZipBombGuard()` to defend against
1105
- * decompression-bomb attacks:
1106
- *
1107
- * ```ts
1108
- * import { composeScanners, createZipBombGuard, CommonHeuristicsScanner } from 'pompelmi';
1109
- * const scanner = composeScanners(
1110
- * [['zipGuard', createZipBombGuard()], ['heuristics', CommonHeuristicsScanner]]
1111
- * );
1112
- * ```
1113
- */
1114
- const ARCHIVES = definePolicy({
1115
- includeExtensions: ['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar'],
1116
- allowedMimeTypes: [
1117
- 'application/zip',
1118
- 'application/x-tar',
1119
- 'application/gzip',
1120
- 'application/x-bzip2',
1121
- 'application/x-xz',
1122
- 'application/x-7z-compressed',
1123
- 'application/x-rar-compressed',
1124
- ],
1125
- maxFileSizeBytes: 100 * MB,
1126
- timeoutMs: 30000,
1127
- concurrency: 2,
1128
- failClosed: true,
1129
- });
1130
- /**
1131
- * Named map of all built-in policy packs.
1132
- *
1133
- * ```ts
1134
- * import { POLICY_PACKS } from 'pompelmi/policy-packs';
1135
- * const policy = POLICY_PACKS['strict-public-upload'];
1136
- * ```
1137
- */
1138
- const POLICY_PACKS = {
1139
- 'documents-only': DOCUMENTS_ONLY,
1140
- 'images-only': IMAGES_ONLY,
1141
- 'strict-public-upload': STRICT_PUBLIC_UPLOAD,
1142
- 'conservative-default': CONSERVATIVE_DEFAULT,
1143
- 'archives': ARCHIVES,
1144
- };
1145
- /**
1146
- * Look up a policy pack by name.
1147
- * Throws if the name is not recognised.
1148
- */
1149
- function getPolicyPack(name) {
1150
- const policy = POLICY_PACKS[name];
1151
- if (!policy)
1152
- throw new Error(`Unknown policy pack: '${name}'. Valid names: ${Object.keys(POLICY_PACKS).join(', ')}`);
1153
- return policy;
1154
- }
1155
-
1156
- function mapMatchesToVerdict(matches = []) {
1157
- if (!matches.length)
1158
- return 'clean';
1159
- const malHints = ['trojan', 'ransom', 'worm', 'spy', 'rootkit', 'keylog', 'botnet'];
1160
- const tagSet = new Set(matches.flatMap(m => (m.tags ?? []).map(t => t.toLowerCase())));
1161
- const nameHit = (r) => malHints.some(h => r.toLowerCase().includes(h));
1162
- const isMal = matches.some(m => nameHit(m.rule)) || tagSet.has('malware') || tagSet.has('critical');
1163
- return isMal ? 'malicious' : 'suspicious';
1164
- }
1165
-
1166
1185
  /**
1167
1186
  * Export utilities for scan results
1168
1187
  * @module utils/export
@@ -1178,19 +1197,15 @@ class ScanResultExporter {
1178
1197
  const data = Array.isArray(reports) ? reports : [reports];
1179
1198
  if (!options.includeDetails) {
1180
1199
  // Simplified output
1181
- const simplified = data.map(r => ({
1200
+ const simplified = data.map((r) => ({
1182
1201
  verdict: r.verdict,
1183
1202
  file: r.file?.name,
1184
1203
  matches: r.matches.length,
1185
1204
  durationMs: r.durationMs,
1186
1205
  }));
1187
- return options.prettyPrint
1188
- ? JSON.stringify(simplified, null, 2)
1189
- : JSON.stringify(simplified);
1206
+ return options.prettyPrint ? JSON.stringify(simplified, null, 2) : JSON.stringify(simplified);
1190
1207
  }
1191
- return options.prettyPrint
1192
- ? JSON.stringify(data, null, 2)
1193
- : JSON.stringify(data);
1208
+ return options.prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
1194
1209
  }
1195
1210
  /**
1196
1211
  * Export to CSV format
@@ -1198,68 +1213,68 @@ class ScanResultExporter {
1198
1213
  toCSV(reports, options = {}) {
1199
1214
  const data = Array.isArray(reports) ? reports : [reports];
1200
1215
  const headers = [
1201
- 'filename',
1202
- 'verdict',
1203
- 'matches_count',
1204
- 'file_size',
1205
- 'mime_type',
1206
- 'duration_ms',
1207
- 'engine',
1216
+ "filename",
1217
+ "verdict",
1218
+ "matches_count",
1219
+ "file_size",
1220
+ "mime_type",
1221
+ "duration_ms",
1222
+ "engine",
1208
1223
  ];
1209
1224
  if (options.includeDetails) {
1210
- headers.push('reasons', 'match_rules');
1225
+ headers.push("reasons", "match_rules");
1211
1226
  }
1212
- const rows = data.map(report => {
1227
+ const rows = data.map((report) => {
1213
1228
  const row = [
1214
- this.escapeCsv(report.file?.name || 'unknown'),
1229
+ this.escapeCsv(report.file?.name || "unknown"),
1215
1230
  report.verdict,
1216
1231
  report.matches.length.toString(),
1217
1232
  (report.file?.size || 0).toString(),
1218
- this.escapeCsv(report.file?.mimeType || 'unknown'),
1233
+ this.escapeCsv(report.file?.mimeType || "unknown"),
1219
1234
  (report.durationMs || 0).toString(),
1220
- report.engine || 'unknown',
1235
+ report.engine || "unknown",
1221
1236
  ];
1222
1237
  if (options.includeDetails) {
1223
- row.push(this.escapeCsv((report.reasons || []).join('; ')), this.escapeCsv(report.matches.map(m => m.rule).join('; ')));
1238
+ row.push(this.escapeCsv((report.reasons || []).join("; ")), this.escapeCsv(report.matches.map((m) => m.rule).join("; ")));
1224
1239
  }
1225
- return row.join(',');
1240
+ return row.join(",");
1226
1241
  });
1227
- return [headers.join(','), ...rows].join('\n');
1242
+ return [headers.join(","), ...rows].join("\n");
1228
1243
  }
1229
1244
  /**
1230
1245
  * Export to Markdown format
1231
1246
  */
1232
1247
  toMarkdown(reports, options = {}) {
1233
1248
  const data = Array.isArray(reports) ? reports : [reports];
1234
- let md = '# Scan Results\n\n';
1249
+ let md = "# Scan Results\n\n";
1235
1250
  md += `**Total Scans:** ${data.length}\n\n`;
1236
- const clean = data.filter(r => r.verdict === 'clean').length;
1237
- const suspicious = data.filter(r => r.verdict === 'suspicious').length;
1238
- const malicious = data.filter(r => r.verdict === 'malicious').length;
1239
- md += '## Summary\n\n';
1251
+ const clean = data.filter((r) => r.verdict === "clean").length;
1252
+ const suspicious = data.filter((r) => r.verdict === "suspicious").length;
1253
+ const malicious = data.filter((r) => r.verdict === "malicious").length;
1254
+ md += "## Summary\n\n";
1240
1255
  md += `- ✅ Clean: ${clean}\n`;
1241
1256
  md += `- ⚠️ Suspicious: ${suspicious}\n`;
1242
1257
  md += `- ❌ Malicious: ${malicious}\n\n`;
1243
- md += '## Detailed Results\n\n';
1258
+ md += "## Detailed Results\n\n";
1244
1259
  for (const report of data) {
1245
- const icon = report.verdict === 'clean' ? '' : report.verdict === 'suspicious' ? '⚠️' : '';
1246
- md += `### ${icon} ${report.file?.name || 'Unknown'}\n\n`;
1260
+ const icon = report.verdict === "clean" ? "" : report.verdict === "suspicious" ? "⚠️" : "";
1261
+ md += `### ${icon} ${report.file?.name || "Unknown"}\n\n`;
1247
1262
  md += `- **Verdict:** ${report.verdict}\n`;
1248
1263
  md += `- **Size:** ${this.formatBytes(report.file?.size || 0)}\n`;
1249
- md += `- **MIME Type:** ${report.file?.mimeType || 'unknown'}\n`;
1264
+ md += `- **MIME Type:** ${report.file?.mimeType || "unknown"}\n`;
1250
1265
  md += `- **Duration:** ${report.durationMs || 0}ms\n`;
1251
1266
  md += `- **Matches:** ${report.matches.length}\n`;
1252
1267
  if (options.includeDetails && report.matches.length > 0) {
1253
- md += '\n**Match Details:**\n';
1268
+ md += "\n**Match Details:**\n";
1254
1269
  for (const match of report.matches) {
1255
1270
  md += `- ${match.rule}`;
1256
1271
  if (match.tags && match.tags.length > 0) {
1257
- md += ` (${match.tags.join(', ')})`;
1272
+ md += ` (${match.tags.join(", ")})`;
1258
1273
  }
1259
- md += '\n';
1274
+ md += "\n";
1260
1275
  }
1261
1276
  }
1262
- md += '\n';
1277
+ md += "\n";
1263
1278
  }
1264
1279
  return md;
1265
1280
  }
@@ -1269,20 +1284,20 @@ class ScanResultExporter {
1269
1284
  */
1270
1285
  toSARIF(reports, options = {}) {
1271
1286
  const data = Array.isArray(reports) ? reports : [reports];
1272
- const results = data.flatMap(report => {
1273
- if (report.verdict === 'clean')
1287
+ const results = data.flatMap((report) => {
1288
+ if (report.verdict === "clean")
1274
1289
  return [];
1275
- return report.matches.map(match => ({
1290
+ return report.matches.map((match) => ({
1276
1291
  ruleId: match.rule,
1277
- level: report.verdict === 'malicious' ? 'error' : 'warning',
1292
+ level: report.verdict === "malicious" ? "error" : "warning",
1278
1293
  message: {
1279
- text: `${match.rule} detected in ${report.file?.name || 'unknown file'}`,
1294
+ text: `${match.rule} detected in ${report.file?.name || "unknown file"}`,
1280
1295
  },
1281
1296
  locations: [
1282
1297
  {
1283
1298
  physicalLocation: {
1284
1299
  artifactLocation: {
1285
- uri: report.file?.name || 'unknown',
1300
+ uri: report.file?.name || "unknown",
1286
1301
  },
1287
1302
  },
1288
1303
  },
@@ -1294,33 +1309,31 @@ class ScanResultExporter {
1294
1309
  }));
1295
1310
  });
1296
1311
  const sarif = {
1297
- version: '2.1.0',
1298
- $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
1312
+ version: "2.1.0",
1313
+ $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
1299
1314
  runs: [
1300
1315
  {
1301
1316
  tool: {
1302
1317
  driver: {
1303
- name: 'Pompelmi',
1304
- version: '0.29.0',
1305
- informationUri: 'https://pompelmi.github.io/pompelmi/',
1318
+ name: "Pompelmi",
1319
+ version: "0.29.0",
1320
+ informationUri: "https://pompelmi.github.io/pompelmi/",
1306
1321
  },
1307
1322
  },
1308
1323
  results,
1309
1324
  },
1310
1325
  ],
1311
1326
  };
1312
- return options.prettyPrint
1313
- ? JSON.stringify(sarif, null, 2)
1314
- : JSON.stringify(sarif);
1327
+ return options.prettyPrint ? JSON.stringify(sarif, null, 2) : JSON.stringify(sarif);
1315
1328
  }
1316
1329
  /**
1317
1330
  * Export to HTML format
1318
1331
  */
1319
1332
  toHTML(reports, options = {}) {
1320
1333
  const data = Array.isArray(reports) ? reports : [reports];
1321
- const clean = data.filter(r => r.verdict === 'clean').length;
1322
- const suspicious = data.filter(r => r.verdict === 'suspicious').length;
1323
- const malicious = data.filter(r => r.verdict === 'malicious').length;
1334
+ const clean = data.filter((r) => r.verdict === "clean").length;
1335
+ const suspicious = data.filter((r) => r.verdict === "suspicious").length;
1336
+ const malicious = data.filter((r) => r.verdict === "malicious").length;
1324
1337
  let html = `<!DOCTYPE html>
1325
1338
  <html lang="en">
1326
1339
  <head>
@@ -1352,11 +1365,11 @@ class ScanResultExporter {
1352
1365
  for (const report of data) {
1353
1366
  const statusClass = report.verdict;
1354
1367
  html += `<div class="result ${statusClass}">`;
1355
- html += `<h3>${this.escapeHtml(report.file?.name || 'Unknown')}</h3>`;
1368
+ html += `<h3>${this.escapeHtml(report.file?.name || "Unknown")}</h3>`;
1356
1369
  html += `<table>`;
1357
1370
  html += `<tr><th>Verdict</th><td>${report.verdict.toUpperCase()}</td></tr>`;
1358
1371
  html += `<tr><th>Size</th><td>${this.formatBytes(report.file?.size || 0)}</td></tr>`;
1359
- html += `<tr><th>MIME Type</th><td>${this.escapeHtml(report.file?.mimeType || 'unknown')}</td></tr>`;
1372
+ html += `<tr><th>MIME Type</th><td>${this.escapeHtml(report.file?.mimeType || "unknown")}</td></tr>`;
1360
1373
  html += `<tr><th>Duration</th><td>${report.durationMs || 0}ms</td></tr>`;
1361
1374
  html += `<tr><th>Matches</th><td>${report.matches.length}</td></tr>`;
1362
1375
  html += `</table>`;
@@ -1365,7 +1378,7 @@ class ScanResultExporter {
1365
1378
  for (const match of report.matches) {
1366
1379
  html += `<li><strong>${this.escapeHtml(match.rule)}</strong>`;
1367
1380
  if (match.tags && match.tags.length > 0) {
1368
- html += ` ${match.tags.map(tag => `<span class="badge">${this.escapeHtml(tag)}</span>`).join('')}`;
1381
+ html += ` ${match.tags.map((tag) => `<span class="badge">${this.escapeHtml(tag)}</span>`).join("")}`;
1369
1382
  }
1370
1383
  html += `</li>`;
1371
1384
  }
@@ -1381,41 +1394,41 @@ class ScanResultExporter {
1381
1394
  */
1382
1395
  export(reports, format, options = {}) {
1383
1396
  switch (format) {
1384
- case 'json':
1397
+ case "json":
1385
1398
  return this.toJSON(reports, options);
1386
- case 'csv':
1399
+ case "csv":
1387
1400
  return this.toCSV(reports, options);
1388
- case 'markdown':
1401
+ case "markdown":
1389
1402
  return this.toMarkdown(reports, options);
1390
- case 'html':
1403
+ case "html":
1391
1404
  return this.toHTML(reports, options);
1392
- case 'sarif':
1405
+ case "sarif":
1393
1406
  return this.toSARIF(reports, options);
1394
1407
  default:
1395
1408
  throw new Error(`Unsupported export format: ${format}`);
1396
1409
  }
1397
1410
  }
1398
1411
  escapeCsv(value) {
1399
- if (value.includes(',') || value.includes('"') || value.includes('\n')) {
1412
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
1400
1413
  return `"${value.replace(/"/g, '""')}"`;
1401
1414
  }
1402
1415
  return value;
1403
1416
  }
1404
1417
  escapeHtml(value) {
1405
1418
  return value
1406
- .replace(/&/g, '&amp;')
1407
- .replace(/</g, '&lt;')
1408
- .replace(/>/g, '&gt;')
1409
- .replace(/"/g, '&quot;')
1410
- .replace(/'/g, '&#039;');
1419
+ .replace(/&/g, "&amp;")
1420
+ .replace(/</g, "&lt;")
1421
+ .replace(/>/g, "&gt;")
1422
+ .replace(/"/g, "&quot;")
1423
+ .replace(/'/g, "&#039;");
1411
1424
  }
1412
1425
  formatBytes(bytes) {
1413
1426
  if (bytes === 0)
1414
- return '0 Bytes';
1427
+ return "0 Bytes";
1415
1428
  const k = 1024;
1416
- const sizes = ['Bytes', 'KB', 'MB', 'GB'];
1429
+ const sizes = ["Bytes", "KB", "MB", "GB"];
1417
1430
  const i = Math.floor(Math.log(bytes) / Math.log(k));
1418
- return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
1431
+ return Math.round((bytes / k ** i) * 100) / 100 + " " + sizes[i];
1419
1432
  }
1420
1433
  }
1421
1434
  /**
@@ -1426,6 +1439,31 @@ function exportScanResults(reports, format, options) {
1426
1439
  return exporter.export(reports, format, options);
1427
1440
  }
1428
1441
 
1442
+ /**
1443
+ * Validates a File by MIME type and size (max 5 MB).
1444
+ */
1445
+ function validateFile(file) {
1446
+ const maxSize = 5 * 1024 * 1024;
1447
+ const allowedTypes = ["text/plain", "application/json", "text/csv"];
1448
+ if (!allowedTypes.includes(file.type)) {
1449
+ return { valid: false, error: "Unsupported file type" };
1450
+ }
1451
+ if (file.size > maxSize) {
1452
+ return { valid: false, error: "File too large (max 5 MB)" };
1453
+ }
1454
+ return { valid: true };
1455
+ }
1456
+
1457
+ function mapMatchesToVerdict(matches = []) {
1458
+ if (!matches.length)
1459
+ return "clean";
1460
+ const malHints = ["trojan", "ransom", "worm", "spy", "rootkit", "keylog", "botnet"];
1461
+ const tagSet = new Set(matches.flatMap((m) => (m.tags ?? []).map((t) => t.toLowerCase())));
1462
+ const nameHit = (r) => malHints.some((h) => r.toLowerCase().includes(h));
1463
+ const isMal = matches.some((m) => nameHit(m.rule)) || tagSet.has("malware") || tagSet.has("critical");
1464
+ return isMal ? "malicious" : "suspicious";
1465
+ }
1466
+
1429
1467
  /**
1430
1468
  * React Hook: handles <input type="file" onChange> with validation + scanning.
1431
1469
  */