pompelmi 0.34.9 → 0.35.0
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 -14
- 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 +585 -534
- package/dist/pompelmi.browser.cjs.map +1 -1
- package/dist/pompelmi.browser.esm.js +585 -534
- package/dist/pompelmi.browser.esm.js.map +1 -1
- package/dist/pompelmi.cjs +2066 -2016
- package/dist/pompelmi.cjs.map +1 -1
- package/dist/pompelmi.esm.js +2066 -2016
- 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 +585 -534
- package/dist/pompelmi.react.cjs.map +1 -1
- package/dist/pompelmi.react.esm.js +585 -534
- 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 +6 -6
|
@@ -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
|
: {};
|
|
@@ -189,24 +431,32 @@ function createPresetScanner(preset, opts = {}) {
|
|
|
189
431
|
// Always include heuristics (EICAR, PHP webshells, JS obfuscation, PE hints, etc.)
|
|
190
432
|
scanners.push(CommonHeuristicsScanner);
|
|
191
433
|
// Add decompilation scanners based on preset
|
|
192
|
-
if (preset ===
|
|
193
|
-
preset ===
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
434
|
+
if (preset === "decompilation-basic" ||
|
|
435
|
+
preset === "decompilation-deep" ||
|
|
436
|
+
preset === "malware-analysis" ||
|
|
437
|
+
opts.enableDecompilation) {
|
|
438
|
+
const depth = preset === "decompilation-deep"
|
|
439
|
+
? "deep"
|
|
440
|
+
: preset === "decompilation-basic"
|
|
441
|
+
? "basic"
|
|
442
|
+
: opts.decompilationDepth || "basic";
|
|
443
|
+
if (!opts.decompilationEngine ||
|
|
444
|
+
opts.decompilationEngine === "binaryninja-hlil" ||
|
|
445
|
+
opts.decompilationEngine === "both") {
|
|
198
446
|
try {
|
|
199
447
|
// Dynamic import to avoid bundling issues - using Function to bypass TypeScript type checking
|
|
200
|
-
const importModule = new Function(
|
|
201
|
-
importModule(
|
|
448
|
+
const importModule = new Function("specifier", "return import(specifier)");
|
|
449
|
+
importModule("@pompelmi/engine-binaryninja")
|
|
450
|
+
.then((mod) => {
|
|
202
451
|
const binjaScanner = mod.createBinaryNinjaScanner({
|
|
203
452
|
timeout: opts.decompilationTimeout || opts.timeout || 30000,
|
|
204
453
|
depth,
|
|
205
454
|
pythonPath: opts.pythonPath,
|
|
206
|
-
binaryNinjaPath: opts.binaryNinjaPath
|
|
455
|
+
binaryNinjaPath: opts.binaryNinjaPath,
|
|
207
456
|
});
|
|
208
457
|
scanners.push(binjaScanner);
|
|
209
|
-
})
|
|
458
|
+
})
|
|
459
|
+
.catch(() => {
|
|
210
460
|
// Binary Ninja engine not available - silently skip
|
|
211
461
|
});
|
|
212
462
|
}
|
|
@@ -214,19 +464,23 @@ function createPresetScanner(preset, opts = {}) {
|
|
|
214
464
|
// Engine not installed
|
|
215
465
|
}
|
|
216
466
|
}
|
|
217
|
-
if (!opts.decompilationEngine ||
|
|
467
|
+
if (!opts.decompilationEngine ||
|
|
468
|
+
opts.decompilationEngine === "ghidra-pcode" ||
|
|
469
|
+
opts.decompilationEngine === "both") {
|
|
218
470
|
try {
|
|
219
471
|
// Dynamic import for Ghidra engine (when implemented) - using Function to bypass TypeScript type checking
|
|
220
|
-
const importModule = new Function(
|
|
221
|
-
importModule(
|
|
472
|
+
const importModule = new Function("specifier", "return import(specifier)");
|
|
473
|
+
importModule("@pompelmi/engine-ghidra")
|
|
474
|
+
.then((mod) => {
|
|
222
475
|
const ghidraScanner = mod.createGhidraScanner({
|
|
223
476
|
timeout: opts.decompilationTimeout || opts.timeout || 30000,
|
|
224
477
|
depth,
|
|
225
478
|
ghidraPath: opts.ghidraPath,
|
|
226
|
-
analyzeHeadless: opts.analyzeHeadless
|
|
479
|
+
analyzeHeadless: opts.analyzeHeadless,
|
|
227
480
|
});
|
|
228
481
|
scanners.push(ghidraScanner);
|
|
229
|
-
})
|
|
482
|
+
})
|
|
483
|
+
.catch(() => {
|
|
230
484
|
// Ghidra engine not available - silently skip
|
|
231
485
|
});
|
|
232
486
|
}
|
|
@@ -244,96 +498,6 @@ function createPresetScanner(preset, opts = {}) {
|
|
|
244
498
|
return composeScanners(...scanners);
|
|
245
499
|
}
|
|
246
500
|
|
|
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);
|
|
272
|
-
}
|
|
273
|
-
return now - this.startTime;
|
|
274
|
-
}
|
|
275
|
-
/**
|
|
276
|
-
* Generate final metrics report
|
|
277
|
-
*/
|
|
278
|
-
getMetrics(bytesScanned) {
|
|
279
|
-
const totalDuration = this.getDuration();
|
|
280
|
-
const throughput = totalDuration > 0 ? (bytesScanned / totalDuration) * 1000 : 0;
|
|
281
|
-
return {
|
|
282
|
-
totalDurationMs: totalDuration,
|
|
283
|
-
heuristicsDurationMs: this.checkpoints.has('heuristics_end')
|
|
284
|
-
? (this.checkpoints.get('heuristics_end') ?? 0) - (this.checkpoints.get('heuristics_start') ?? 0)
|
|
285
|
-
: undefined,
|
|
286
|
-
yaraDurationMs: this.checkpoints.has('yara_end')
|
|
287
|
-
? (this.checkpoints.get('yara_end') ?? 0) - (this.checkpoints.get('yara_start') ?? 0)
|
|
288
|
-
: undefined,
|
|
289
|
-
prepDurationMs: this.checkpoints.has('prep_end')
|
|
290
|
-
? (this.checkpoints.get('prep_end') ?? 0) - this.startTime
|
|
291
|
-
: undefined,
|
|
292
|
-
throughputBps: throughput,
|
|
293
|
-
bytesScanned,
|
|
294
|
-
startedAt: this.startTime,
|
|
295
|
-
completedAt: Date.now(),
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
/**
|
|
300
|
-
* Aggregate statistics from multiple scan reports
|
|
301
|
-
*/
|
|
302
|
-
function aggregateScanStats(reports) {
|
|
303
|
-
let cleanCount = 0;
|
|
304
|
-
let suspiciousCount = 0;
|
|
305
|
-
let maliciousCount = 0;
|
|
306
|
-
let totalDuration = 0;
|
|
307
|
-
let totalBytes = 0;
|
|
308
|
-
let validDurationCount = 0;
|
|
309
|
-
for (const report of reports) {
|
|
310
|
-
if (report.verdict === 'clean')
|
|
311
|
-
cleanCount++;
|
|
312
|
-
else if (report.verdict === 'suspicious')
|
|
313
|
-
suspiciousCount++;
|
|
314
|
-
else if (report.verdict === 'malicious')
|
|
315
|
-
maliciousCount++;
|
|
316
|
-
if (report.durationMs !== undefined) {
|
|
317
|
-
totalDuration += report.durationMs;
|
|
318
|
-
validDurationCount++;
|
|
319
|
-
}
|
|
320
|
-
if (report.file?.size !== undefined) {
|
|
321
|
-
totalBytes += report.file.size;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
const avgDuration = validDurationCount > 0 ? totalDuration / validDurationCount : 0;
|
|
325
|
-
const avgThroughput = totalDuration > 0 ? (totalBytes / totalDuration) * 1000 : 0;
|
|
326
|
-
return {
|
|
327
|
-
totalScans: reports.length,
|
|
328
|
-
cleanCount,
|
|
329
|
-
suspiciousCount,
|
|
330
|
-
maliciousCount,
|
|
331
|
-
avgDurationMs: avgDuration,
|
|
332
|
-
avgThroughputBps: avgThroughput,
|
|
333
|
-
totalBytesScanned: totalBytes,
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
501
|
/**
|
|
338
502
|
* Advanced threat detection utilities
|
|
339
503
|
* @module utils/advanced-detection
|
|
@@ -347,25 +511,25 @@ function detectPolyglot(bytes) {
|
|
|
347
511
|
// Check for PDF/ZIP polyglot
|
|
348
512
|
if (isPDFZipPolyglot(bytes)) {
|
|
349
513
|
matches.push({
|
|
350
|
-
rule:
|
|
351
|
-
severity:
|
|
352
|
-
meta: { description:
|
|
514
|
+
rule: "polyglot_pdf_zip",
|
|
515
|
+
severity: "high",
|
|
516
|
+
meta: { description: "File can be interpreted as both PDF and ZIP" },
|
|
353
517
|
});
|
|
354
518
|
}
|
|
355
519
|
// Check for image/script polyglot
|
|
356
520
|
if (isImageScriptPolyglot(bytes)) {
|
|
357
521
|
matches.push({
|
|
358
|
-
rule:
|
|
359
|
-
severity:
|
|
360
|
-
meta: { description:
|
|
522
|
+
rule: "polyglot_image_script",
|
|
523
|
+
severity: "high",
|
|
524
|
+
meta: { description: "Image file contains executable script content" },
|
|
361
525
|
});
|
|
362
526
|
}
|
|
363
527
|
// Check for GIFAR (GIF/JAR polyglot)
|
|
364
528
|
if (isGIFAR(bytes)) {
|
|
365
529
|
matches.push({
|
|
366
|
-
rule:
|
|
367
|
-
severity:
|
|
368
|
-
meta: { description:
|
|
530
|
+
rule: "polyglot_gifar",
|
|
531
|
+
severity: "critical",
|
|
532
|
+
meta: { description: "GIF file contains Java archive" },
|
|
369
533
|
});
|
|
370
534
|
}
|
|
371
535
|
return matches;
|
|
@@ -375,7 +539,7 @@ function detectPolyglot(bytes) {
|
|
|
375
539
|
*/
|
|
376
540
|
function detectObfuscatedScripts(bytes) {
|
|
377
541
|
const matches = [];
|
|
378
|
-
const text = new TextDecoder(
|
|
542
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, Math.min(64 * 1024, bytes.length)));
|
|
379
543
|
// Check for common obfuscation patterns
|
|
380
544
|
const obfuscationPatterns = [
|
|
381
545
|
/eval\s*\(\s*unescape\s*\(/gi,
|
|
@@ -387,10 +551,10 @@ function detectObfuscatedScripts(bytes) {
|
|
|
387
551
|
for (const pattern of obfuscationPatterns) {
|
|
388
552
|
if (pattern.test(text)) {
|
|
389
553
|
matches.push({
|
|
390
|
-
rule:
|
|
391
|
-
severity:
|
|
554
|
+
rule: "obfuscated_script",
|
|
555
|
+
severity: "medium",
|
|
392
556
|
meta: {
|
|
393
|
-
description:
|
|
557
|
+
description: "Detected obfuscated script content",
|
|
394
558
|
pattern: pattern.source,
|
|
395
559
|
},
|
|
396
560
|
});
|
|
@@ -430,7 +594,10 @@ function isPDFZipPolyglot(bytes) {
|
|
|
430
594
|
// Check for ZIP signature anywhere in the file
|
|
431
595
|
let hasZIP = false;
|
|
432
596
|
for (let i = 0; i < Math.min(bytes.length - 4, 1024); i++) {
|
|
433
|
-
if (bytes[i] === 0x50 &&
|
|
597
|
+
if (bytes[i] === 0x50 &&
|
|
598
|
+
bytes[i + 1] === 0x4b &&
|
|
599
|
+
bytes[i + 2] === 0x03 &&
|
|
600
|
+
bytes[i + 3] === 0x04) {
|
|
434
601
|
hasZIP = true;
|
|
435
602
|
break;
|
|
436
603
|
}
|
|
@@ -441,14 +608,13 @@ function isImageScriptPolyglot(bytes) {
|
|
|
441
608
|
if (bytes.length < 100)
|
|
442
609
|
return false;
|
|
443
610
|
// 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
|
-
);
|
|
611
|
+
const isImage = (bytes[0] === 0xff && bytes[1] === 0xd8) || // JPEG
|
|
612
|
+
(bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) || // PNG
|
|
613
|
+
(bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46); // GIF
|
|
448
614
|
if (!isImage)
|
|
449
615
|
return false;
|
|
450
616
|
// Check for script content
|
|
451
|
-
const text = new TextDecoder(
|
|
617
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
452
618
|
return /<script|javascript:|eval\(|function\s*\(/i.test(text);
|
|
453
619
|
}
|
|
454
620
|
function isGIFAR(bytes) {
|
|
@@ -459,7 +625,10 @@ function isGIFAR(bytes) {
|
|
|
459
625
|
// Check for ZIP/JAR signature
|
|
460
626
|
let hasZIP = false;
|
|
461
627
|
for (let i = 0; i < Math.min(bytes.length - 4, 1024); i++) {
|
|
462
|
-
if (bytes[i] === 0x50 &&
|
|
628
|
+
if (bytes[i] === 0x50 &&
|
|
629
|
+
bytes[i + 1] === 0x4b &&
|
|
630
|
+
bytes[i + 2] === 0x03 &&
|
|
631
|
+
bytes[i + 3] === 0x04) {
|
|
463
632
|
hasZIP = true;
|
|
464
633
|
break;
|
|
465
634
|
}
|
|
@@ -471,13 +640,13 @@ function isArchive(bytes) {
|
|
|
471
640
|
return false;
|
|
472
641
|
return (
|
|
473
642
|
// ZIP
|
|
474
|
-
(bytes[0] === 0x50 && bytes[1] ===
|
|
643
|
+
(bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04) ||
|
|
475
644
|
// RAR
|
|
476
645
|
(bytes[0] === 0x52 && bytes[1] === 0x61 && bytes[2] === 0x72 && bytes[3] === 0x21) ||
|
|
477
646
|
// 7z
|
|
478
|
-
(bytes[0] === 0x37 && bytes[1] ===
|
|
647
|
+
(bytes[0] === 0x37 && bytes[1] === 0x7a && bytes[2] === 0xbc && bytes[3] === 0xaf) ||
|
|
479
648
|
// tar.gz
|
|
480
|
-
(bytes[0] ===
|
|
649
|
+
(bytes[0] === 0x1f && bytes[1] === 0x8b));
|
|
481
650
|
}
|
|
482
651
|
|
|
483
652
|
/**
|
|
@@ -505,10 +674,10 @@ class ScanCacheManager {
|
|
|
505
674
|
* Generate cache key from file content
|
|
506
675
|
*/
|
|
507
676
|
generateKey(content, preset) {
|
|
508
|
-
const hash = createHash(
|
|
677
|
+
const hash = createHash("sha256")
|
|
509
678
|
.update(content)
|
|
510
|
-
.update(preset ||
|
|
511
|
-
.digest(
|
|
679
|
+
.update(preset || "default")
|
|
680
|
+
.digest("hex");
|
|
512
681
|
return hash;
|
|
513
682
|
}
|
|
514
683
|
/**
|
|
@@ -652,35 +821,132 @@ function getDefaultCache(options) {
|
|
|
652
821
|
return defaultCache;
|
|
653
822
|
}
|
|
654
823
|
|
|
655
|
-
/**
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
824
|
+
/**
|
|
825
|
+
* Performance monitoring utilities for pompelmi scans
|
|
826
|
+
* @module utils/performance-metrics
|
|
827
|
+
*/
|
|
828
|
+
/**
|
|
829
|
+
* Track performance metrics for a scan operation
|
|
830
|
+
*/
|
|
831
|
+
class PerformanceTracker {
|
|
832
|
+
constructor() {
|
|
833
|
+
this.checkpoints = new Map();
|
|
834
|
+
this.startTime = Date.now();
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Mark a checkpoint in the scan process
|
|
838
|
+
*/
|
|
839
|
+
checkpoint(name) {
|
|
840
|
+
this.checkpoints.set(name, Date.now());
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Get duration since start or since a specific checkpoint
|
|
844
|
+
*/
|
|
845
|
+
getDuration(since) {
|
|
846
|
+
const now = Date.now();
|
|
847
|
+
if (since && this.checkpoints.has(since)) {
|
|
848
|
+
return now - (this.checkpoints.get(since) ?? now);
|
|
849
|
+
}
|
|
850
|
+
return now - this.startTime;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Generate final metrics report
|
|
854
|
+
*/
|
|
855
|
+
getMetrics(bytesScanned) {
|
|
856
|
+
const totalDuration = this.getDuration();
|
|
857
|
+
const throughput = totalDuration > 0 ? (bytesScanned / totalDuration) * 1000 : 0;
|
|
858
|
+
return {
|
|
859
|
+
totalDurationMs: totalDuration,
|
|
860
|
+
heuristicsDurationMs: this.checkpoints.has("heuristics_end")
|
|
861
|
+
? (this.checkpoints.get("heuristics_end") ?? 0) -
|
|
862
|
+
(this.checkpoints.get("heuristics_start") ?? 0)
|
|
863
|
+
: undefined,
|
|
864
|
+
yaraDurationMs: this.checkpoints.has("yara_end")
|
|
865
|
+
? (this.checkpoints.get("yara_end") ?? 0) - (this.checkpoints.get("yara_start") ?? 0)
|
|
866
|
+
: undefined,
|
|
867
|
+
prepDurationMs: this.checkpoints.has("prep_end")
|
|
868
|
+
? (this.checkpoints.get("prep_end") ?? 0) - this.startTime
|
|
869
|
+
: undefined,
|
|
870
|
+
throughputBps: throughput,
|
|
871
|
+
bytesScanned,
|
|
872
|
+
startedAt: this.startTime,
|
|
873
|
+
completedAt: Date.now(),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Aggregate statistics from multiple scan reports
|
|
879
|
+
*/
|
|
880
|
+
function aggregateScanStats(reports) {
|
|
881
|
+
let cleanCount = 0;
|
|
882
|
+
let suspiciousCount = 0;
|
|
883
|
+
let maliciousCount = 0;
|
|
884
|
+
let totalDuration = 0;
|
|
885
|
+
let totalBytes = 0;
|
|
886
|
+
let validDurationCount = 0;
|
|
887
|
+
for (const report of reports) {
|
|
888
|
+
if (report.verdict === "clean")
|
|
889
|
+
cleanCount++;
|
|
890
|
+
else if (report.verdict === "suspicious")
|
|
891
|
+
suspiciousCount++;
|
|
892
|
+
else if (report.verdict === "malicious")
|
|
893
|
+
maliciousCount++;
|
|
894
|
+
if (report.durationMs !== undefined) {
|
|
895
|
+
totalDuration += report.durationMs;
|
|
896
|
+
validDurationCount++;
|
|
897
|
+
}
|
|
898
|
+
if (report.file?.size !== undefined) {
|
|
899
|
+
totalBytes += report.file.size;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const avgDuration = validDurationCount > 0 ? totalDuration / validDurationCount : 0;
|
|
903
|
+
const avgThroughput = totalDuration > 0 ? (totalBytes / totalDuration) * 1000 : 0;
|
|
904
|
+
return {
|
|
905
|
+
totalScans: reports.length,
|
|
906
|
+
cleanCount,
|
|
907
|
+
suspiciousCount,
|
|
908
|
+
maliciousCount,
|
|
909
|
+
avgDurationMs: avgDuration,
|
|
910
|
+
avgThroughputBps: avgThroughput,
|
|
911
|
+
totalBytesScanned: totalBytes,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Mappa veloce estensione -> mime (basic) */
|
|
916
|
+
function guessMimeByExt(name) {
|
|
917
|
+
if (!name)
|
|
918
|
+
return;
|
|
919
|
+
const ext = name.toLowerCase().split(".").pop();
|
|
920
|
+
switch (ext) {
|
|
921
|
+
case "zip":
|
|
922
|
+
return "application/zip";
|
|
923
|
+
case "png":
|
|
924
|
+
return "image/png";
|
|
925
|
+
case "jpg":
|
|
926
|
+
case "jpeg":
|
|
927
|
+
return "image/jpeg";
|
|
928
|
+
case "pdf":
|
|
929
|
+
return "application/pdf";
|
|
930
|
+
case "txt":
|
|
931
|
+
return "text/plain";
|
|
932
|
+
default:
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
/** Heuristica semplice per verdetto */
|
|
937
|
+
function computeVerdict(matches) {
|
|
938
|
+
if (!matches.length)
|
|
939
|
+
return "clean";
|
|
940
|
+
// se la regola contiene 'zip_' lo marchiamo "suspicious"
|
|
941
|
+
const anyHigh = matches.some((m) => (m.tags ?? []).includes("critical") || (m.tags ?? []).includes("high"));
|
|
942
|
+
return anyHigh ? "malicious" : "suspicious";
|
|
943
|
+
}
|
|
944
|
+
/** Converte i Match (heuristics) in YaraMatch-like per uniformare l'output */
|
|
945
|
+
function toYaraMatches(ms) {
|
|
946
|
+
return ms.map((m) => ({
|
|
681
947
|
rule: m.rule,
|
|
682
|
-
namespace:
|
|
683
|
-
tags: [
|
|
948
|
+
namespace: "heuristics",
|
|
949
|
+
tags: ["heuristics"].concat(m.severity ? [m.severity] : []),
|
|
684
950
|
meta: m.meta,
|
|
685
951
|
}));
|
|
686
952
|
}
|
|
@@ -694,26 +960,28 @@ async function scanBytes(input, opts = {}) {
|
|
|
694
960
|
return cached;
|
|
695
961
|
}
|
|
696
962
|
}
|
|
697
|
-
const perfTracker =
|
|
963
|
+
const perfTracker = opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking
|
|
698
964
|
? new PerformanceTracker()
|
|
699
965
|
: null;
|
|
700
|
-
perfTracker?.checkpoint(
|
|
701
|
-
const preset = opts.preset ?? opts.config?.defaultPreset ??
|
|
966
|
+
perfTracker?.checkpoint("prep_start");
|
|
967
|
+
const preset = opts.preset ?? opts.config?.defaultPreset ?? "zip-basic";
|
|
702
968
|
const ctx = {
|
|
703
969
|
...opts.ctx,
|
|
704
970
|
mimeType: opts.ctx?.mimeType ?? guessMimeByExt(opts.ctx?.filename),
|
|
705
971
|
size: opts.ctx?.size ?? input.byteLength,
|
|
706
972
|
};
|
|
707
|
-
perfTracker?.checkpoint(
|
|
708
|
-
perfTracker?.checkpoint(
|
|
973
|
+
perfTracker?.checkpoint("prep_end");
|
|
974
|
+
perfTracker?.checkpoint("heuristics_start");
|
|
709
975
|
const scanFn = createPresetScanner(preset);
|
|
710
|
-
const matchesH = await (typeof scanFn === "function"
|
|
711
|
-
|
|
712
|
-
|
|
976
|
+
const matchesH = await (typeof scanFn === "function"
|
|
977
|
+
? scanFn
|
|
978
|
+
: scanFn.scan)(input, ctx);
|
|
979
|
+
const allMatches = [...matchesH];
|
|
980
|
+
perfTracker?.checkpoint("heuristics_end");
|
|
713
981
|
// Advanced detection (enabled by default, can be overridden by config)
|
|
714
982
|
const advancedEnabled = opts.enableAdvancedDetection ?? opts.config?.advanced?.enablePolyglotDetection ?? true;
|
|
715
983
|
if (advancedEnabled) {
|
|
716
|
-
perfTracker?.checkpoint(
|
|
984
|
+
perfTracker?.checkpoint("advanced_start");
|
|
717
985
|
// Detect polyglot files
|
|
718
986
|
if (opts.config?.advanced?.enablePolyglotDetection !== false) {
|
|
719
987
|
const polyglotMatches = detectPolyglot(input);
|
|
@@ -728,37 +996,38 @@ async function scanBytes(input, opts = {}) {
|
|
|
728
996
|
if (opts.config?.advanced?.enableNestedArchiveAnalysis !== false) {
|
|
729
997
|
const nestingAnalysis = analyzeNestedArchives(input);
|
|
730
998
|
const maxDepth = opts.config?.advanced?.maxArchiveDepth ?? 5;
|
|
731
|
-
if (nestingAnalysis.hasExcessiveNesting ||
|
|
999
|
+
if (nestingAnalysis.hasExcessiveNesting || nestingAnalysis.depth > maxDepth) {
|
|
732
1000
|
allMatches.push({
|
|
733
|
-
rule:
|
|
734
|
-
severity:
|
|
1001
|
+
rule: "excessive_archive_nesting",
|
|
1002
|
+
severity: "high",
|
|
735
1003
|
meta: {
|
|
736
|
-
description:
|
|
1004
|
+
description: "Excessive archive nesting detected",
|
|
737
1005
|
depth: nestingAnalysis.depth,
|
|
738
1006
|
maxAllowed: maxDepth,
|
|
739
1007
|
},
|
|
740
1008
|
});
|
|
741
1009
|
}
|
|
742
1010
|
}
|
|
743
|
-
perfTracker?.checkpoint(
|
|
1011
|
+
perfTracker?.checkpoint("advanced_end");
|
|
744
1012
|
}
|
|
745
1013
|
const matches = toYaraMatches(allMatches);
|
|
746
1014
|
const verdict = computeVerdict(matches);
|
|
747
1015
|
perfTracker ? perfTracker.getDuration() : Date.now();
|
|
748
1016
|
const durationMs = perfTracker ? perfTracker.getDuration() : 0;
|
|
749
1017
|
const report = {
|
|
750
|
-
ok: verdict ===
|
|
1018
|
+
ok: verdict === "clean",
|
|
751
1019
|
verdict,
|
|
752
1020
|
matches,
|
|
753
|
-
reasons: matches.map(m => m.rule),
|
|
1021
|
+
reasons: matches.map((m) => m.rule),
|
|
754
1022
|
file: { name: ctx.filename, mimeType: ctx.mimeType, size: ctx.size },
|
|
755
1023
|
durationMs,
|
|
756
|
-
engine:
|
|
1024
|
+
engine: "heuristics",
|
|
757
1025
|
truncated: false,
|
|
758
1026
|
timedOut: false,
|
|
759
1027
|
};
|
|
760
1028
|
// Add performance metrics if tracking enabled
|
|
761
|
-
if (perfTracker &&
|
|
1029
|
+
if (perfTracker &&
|
|
1030
|
+
(opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking)) {
|
|
762
1031
|
report.performanceMetrics = perfTracker.getMetrics(input.byteLength);
|
|
763
1032
|
}
|
|
764
1033
|
// Cache result if enabled
|
|
@@ -772,10 +1041,7 @@ async function scanBytes(input, opts = {}) {
|
|
|
772
1041
|
}
|
|
773
1042
|
/** Scan di un file su disco (Node). Import dinamico per non vincolare il bundle browser. */
|
|
774
1043
|
async function scanFile(filePath, opts = {}) {
|
|
775
|
-
const [{ readFile, stat }, path] = await Promise.all([
|
|
776
|
-
import('fs/promises'),
|
|
777
|
-
import('path'),
|
|
778
|
-
]);
|
|
1044
|
+
const [{ readFile, stat }, path] = await Promise.all([import('fs/promises'), import('path')]);
|
|
779
1045
|
const [buf, st] = await Promise.all([readFile(filePath), stat(filePath)]);
|
|
780
1046
|
const ctx = {
|
|
781
1047
|
filename: path.basename(filePath),
|
|
@@ -799,21 +1065,6 @@ async function scanFiles(files, opts = {}) {
|
|
|
799
1065
|
return out;
|
|
800
1066
|
}
|
|
801
1067
|
|
|
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
1068
|
const SIG_CEN = 0x02014b50;
|
|
818
1069
|
const DEFAULTS = {
|
|
819
1070
|
maxEntries: 1000,
|
|
@@ -830,7 +1081,7 @@ function r32(buf, off) {
|
|
|
830
1081
|
}
|
|
831
1082
|
function isZipLike(buf) {
|
|
832
1083
|
// local file header at start is common
|
|
833
|
-
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
|
|
1084
|
+
return (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
|
|
834
1085
|
}
|
|
835
1086
|
function lastIndexOfEOCD(buf, window) {
|
|
836
1087
|
const sig = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
@@ -839,7 +1090,7 @@ function lastIndexOfEOCD(buf, window) {
|
|
|
839
1090
|
return idx >= start ? idx : -1;
|
|
840
1091
|
}
|
|
841
1092
|
function hasTraversal(name) {
|
|
842
|
-
return name.includes(
|
|
1093
|
+
return (name.includes("../") || name.includes("..\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name));
|
|
843
1094
|
}
|
|
844
1095
|
function createZipBombGuard(opts = {}) {
|
|
845
1096
|
const cfg = { ...DEFAULTS, ...opts };
|
|
@@ -853,7 +1104,7 @@ function createZipBombGuard(opts = {}) {
|
|
|
853
1104
|
const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
|
|
854
1105
|
if (eocdPos < 0 || eocdPos + 22 > buf.length) {
|
|
855
1106
|
// ZIP but no EOCD — malformed or polyglot → suspicious
|
|
856
|
-
matches.push({ rule:
|
|
1107
|
+
matches.push({ rule: "zip_eocd_not_found", severity: "medium" });
|
|
857
1108
|
return matches;
|
|
858
1109
|
}
|
|
859
1110
|
const totalEntries = r16(buf, eocdPos + 10);
|
|
@@ -861,7 +1112,7 @@ function createZipBombGuard(opts = {}) {
|
|
|
861
1112
|
const cdOffset = r32(buf, eocdPos + 16);
|
|
862
1113
|
// Bounds check
|
|
863
1114
|
if (cdOffset + cdSize > buf.length) {
|
|
864
|
-
matches.push({ rule:
|
|
1115
|
+
matches.push({ rule: "zip_cd_out_of_bounds", severity: "medium" });
|
|
865
1116
|
return matches;
|
|
866
1117
|
}
|
|
867
1118
|
// Iterate central directory entries
|
|
@@ -882,287 +1133,68 @@ function createZipBombGuard(opts = {}) {
|
|
|
882
1133
|
const nameEnd = nameStart + fnLen;
|
|
883
1134
|
if (nameEnd > buf.length)
|
|
884
1135
|
break;
|
|
885
|
-
const name = buf.toString(
|
|
1136
|
+
const name = buf.toString("utf8", nameStart, nameEnd);
|
|
886
1137
|
sumComp += compSize;
|
|
887
1138
|
sumUnc += uncSize;
|
|
888
1139
|
seen++;
|
|
889
1140
|
if (name.length > cfg.maxEntryNameLength) {
|
|
890
|
-
matches.push({
|
|
1141
|
+
matches.push({
|
|
1142
|
+
rule: "zip_entry_name_too_long",
|
|
1143
|
+
severity: "medium",
|
|
1144
|
+
meta: { name, length: name.length },
|
|
1145
|
+
});
|
|
891
1146
|
}
|
|
892
1147
|
if (hasTraversal(name)) {
|
|
893
|
-
matches.push({ rule:
|
|
1148
|
+
matches.push({ rule: "zip_path_traversal_entry", severity: "medium", meta: { name } });
|
|
894
1149
|
}
|
|
895
1150
|
// move to next entry
|
|
896
1151
|
ptr = nameEnd + exLen + cmLen;
|
|
897
1152
|
}
|
|
898
1153
|
if (seen !== totalEntries) {
|
|
899
1154
|
// central dir truncated/odd, still report what we found
|
|
900
|
-
matches.push({
|
|
1155
|
+
matches.push({
|
|
1156
|
+
rule: "zip_cd_truncated",
|
|
1157
|
+
severity: "medium",
|
|
1158
|
+
meta: { seen, totalEntries },
|
|
1159
|
+
});
|
|
901
1160
|
}
|
|
902
1161
|
// Heuristics thresholds
|
|
903
1162
|
if (seen > cfg.maxEntries) {
|
|
904
|
-
matches.push({
|
|
1163
|
+
matches.push({
|
|
1164
|
+
rule: "zip_too_many_entries",
|
|
1165
|
+
severity: "medium",
|
|
1166
|
+
meta: { seen, limit: cfg.maxEntries },
|
|
1167
|
+
});
|
|
905
1168
|
}
|
|
906
1169
|
if (sumUnc > cfg.maxTotalUncompressedBytes) {
|
|
907
1170
|
matches.push({
|
|
908
|
-
rule:
|
|
909
|
-
severity:
|
|
910
|
-
meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes }
|
|
1171
|
+
rule: "zip_total_uncompressed_too_large",
|
|
1172
|
+
severity: "medium",
|
|
1173
|
+
meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes },
|
|
911
1174
|
});
|
|
912
1175
|
}
|
|
913
1176
|
if (sumComp === 0 && sumUnc > 0) {
|
|
914
|
-
matches.push({
|
|
1177
|
+
matches.push({
|
|
1178
|
+
rule: "zip_suspicious_ratio",
|
|
1179
|
+
severity: "medium",
|
|
1180
|
+
meta: { ratio: Infinity },
|
|
1181
|
+
});
|
|
915
1182
|
}
|
|
916
1183
|
else if (sumComp > 0) {
|
|
917
1184
|
const ratio = sumUnc / Math.max(1, sumComp);
|
|
918
1185
|
if (ratio >= cfg.maxCompressionRatio) {
|
|
919
|
-
matches.push({
|
|
1186
|
+
matches.push({
|
|
1187
|
+
rule: "zip_suspicious_ratio",
|
|
1188
|
+
severity: "medium",
|
|
1189
|
+
meta: { ratio, limit: cfg.maxCompressionRatio },
|
|
1190
|
+
});
|
|
920
1191
|
}
|
|
921
1192
|
}
|
|
922
1193
|
return matches;
|
|
923
|
-
}
|
|
1194
|
+
},
|
|
924
1195
|
};
|
|
925
1196
|
}
|
|
926
1197
|
|
|
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
1198
|
/**
|
|
1167
1199
|
* Export utilities for scan results
|
|
1168
1200
|
* @module utils/export
|
|
@@ -1178,19 +1210,15 @@ class ScanResultExporter {
|
|
|
1178
1210
|
const data = Array.isArray(reports) ? reports : [reports];
|
|
1179
1211
|
if (!options.includeDetails) {
|
|
1180
1212
|
// Simplified output
|
|
1181
|
-
const simplified = data.map(r => ({
|
|
1213
|
+
const simplified = data.map((r) => ({
|
|
1182
1214
|
verdict: r.verdict,
|
|
1183
1215
|
file: r.file?.name,
|
|
1184
1216
|
matches: r.matches.length,
|
|
1185
1217
|
durationMs: r.durationMs,
|
|
1186
1218
|
}));
|
|
1187
|
-
return options.prettyPrint
|
|
1188
|
-
? JSON.stringify(simplified, null, 2)
|
|
1189
|
-
: JSON.stringify(simplified);
|
|
1219
|
+
return options.prettyPrint ? JSON.stringify(simplified, null, 2) : JSON.stringify(simplified);
|
|
1190
1220
|
}
|
|
1191
|
-
return options.prettyPrint
|
|
1192
|
-
? JSON.stringify(data, null, 2)
|
|
1193
|
-
: JSON.stringify(data);
|
|
1221
|
+
return options.prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
|
1194
1222
|
}
|
|
1195
1223
|
/**
|
|
1196
1224
|
* Export to CSV format
|
|
@@ -1198,68 +1226,68 @@ class ScanResultExporter {
|
|
|
1198
1226
|
toCSV(reports, options = {}) {
|
|
1199
1227
|
const data = Array.isArray(reports) ? reports : [reports];
|
|
1200
1228
|
const headers = [
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1229
|
+
"filename",
|
|
1230
|
+
"verdict",
|
|
1231
|
+
"matches_count",
|
|
1232
|
+
"file_size",
|
|
1233
|
+
"mime_type",
|
|
1234
|
+
"duration_ms",
|
|
1235
|
+
"engine",
|
|
1208
1236
|
];
|
|
1209
1237
|
if (options.includeDetails) {
|
|
1210
|
-
headers.push(
|
|
1238
|
+
headers.push("reasons", "match_rules");
|
|
1211
1239
|
}
|
|
1212
|
-
const rows = data.map(report => {
|
|
1240
|
+
const rows = data.map((report) => {
|
|
1213
1241
|
const row = [
|
|
1214
|
-
this.escapeCsv(report.file?.name ||
|
|
1242
|
+
this.escapeCsv(report.file?.name || "unknown"),
|
|
1215
1243
|
report.verdict,
|
|
1216
1244
|
report.matches.length.toString(),
|
|
1217
1245
|
(report.file?.size || 0).toString(),
|
|
1218
|
-
this.escapeCsv(report.file?.mimeType ||
|
|
1246
|
+
this.escapeCsv(report.file?.mimeType || "unknown"),
|
|
1219
1247
|
(report.durationMs || 0).toString(),
|
|
1220
|
-
report.engine ||
|
|
1248
|
+
report.engine || "unknown",
|
|
1221
1249
|
];
|
|
1222
1250
|
if (options.includeDetails) {
|
|
1223
|
-
row.push(this.escapeCsv((report.reasons || []).join(
|
|
1251
|
+
row.push(this.escapeCsv((report.reasons || []).join("; ")), this.escapeCsv(report.matches.map((m) => m.rule).join("; ")));
|
|
1224
1252
|
}
|
|
1225
|
-
return row.join(
|
|
1253
|
+
return row.join(",");
|
|
1226
1254
|
});
|
|
1227
|
-
return [headers.join(
|
|
1255
|
+
return [headers.join(","), ...rows].join("\n");
|
|
1228
1256
|
}
|
|
1229
1257
|
/**
|
|
1230
1258
|
* Export to Markdown format
|
|
1231
1259
|
*/
|
|
1232
1260
|
toMarkdown(reports, options = {}) {
|
|
1233
1261
|
const data = Array.isArray(reports) ? reports : [reports];
|
|
1234
|
-
let md =
|
|
1262
|
+
let md = "# Scan Results\n\n";
|
|
1235
1263
|
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 +=
|
|
1264
|
+
const clean = data.filter((r) => r.verdict === "clean").length;
|
|
1265
|
+
const suspicious = data.filter((r) => r.verdict === "suspicious").length;
|
|
1266
|
+
const malicious = data.filter((r) => r.verdict === "malicious").length;
|
|
1267
|
+
md += "## Summary\n\n";
|
|
1240
1268
|
md += `- ✅ Clean: ${clean}\n`;
|
|
1241
1269
|
md += `- ⚠️ Suspicious: ${suspicious}\n`;
|
|
1242
1270
|
md += `- ❌ Malicious: ${malicious}\n\n`;
|
|
1243
|
-
md +=
|
|
1271
|
+
md += "## Detailed Results\n\n";
|
|
1244
1272
|
for (const report of data) {
|
|
1245
|
-
const icon = report.verdict ===
|
|
1246
|
-
md += `### ${icon} ${report.file?.name ||
|
|
1273
|
+
const icon = report.verdict === "clean" ? "✅" : report.verdict === "suspicious" ? "⚠️" : "❌";
|
|
1274
|
+
md += `### ${icon} ${report.file?.name || "Unknown"}\n\n`;
|
|
1247
1275
|
md += `- **Verdict:** ${report.verdict}\n`;
|
|
1248
1276
|
md += `- **Size:** ${this.formatBytes(report.file?.size || 0)}\n`;
|
|
1249
|
-
md += `- **MIME Type:** ${report.file?.mimeType ||
|
|
1277
|
+
md += `- **MIME Type:** ${report.file?.mimeType || "unknown"}\n`;
|
|
1250
1278
|
md += `- **Duration:** ${report.durationMs || 0}ms\n`;
|
|
1251
1279
|
md += `- **Matches:** ${report.matches.length}\n`;
|
|
1252
1280
|
if (options.includeDetails && report.matches.length > 0) {
|
|
1253
|
-
md +=
|
|
1281
|
+
md += "\n**Match Details:**\n";
|
|
1254
1282
|
for (const match of report.matches) {
|
|
1255
1283
|
md += `- ${match.rule}`;
|
|
1256
1284
|
if (match.tags && match.tags.length > 0) {
|
|
1257
|
-
md += ` (${match.tags.join(
|
|
1285
|
+
md += ` (${match.tags.join(", ")})`;
|
|
1258
1286
|
}
|
|
1259
|
-
md +=
|
|
1287
|
+
md += "\n";
|
|
1260
1288
|
}
|
|
1261
1289
|
}
|
|
1262
|
-
md +=
|
|
1290
|
+
md += "\n";
|
|
1263
1291
|
}
|
|
1264
1292
|
return md;
|
|
1265
1293
|
}
|
|
@@ -1269,20 +1297,20 @@ class ScanResultExporter {
|
|
|
1269
1297
|
*/
|
|
1270
1298
|
toSARIF(reports, options = {}) {
|
|
1271
1299
|
const data = Array.isArray(reports) ? reports : [reports];
|
|
1272
|
-
const results = data.flatMap(report => {
|
|
1273
|
-
if (report.verdict ===
|
|
1300
|
+
const results = data.flatMap((report) => {
|
|
1301
|
+
if (report.verdict === "clean")
|
|
1274
1302
|
return [];
|
|
1275
|
-
return report.matches.map(match => ({
|
|
1303
|
+
return report.matches.map((match) => ({
|
|
1276
1304
|
ruleId: match.rule,
|
|
1277
|
-
level: report.verdict ===
|
|
1305
|
+
level: report.verdict === "malicious" ? "error" : "warning",
|
|
1278
1306
|
message: {
|
|
1279
|
-
text: `${match.rule} detected in ${report.file?.name ||
|
|
1307
|
+
text: `${match.rule} detected in ${report.file?.name || "unknown file"}`,
|
|
1280
1308
|
},
|
|
1281
1309
|
locations: [
|
|
1282
1310
|
{
|
|
1283
1311
|
physicalLocation: {
|
|
1284
1312
|
artifactLocation: {
|
|
1285
|
-
uri: report.file?.name ||
|
|
1313
|
+
uri: report.file?.name || "unknown",
|
|
1286
1314
|
},
|
|
1287
1315
|
},
|
|
1288
1316
|
},
|
|
@@ -1294,33 +1322,31 @@ class ScanResultExporter {
|
|
|
1294
1322
|
}));
|
|
1295
1323
|
});
|
|
1296
1324
|
const sarif = {
|
|
1297
|
-
version:
|
|
1298
|
-
$schema:
|
|
1325
|
+
version: "2.1.0",
|
|
1326
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
1299
1327
|
runs: [
|
|
1300
1328
|
{
|
|
1301
1329
|
tool: {
|
|
1302
1330
|
driver: {
|
|
1303
|
-
name:
|
|
1304
|
-
version:
|
|
1305
|
-
informationUri:
|
|
1331
|
+
name: "Pompelmi",
|
|
1332
|
+
version: "0.29.0",
|
|
1333
|
+
informationUri: "https://pompelmi.github.io/pompelmi/",
|
|
1306
1334
|
},
|
|
1307
1335
|
},
|
|
1308
1336
|
results,
|
|
1309
1337
|
},
|
|
1310
1338
|
],
|
|
1311
1339
|
};
|
|
1312
|
-
return options.prettyPrint
|
|
1313
|
-
? JSON.stringify(sarif, null, 2)
|
|
1314
|
-
: JSON.stringify(sarif);
|
|
1340
|
+
return options.prettyPrint ? JSON.stringify(sarif, null, 2) : JSON.stringify(sarif);
|
|
1315
1341
|
}
|
|
1316
1342
|
/**
|
|
1317
1343
|
* Export to HTML format
|
|
1318
1344
|
*/
|
|
1319
1345
|
toHTML(reports, options = {}) {
|
|
1320
1346
|
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 ===
|
|
1347
|
+
const clean = data.filter((r) => r.verdict === "clean").length;
|
|
1348
|
+
const suspicious = data.filter((r) => r.verdict === "suspicious").length;
|
|
1349
|
+
const malicious = data.filter((r) => r.verdict === "malicious").length;
|
|
1324
1350
|
let html = `<!DOCTYPE html>
|
|
1325
1351
|
<html lang="en">
|
|
1326
1352
|
<head>
|
|
@@ -1352,11 +1378,11 @@ class ScanResultExporter {
|
|
|
1352
1378
|
for (const report of data) {
|
|
1353
1379
|
const statusClass = report.verdict;
|
|
1354
1380
|
html += `<div class="result ${statusClass}">`;
|
|
1355
|
-
html += `<h3>${this.escapeHtml(report.file?.name ||
|
|
1381
|
+
html += `<h3>${this.escapeHtml(report.file?.name || "Unknown")}</h3>`;
|
|
1356
1382
|
html += `<table>`;
|
|
1357
1383
|
html += `<tr><th>Verdict</th><td>${report.verdict.toUpperCase()}</td></tr>`;
|
|
1358
1384
|
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 ||
|
|
1385
|
+
html += `<tr><th>MIME Type</th><td>${this.escapeHtml(report.file?.mimeType || "unknown")}</td></tr>`;
|
|
1360
1386
|
html += `<tr><th>Duration</th><td>${report.durationMs || 0}ms</td></tr>`;
|
|
1361
1387
|
html += `<tr><th>Matches</th><td>${report.matches.length}</td></tr>`;
|
|
1362
1388
|
html += `</table>`;
|
|
@@ -1365,7 +1391,7 @@ class ScanResultExporter {
|
|
|
1365
1391
|
for (const match of report.matches) {
|
|
1366
1392
|
html += `<li><strong>${this.escapeHtml(match.rule)}</strong>`;
|
|
1367
1393
|
if (match.tags && match.tags.length > 0) {
|
|
1368
|
-
html += ` ${match.tags.map(tag => `<span class="badge">${this.escapeHtml(tag)}</span>`).join(
|
|
1394
|
+
html += ` ${match.tags.map((tag) => `<span class="badge">${this.escapeHtml(tag)}</span>`).join("")}`;
|
|
1369
1395
|
}
|
|
1370
1396
|
html += `</li>`;
|
|
1371
1397
|
}
|
|
@@ -1381,41 +1407,41 @@ class ScanResultExporter {
|
|
|
1381
1407
|
*/
|
|
1382
1408
|
export(reports, format, options = {}) {
|
|
1383
1409
|
switch (format) {
|
|
1384
|
-
case
|
|
1410
|
+
case "json":
|
|
1385
1411
|
return this.toJSON(reports, options);
|
|
1386
|
-
case
|
|
1412
|
+
case "csv":
|
|
1387
1413
|
return this.toCSV(reports, options);
|
|
1388
|
-
case
|
|
1414
|
+
case "markdown":
|
|
1389
1415
|
return this.toMarkdown(reports, options);
|
|
1390
|
-
case
|
|
1416
|
+
case "html":
|
|
1391
1417
|
return this.toHTML(reports, options);
|
|
1392
|
-
case
|
|
1418
|
+
case "sarif":
|
|
1393
1419
|
return this.toSARIF(reports, options);
|
|
1394
1420
|
default:
|
|
1395
1421
|
throw new Error(`Unsupported export format: ${format}`);
|
|
1396
1422
|
}
|
|
1397
1423
|
}
|
|
1398
1424
|
escapeCsv(value) {
|
|
1399
|
-
if (value.includes(
|
|
1425
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
1400
1426
|
return `"${value.replace(/"/g, '""')}"`;
|
|
1401
1427
|
}
|
|
1402
1428
|
return value;
|
|
1403
1429
|
}
|
|
1404
1430
|
escapeHtml(value) {
|
|
1405
1431
|
return value
|
|
1406
|
-
.replace(/&/g,
|
|
1407
|
-
.replace(/</g,
|
|
1408
|
-
.replace(/>/g,
|
|
1409
|
-
.replace(/"/g,
|
|
1410
|
-
.replace(/'/g,
|
|
1432
|
+
.replace(/&/g, "&")
|
|
1433
|
+
.replace(/</g, "<")
|
|
1434
|
+
.replace(/>/g, ">")
|
|
1435
|
+
.replace(/"/g, """)
|
|
1436
|
+
.replace(/'/g, "'");
|
|
1411
1437
|
}
|
|
1412
1438
|
formatBytes(bytes) {
|
|
1413
1439
|
if (bytes === 0)
|
|
1414
|
-
return
|
|
1440
|
+
return "0 Bytes";
|
|
1415
1441
|
const k = 1024;
|
|
1416
|
-
const sizes = [
|
|
1442
|
+
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
1417
1443
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1418
|
-
return Math.round(bytes /
|
|
1444
|
+
return Math.round((bytes / k ** i) * 100) / 100 + " " + sizes[i];
|
|
1419
1445
|
}
|
|
1420
1446
|
}
|
|
1421
1447
|
/**
|
|
@@ -1426,6 +1452,31 @@ function exportScanResults(reports, format, options) {
|
|
|
1426
1452
|
return exporter.export(reports, format, options);
|
|
1427
1453
|
}
|
|
1428
1454
|
|
|
1455
|
+
/**
|
|
1456
|
+
* Validates a File by MIME type and size (max 5 MB).
|
|
1457
|
+
*/
|
|
1458
|
+
function validateFile(file) {
|
|
1459
|
+
const maxSize = 5 * 1024 * 1024;
|
|
1460
|
+
const allowedTypes = ["text/plain", "application/json", "text/csv"];
|
|
1461
|
+
if (!allowedTypes.includes(file.type)) {
|
|
1462
|
+
return { valid: false, error: "Unsupported file type" };
|
|
1463
|
+
}
|
|
1464
|
+
if (file.size > maxSize) {
|
|
1465
|
+
return { valid: false, error: "File too large (max 5 MB)" };
|
|
1466
|
+
}
|
|
1467
|
+
return { valid: true };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function mapMatchesToVerdict(matches = []) {
|
|
1471
|
+
if (!matches.length)
|
|
1472
|
+
return "clean";
|
|
1473
|
+
const malHints = ["trojan", "ransom", "worm", "spy", "rootkit", "keylog", "botnet"];
|
|
1474
|
+
const tagSet = new Set(matches.flatMap((m) => (m.tags ?? []).map((t) => t.toLowerCase())));
|
|
1475
|
+
const nameHit = (r) => malHints.some((h) => r.toLowerCase().includes(h));
|
|
1476
|
+
const isMal = matches.some((m) => nameHit(m.rule)) || tagSet.has("malware") || tagSet.has("critical");
|
|
1477
|
+
return isMal ? "malicious" : "suspicious";
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1429
1480
|
/**
|
|
1430
1481
|
* React Hook: handles <input type="file" onChange> with validation + scanning.
|
|
1431
1482
|
*/
|