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.
- package/README.md +26 -15
- package/dist/pompelmi.audit.cjs +13 -15
- package/dist/pompelmi.audit.cjs.map +1 -1
- package/dist/pompelmi.audit.esm.js +13 -15
- package/dist/pompelmi.audit.esm.js.map +1 -1
- package/dist/pompelmi.browser.cjs +595 -557
- package/dist/pompelmi.browser.cjs.map +1 -1
- package/dist/pompelmi.browser.esm.js +595 -557
- package/dist/pompelmi.browser.esm.js.map +1 -1
- package/dist/pompelmi.cjs +2056 -2015
- package/dist/pompelmi.cjs.map +1 -1
- package/dist/pompelmi.esm.js +2056 -2015
- package/dist/pompelmi.esm.js.map +1 -1
- package/dist/pompelmi.hooks.cjs +2 -2
- package/dist/pompelmi.hooks.cjs.map +1 -1
- package/dist/pompelmi.hooks.esm.js +2 -2
- package/dist/pompelmi.hooks.esm.js.map +1 -1
- package/dist/pompelmi.policy-packs.cjs +74 -73
- package/dist/pompelmi.policy-packs.cjs.map +1 -1
- package/dist/pompelmi.policy-packs.esm.js +74 -73
- package/dist/pompelmi.policy-packs.esm.js.map +1 -1
- package/dist/pompelmi.quarantine.cjs +135 -133
- package/dist/pompelmi.quarantine.cjs.map +1 -1
- package/dist/pompelmi.quarantine.esm.js +135 -133
- package/dist/pompelmi.quarantine.esm.js.map +1 -1
- package/dist/pompelmi.react.cjs +595 -557
- package/dist/pompelmi.react.cjs.map +1 -1
- package/dist/pompelmi.react.esm.js +595 -557
- package/dist/pompelmi.react.esm.js.map +1 -1
- package/dist/types/audit.d.ts +12 -12
- package/dist/types/browser-index.d.ts +12 -12
- package/dist/types/config.d.ts +4 -4
- package/dist/types/engines/dynamic-taint.d.ts +1 -1
- package/dist/types/engines/hybrid-orchestrator.d.ts +1 -1
- package/dist/types/engines/hybrid-taint-integration.d.ts +6 -6
- package/dist/types/engines/taint-policies.d.ts +4 -4
- package/dist/types/hipaa-compliance.d.ts +2 -2
- package/dist/types/hooks.d.ts +2 -2
- package/dist/types/index.d.ts +20 -20
- package/dist/types/node/scanDir.d.ts +5 -5
- package/dist/types/policy-packs.d.ts +2 -2
- package/dist/types/presets.d.ts +3 -3
- package/dist/types/quarantine/index.d.ts +3 -3
- package/dist/types/quarantine/storage.d.ts +1 -1
- package/dist/types/quarantine/types.d.ts +3 -3
- package/dist/types/quarantine/workflow.d.ts +4 -4
- package/dist/types/react-index.d.ts +2 -2
- package/dist/types/risk.d.ts +1 -1
- package/dist/types/scan/remote.d.ts +2 -2
- package/dist/types/scan.d.ts +5 -5
- package/dist/types/scanners/common-heuristics.d.ts +1 -1
- package/dist/types/scanners/zip-bomb-guard.d.ts +1 -1
- package/dist/types/src/audit.d.ts +84 -0
- package/dist/types/src/browser-index.d.ts +29 -0
- package/dist/types/src/config.d.ts +143 -0
- package/dist/types/src/engines/dynamic-taint.d.ts +102 -0
- package/dist/types/src/engines/hybrid-orchestrator.d.ts +65 -0
- package/dist/types/src/engines/hybrid-taint-integration.d.ts +129 -0
- package/dist/types/src/engines/taint-policies.d.ts +84 -0
- package/dist/types/src/hipaa-compliance.d.ts +110 -0
- package/dist/types/src/hooks.d.ts +89 -0
- package/dist/types/src/index.d.ts +29 -0
- package/dist/types/src/magic.d.ts +7 -0
- package/dist/types/src/node/scanDir.d.ts +30 -0
- package/dist/types/src/policy-packs.d.ts +98 -0
- package/dist/types/src/policy.d.ts +12 -0
- package/dist/types/src/presets.d.ts +72 -0
- package/dist/types/src/quarantine/index.d.ts +18 -0
- package/dist/types/src/quarantine/storage.d.ts +77 -0
- package/dist/types/src/quarantine/types.d.ts +78 -0
- package/dist/types/src/quarantine/workflow.d.ts +97 -0
- package/dist/types/src/react-index.d.ts +13 -0
- package/dist/types/src/risk.d.ts +18 -0
- package/dist/types/src/scan/remote.d.ts +12 -0
- package/dist/types/src/scan.d.ts +17 -0
- package/dist/types/src/scanners/common-heuristics.d.ts +14 -0
- package/dist/types/src/scanners/zip-bomb-guard.d.ts +9 -0
- package/dist/types/src/scanners/zipTraversalGuard.d.ts +19 -0
- package/dist/types/src/stream.d.ts +10 -0
- package/dist/types/src/types/decompilation.d.ts +96 -0
- package/dist/types/src/types/taint-tracking.d.ts +495 -0
- package/dist/types/src/types.d.ts +48 -0
- package/dist/types/src/useFileScanner.d.ts +15 -0
- package/dist/types/src/utils/advanced-detection.d.ts +21 -0
- package/dist/types/src/utils/batch-scanner.d.ts +62 -0
- package/dist/types/src/utils/cache-manager.d.ts +95 -0
- package/dist/types/src/utils/export.d.ts +51 -0
- package/dist/types/src/utils/performance-metrics.d.ts +68 -0
- package/dist/types/src/utils/threat-intelligence.d.ts +96 -0
- package/dist/types/src/validate.d.ts +7 -0
- package/dist/types/src/verdict.d.ts +2 -0
- package/dist/types/src/yara/browser.d.ts +7 -0
- package/dist/types/src/yara/index.d.ts +17 -0
- package/dist/types/src/yara/node.d.ts +2 -0
- package/dist/types/src/yara/remote.d.ts +10 -0
- package/dist/types/src/yara-bridge.d.ts +3 -0
- package/dist/types/src/zip.d.ts +13 -0
- package/dist/types/types/decompilation.d.ts +4 -4
- package/dist/types/types/taint-tracking.d.ts +19 -19
- package/dist/types/types.d.ts +3 -3
- package/dist/types/useFileScanner.d.ts +1 -1
- package/dist/types/utils/advanced-detection.d.ts +1 -1
- package/dist/types/utils/batch-scanner.d.ts +3 -3
- package/dist/types/utils/cache-manager.d.ts +1 -1
- package/dist/types/utils/export.d.ts +2 -2
- package/dist/types/utils/threat-intelligence.d.ts +4 -4
- package/dist/types/verdict.d.ts +1 -1
- package/dist/types/yara/browser.d.ts +1 -1
- package/dist/types/yara/index.d.ts +1 -1
- package/dist/types/yara/node.d.ts +1 -1
- package/dist/types/yara/remote.d.ts +2 -2
- 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,
|
|
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 = [
|
|
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,
|
|
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 = [
|
|
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:
|
|
280
|
+
matches.push({ rule: "office_ole_container", severity: "suspicious" });
|
|
51
281
|
}
|
|
52
282
|
if (hasOoxmlMacros(buf)) {
|
|
53
|
-
matches.push({ rule:
|
|
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:
|
|
61
|
-
severity:
|
|
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:
|
|
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({
|
|
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) => {
|
|
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 &&
|
|
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(([
|
|
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
|
|
189
|
-
|
|
190
|
-
scanners.push(CommonHeuristicsScanner);
|
|
430
|
+
const baseScanners = [CommonHeuristicsScanner];
|
|
431
|
+
const dynamicScannerPromises = [];
|
|
191
432
|
// Add decompilation scanners based on preset
|
|
192
|
-
if (preset ===
|
|
193
|
-
preset ===
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 (
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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:
|
|
351
|
-
severity:
|
|
352
|
-
meta: { description:
|
|
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:
|
|
359
|
-
severity:
|
|
360
|
-
meta: { description:
|
|
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:
|
|
367
|
-
severity:
|
|
368
|
-
meta: { description:
|
|
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(
|
|
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:
|
|
391
|
-
severity:
|
|
541
|
+
rule: "obfuscated_script",
|
|
542
|
+
severity: "medium",
|
|
392
543
|
meta: {
|
|
393
|
-
description:
|
|
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 &&
|
|
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 = (
|
|
445
|
-
(bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] ===
|
|
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(
|
|
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 &&
|
|
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] ===
|
|
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] ===
|
|
634
|
+
(bytes[0] === 0x37 && bytes[1] === 0x7a && bytes[2] === 0xbc && bytes[3] === 0xaf) ||
|
|
479
635
|
// tar.gz
|
|
480
|
-
(bytes[0] ===
|
|
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(
|
|
664
|
+
const hash = createHash("sha256")
|
|
509
665
|
.update(content)
|
|
510
|
-
.update(preset ||
|
|
511
|
-
.digest(
|
|
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
|
-
/**
|
|
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(
|
|
906
|
+
const ext = name.toLowerCase().split(".").pop();
|
|
660
907
|
switch (ext) {
|
|
661
|
-
case
|
|
662
|
-
|
|
663
|
-
case
|
|
664
|
-
|
|
665
|
-
case
|
|
666
|
-
case
|
|
667
|
-
|
|
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
|
|
926
|
+
return "clean";
|
|
674
927
|
// se la regola contiene 'zip_' lo marchiamo "suspicious"
|
|
675
|
-
const anyHigh = matches.some(m => (m.tags ?? []).includes(
|
|
676
|
-
return anyHigh ?
|
|
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:
|
|
683
|
-
tags: [
|
|
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 =
|
|
950
|
+
const perfTracker = opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking
|
|
698
951
|
? new PerformanceTracker()
|
|
699
952
|
: null;
|
|
700
|
-
perfTracker?.checkpoint(
|
|
701
|
-
const preset = opts.preset ?? opts.config?.defaultPreset ??
|
|
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(
|
|
708
|
-
perfTracker?.checkpoint(
|
|
960
|
+
perfTracker?.checkpoint("prep_end");
|
|
961
|
+
perfTracker?.checkpoint("heuristics_start");
|
|
709
962
|
const scanFn = createPresetScanner(preset);
|
|
710
|
-
const matchesH = await (typeof scanFn === "function"
|
|
711
|
-
|
|
712
|
-
|
|
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(
|
|
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 ||
|
|
986
|
+
if (nestingAnalysis.hasExcessiveNesting || nestingAnalysis.depth > maxDepth) {
|
|
732
987
|
allMatches.push({
|
|
733
|
-
rule:
|
|
734
|
-
severity:
|
|
988
|
+
rule: "excessive_archive_nesting",
|
|
989
|
+
severity: "high",
|
|
735
990
|
meta: {
|
|
736
|
-
description:
|
|
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(
|
|
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 ===
|
|
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:
|
|
1011
|
+
engine: "heuristics",
|
|
757
1012
|
truncated: false,
|
|
758
1013
|
timedOut: false,
|
|
759
1014
|
};
|
|
760
1015
|
// Add performance metrics if tracking enabled
|
|
761
|
-
if (perfTracker &&
|
|
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(
|
|
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:
|
|
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:
|
|
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(
|
|
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({
|
|
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:
|
|
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({
|
|
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({
|
|
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:
|
|
909
|
-
severity:
|
|
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({
|
|
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({
|
|
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
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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(
|
|
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 ||
|
|
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 ||
|
|
1233
|
+
this.escapeCsv(report.file?.mimeType || "unknown"),
|
|
1219
1234
|
(report.durationMs || 0).toString(),
|
|
1220
|
-
report.engine ||
|
|
1235
|
+
report.engine || "unknown",
|
|
1221
1236
|
];
|
|
1222
1237
|
if (options.includeDetails) {
|
|
1223
|
-
row.push(this.escapeCsv((report.reasons || []).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(
|
|
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 =
|
|
1249
|
+
let md = "# Scan Results\n\n";
|
|
1235
1250
|
md += `**Total Scans:** ${data.length}\n\n`;
|
|
1236
|
-
const clean = data.filter(r => r.verdict ===
|
|
1237
|
-
const suspicious = data.filter(r => r.verdict ===
|
|
1238
|
-
const malicious = data.filter(r => r.verdict ===
|
|
1239
|
-
md +=
|
|
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 +=
|
|
1258
|
+
md += "## Detailed Results\n\n";
|
|
1244
1259
|
for (const report of data) {
|
|
1245
|
-
const icon = report.verdict ===
|
|
1246
|
-
md += `### ${icon} ${report.file?.name ||
|
|
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 ||
|
|
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 +=
|
|
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 +=
|
|
1274
|
+
md += "\n";
|
|
1260
1275
|
}
|
|
1261
1276
|
}
|
|
1262
|
-
md +=
|
|
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 ===
|
|
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 ===
|
|
1292
|
+
level: report.verdict === "malicious" ? "error" : "warning",
|
|
1278
1293
|
message: {
|
|
1279
|
-
text: `${match.rule} detected in ${report.file?.name ||
|
|
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 ||
|
|
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:
|
|
1298
|
-
$schema:
|
|
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:
|
|
1304
|
-
version:
|
|
1305
|
-
informationUri:
|
|
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 ===
|
|
1322
|
-
const suspicious = data.filter(r => r.verdict ===
|
|
1323
|
-
const malicious = data.filter(r => r.verdict ===
|
|
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 ||
|
|
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 ||
|
|
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
|
|
1397
|
+
case "json":
|
|
1385
1398
|
return this.toJSON(reports, options);
|
|
1386
|
-
case
|
|
1399
|
+
case "csv":
|
|
1387
1400
|
return this.toCSV(reports, options);
|
|
1388
|
-
case
|
|
1401
|
+
case "markdown":
|
|
1389
1402
|
return this.toMarkdown(reports, options);
|
|
1390
|
-
case
|
|
1403
|
+
case "html":
|
|
1391
1404
|
return this.toHTML(reports, options);
|
|
1392
|
-
case
|
|
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(
|
|
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,
|
|
1407
|
-
.replace(/</g,
|
|
1408
|
-
.replace(/>/g,
|
|
1409
|
-
.replace(/"/g,
|
|
1410
|
-
.replace(/'/g,
|
|
1419
|
+
.replace(/&/g, "&")
|
|
1420
|
+
.replace(/</g, "<")
|
|
1421
|
+
.replace(/>/g, ">")
|
|
1422
|
+
.replace(/"/g, """)
|
|
1423
|
+
.replace(/'/g, "'");
|
|
1411
1424
|
}
|
|
1412
1425
|
formatBytes(bytes) {
|
|
1413
1426
|
if (bytes === 0)
|
|
1414
|
-
return
|
|
1427
|
+
return "0 Bytes";
|
|
1415
1428
|
const k = 1024;
|
|
1416
|
-
const sizes = [
|
|
1429
|
+
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
1417
1430
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1418
|
-
return Math.round(bytes /
|
|
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
|
*/
|