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