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