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