pompelmi 0.34.10 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -15
- package/dist/pompelmi.audit.cjs +13 -15
- package/dist/pompelmi.audit.cjs.map +1 -1
- package/dist/pompelmi.audit.esm.js +13 -15
- package/dist/pompelmi.audit.esm.js.map +1 -1
- package/dist/pompelmi.browser.cjs +595 -557
- package/dist/pompelmi.browser.cjs.map +1 -1
- package/dist/pompelmi.browser.esm.js +595 -557
- package/dist/pompelmi.browser.esm.js.map +1 -1
- package/dist/pompelmi.cjs +2056 -2015
- package/dist/pompelmi.cjs.map +1 -1
- package/dist/pompelmi.esm.js +2056 -2015
- package/dist/pompelmi.esm.js.map +1 -1
- package/dist/pompelmi.hooks.cjs +2 -2
- package/dist/pompelmi.hooks.cjs.map +1 -1
- package/dist/pompelmi.hooks.esm.js +2 -2
- package/dist/pompelmi.hooks.esm.js.map +1 -1
- package/dist/pompelmi.policy-packs.cjs +74 -73
- package/dist/pompelmi.policy-packs.cjs.map +1 -1
- package/dist/pompelmi.policy-packs.esm.js +74 -73
- package/dist/pompelmi.policy-packs.esm.js.map +1 -1
- package/dist/pompelmi.quarantine.cjs +135 -133
- package/dist/pompelmi.quarantine.cjs.map +1 -1
- package/dist/pompelmi.quarantine.esm.js +135 -133
- package/dist/pompelmi.quarantine.esm.js.map +1 -1
- package/dist/pompelmi.react.cjs +595 -557
- package/dist/pompelmi.react.cjs.map +1 -1
- package/dist/pompelmi.react.esm.js +595 -557
- package/dist/pompelmi.react.esm.js.map +1 -1
- package/dist/types/audit.d.ts +12 -12
- package/dist/types/browser-index.d.ts +12 -12
- package/dist/types/config.d.ts +4 -4
- package/dist/types/engines/dynamic-taint.d.ts +1 -1
- package/dist/types/engines/hybrid-orchestrator.d.ts +1 -1
- package/dist/types/engines/hybrid-taint-integration.d.ts +6 -6
- package/dist/types/engines/taint-policies.d.ts +4 -4
- package/dist/types/hipaa-compliance.d.ts +2 -2
- package/dist/types/hooks.d.ts +2 -2
- package/dist/types/index.d.ts +20 -20
- package/dist/types/node/scanDir.d.ts +5 -5
- package/dist/types/policy-packs.d.ts +2 -2
- package/dist/types/presets.d.ts +3 -3
- package/dist/types/quarantine/index.d.ts +3 -3
- package/dist/types/quarantine/storage.d.ts +1 -1
- package/dist/types/quarantine/types.d.ts +3 -3
- package/dist/types/quarantine/workflow.d.ts +4 -4
- package/dist/types/react-index.d.ts +2 -2
- package/dist/types/risk.d.ts +1 -1
- package/dist/types/scan/remote.d.ts +2 -2
- package/dist/types/scan.d.ts +5 -5
- package/dist/types/scanners/common-heuristics.d.ts +1 -1
- package/dist/types/scanners/zip-bomb-guard.d.ts +1 -1
- package/dist/types/src/audit.d.ts +84 -0
- package/dist/types/src/browser-index.d.ts +29 -0
- package/dist/types/src/config.d.ts +143 -0
- package/dist/types/src/engines/dynamic-taint.d.ts +102 -0
- package/dist/types/src/engines/hybrid-orchestrator.d.ts +65 -0
- package/dist/types/src/engines/hybrid-taint-integration.d.ts +129 -0
- package/dist/types/src/engines/taint-policies.d.ts +84 -0
- package/dist/types/src/hipaa-compliance.d.ts +110 -0
- package/dist/types/src/hooks.d.ts +89 -0
- package/dist/types/src/index.d.ts +29 -0
- package/dist/types/src/magic.d.ts +7 -0
- package/dist/types/src/node/scanDir.d.ts +30 -0
- package/dist/types/src/policy-packs.d.ts +98 -0
- package/dist/types/src/policy.d.ts +12 -0
- package/dist/types/src/presets.d.ts +72 -0
- package/dist/types/src/quarantine/index.d.ts +18 -0
- package/dist/types/src/quarantine/storage.d.ts +77 -0
- package/dist/types/src/quarantine/types.d.ts +78 -0
- package/dist/types/src/quarantine/workflow.d.ts +97 -0
- package/dist/types/src/react-index.d.ts +13 -0
- package/dist/types/src/risk.d.ts +18 -0
- package/dist/types/src/scan/remote.d.ts +12 -0
- package/dist/types/src/scan.d.ts +17 -0
- package/dist/types/src/scanners/common-heuristics.d.ts +14 -0
- package/dist/types/src/scanners/zip-bomb-guard.d.ts +9 -0
- package/dist/types/src/scanners/zipTraversalGuard.d.ts +19 -0
- package/dist/types/src/stream.d.ts +10 -0
- package/dist/types/src/types/decompilation.d.ts +96 -0
- package/dist/types/src/types/taint-tracking.d.ts +495 -0
- package/dist/types/src/types.d.ts +48 -0
- package/dist/types/src/useFileScanner.d.ts +15 -0
- package/dist/types/src/utils/advanced-detection.d.ts +21 -0
- package/dist/types/src/utils/batch-scanner.d.ts +62 -0
- package/dist/types/src/utils/cache-manager.d.ts +95 -0
- package/dist/types/src/utils/export.d.ts +51 -0
- package/dist/types/src/utils/performance-metrics.d.ts +68 -0
- package/dist/types/src/utils/threat-intelligence.d.ts +96 -0
- package/dist/types/src/validate.d.ts +7 -0
- package/dist/types/src/verdict.d.ts +2 -0
- package/dist/types/src/yara/browser.d.ts +7 -0
- package/dist/types/src/yara/index.d.ts +17 -0
- package/dist/types/src/yara/node.d.ts +2 -0
- package/dist/types/src/yara/remote.d.ts +10 -0
- package/dist/types/src/yara-bridge.d.ts +3 -0
- package/dist/types/src/zip.d.ts +13 -0
- package/dist/types/types/decompilation.d.ts +4 -4
- package/dist/types/types/taint-tracking.d.ts +19 -19
- package/dist/types/types.d.ts +3 -3
- package/dist/types/useFileScanner.d.ts +1 -1
- package/dist/types/utils/advanced-detection.d.ts +1 -1
- package/dist/types/utils/batch-scanner.d.ts +3 -3
- package/dist/types/utils/cache-manager.d.ts +1 -1
- package/dist/types/utils/export.d.ts +2 -2
- package/dist/types/utils/threat-intelligence.d.ts +4 -4
- package/dist/types/verdict.d.ts +1 -1
- package/dist/types/yara/browser.d.ts +1 -1
- package/dist/types/yara/index.d.ts +1 -1
- package/dist/types/yara/node.d.ts +1 -1
- package/dist/types/yara/remote.d.ts +2 -2
- package/package.json +7 -7
|
@@ -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
|
: {};
|
|
@@ -132,7 +374,7 @@ function composeScanners(...args) {
|
|
|
132
374
|
const all = [];
|
|
133
375
|
if (opts.parallel) {
|
|
134
376
|
// Parallel execution — collect all results then return
|
|
135
|
-
const results = await Promise.allSettled(entries.map(([
|
|
377
|
+
const results = await Promise.allSettled(entries.map(([_name, scanner]) => runWithTimeout(() => toScanFn(scanner)(input, ctx), opts.timeoutMsPerScanner)));
|
|
136
378
|
for (let i = 0; i < results.length; i++) {
|
|
137
379
|
const result = results[i];
|
|
138
380
|
if (result.status === "fulfilled" && Array.isArray(result.value)) {
|
|
@@ -186,152 +428,61 @@ function composeScanners(...args) {
|
|
|
186
428
|
};
|
|
187
429
|
}
|
|
188
430
|
function createPresetScanner(preset, opts = {}) {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
scanners.push(CommonHeuristicsScanner);
|
|
431
|
+
const baseScanners = [CommonHeuristicsScanner];
|
|
432
|
+
const dynamicScannerPromises = [];
|
|
192
433
|
// Add decompilation scanners based on preset
|
|
193
|
-
if (preset ===
|
|
194
|
-
preset ===
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
pythonPath: opts.pythonPath,
|
|
207
|
-
binaryNinjaPath: opts.binaryNinjaPath
|
|
208
|
-
});
|
|
209
|
-
scanners.push(binjaScanner);
|
|
210
|
-
}).catch(() => {
|
|
211
|
-
// Binary Ninja engine not available - silently skip
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
// Engine not installed
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
if (!opts.decompilationEngine || opts.decompilationEngine === 'ghidra-pcode' || opts.decompilationEngine === 'both') {
|
|
219
|
-
try {
|
|
220
|
-
// Dynamic import for Ghidra engine (when implemented) - using Function to bypass TypeScript type checking
|
|
221
|
-
const importModule = new Function('specifier', 'return import(specifier)');
|
|
222
|
-
importModule('@pompelmi/engine-ghidra').then((mod) => {
|
|
223
|
-
const ghidraScanner = mod.createGhidraScanner({
|
|
224
|
-
timeout: opts.decompilationTimeout || opts.timeout || 30000,
|
|
225
|
-
depth,
|
|
226
|
-
ghidraPath: opts.ghidraPath,
|
|
227
|
-
analyzeHeadless: opts.analyzeHeadless
|
|
228
|
-
});
|
|
229
|
-
scanners.push(ghidraScanner);
|
|
230
|
-
}).catch(() => {
|
|
231
|
-
// Ghidra engine not available - silently skip
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
catch {
|
|
235
|
-
// Engine not installed
|
|
236
|
-
}
|
|
434
|
+
if (preset === "decompilation-basic" ||
|
|
435
|
+
preset === "decompilation-deep" ||
|
|
436
|
+
preset === "malware-analysis" ||
|
|
437
|
+
opts.enableDecompilation) {
|
|
438
|
+
const depth = preset === "decompilation-deep" || preset === "malware-analysis"
|
|
439
|
+
? "deep"
|
|
440
|
+
: preset === "decompilation-basic"
|
|
441
|
+
? "basic"
|
|
442
|
+
: opts.decompilationDepth || "basic";
|
|
443
|
+
let importModule;
|
|
444
|
+
try {
|
|
445
|
+
// Dynamic import to avoid bundling issues - using Function to bypass TypeScript type checking
|
|
446
|
+
importModule = new Function("specifier", "return import(specifier)");
|
|
237
447
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
// Fallback scanner that returns no matches
|
|
241
|
-
return async (_input, _ctx) => {
|
|
242
|
-
return [];
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
return composeScanners(...scanners);
|
|
246
|
-
}
|
|
247
|
-
|
|
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);
|
|
448
|
+
catch {
|
|
449
|
+
importModule = undefined;
|
|
273
450
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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++;
|
|
451
|
+
if (importModule &&
|
|
452
|
+
(!opts.decompilationEngine ||
|
|
453
|
+
opts.decompilationEngine === "binaryninja-hlil" ||
|
|
454
|
+
opts.decompilationEngine === "both")) {
|
|
455
|
+
dynamicScannerPromises.push(importModule("@pompelmi/engine-binaryninja")
|
|
456
|
+
.then((mod) => mod.createBinaryNinjaScanner({
|
|
457
|
+
timeout: opts.decompilationTimeout || opts.timeout || 30000,
|
|
458
|
+
depth,
|
|
459
|
+
pythonPath: opts.pythonPath,
|
|
460
|
+
binaryNinjaPath: opts.binaryNinjaPath,
|
|
461
|
+
}))
|
|
462
|
+
.catch(() => null));
|
|
320
463
|
}
|
|
321
|
-
if (
|
|
322
|
-
|
|
464
|
+
if (importModule &&
|
|
465
|
+
(!opts.decompilationEngine ||
|
|
466
|
+
opts.decompilationEngine === "ghidra-pcode" ||
|
|
467
|
+
opts.decompilationEngine === "both")) {
|
|
468
|
+
dynamicScannerPromises.push(importModule("@pompelmi/engine-ghidra")
|
|
469
|
+
.then((mod) => mod.createGhidraScanner({
|
|
470
|
+
timeout: opts.decompilationTimeout || opts.timeout || 30000,
|
|
471
|
+
depth,
|
|
472
|
+
ghidraPath: opts.ghidraPath,
|
|
473
|
+
analyzeHeadless: opts.analyzeHeadless,
|
|
474
|
+
}))
|
|
475
|
+
.catch(() => null));
|
|
323
476
|
}
|
|
324
477
|
}
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
avgThroughputBps: avgThroughput,
|
|
334
|
-
totalBytesScanned: totalBytes,
|
|
478
|
+
let composedScannerPromise;
|
|
479
|
+
const getComposedScanner = async () => {
|
|
480
|
+
composedScannerPromise ?? (composedScannerPromise = Promise.all(dynamicScannerPromises).then((dynamicScanners) => composeScanners(...baseScanners, ...dynamicScanners.filter((scanner) => scanner !== null))));
|
|
481
|
+
return composedScannerPromise;
|
|
482
|
+
};
|
|
483
|
+
return async (input, ctx) => {
|
|
484
|
+
const scanner = await getComposedScanner();
|
|
485
|
+
return scanner(input, ctx);
|
|
335
486
|
};
|
|
336
487
|
}
|
|
337
488
|
|
|
@@ -348,25 +499,25 @@ function detectPolyglot(bytes) {
|
|
|
348
499
|
// Check for PDF/ZIP polyglot
|
|
349
500
|
if (isPDFZipPolyglot(bytes)) {
|
|
350
501
|
matches.push({
|
|
351
|
-
rule:
|
|
352
|
-
severity:
|
|
353
|
-
meta: { description:
|
|
502
|
+
rule: "polyglot_pdf_zip",
|
|
503
|
+
severity: "high",
|
|
504
|
+
meta: { description: "File can be interpreted as both PDF and ZIP" },
|
|
354
505
|
});
|
|
355
506
|
}
|
|
356
507
|
// Check for image/script polyglot
|
|
357
508
|
if (isImageScriptPolyglot(bytes)) {
|
|
358
509
|
matches.push({
|
|
359
|
-
rule:
|
|
360
|
-
severity:
|
|
361
|
-
meta: { description:
|
|
510
|
+
rule: "polyglot_image_script",
|
|
511
|
+
severity: "high",
|
|
512
|
+
meta: { description: "Image file contains executable script content" },
|
|
362
513
|
});
|
|
363
514
|
}
|
|
364
515
|
// Check for GIFAR (GIF/JAR polyglot)
|
|
365
516
|
if (isGIFAR(bytes)) {
|
|
366
517
|
matches.push({
|
|
367
|
-
rule:
|
|
368
|
-
severity:
|
|
369
|
-
meta: { description:
|
|
518
|
+
rule: "polyglot_gifar",
|
|
519
|
+
severity: "critical",
|
|
520
|
+
meta: { description: "GIF file contains Java archive" },
|
|
370
521
|
});
|
|
371
522
|
}
|
|
372
523
|
return matches;
|
|
@@ -376,7 +527,7 @@ function detectPolyglot(bytes) {
|
|
|
376
527
|
*/
|
|
377
528
|
function detectObfuscatedScripts(bytes) {
|
|
378
529
|
const matches = [];
|
|
379
|
-
const text = new TextDecoder(
|
|
530
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, Math.min(64 * 1024, bytes.length)));
|
|
380
531
|
// Check for common obfuscation patterns
|
|
381
532
|
const obfuscationPatterns = [
|
|
382
533
|
/eval\s*\(\s*unescape\s*\(/gi,
|
|
@@ -388,10 +539,10 @@ function detectObfuscatedScripts(bytes) {
|
|
|
388
539
|
for (const pattern of obfuscationPatterns) {
|
|
389
540
|
if (pattern.test(text)) {
|
|
390
541
|
matches.push({
|
|
391
|
-
rule:
|
|
392
|
-
severity:
|
|
542
|
+
rule: "obfuscated_script",
|
|
543
|
+
severity: "medium",
|
|
393
544
|
meta: {
|
|
394
|
-
description:
|
|
545
|
+
description: "Detected obfuscated script content",
|
|
395
546
|
pattern: pattern.source,
|
|
396
547
|
},
|
|
397
548
|
});
|
|
@@ -431,7 +582,10 @@ function isPDFZipPolyglot(bytes) {
|
|
|
431
582
|
// Check for ZIP signature anywhere in the file
|
|
432
583
|
let hasZIP = false;
|
|
433
584
|
for (let i = 0; i < Math.min(bytes.length - 4, 1024); i++) {
|
|
434
|
-
if (bytes[i] === 0x50 &&
|
|
585
|
+
if (bytes[i] === 0x50 &&
|
|
586
|
+
bytes[i + 1] === 0x4b &&
|
|
587
|
+
bytes[i + 2] === 0x03 &&
|
|
588
|
+
bytes[i + 3] === 0x04) {
|
|
435
589
|
hasZIP = true;
|
|
436
590
|
break;
|
|
437
591
|
}
|
|
@@ -442,14 +596,13 @@ function isImageScriptPolyglot(bytes) {
|
|
|
442
596
|
if (bytes.length < 100)
|
|
443
597
|
return false;
|
|
444
598
|
// 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
|
-
);
|
|
599
|
+
const isImage = (bytes[0] === 0xff && bytes[1] === 0xd8) || // JPEG
|
|
600
|
+
(bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) || // PNG
|
|
601
|
+
(bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46); // GIF
|
|
449
602
|
if (!isImage)
|
|
450
603
|
return false;
|
|
451
604
|
// Check for script content
|
|
452
|
-
const text = new TextDecoder(
|
|
605
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
453
606
|
return /<script|javascript:|eval\(|function\s*\(/i.test(text);
|
|
454
607
|
}
|
|
455
608
|
function isGIFAR(bytes) {
|
|
@@ -460,7 +613,10 @@ function isGIFAR(bytes) {
|
|
|
460
613
|
// Check for ZIP/JAR signature
|
|
461
614
|
let hasZIP = false;
|
|
462
615
|
for (let i = 0; i < Math.min(bytes.length - 4, 1024); i++) {
|
|
463
|
-
if (bytes[i] === 0x50 &&
|
|
616
|
+
if (bytes[i] === 0x50 &&
|
|
617
|
+
bytes[i + 1] === 0x4b &&
|
|
618
|
+
bytes[i + 2] === 0x03 &&
|
|
619
|
+
bytes[i + 3] === 0x04) {
|
|
464
620
|
hasZIP = true;
|
|
465
621
|
break;
|
|
466
622
|
}
|
|
@@ -472,13 +628,13 @@ function isArchive(bytes) {
|
|
|
472
628
|
return false;
|
|
473
629
|
return (
|
|
474
630
|
// ZIP
|
|
475
|
-
(bytes[0] === 0x50 && bytes[1] ===
|
|
631
|
+
(bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04) ||
|
|
476
632
|
// RAR
|
|
477
633
|
(bytes[0] === 0x52 && bytes[1] === 0x61 && bytes[2] === 0x72 && bytes[3] === 0x21) ||
|
|
478
634
|
// 7z
|
|
479
|
-
(bytes[0] === 0x37 && bytes[1] ===
|
|
635
|
+
(bytes[0] === 0x37 && bytes[1] === 0x7a && bytes[2] === 0xbc && bytes[3] === 0xaf) ||
|
|
480
636
|
// tar.gz
|
|
481
|
-
(bytes[0] ===
|
|
637
|
+
(bytes[0] === 0x1f && bytes[1] === 0x8b));
|
|
482
638
|
}
|
|
483
639
|
|
|
484
640
|
/**
|
|
@@ -506,10 +662,10 @@ class ScanCacheManager {
|
|
|
506
662
|
* Generate cache key from file content
|
|
507
663
|
*/
|
|
508
664
|
generateKey(content, preset) {
|
|
509
|
-
const hash = crypto.createHash(
|
|
665
|
+
const hash = crypto.createHash("sha256")
|
|
510
666
|
.update(content)
|
|
511
|
-
.update(preset ||
|
|
512
|
-
.digest(
|
|
667
|
+
.update(preset || "default")
|
|
668
|
+
.digest("hex");
|
|
513
669
|
return hash;
|
|
514
670
|
}
|
|
515
671
|
/**
|
|
@@ -653,35 +809,132 @@ function getDefaultCache(options) {
|
|
|
653
809
|
return defaultCache;
|
|
654
810
|
}
|
|
655
811
|
|
|
656
|
-
/**
|
|
812
|
+
/**
|
|
813
|
+
* Performance monitoring utilities for pompelmi scans
|
|
814
|
+
* @module utils/performance-metrics
|
|
815
|
+
*/
|
|
816
|
+
/**
|
|
817
|
+
* Track performance metrics for a scan operation
|
|
818
|
+
*/
|
|
819
|
+
class PerformanceTracker {
|
|
820
|
+
constructor() {
|
|
821
|
+
this.checkpoints = new Map();
|
|
822
|
+
this.startTime = Date.now();
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Mark a checkpoint in the scan process
|
|
826
|
+
*/
|
|
827
|
+
checkpoint(name) {
|
|
828
|
+
this.checkpoints.set(name, Date.now());
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Get duration since start or since a specific checkpoint
|
|
832
|
+
*/
|
|
833
|
+
getDuration(since) {
|
|
834
|
+
const now = Date.now();
|
|
835
|
+
if (since && this.checkpoints.has(since)) {
|
|
836
|
+
return now - (this.checkpoints.get(since) ?? now);
|
|
837
|
+
}
|
|
838
|
+
return now - this.startTime;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Generate final metrics report
|
|
842
|
+
*/
|
|
843
|
+
getMetrics(bytesScanned) {
|
|
844
|
+
const totalDuration = this.getDuration();
|
|
845
|
+
const throughput = totalDuration > 0 ? (bytesScanned / totalDuration) * 1000 : 0;
|
|
846
|
+
return {
|
|
847
|
+
totalDurationMs: totalDuration,
|
|
848
|
+
heuristicsDurationMs: this.checkpoints.has("heuristics_end")
|
|
849
|
+
? (this.checkpoints.get("heuristics_end") ?? 0) -
|
|
850
|
+
(this.checkpoints.get("heuristics_start") ?? 0)
|
|
851
|
+
: undefined,
|
|
852
|
+
yaraDurationMs: this.checkpoints.has("yara_end")
|
|
853
|
+
? (this.checkpoints.get("yara_end") ?? 0) - (this.checkpoints.get("yara_start") ?? 0)
|
|
854
|
+
: undefined,
|
|
855
|
+
prepDurationMs: this.checkpoints.has("prep_end")
|
|
856
|
+
? (this.checkpoints.get("prep_end") ?? 0) - this.startTime
|
|
857
|
+
: undefined,
|
|
858
|
+
throughputBps: throughput,
|
|
859
|
+
bytesScanned,
|
|
860
|
+
startedAt: this.startTime,
|
|
861
|
+
completedAt: Date.now(),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Aggregate statistics from multiple scan reports
|
|
867
|
+
*/
|
|
868
|
+
function aggregateScanStats(reports) {
|
|
869
|
+
let cleanCount = 0;
|
|
870
|
+
let suspiciousCount = 0;
|
|
871
|
+
let maliciousCount = 0;
|
|
872
|
+
let totalDuration = 0;
|
|
873
|
+
let totalBytes = 0;
|
|
874
|
+
let validDurationCount = 0;
|
|
875
|
+
for (const report of reports) {
|
|
876
|
+
if (report.verdict === "clean")
|
|
877
|
+
cleanCount++;
|
|
878
|
+
else if (report.verdict === "suspicious")
|
|
879
|
+
suspiciousCount++;
|
|
880
|
+
else if (report.verdict === "malicious")
|
|
881
|
+
maliciousCount++;
|
|
882
|
+
if (report.durationMs !== undefined) {
|
|
883
|
+
totalDuration += report.durationMs;
|
|
884
|
+
validDurationCount++;
|
|
885
|
+
}
|
|
886
|
+
if (report.file?.size !== undefined) {
|
|
887
|
+
totalBytes += report.file.size;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const avgDuration = validDurationCount > 0 ? totalDuration / validDurationCount : 0;
|
|
891
|
+
const avgThroughput = totalDuration > 0 ? (totalBytes / totalDuration) * 1000 : 0;
|
|
892
|
+
return {
|
|
893
|
+
totalScans: reports.length,
|
|
894
|
+
cleanCount,
|
|
895
|
+
suspiciousCount,
|
|
896
|
+
maliciousCount,
|
|
897
|
+
avgDurationMs: avgDuration,
|
|
898
|
+
avgThroughputBps: avgThroughput,
|
|
899
|
+
totalBytesScanned: totalBytes,
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Mappa veloce estensione -> mime (basic) */
|
|
657
904
|
function guessMimeByExt(name) {
|
|
658
905
|
if (!name)
|
|
659
906
|
return;
|
|
660
|
-
const ext = name.toLowerCase().split(
|
|
907
|
+
const ext = name.toLowerCase().split(".").pop();
|
|
661
908
|
switch (ext) {
|
|
662
|
-
case
|
|
663
|
-
|
|
664
|
-
case
|
|
665
|
-
|
|
666
|
-
case
|
|
667
|
-
case
|
|
668
|
-
|
|
909
|
+
case "zip":
|
|
910
|
+
return "application/zip";
|
|
911
|
+
case "png":
|
|
912
|
+
return "image/png";
|
|
913
|
+
case "jpg":
|
|
914
|
+
case "jpeg":
|
|
915
|
+
return "image/jpeg";
|
|
916
|
+
case "pdf":
|
|
917
|
+
return "application/pdf";
|
|
918
|
+
case "txt":
|
|
919
|
+
return "text/plain";
|
|
920
|
+
default:
|
|
921
|
+
return;
|
|
669
922
|
}
|
|
670
923
|
}
|
|
671
924
|
/** Heuristica semplice per verdetto */
|
|
672
925
|
function computeVerdict(matches) {
|
|
673
926
|
if (!matches.length)
|
|
674
|
-
return
|
|
927
|
+
return "clean";
|
|
675
928
|
// se la regola contiene 'zip_' lo marchiamo "suspicious"
|
|
676
|
-
const anyHigh = matches.some(m => (m.tags ?? []).includes(
|
|
677
|
-
return anyHigh ?
|
|
929
|
+
const anyHigh = matches.some((m) => (m.tags ?? []).includes("critical") || (m.tags ?? []).includes("high"));
|
|
930
|
+
return anyHigh ? "malicious" : "suspicious";
|
|
678
931
|
}
|
|
679
932
|
/** Converte i Match (heuristics) in YaraMatch-like per uniformare l'output */
|
|
680
933
|
function toYaraMatches(ms) {
|
|
681
|
-
return ms.map(m => ({
|
|
934
|
+
return ms.map((m) => ({
|
|
682
935
|
rule: m.rule,
|
|
683
|
-
namespace:
|
|
684
|
-
tags: [
|
|
936
|
+
namespace: "heuristics",
|
|
937
|
+
tags: ["heuristics"].concat(m.severity ? [m.severity] : []),
|
|
685
938
|
meta: m.meta,
|
|
686
939
|
}));
|
|
687
940
|
}
|
|
@@ -695,26 +948,28 @@ async function scanBytes(input, opts = {}) {
|
|
|
695
948
|
return cached;
|
|
696
949
|
}
|
|
697
950
|
}
|
|
698
|
-
const perfTracker =
|
|
951
|
+
const perfTracker = opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking
|
|
699
952
|
? new PerformanceTracker()
|
|
700
953
|
: null;
|
|
701
|
-
perfTracker?.checkpoint(
|
|
702
|
-
const preset = opts.preset ?? opts.config?.defaultPreset ??
|
|
954
|
+
perfTracker?.checkpoint("prep_start");
|
|
955
|
+
const preset = opts.preset ?? opts.config?.defaultPreset ?? "zip-basic";
|
|
703
956
|
const ctx = {
|
|
704
957
|
...opts.ctx,
|
|
705
958
|
mimeType: opts.ctx?.mimeType ?? guessMimeByExt(opts.ctx?.filename),
|
|
706
959
|
size: opts.ctx?.size ?? input.byteLength,
|
|
707
960
|
};
|
|
708
|
-
perfTracker?.checkpoint(
|
|
709
|
-
perfTracker?.checkpoint(
|
|
961
|
+
perfTracker?.checkpoint("prep_end");
|
|
962
|
+
perfTracker?.checkpoint("heuristics_start");
|
|
710
963
|
const scanFn = createPresetScanner(preset);
|
|
711
|
-
const matchesH = await (typeof scanFn === "function"
|
|
712
|
-
|
|
713
|
-
|
|
964
|
+
const matchesH = await (typeof scanFn === "function"
|
|
965
|
+
? scanFn
|
|
966
|
+
: scanFn.scan)(input, ctx);
|
|
967
|
+
const allMatches = [...matchesH];
|
|
968
|
+
perfTracker?.checkpoint("heuristics_end");
|
|
714
969
|
// Advanced detection (enabled by default, can be overridden by config)
|
|
715
970
|
const advancedEnabled = opts.enableAdvancedDetection ?? opts.config?.advanced?.enablePolyglotDetection ?? true;
|
|
716
971
|
if (advancedEnabled) {
|
|
717
|
-
perfTracker?.checkpoint(
|
|
972
|
+
perfTracker?.checkpoint("advanced_start");
|
|
718
973
|
// Detect polyglot files
|
|
719
974
|
if (opts.config?.advanced?.enablePolyglotDetection !== false) {
|
|
720
975
|
const polyglotMatches = detectPolyglot(input);
|
|
@@ -729,37 +984,38 @@ async function scanBytes(input, opts = {}) {
|
|
|
729
984
|
if (opts.config?.advanced?.enableNestedArchiveAnalysis !== false) {
|
|
730
985
|
const nestingAnalysis = analyzeNestedArchives(input);
|
|
731
986
|
const maxDepth = opts.config?.advanced?.maxArchiveDepth ?? 5;
|
|
732
|
-
if (nestingAnalysis.hasExcessiveNesting ||
|
|
987
|
+
if (nestingAnalysis.hasExcessiveNesting || nestingAnalysis.depth > maxDepth) {
|
|
733
988
|
allMatches.push({
|
|
734
|
-
rule:
|
|
735
|
-
severity:
|
|
989
|
+
rule: "excessive_archive_nesting",
|
|
990
|
+
severity: "high",
|
|
736
991
|
meta: {
|
|
737
|
-
description:
|
|
992
|
+
description: "Excessive archive nesting detected",
|
|
738
993
|
depth: nestingAnalysis.depth,
|
|
739
994
|
maxAllowed: maxDepth,
|
|
740
995
|
},
|
|
741
996
|
});
|
|
742
997
|
}
|
|
743
998
|
}
|
|
744
|
-
perfTracker?.checkpoint(
|
|
999
|
+
perfTracker?.checkpoint("advanced_end");
|
|
745
1000
|
}
|
|
746
1001
|
const matches = toYaraMatches(allMatches);
|
|
747
1002
|
const verdict = computeVerdict(matches);
|
|
748
1003
|
perfTracker ? perfTracker.getDuration() : Date.now();
|
|
749
1004
|
const durationMs = perfTracker ? perfTracker.getDuration() : 0;
|
|
750
1005
|
const report = {
|
|
751
|
-
ok: verdict ===
|
|
1006
|
+
ok: verdict === "clean",
|
|
752
1007
|
verdict,
|
|
753
1008
|
matches,
|
|
754
|
-
reasons: matches.map(m => m.rule),
|
|
1009
|
+
reasons: matches.map((m) => m.rule),
|
|
755
1010
|
file: { name: ctx.filename, mimeType: ctx.mimeType, size: ctx.size },
|
|
756
1011
|
durationMs,
|
|
757
|
-
engine:
|
|
1012
|
+
engine: "heuristics",
|
|
758
1013
|
truncated: false,
|
|
759
1014
|
timedOut: false,
|
|
760
1015
|
};
|
|
761
1016
|
// Add performance metrics if tracking enabled
|
|
762
|
-
if (perfTracker &&
|
|
1017
|
+
if (perfTracker &&
|
|
1018
|
+
(opts.enablePerformanceTracking || opts.config?.performance?.enablePerformanceTracking)) {
|
|
763
1019
|
report.performanceMetrics = perfTracker.getMetrics(input.byteLength);
|
|
764
1020
|
}
|
|
765
1021
|
// Cache result if enabled
|
|
@@ -773,10 +1029,7 @@ async function scanBytes(input, opts = {}) {
|
|
|
773
1029
|
}
|
|
774
1030
|
/** Scan di un file su disco (Node). Import dinamico per non vincolare il bundle browser. */
|
|
775
1031
|
async function scanFile(filePath, opts = {}) {
|
|
776
|
-
const [{ readFile, stat }, path] = await Promise.all([
|
|
777
|
-
import('fs/promises'),
|
|
778
|
-
import('path'),
|
|
779
|
-
]);
|
|
1032
|
+
const [{ readFile, stat }, path] = await Promise.all([import('fs/promises'), import('path')]);
|
|
780
1033
|
const [buf, st] = await Promise.all([readFile(filePath), stat(filePath)]);
|
|
781
1034
|
const ctx = {
|
|
782
1035
|
filename: path.basename(filePath),
|
|
@@ -800,21 +1053,6 @@ async function scanFiles(files, opts = {}) {
|
|
|
800
1053
|
return out;
|
|
801
1054
|
}
|
|
802
1055
|
|
|
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
1056
|
const SIG_CEN = 0x02014b50;
|
|
819
1057
|
const DEFAULTS = {
|
|
820
1058
|
maxEntries: 1000,
|
|
@@ -831,7 +1069,7 @@ function r32(buf, off) {
|
|
|
831
1069
|
}
|
|
832
1070
|
function isZipLike(buf) {
|
|
833
1071
|
// local file header at start is common
|
|
834
|
-
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04;
|
|
1072
|
+
return (buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
|
|
835
1073
|
}
|
|
836
1074
|
function lastIndexOfEOCD(buf, window) {
|
|
837
1075
|
const sig = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
@@ -840,7 +1078,7 @@ function lastIndexOfEOCD(buf, window) {
|
|
|
840
1078
|
return idx >= start ? idx : -1;
|
|
841
1079
|
}
|
|
842
1080
|
function hasTraversal(name) {
|
|
843
|
-
return name.includes(
|
|
1081
|
+
return (name.includes("../") || name.includes("..\\") || name.startsWith("/") || /^[A-Za-z]:/.test(name));
|
|
844
1082
|
}
|
|
845
1083
|
function createZipBombGuard(opts = {}) {
|
|
846
1084
|
const cfg = { ...DEFAULTS, ...opts };
|
|
@@ -854,7 +1092,7 @@ function createZipBombGuard(opts = {}) {
|
|
|
854
1092
|
const eocdPos = lastIndexOfEOCD(buf, cfg.eocdSearchWindow);
|
|
855
1093
|
if (eocdPos < 0 || eocdPos + 22 > buf.length) {
|
|
856
1094
|
// ZIP but no EOCD — malformed or polyglot → suspicious
|
|
857
|
-
matches.push({ rule:
|
|
1095
|
+
matches.push({ rule: "zip_eocd_not_found", severity: "medium" });
|
|
858
1096
|
return matches;
|
|
859
1097
|
}
|
|
860
1098
|
const totalEntries = r16(buf, eocdPos + 10);
|
|
@@ -862,7 +1100,7 @@ function createZipBombGuard(opts = {}) {
|
|
|
862
1100
|
const cdOffset = r32(buf, eocdPos + 16);
|
|
863
1101
|
// Bounds check
|
|
864
1102
|
if (cdOffset + cdSize > buf.length) {
|
|
865
|
-
matches.push({ rule:
|
|
1103
|
+
matches.push({ rule: "zip_cd_out_of_bounds", severity: "medium" });
|
|
866
1104
|
return matches;
|
|
867
1105
|
}
|
|
868
1106
|
// Iterate central directory entries
|
|
@@ -883,287 +1121,68 @@ function createZipBombGuard(opts = {}) {
|
|
|
883
1121
|
const nameEnd = nameStart + fnLen;
|
|
884
1122
|
if (nameEnd > buf.length)
|
|
885
1123
|
break;
|
|
886
|
-
const name = buf.toString(
|
|
1124
|
+
const name = buf.toString("utf8", nameStart, nameEnd);
|
|
887
1125
|
sumComp += compSize;
|
|
888
1126
|
sumUnc += uncSize;
|
|
889
1127
|
seen++;
|
|
890
1128
|
if (name.length > cfg.maxEntryNameLength) {
|
|
891
|
-
matches.push({
|
|
1129
|
+
matches.push({
|
|
1130
|
+
rule: "zip_entry_name_too_long",
|
|
1131
|
+
severity: "medium",
|
|
1132
|
+
meta: { name, length: name.length },
|
|
1133
|
+
});
|
|
892
1134
|
}
|
|
893
1135
|
if (hasTraversal(name)) {
|
|
894
|
-
matches.push({ rule:
|
|
1136
|
+
matches.push({ rule: "zip_path_traversal_entry", severity: "medium", meta: { name } });
|
|
895
1137
|
}
|
|
896
1138
|
// move to next entry
|
|
897
1139
|
ptr = nameEnd + exLen + cmLen;
|
|
898
1140
|
}
|
|
899
1141
|
if (seen !== totalEntries) {
|
|
900
1142
|
// central dir truncated/odd, still report what we found
|
|
901
|
-
matches.push({
|
|
1143
|
+
matches.push({
|
|
1144
|
+
rule: "zip_cd_truncated",
|
|
1145
|
+
severity: "medium",
|
|
1146
|
+
meta: { seen, totalEntries },
|
|
1147
|
+
});
|
|
902
1148
|
}
|
|
903
1149
|
// Heuristics thresholds
|
|
904
1150
|
if (seen > cfg.maxEntries) {
|
|
905
|
-
matches.push({
|
|
1151
|
+
matches.push({
|
|
1152
|
+
rule: "zip_too_many_entries",
|
|
1153
|
+
severity: "medium",
|
|
1154
|
+
meta: { seen, limit: cfg.maxEntries },
|
|
1155
|
+
});
|
|
906
1156
|
}
|
|
907
1157
|
if (sumUnc > cfg.maxTotalUncompressedBytes) {
|
|
908
1158
|
matches.push({
|
|
909
|
-
rule:
|
|
910
|
-
severity:
|
|
911
|
-
meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes }
|
|
1159
|
+
rule: "zip_total_uncompressed_too_large",
|
|
1160
|
+
severity: "medium",
|
|
1161
|
+
meta: { totalUncompressed: sumUnc, limit: cfg.maxTotalUncompressedBytes },
|
|
912
1162
|
});
|
|
913
1163
|
}
|
|
914
1164
|
if (sumComp === 0 && sumUnc > 0) {
|
|
915
|
-
matches.push({
|
|
1165
|
+
matches.push({
|
|
1166
|
+
rule: "zip_suspicious_ratio",
|
|
1167
|
+
severity: "medium",
|
|
1168
|
+
meta: { ratio: Infinity },
|
|
1169
|
+
});
|
|
916
1170
|
}
|
|
917
1171
|
else if (sumComp > 0) {
|
|
918
1172
|
const ratio = sumUnc / Math.max(1, sumComp);
|
|
919
1173
|
if (ratio >= cfg.maxCompressionRatio) {
|
|
920
|
-
matches.push({
|
|
1174
|
+
matches.push({
|
|
1175
|
+
rule: "zip_suspicious_ratio",
|
|
1176
|
+
severity: "medium",
|
|
1177
|
+
meta: { ratio, limit: cfg.maxCompressionRatio },
|
|
1178
|
+
});
|
|
921
1179
|
}
|
|
922
1180
|
}
|
|
923
1181
|
return matches;
|
|
924
|
-
}
|
|
1182
|
+
},
|
|
925
1183
|
};
|
|
926
1184
|
}
|
|
927
1185
|
|
|
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
1186
|
/**
|
|
1168
1187
|
* Export utilities for scan results
|
|
1169
1188
|
* @module utils/export
|
|
@@ -1179,19 +1198,15 @@ class ScanResultExporter {
|
|
|
1179
1198
|
const data = Array.isArray(reports) ? reports : [reports];
|
|
1180
1199
|
if (!options.includeDetails) {
|
|
1181
1200
|
// Simplified output
|
|
1182
|
-
const simplified = data.map(r => ({
|
|
1201
|
+
const simplified = data.map((r) => ({
|
|
1183
1202
|
verdict: r.verdict,
|
|
1184
1203
|
file: r.file?.name,
|
|
1185
1204
|
matches: r.matches.length,
|
|
1186
1205
|
durationMs: r.durationMs,
|
|
1187
1206
|
}));
|
|
1188
|
-
return options.prettyPrint
|
|
1189
|
-
? JSON.stringify(simplified, null, 2)
|
|
1190
|
-
: JSON.stringify(simplified);
|
|
1207
|
+
return options.prettyPrint ? JSON.stringify(simplified, null, 2) : JSON.stringify(simplified);
|
|
1191
1208
|
}
|
|
1192
|
-
return options.prettyPrint
|
|
1193
|
-
? JSON.stringify(data, null, 2)
|
|
1194
|
-
: JSON.stringify(data);
|
|
1209
|
+
return options.prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
|
1195
1210
|
}
|
|
1196
1211
|
/**
|
|
1197
1212
|
* Export to CSV format
|
|
@@ -1199,68 +1214,68 @@ class ScanResultExporter {
|
|
|
1199
1214
|
toCSV(reports, options = {}) {
|
|
1200
1215
|
const data = Array.isArray(reports) ? reports : [reports];
|
|
1201
1216
|
const headers = [
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1217
|
+
"filename",
|
|
1218
|
+
"verdict",
|
|
1219
|
+
"matches_count",
|
|
1220
|
+
"file_size",
|
|
1221
|
+
"mime_type",
|
|
1222
|
+
"duration_ms",
|
|
1223
|
+
"engine",
|
|
1209
1224
|
];
|
|
1210
1225
|
if (options.includeDetails) {
|
|
1211
|
-
headers.push(
|
|
1226
|
+
headers.push("reasons", "match_rules");
|
|
1212
1227
|
}
|
|
1213
|
-
const rows = data.map(report => {
|
|
1228
|
+
const rows = data.map((report) => {
|
|
1214
1229
|
const row = [
|
|
1215
|
-
this.escapeCsv(report.file?.name ||
|
|
1230
|
+
this.escapeCsv(report.file?.name || "unknown"),
|
|
1216
1231
|
report.verdict,
|
|
1217
1232
|
report.matches.length.toString(),
|
|
1218
1233
|
(report.file?.size || 0).toString(),
|
|
1219
|
-
this.escapeCsv(report.file?.mimeType ||
|
|
1234
|
+
this.escapeCsv(report.file?.mimeType || "unknown"),
|
|
1220
1235
|
(report.durationMs || 0).toString(),
|
|
1221
|
-
report.engine ||
|
|
1236
|
+
report.engine || "unknown",
|
|
1222
1237
|
];
|
|
1223
1238
|
if (options.includeDetails) {
|
|
1224
|
-
row.push(this.escapeCsv((report.reasons || []).join(
|
|
1239
|
+
row.push(this.escapeCsv((report.reasons || []).join("; ")), this.escapeCsv(report.matches.map((m) => m.rule).join("; ")));
|
|
1225
1240
|
}
|
|
1226
|
-
return row.join(
|
|
1241
|
+
return row.join(",");
|
|
1227
1242
|
});
|
|
1228
|
-
return [headers.join(
|
|
1243
|
+
return [headers.join(","), ...rows].join("\n");
|
|
1229
1244
|
}
|
|
1230
1245
|
/**
|
|
1231
1246
|
* Export to Markdown format
|
|
1232
1247
|
*/
|
|
1233
1248
|
toMarkdown(reports, options = {}) {
|
|
1234
1249
|
const data = Array.isArray(reports) ? reports : [reports];
|
|
1235
|
-
let md =
|
|
1250
|
+
let md = "# Scan Results\n\n";
|
|
1236
1251
|
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 +=
|
|
1252
|
+
const clean = data.filter((r) => r.verdict === "clean").length;
|
|
1253
|
+
const suspicious = data.filter((r) => r.verdict === "suspicious").length;
|
|
1254
|
+
const malicious = data.filter((r) => r.verdict === "malicious").length;
|
|
1255
|
+
md += "## Summary\n\n";
|
|
1241
1256
|
md += `- ✅ Clean: ${clean}\n`;
|
|
1242
1257
|
md += `- ⚠️ Suspicious: ${suspicious}\n`;
|
|
1243
1258
|
md += `- ❌ Malicious: ${malicious}\n\n`;
|
|
1244
|
-
md +=
|
|
1259
|
+
md += "## Detailed Results\n\n";
|
|
1245
1260
|
for (const report of data) {
|
|
1246
|
-
const icon = report.verdict ===
|
|
1247
|
-
md += `### ${icon} ${report.file?.name ||
|
|
1261
|
+
const icon = report.verdict === "clean" ? "✅" : report.verdict === "suspicious" ? "⚠️" : "❌";
|
|
1262
|
+
md += `### ${icon} ${report.file?.name || "Unknown"}\n\n`;
|
|
1248
1263
|
md += `- **Verdict:** ${report.verdict}\n`;
|
|
1249
1264
|
md += `- **Size:** ${this.formatBytes(report.file?.size || 0)}\n`;
|
|
1250
|
-
md += `- **MIME Type:** ${report.file?.mimeType ||
|
|
1265
|
+
md += `- **MIME Type:** ${report.file?.mimeType || "unknown"}\n`;
|
|
1251
1266
|
md += `- **Duration:** ${report.durationMs || 0}ms\n`;
|
|
1252
1267
|
md += `- **Matches:** ${report.matches.length}\n`;
|
|
1253
1268
|
if (options.includeDetails && report.matches.length > 0) {
|
|
1254
|
-
md +=
|
|
1269
|
+
md += "\n**Match Details:**\n";
|
|
1255
1270
|
for (const match of report.matches) {
|
|
1256
1271
|
md += `- ${match.rule}`;
|
|
1257
1272
|
if (match.tags && match.tags.length > 0) {
|
|
1258
|
-
md += ` (${match.tags.join(
|
|
1273
|
+
md += ` (${match.tags.join(", ")})`;
|
|
1259
1274
|
}
|
|
1260
|
-
md +=
|
|
1275
|
+
md += "\n";
|
|
1261
1276
|
}
|
|
1262
1277
|
}
|
|
1263
|
-
md +=
|
|
1278
|
+
md += "\n";
|
|
1264
1279
|
}
|
|
1265
1280
|
return md;
|
|
1266
1281
|
}
|
|
@@ -1270,20 +1285,20 @@ class ScanResultExporter {
|
|
|
1270
1285
|
*/
|
|
1271
1286
|
toSARIF(reports, options = {}) {
|
|
1272
1287
|
const data = Array.isArray(reports) ? reports : [reports];
|
|
1273
|
-
const results = data.flatMap(report => {
|
|
1274
|
-
if (report.verdict ===
|
|
1288
|
+
const results = data.flatMap((report) => {
|
|
1289
|
+
if (report.verdict === "clean")
|
|
1275
1290
|
return [];
|
|
1276
|
-
return report.matches.map(match => ({
|
|
1291
|
+
return report.matches.map((match) => ({
|
|
1277
1292
|
ruleId: match.rule,
|
|
1278
|
-
level: report.verdict ===
|
|
1293
|
+
level: report.verdict === "malicious" ? "error" : "warning",
|
|
1279
1294
|
message: {
|
|
1280
|
-
text: `${match.rule} detected in ${report.file?.name ||
|
|
1295
|
+
text: `${match.rule} detected in ${report.file?.name || "unknown file"}`,
|
|
1281
1296
|
},
|
|
1282
1297
|
locations: [
|
|
1283
1298
|
{
|
|
1284
1299
|
physicalLocation: {
|
|
1285
1300
|
artifactLocation: {
|
|
1286
|
-
uri: report.file?.name ||
|
|
1301
|
+
uri: report.file?.name || "unknown",
|
|
1287
1302
|
},
|
|
1288
1303
|
},
|
|
1289
1304
|
},
|
|
@@ -1295,33 +1310,31 @@ class ScanResultExporter {
|
|
|
1295
1310
|
}));
|
|
1296
1311
|
});
|
|
1297
1312
|
const sarif = {
|
|
1298
|
-
version:
|
|
1299
|
-
$schema:
|
|
1313
|
+
version: "2.1.0",
|
|
1314
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
1300
1315
|
runs: [
|
|
1301
1316
|
{
|
|
1302
1317
|
tool: {
|
|
1303
1318
|
driver: {
|
|
1304
|
-
name:
|
|
1305
|
-
version:
|
|
1306
|
-
informationUri:
|
|
1319
|
+
name: "Pompelmi",
|
|
1320
|
+
version: "0.29.0",
|
|
1321
|
+
informationUri: "https://pompelmi.github.io/pompelmi/",
|
|
1307
1322
|
},
|
|
1308
1323
|
},
|
|
1309
1324
|
results,
|
|
1310
1325
|
},
|
|
1311
1326
|
],
|
|
1312
1327
|
};
|
|
1313
|
-
return options.prettyPrint
|
|
1314
|
-
? JSON.stringify(sarif, null, 2)
|
|
1315
|
-
: JSON.stringify(sarif);
|
|
1328
|
+
return options.prettyPrint ? JSON.stringify(sarif, null, 2) : JSON.stringify(sarif);
|
|
1316
1329
|
}
|
|
1317
1330
|
/**
|
|
1318
1331
|
* Export to HTML format
|
|
1319
1332
|
*/
|
|
1320
1333
|
toHTML(reports, options = {}) {
|
|
1321
1334
|
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 ===
|
|
1335
|
+
const clean = data.filter((r) => r.verdict === "clean").length;
|
|
1336
|
+
const suspicious = data.filter((r) => r.verdict === "suspicious").length;
|
|
1337
|
+
const malicious = data.filter((r) => r.verdict === "malicious").length;
|
|
1325
1338
|
let html = `<!DOCTYPE html>
|
|
1326
1339
|
<html lang="en">
|
|
1327
1340
|
<head>
|
|
@@ -1353,11 +1366,11 @@ class ScanResultExporter {
|
|
|
1353
1366
|
for (const report of data) {
|
|
1354
1367
|
const statusClass = report.verdict;
|
|
1355
1368
|
html += `<div class="result ${statusClass}">`;
|
|
1356
|
-
html += `<h3>${this.escapeHtml(report.file?.name ||
|
|
1369
|
+
html += `<h3>${this.escapeHtml(report.file?.name || "Unknown")}</h3>`;
|
|
1357
1370
|
html += `<table>`;
|
|
1358
1371
|
html += `<tr><th>Verdict</th><td>${report.verdict.toUpperCase()}</td></tr>`;
|
|
1359
1372
|
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 ||
|
|
1373
|
+
html += `<tr><th>MIME Type</th><td>${this.escapeHtml(report.file?.mimeType || "unknown")}</td></tr>`;
|
|
1361
1374
|
html += `<tr><th>Duration</th><td>${report.durationMs || 0}ms</td></tr>`;
|
|
1362
1375
|
html += `<tr><th>Matches</th><td>${report.matches.length}</td></tr>`;
|
|
1363
1376
|
html += `</table>`;
|
|
@@ -1366,7 +1379,7 @@ class ScanResultExporter {
|
|
|
1366
1379
|
for (const match of report.matches) {
|
|
1367
1380
|
html += `<li><strong>${this.escapeHtml(match.rule)}</strong>`;
|
|
1368
1381
|
if (match.tags && match.tags.length > 0) {
|
|
1369
|
-
html += ` ${match.tags.map(tag => `<span class="badge">${this.escapeHtml(tag)}</span>`).join(
|
|
1382
|
+
html += ` ${match.tags.map((tag) => `<span class="badge">${this.escapeHtml(tag)}</span>`).join("")}`;
|
|
1370
1383
|
}
|
|
1371
1384
|
html += `</li>`;
|
|
1372
1385
|
}
|
|
@@ -1382,41 +1395,41 @@ class ScanResultExporter {
|
|
|
1382
1395
|
*/
|
|
1383
1396
|
export(reports, format, options = {}) {
|
|
1384
1397
|
switch (format) {
|
|
1385
|
-
case
|
|
1398
|
+
case "json":
|
|
1386
1399
|
return this.toJSON(reports, options);
|
|
1387
|
-
case
|
|
1400
|
+
case "csv":
|
|
1388
1401
|
return this.toCSV(reports, options);
|
|
1389
|
-
case
|
|
1402
|
+
case "markdown":
|
|
1390
1403
|
return this.toMarkdown(reports, options);
|
|
1391
|
-
case
|
|
1404
|
+
case "html":
|
|
1392
1405
|
return this.toHTML(reports, options);
|
|
1393
|
-
case
|
|
1406
|
+
case "sarif":
|
|
1394
1407
|
return this.toSARIF(reports, options);
|
|
1395
1408
|
default:
|
|
1396
1409
|
throw new Error(`Unsupported export format: ${format}`);
|
|
1397
1410
|
}
|
|
1398
1411
|
}
|
|
1399
1412
|
escapeCsv(value) {
|
|
1400
|
-
if (value.includes(
|
|
1413
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
1401
1414
|
return `"${value.replace(/"/g, '""')}"`;
|
|
1402
1415
|
}
|
|
1403
1416
|
return value;
|
|
1404
1417
|
}
|
|
1405
1418
|
escapeHtml(value) {
|
|
1406
1419
|
return value
|
|
1407
|
-
.replace(/&/g,
|
|
1408
|
-
.replace(/</g,
|
|
1409
|
-
.replace(/>/g,
|
|
1410
|
-
.replace(/"/g,
|
|
1411
|
-
.replace(/'/g,
|
|
1420
|
+
.replace(/&/g, "&")
|
|
1421
|
+
.replace(/</g, "<")
|
|
1422
|
+
.replace(/>/g, ">")
|
|
1423
|
+
.replace(/"/g, """)
|
|
1424
|
+
.replace(/'/g, "'");
|
|
1412
1425
|
}
|
|
1413
1426
|
formatBytes(bytes) {
|
|
1414
1427
|
if (bytes === 0)
|
|
1415
|
-
return
|
|
1428
|
+
return "0 Bytes";
|
|
1416
1429
|
const k = 1024;
|
|
1417
|
-
const sizes = [
|
|
1430
|
+
const sizes = ["Bytes", "KB", "MB", "GB"];
|
|
1418
1431
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
1419
|
-
return Math.round(bytes /
|
|
1432
|
+
return Math.round((bytes / k ** i) * 100) / 100 + " " + sizes[i];
|
|
1420
1433
|
}
|
|
1421
1434
|
}
|
|
1422
1435
|
/**
|
|
@@ -1427,6 +1440,31 @@ function exportScanResults(reports, format, options) {
|
|
|
1427
1440
|
return exporter.export(reports, format, options);
|
|
1428
1441
|
}
|
|
1429
1442
|
|
|
1443
|
+
/**
|
|
1444
|
+
* Validates a File by MIME type and size (max 5 MB).
|
|
1445
|
+
*/
|
|
1446
|
+
function validateFile(file) {
|
|
1447
|
+
const maxSize = 5 * 1024 * 1024;
|
|
1448
|
+
const allowedTypes = ["text/plain", "application/json", "text/csv"];
|
|
1449
|
+
if (!allowedTypes.includes(file.type)) {
|
|
1450
|
+
return { valid: false, error: "Unsupported file type" };
|
|
1451
|
+
}
|
|
1452
|
+
if (file.size > maxSize) {
|
|
1453
|
+
return { valid: false, error: "File too large (max 5 MB)" };
|
|
1454
|
+
}
|
|
1455
|
+
return { valid: true };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function mapMatchesToVerdict(matches = []) {
|
|
1459
|
+
if (!matches.length)
|
|
1460
|
+
return "clean";
|
|
1461
|
+
const malHints = ["trojan", "ransom", "worm", "spy", "rootkit", "keylog", "botnet"];
|
|
1462
|
+
const tagSet = new Set(matches.flatMap((m) => (m.tags ?? []).map((t) => t.toLowerCase())));
|
|
1463
|
+
const nameHit = (r) => malHints.some((h) => r.toLowerCase().includes(h));
|
|
1464
|
+
const isMal = matches.some((m) => nameHit(m.rule)) || tagSet.has("malware") || tagSet.has("critical");
|
|
1465
|
+
return isMal ? "malicious" : "suspicious";
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1430
1468
|
exports.ARCHIVES = ARCHIVES;
|
|
1431
1469
|
exports.CONSERVATIVE_DEFAULT = CONSERVATIVE_DEFAULT;
|
|
1432
1470
|
exports.CommonHeuristicsScanner = CommonHeuristicsScanner;
|