@blamejs/core 0.7.4 → 0.7.19
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/CHANGELOG.md +30 -0
- package/README.md +1 -0
- package/index.js +27 -1
- package/lib/api-key.js +2 -5
- package/lib/auth/jwt-external.js +365 -0
- package/lib/auth/jwt.js +27 -1
- package/lib/auth/password.js +34 -0
- package/lib/codepoint-class.js +196 -0
- package/lib/csv.js +25 -36
- package/lib/db-declare-view.js +3 -4
- package/lib/file-upload.js +213 -10
- package/lib/framework-error.js +78 -0
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/mail-dkim.js +13 -6
- package/lib/mail.js +19 -0
- package/lib/middleware/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +79 -0
- package/lib/middleware/index.js +3 -0
- package/lib/numeric-bounds.js +20 -0
- package/lib/session.js +61 -4
- package/lib/static.js +184 -4
- package/lib/validate-opts.js +21 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* guard-archive — archive content-safety primitive (b.guardArchive).
|
|
4
|
+
*
|
|
5
|
+
* Threat catalog grounded in current research (multiple 2025-2026 CVEs):
|
|
6
|
+
* - CVE-2025-3445 mholt/archiver Zip Slip
|
|
7
|
+
* - CVE-2025-32779 EDDI Zip Slip
|
|
8
|
+
* - CVE-2025-62156 Argo Workflows Zip Slip
|
|
9
|
+
* - CVE-2025-66945 Zdir Pro Path Traversal
|
|
10
|
+
* - CVE-2025-45582 GNU Tar Path Traversal (two-step symlink bypass)
|
|
11
|
+
* - CVE-2025-11001 / 11002 7-Zip symlink + directory traversal RCE
|
|
12
|
+
* - CVE-2025-4138 Python tarfile extraction-filter symlink bypass
|
|
13
|
+
* - CVE-2025-4517 Python tarfile path traversal
|
|
14
|
+
* - CVE-2025-10854 txtai Framework path traversal
|
|
15
|
+
* - CVE-2025-12060 Keras path traversal
|
|
16
|
+
* - CVE-2026-26960 node-tar hardlink-via-symlink-chain escape
|
|
17
|
+
*
|
|
18
|
+
* var rv = b.guardArchive.validateEntries(entries, { profile: "strict" });
|
|
19
|
+
* var fmt = b.guardArchive.inspectMagic(buffer);
|
|
20
|
+
* var g = b.guardArchive.gate({ profile: "strict" });
|
|
21
|
+
*
|
|
22
|
+
* **Scope.** This primitive validates archive METADATA (entry list +
|
|
23
|
+
* sizes + flags + types) before extraction. It does NOT include a
|
|
24
|
+
* pure-JS unzip / untar implementation — the framework's no-deps rule
|
|
25
|
+
* argues against shipping a parser for every archive format. Operators
|
|
26
|
+
* use their archive library (built-in zlib for gzip/deflate, OS tar /
|
|
27
|
+
* unzip CLI, or vendored libraries) to enumerate entries, then validate
|
|
28
|
+
* the list before extracting. The gate's job is to refuse hostile
|
|
29
|
+
* metadata BEFORE files touch the filesystem.
|
|
30
|
+
*
|
|
31
|
+
* var entries = parseZipCentralDirectory(uploadedBuffer);
|
|
32
|
+
* var rv = b.guardArchive.validateEntries(entries, { profile: "strict" });
|
|
33
|
+
* if (!rv.ok) throw new Error("hostile archive: " + rv.issues[0].snippet);
|
|
34
|
+
* await extractEachEntry(entries, extractionRoot);
|
|
35
|
+
*
|
|
36
|
+
* Entry shape (operator passes one of these per archive entry):
|
|
37
|
+
*
|
|
38
|
+
* {
|
|
39
|
+
* name: string, // entry filename / path WITHIN archive
|
|
40
|
+
* size: number, // uncompressed size in bytes
|
|
41
|
+
* compressedSize: number, // compressed size (optional; enables ratio check)
|
|
42
|
+
* isSymlink: boolean, // true if entry creates a symbolic link
|
|
43
|
+
* isHardlink: boolean, // true if entry creates a hardlink
|
|
44
|
+
* linkTarget: string, // when isSymlink/isHardlink: where it points
|
|
45
|
+
* isDirectory: boolean, // directory entry (no extraction needed)
|
|
46
|
+
* isEncrypted: boolean, // entry is encrypted
|
|
47
|
+
* attrs: object, // optional: extra format-specific metadata
|
|
48
|
+
* }
|
|
49
|
+
*
|
|
50
|
+
* Threat catalog covered:
|
|
51
|
+
*
|
|
52
|
+
* 1. Zip slip / path traversal — entry name with `../`, `..\\`, or
|
|
53
|
+
* absolute path (leading `/` or `\\` or drive letter). Composes
|
|
54
|
+
* `b.guardFilename` for per-entry-name validation; archive-level
|
|
55
|
+
* adds the absolute-path check that filename-leaf doesn't.
|
|
56
|
+
*
|
|
57
|
+
* 2. Symlink escape — entry creates a symbolic link whose `linkTarget`
|
|
58
|
+
* contains `..` or absolute path that resolves outside the
|
|
59
|
+
* extraction root. Refused or audited per profile.
|
|
60
|
+
*
|
|
61
|
+
* 3. Hardlink escape — same as symlink but via the hardlink mechanism
|
|
62
|
+
* (CVE-2026-26960 node-tar class). The extraction step typically
|
|
63
|
+
* resolves hardlink targets relative to extraction root; entries
|
|
64
|
+
* with `..` in linkTarget escape.
|
|
65
|
+
*
|
|
66
|
+
* 4. Symlink-chained traversal — operator pre-extracts a symlink, then
|
|
67
|
+
* a later entry writes through the symlink's target. We refuse any
|
|
68
|
+
* entry whose extraction path passes THROUGH a symlink already in
|
|
69
|
+
* the entry list (when the operator passes pre-sorted entries).
|
|
70
|
+
*
|
|
71
|
+
* 5. Decompression-ratio bombs — per-entry compressedSize/size ratio
|
|
72
|
+
* cap (default: 100:1 strict, 1000:1 permissive). Aggregate ratio
|
|
73
|
+
* across all entries also capped.
|
|
74
|
+
*
|
|
75
|
+
* 6. Total-size cap — sum of uncompressed sizes (anti-DoS).
|
|
76
|
+
*
|
|
77
|
+
* 7. File-count cap — number of entries.
|
|
78
|
+
*
|
|
79
|
+
* 8. Nested-archive depth — refuses entries that are themselves
|
|
80
|
+
* archives unless `maxNestedDepth > 0`. Entry name suffixes are
|
|
81
|
+
* checked against an archive-extension catalog (.zip / .tar /
|
|
82
|
+
* .tar.gz / .tgz / .gz / .bz2 / .xz / .7z / .rar / .ar / .cpio /
|
|
83
|
+
* .lzma / .zst).
|
|
84
|
+
*
|
|
85
|
+
* 9. Per-entry-name validation via b.guardFilename — applies the full
|
|
86
|
+
* filename-safety catalog (path traversal / null-byte / Windows
|
|
87
|
+
* reserved names / NTFS ADS / RTLO bidi / overlong UTF-8 / shell-
|
|
88
|
+
* exec extensions / double-extension) to every entry's name.
|
|
89
|
+
*
|
|
90
|
+
* 10. Duplicate entry names — second entry with the same name silently
|
|
91
|
+
* overwrites the first on extraction. Refused.
|
|
92
|
+
*
|
|
93
|
+
* 11. Mixed-case duplicate names — case-insensitive collision on Windows
|
|
94
|
+
* / macOS HFS+ / APFS-non-case-sensitive volumes. Audited.
|
|
95
|
+
*
|
|
96
|
+
* 12. Encryption-claim mismatch — operator opts in to either "all
|
|
97
|
+
* entries encrypted" or "no entries encrypted"; mixing flagged.
|
|
98
|
+
*
|
|
99
|
+
* 13. Format-claim mismatch — `inspectMagic(buffer)` reads the first
|
|
100
|
+
* bytes and returns the detected format. Operator can compare
|
|
101
|
+
* against the declared content-type / extension; mismatch flagged.
|
|
102
|
+
*
|
|
103
|
+
* 14. Sparse archive (tar) — sparse entries can claim large
|
|
104
|
+
* uncompressed size with zero data; refused unless explicitly
|
|
105
|
+
* allowed.
|
|
106
|
+
*
|
|
107
|
+
* 15. Anti-DoS caps — total entry count, per-entry size, total size,
|
|
108
|
+
* compression ratio, recursion depth.
|
|
109
|
+
*
|
|
110
|
+
* Profiles:
|
|
111
|
+
* strict — every threat refused; no symlinks; no hardlinks;
|
|
112
|
+
* no nested archives; 100 entry max; 100 MiB total;
|
|
113
|
+
* 100:1 ratio cap; case-insensitive collision refused.
|
|
114
|
+
* balanced — symlinks within extraction-root allowed; no hardlinks;
|
|
115
|
+
* nested-depth 2; 10000 entries; 1 GiB total; 100:1
|
|
116
|
+
* per-entry / 1000:1 aggregate; case-collision audited.
|
|
117
|
+
* permissive — symlinks + hardlinks within root allowed; nested-depth
|
|
118
|
+
* 4; 100000 entries; 10 GiB total; 1000:1 ratio.
|
|
119
|
+
*
|
|
120
|
+
* Compliance postures: hipaa / pci-dss / gdpr / soc2-cc7 — strict
|
|
121
|
+
* overlay + forensic snapshots.
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
var lazyRequire = require("./lazy-require");
|
|
125
|
+
var gateContract = require("./gate-contract");
|
|
126
|
+
var C = require("./constants");
|
|
127
|
+
var numericBounds = require("./numeric-bounds");
|
|
128
|
+
var guardFilename = require("./guard-filename");
|
|
129
|
+
var { GuardArchiveError } = require("./framework-error");
|
|
130
|
+
|
|
131
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
132
|
+
void observability;
|
|
133
|
+
|
|
134
|
+
var _err = GuardArchiveError.factory;
|
|
135
|
+
|
|
136
|
+
// Archive extension catalog — entries with these suffixes are treated
|
|
137
|
+
// as nested archives.
|
|
138
|
+
var ARCHIVE_EXTENSIONS = Object.freeze([
|
|
139
|
+
".zip", ".jar", ".war", ".ear", ".apk", ".ipa",
|
|
140
|
+
".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz",
|
|
141
|
+
".tar.zst", ".tzst", ".tar.lzma", ".tlz",
|
|
142
|
+
".gz", ".bz2", ".xz", ".lzma", ".lz4", ".zst",
|
|
143
|
+
".7z", ".rar", ".ar", ".cpio", ".cab", ".iso", ".dmg",
|
|
144
|
+
".deb", ".rpm", ".msi",
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
// Magic-byte signatures keyed by format name. First N bytes uniquely
|
|
148
|
+
// identify the format; we read up to 8 bytes for matching.
|
|
149
|
+
var MAGIC_SIGNATURES = Object.freeze([
|
|
150
|
+
{ format: "zip", bytes: [0x50, 0x4B, 0x03, 0x04] }, // allow:raw-byte-literal — ZIP local file header magic per APPNOTE.TXT §4.3.7
|
|
151
|
+
{ format: "zip", bytes: [0x50, 0x4B, 0x05, 0x06] }, // allow:raw-byte-literal — ZIP empty-archive end-of-central-directory magic
|
|
152
|
+
{ format: "zip", bytes: [0x50, 0x4B, 0x07, 0x08] }, // allow:raw-byte-literal — ZIP spanned-archive marker
|
|
153
|
+
{ format: "gzip", bytes: [0x1F, 0x8B] }, // allow:raw-byte-literal — gzip magic per RFC 1952 §2.3.1
|
|
154
|
+
{ format: "bzip2", bytes: [0x42, 0x5A, 0x68] }, // allow:raw-byte-literal — bzip2 "BZh" magic
|
|
155
|
+
{ format: "xz", bytes: [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00] }, // allow:raw-byte-literal — XZ magic per xz spec §2.1.1.1
|
|
156
|
+
{ format: "7z", bytes: [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C] }, // allow:raw-byte-literal — 7-zip magic per 7z spec
|
|
157
|
+
{ format: "rar4", bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00] }, // allow:raw-byte-literal — RAR4 magic
|
|
158
|
+
{ format: "rar5", bytes: [0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00] }, // allow:raw-byte-literal — RAR5 magic
|
|
159
|
+
{ format: "lzma", bytes: [0x5D, 0x00, 0x00] }, // allow:raw-byte-literal — LZMA magic byte sequence (heuristic)
|
|
160
|
+
{ format: "zstd", bytes: [0x28, 0xB5, 0x2F, 0xFD] }, // allow:raw-byte-literal — Zstandard magic per RFC 8478 §3.1.1
|
|
161
|
+
// tar is identified by the "ustar" magic at byte offset 257 inside
|
|
162
|
+
// the first 512-byte header; handled separately in inspectMagic().
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
// ---- Profile presets ----
|
|
166
|
+
|
|
167
|
+
var PROFILES = Object.freeze({
|
|
168
|
+
"strict": {
|
|
169
|
+
bidiPolicy: "reject",
|
|
170
|
+
controlPolicy: "reject",
|
|
171
|
+
nullBytePolicy: "reject",
|
|
172
|
+
zeroWidthPolicy: "reject",
|
|
173
|
+
traversalPolicy: "reject",
|
|
174
|
+
absolutePathPolicy: "reject",
|
|
175
|
+
symlinkPolicy: "reject",
|
|
176
|
+
hardlinkPolicy: "reject",
|
|
177
|
+
encryptionPolicy: "audit",
|
|
178
|
+
nestedArchivePolicy: "reject",
|
|
179
|
+
duplicateNamePolicy: "reject",
|
|
180
|
+
caseInsensitiveCollisionPolicy: "reject",
|
|
181
|
+
sparseEntryPolicy: "reject",
|
|
182
|
+
filenameProfile: "balanced", // per-entry name validation profile
|
|
183
|
+
maxEntries: 100, // allow:raw-byte-literal — entry count cap, not byte size
|
|
184
|
+
maxTotalBytes: C.BYTES.mib(100),
|
|
185
|
+
maxEntryBytes: C.BYTES.mib(50),
|
|
186
|
+
maxCompressionRatio: 100, // allow:raw-byte-literal — ratio multiplier, not byte size
|
|
187
|
+
maxAggregateRatio: 200, // allow:raw-byte-literal — aggregate-ratio multiplier, not byte size
|
|
188
|
+
maxNestedDepth: 0, // allow:raw-byte-literal — recursion depth, not byte size
|
|
189
|
+
},
|
|
190
|
+
"balanced": {
|
|
191
|
+
bidiPolicy: "reject",
|
|
192
|
+
controlPolicy: "reject",
|
|
193
|
+
nullBytePolicy: "reject",
|
|
194
|
+
zeroWidthPolicy: "strip",
|
|
195
|
+
traversalPolicy: "reject",
|
|
196
|
+
absolutePathPolicy: "reject",
|
|
197
|
+
symlinkPolicy: "audit", // allowed within extraction root
|
|
198
|
+
hardlinkPolicy: "reject",
|
|
199
|
+
encryptionPolicy: "audit",
|
|
200
|
+
nestedArchivePolicy: "audit",
|
|
201
|
+
duplicateNamePolicy: "reject",
|
|
202
|
+
caseInsensitiveCollisionPolicy: "audit",
|
|
203
|
+
sparseEntryPolicy: "audit",
|
|
204
|
+
filenameProfile: "balanced",
|
|
205
|
+
maxEntries: 10000, // allow:raw-byte-literal — entry count cap, not byte size
|
|
206
|
+
maxTotalBytes: C.BYTES.gib(1),
|
|
207
|
+
maxEntryBytes: C.BYTES.mib(500),
|
|
208
|
+
maxCompressionRatio: 100, // allow:raw-byte-literal — ratio multiplier, not byte size
|
|
209
|
+
maxAggregateRatio: 1000, // allow:raw-byte-literal — aggregate-ratio multiplier, not byte size
|
|
210
|
+
maxNestedDepth: 2, // allow:raw-byte-literal — recursion depth, not byte size
|
|
211
|
+
},
|
|
212
|
+
"permissive": {
|
|
213
|
+
bidiPolicy: "audit",
|
|
214
|
+
controlPolicy: "strip",
|
|
215
|
+
nullBytePolicy: "reject",
|
|
216
|
+
zeroWidthPolicy: "strip",
|
|
217
|
+
traversalPolicy: "reject",
|
|
218
|
+
absolutePathPolicy: "reject",
|
|
219
|
+
symlinkPolicy: "audit",
|
|
220
|
+
hardlinkPolicy: "audit",
|
|
221
|
+
encryptionPolicy: "audit",
|
|
222
|
+
nestedArchivePolicy: "audit",
|
|
223
|
+
duplicateNamePolicy: "audit",
|
|
224
|
+
caseInsensitiveCollisionPolicy: "audit",
|
|
225
|
+
sparseEntryPolicy: "audit",
|
|
226
|
+
filenameProfile: "permissive",
|
|
227
|
+
maxEntries: 100000, // allow:raw-byte-literal — entry count cap, not byte size
|
|
228
|
+
maxTotalBytes: C.BYTES.gib(10),
|
|
229
|
+
maxEntryBytes: C.BYTES.gib(2),
|
|
230
|
+
maxCompressionRatio: 1000, // allow:raw-byte-literal — ratio multiplier, not byte size
|
|
231
|
+
maxAggregateRatio: 10000, // allow:raw-byte-literal — aggregate-ratio multiplier, not byte size
|
|
232
|
+
maxNestedDepth: 4, // allow:raw-byte-literal — recursion depth, not byte size
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
var DEFAULTS = Object.freeze(Object.assign({}, PROFILES["strict"], {
|
|
237
|
+
mode: "enforce",
|
|
238
|
+
maxRuntimeMs: C.TIME.seconds(10),
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
242
|
+
"hipaa": Object.assign({}, PROFILES["strict"], {
|
|
243
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
244
|
+
}),
|
|
245
|
+
"pci-dss": Object.assign({}, PROFILES["strict"], {
|
|
246
|
+
forensicSnippetBytes: C.BYTES.bytes(256),
|
|
247
|
+
}),
|
|
248
|
+
"gdpr": Object.assign({}, PROFILES["balanced"], {
|
|
249
|
+
forensicSnippetBytes: C.BYTES.bytes(128),
|
|
250
|
+
}),
|
|
251
|
+
"soc2-cc7": Object.assign({}, PROFILES["strict"], {
|
|
252
|
+
forensicSnippetBytes: C.BYTES.bytes(512),
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ---- Helpers ----
|
|
257
|
+
|
|
258
|
+
function _resolveOpts(opts) {
|
|
259
|
+
return gateContract.resolveProfileAndPosture(opts, {
|
|
260
|
+
profiles: PROFILES,
|
|
261
|
+
compliancePostures: COMPLIANCE_POSTURES,
|
|
262
|
+
defaults: DEFAULTS,
|
|
263
|
+
errorClass: GuardArchiveError,
|
|
264
|
+
errCodePrefix: "archive",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function _isAbsolutePath(name) {
|
|
269
|
+
if (!name || typeof name !== "string") return false;
|
|
270
|
+
if (name.charAt(0) === "/" || name.charAt(0) === "\\") return true;
|
|
271
|
+
// Windows drive-letter prefix (C:\ / C:/).
|
|
272
|
+
if (/^[A-Za-z]:[\\/]/.test(name)) return true;
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function _hasTraversal(name) {
|
|
277
|
+
if (!name || typeof name !== "string") return false;
|
|
278
|
+
if (/(^|[/\\])\.\.($|[/\\])/.test(name)) return true;
|
|
279
|
+
if (name === ".." || name === ".") return true;
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function _isArchiveName(name) {
|
|
284
|
+
var lower = String(name || "").toLowerCase();
|
|
285
|
+
for (var i = 0; i < ARCHIVE_EXTENSIONS.length; i += 1) {
|
|
286
|
+
if (lower.endsWith(ARCHIVE_EXTENSIONS[i])) return true;
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function _bufferStartsWith(buf, sig) {
|
|
292
|
+
if (!Buffer.isBuffer(buf) || buf.length < sig.length) return false;
|
|
293
|
+
for (var i = 0; i < sig.length; i += 1) {
|
|
294
|
+
if (buf[i] !== sig[i]) return false;
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// inspectMagic — reads the first bytes of a buffer and returns the
|
|
300
|
+
// detected archive format, or null if not recognized.
|
|
301
|
+
function inspectMagic(buffer) {
|
|
302
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
|
|
303
|
+
for (var i = 0; i < MAGIC_SIGNATURES.length; i += 1) {
|
|
304
|
+
var entry = MAGIC_SIGNATURES[i];
|
|
305
|
+
if (_bufferStartsWith(buffer, entry.bytes)) {
|
|
306
|
+
return { format: entry.format, magic: entry.bytes.slice() };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// tar — "ustar" magic at offset 257 within the first 512-byte block.
|
|
310
|
+
if (buffer.length >= 263) {
|
|
311
|
+
var ustar = "ustar";
|
|
312
|
+
var match = true;
|
|
313
|
+
for (var ti = 0; ti < ustar.length; ti += 1) {
|
|
314
|
+
if (buffer[257 + ti] !== ustar.charCodeAt(ti)) { match = false; break; }
|
|
315
|
+
}
|
|
316
|
+
if (match) return { format: "tar", magic: null };
|
|
317
|
+
}
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// checkExtractionPath — single-entry helper. Returns { ok, reason } for
|
|
322
|
+
// a candidate (entryName, extractionRoot) pair. Anchored, for callers
|
|
323
|
+
// that already enumerate entries and want a per-call boolean.
|
|
324
|
+
function checkExtractionPath(entryName, extractionRoot) {
|
|
325
|
+
if (typeof entryName !== "string" || entryName.length === 0) {
|
|
326
|
+
return { ok: false, reason: "empty entry name" };
|
|
327
|
+
}
|
|
328
|
+
if (_hasTraversal(entryName)) {
|
|
329
|
+
return { ok: false, reason: "entry name contains .. component (zip slip)" };
|
|
330
|
+
}
|
|
331
|
+
if (_isAbsolutePath(entryName)) {
|
|
332
|
+
return { ok: false, reason: "entry name is an absolute path" };
|
|
333
|
+
}
|
|
334
|
+
// Reject entries containing null bytes regardless of extraction root.
|
|
335
|
+
if (entryName.indexOf("") !== -1) {
|
|
336
|
+
return { ok: false, reason: "entry name contains null byte" };
|
|
337
|
+
}
|
|
338
|
+
void extractionRoot;
|
|
339
|
+
// For runtime resolution: the operator's extraction code should
|
|
340
|
+
// additionally call path.resolve(extractionRoot, entryName) and
|
|
341
|
+
// check that the result startsWith path.resolve(extractionRoot) — we
|
|
342
|
+
// cannot do it here without a node:path coupling that the gate
|
|
343
|
+
// wants to keep portable.
|
|
344
|
+
return { ok: true };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function _checkLinkTarget(target, kind) {
|
|
348
|
+
// Return null when ok, or an issue object when escape-shaped.
|
|
349
|
+
if (typeof target !== "string" || target.length === 0) return null;
|
|
350
|
+
if (_isAbsolutePath(target)) {
|
|
351
|
+
return {
|
|
352
|
+
kind: kind + "-escape", severity: "critical",
|
|
353
|
+
ruleId: "archive." + kind + "-absolute",
|
|
354
|
+
snippet: kind + " target " + JSON.stringify(target) +
|
|
355
|
+
" is an absolute path",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (_hasTraversal(target)) {
|
|
359
|
+
return {
|
|
360
|
+
kind: kind + "-escape", severity: "critical",
|
|
361
|
+
ruleId: "archive." + kind + "-traversal",
|
|
362
|
+
snippet: kind + " target " + JSON.stringify(target) +
|
|
363
|
+
" contains .. component",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- Detection pass ----
|
|
370
|
+
|
|
371
|
+
function _detectIssues(entries, opts) {
|
|
372
|
+
var issues = [];
|
|
373
|
+
if (!Array.isArray(entries)) {
|
|
374
|
+
issues.push({
|
|
375
|
+
kind: "bad-input", severity: "high", ruleId: "archive.bad-input",
|
|
376
|
+
snippet: "entries must be an array of { name, size, ... }",
|
|
377
|
+
});
|
|
378
|
+
return issues;
|
|
379
|
+
}
|
|
380
|
+
if (entries.length > opts.maxEntries) {
|
|
381
|
+
issues.push({
|
|
382
|
+
kind: "entry-count-cap", severity: "high",
|
|
383
|
+
ruleId: "archive.entry-count",
|
|
384
|
+
snippet: "entry count " + entries.length + " exceeds maxEntries " + opts.maxEntries,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
var totalSize = 0;
|
|
389
|
+
var totalCompressed = 0;
|
|
390
|
+
var nameSeen = Object.create(null);
|
|
391
|
+
var caseSeen = Object.create(null);
|
|
392
|
+
var encryptedCount = 0;
|
|
393
|
+
var unencryptedCount = 0;
|
|
394
|
+
|
|
395
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
396
|
+
var e = entries[i];
|
|
397
|
+
if (!e || typeof e !== "object") {
|
|
398
|
+
issues.push({
|
|
399
|
+
kind: "bad-entry", severity: "high", ruleId: "archive.bad-entry",
|
|
400
|
+
location: i,
|
|
401
|
+
snippet: "entry at index " + i + " is not a plain object",
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
var name = e.name;
|
|
406
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
407
|
+
issues.push({
|
|
408
|
+
kind: "bad-entry-name", severity: "high",
|
|
409
|
+
ruleId: "archive.bad-entry-name",
|
|
410
|
+
location: i,
|
|
411
|
+
snippet: "entry at index " + i + " has missing/non-string name",
|
|
412
|
+
});
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Path traversal + absolute path.
|
|
417
|
+
if (opts.traversalPolicy !== "allow" && _hasTraversal(name)) {
|
|
418
|
+
issues.push({
|
|
419
|
+
kind: "zip-slip", severity: "critical",
|
|
420
|
+
ruleId: "archive.zip-slip",
|
|
421
|
+
location: i,
|
|
422
|
+
snippet: "entry " + JSON.stringify(name) +
|
|
423
|
+
" contains .. (zip slip — CVE-2025-3445 class)",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
if (opts.absolutePathPolicy !== "allow" && _isAbsolutePath(name)) {
|
|
427
|
+
issues.push({
|
|
428
|
+
kind: "absolute-path", severity: "critical",
|
|
429
|
+
ruleId: "archive.absolute-path",
|
|
430
|
+
location: i,
|
|
431
|
+
snippet: "entry " + JSON.stringify(name) + " is an absolute path",
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Compose guard-filename for per-entry-name validation. Skip
|
|
436
|
+
// separator-in-leaf (archives legitimately use "/" in entry names).
|
|
437
|
+
var entryFilenameOpts = {
|
|
438
|
+
profile: opts.filenameProfile || "balanced",
|
|
439
|
+
pathSeparatorsPolicy: "allow",
|
|
440
|
+
// Archive entries use `/` as the canonical separator; that's
|
|
441
|
+
// not a "leaf has separator" issue.
|
|
442
|
+
};
|
|
443
|
+
try {
|
|
444
|
+
var fnRv = guardFilename.validate(name, entryFilenameOpts);
|
|
445
|
+
// Re-attach any filename issues with archive-context location.
|
|
446
|
+
for (var fi = 0; fi < fnRv.issues.length; fi += 1) {
|
|
447
|
+
var issue = fnRv.issues[fi];
|
|
448
|
+
// Skip duplicates we already flagged at archive-level.
|
|
449
|
+
if (issue.kind === "path-traversal" ||
|
|
450
|
+
issue.kind === "path-traversal-encoded") continue;
|
|
451
|
+
issues.push(Object.assign({}, issue, {
|
|
452
|
+
ruleId: "archive." + issue.ruleId,
|
|
453
|
+
location: i + ":" + (issue.location || 0),
|
|
454
|
+
snippet: "entry " + JSON.stringify(name) + ": " + issue.snippet,
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
} catch (_e) { /* per-entry filename check is best-effort */ }
|
|
458
|
+
|
|
459
|
+
// Symlinks / hardlinks.
|
|
460
|
+
if (e.isSymlink) {
|
|
461
|
+
if (opts.symlinkPolicy === "reject") {
|
|
462
|
+
issues.push({
|
|
463
|
+
kind: "symlink-reject", severity: "critical",
|
|
464
|
+
ruleId: "archive.symlink",
|
|
465
|
+
location: i,
|
|
466
|
+
snippet: "entry " + JSON.stringify(name) +
|
|
467
|
+
" is a symbolic link (profile rejects)",
|
|
468
|
+
});
|
|
469
|
+
} else {
|
|
470
|
+
var symEsc = _checkLinkTarget(e.linkTarget, "symlink");
|
|
471
|
+
if (symEsc) issues.push(Object.assign({ location: i }, symEsc));
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (e.isHardlink) {
|
|
475
|
+
if (opts.hardlinkPolicy === "reject") {
|
|
476
|
+
issues.push({
|
|
477
|
+
kind: "hardlink-reject", severity: "critical",
|
|
478
|
+
ruleId: "archive.hardlink",
|
|
479
|
+
location: i,
|
|
480
|
+
snippet: "entry " + JSON.stringify(name) +
|
|
481
|
+
" is a hardlink (profile rejects; CVE-2026-26960 class)",
|
|
482
|
+
});
|
|
483
|
+
} else {
|
|
484
|
+
var hardEsc = _checkLinkTarget(e.linkTarget, "hardlink");
|
|
485
|
+
if (hardEsc) issues.push(Object.assign({ location: i }, hardEsc));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Per-entry size cap.
|
|
490
|
+
var sz = typeof e.size === "number" ? e.size : 0;
|
|
491
|
+
if (sz > opts.maxEntryBytes) {
|
|
492
|
+
issues.push({
|
|
493
|
+
kind: "entry-size-cap", severity: "high",
|
|
494
|
+
ruleId: "archive.entry-size",
|
|
495
|
+
location: i,
|
|
496
|
+
snippet: "entry " + JSON.stringify(name) + " size " + sz +
|
|
497
|
+
" exceeds maxEntryBytes " + opts.maxEntryBytes,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Compression-ratio bomb.
|
|
502
|
+
var cs = typeof e.compressedSize === "number" ? e.compressedSize : 0;
|
|
503
|
+
if (cs > 0 && sz > 0) {
|
|
504
|
+
var ratio = sz / cs;
|
|
505
|
+
if (ratio > opts.maxCompressionRatio) {
|
|
506
|
+
issues.push({
|
|
507
|
+
kind: "compression-ratio-bomb", severity: "critical",
|
|
508
|
+
ruleId: "archive.compression-ratio",
|
|
509
|
+
location: i,
|
|
510
|
+
snippet: "entry " + JSON.stringify(name) +
|
|
511
|
+
" compression ratio " + ratio.toFixed(1) +
|
|
512
|
+
":1 exceeds maxCompressionRatio " + opts.maxCompressionRatio + ":1",
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
totalCompressed += cs;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
totalSize += sz;
|
|
519
|
+
|
|
520
|
+
// Duplicate-entry-name detection.
|
|
521
|
+
if (name in nameSeen) {
|
|
522
|
+
if (opts.duplicateNamePolicy !== "allow") {
|
|
523
|
+
issues.push({
|
|
524
|
+
kind: "duplicate-entry-name",
|
|
525
|
+
severity: opts.duplicateNamePolicy === "reject" ? "critical" : "warn",
|
|
526
|
+
ruleId: "archive.duplicate-name",
|
|
527
|
+
location: i,
|
|
528
|
+
snippet: "entry " + JSON.stringify(name) +
|
|
529
|
+
" duplicates entry at index " + nameSeen[name] +
|
|
530
|
+
" (later entry would silently overwrite)",
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
nameSeen[name] = i;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Case-insensitive collision.
|
|
538
|
+
var lower = name.toLowerCase();
|
|
539
|
+
if (caseSeen[lower] !== undefined && caseSeen[lower] !== name) {
|
|
540
|
+
if (opts.caseInsensitiveCollisionPolicy !== "allow") {
|
|
541
|
+
issues.push({
|
|
542
|
+
kind: "case-insensitive-collision",
|
|
543
|
+
severity: opts.caseInsensitiveCollisionPolicy === "reject" ? "high" : "warn",
|
|
544
|
+
ruleId: "archive.case-collision",
|
|
545
|
+
location: i,
|
|
546
|
+
snippet: "entry " + JSON.stringify(name) + " collides case-insensitively with " +
|
|
547
|
+
JSON.stringify(caseSeen[lower]),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
} else {
|
|
551
|
+
caseSeen[lower] = name;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Nested-archive detection.
|
|
555
|
+
if (_isArchiveName(name) && !e.isDirectory) {
|
|
556
|
+
if (opts.nestedArchivePolicy === "reject" && opts.maxNestedDepth === 0) {
|
|
557
|
+
issues.push({
|
|
558
|
+
kind: "nested-archive", severity: "critical",
|
|
559
|
+
ruleId: "archive.nested",
|
|
560
|
+
location: i,
|
|
561
|
+
snippet: "entry " + JSON.stringify(name) +
|
|
562
|
+
" is itself an archive (profile rejects nested archives)",
|
|
563
|
+
});
|
|
564
|
+
} else if (opts.nestedArchivePolicy === "audit") {
|
|
565
|
+
issues.push({
|
|
566
|
+
kind: "nested-archive", severity: "warn",
|
|
567
|
+
ruleId: "archive.nested",
|
|
568
|
+
location: i,
|
|
569
|
+
snippet: "entry " + JSON.stringify(name) +
|
|
570
|
+
" is itself an archive (operator must validate recursively up to maxNestedDepth " +
|
|
571
|
+
opts.maxNestedDepth + ")",
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Encryption claim accounting.
|
|
577
|
+
if (e.isEncrypted) encryptedCount += 1;
|
|
578
|
+
else if (!e.isDirectory) unencryptedCount += 1;
|
|
579
|
+
|
|
580
|
+
// Sparse entry — tar-shape sparse entries claim large size but no data.
|
|
581
|
+
if (e.attrs && e.attrs.sparse) {
|
|
582
|
+
if (opts.sparseEntryPolicy === "reject") {
|
|
583
|
+
issues.push({
|
|
584
|
+
kind: "sparse-entry", severity: "high",
|
|
585
|
+
ruleId: "archive.sparse",
|
|
586
|
+
location: i,
|
|
587
|
+
snippet: "entry " + JSON.stringify(name) + " is a tar sparse entry (profile rejects)",
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Total-size cap.
|
|
594
|
+
if (totalSize > opts.maxTotalBytes) {
|
|
595
|
+
issues.push({
|
|
596
|
+
kind: "total-size-cap", severity: "high",
|
|
597
|
+
ruleId: "archive.total-size",
|
|
598
|
+
snippet: "total uncompressed size " + totalSize +
|
|
599
|
+
" bytes exceeds maxTotalBytes " + opts.maxTotalBytes,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Aggregate compression-ratio bomb (sum-of-uncompressed / sum-of-compressed).
|
|
604
|
+
if (totalCompressed > 0) {
|
|
605
|
+
var aggRatio = totalSize / totalCompressed;
|
|
606
|
+
if (aggRatio > opts.maxAggregateRatio) {
|
|
607
|
+
issues.push({
|
|
608
|
+
kind: "aggregate-ratio-bomb", severity: "critical",
|
|
609
|
+
ruleId: "archive.aggregate-ratio",
|
|
610
|
+
snippet: "aggregate compression ratio " + aggRatio.toFixed(1) +
|
|
611
|
+
":1 exceeds maxAggregateRatio " + opts.maxAggregateRatio + ":1",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Encryption-claim mismatch.
|
|
617
|
+
if (encryptedCount > 0 && unencryptedCount > 0 &&
|
|
618
|
+
opts.encryptionPolicy !== "allow") {
|
|
619
|
+
issues.push({
|
|
620
|
+
kind: "encryption-claim-mismatch",
|
|
621
|
+
severity: opts.encryptionPolicy === "reject" ? "high" : "warn",
|
|
622
|
+
ruleId: "archive.encryption-mix",
|
|
623
|
+
snippet: "archive contains both encrypted (" + encryptedCount +
|
|
624
|
+
") and unencrypted (" + unencryptedCount + ") entries",
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return issues;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ---- Public surface ----
|
|
632
|
+
|
|
633
|
+
function validateEntries(entries, opts) {
|
|
634
|
+
opts = _resolveOpts(opts);
|
|
635
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
636
|
+
["maxEntries", "maxTotalBytes", "maxEntryBytes",
|
|
637
|
+
"maxCompressionRatio", "maxAggregateRatio"],
|
|
638
|
+
"guardArchive.validateEntries", GuardArchiveError, "archive.bad-opt");
|
|
639
|
+
if (!Array.isArray(entries)) {
|
|
640
|
+
return {
|
|
641
|
+
ok: false,
|
|
642
|
+
issues: [{ kind: "bad-input", severity: "high",
|
|
643
|
+
snippet: "entries must be an array" }],
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
return gateContract.aggregateIssues(_detectIssues(entries, opts));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function gate(opts) {
|
|
650
|
+
opts = _resolveOpts(opts);
|
|
651
|
+
return gateContract.buildGuardGate(
|
|
652
|
+
opts.name || "guardArchive:" + (opts.profile || "default"),
|
|
653
|
+
opts,
|
|
654
|
+
async function (ctx) {
|
|
655
|
+
// Operator passes ctx.entries (array of entry descriptors). When
|
|
656
|
+
// ctx.bytes is provided WITHOUT ctx.entries, we run inspectMagic
|
|
657
|
+
// for format detection but cannot validate entries (no parser
|
|
658
|
+
// shipped). Refuse with a "no entry list" issue so operators
|
|
659
|
+
// wire the parsed entries explicitly.
|
|
660
|
+
var entries = ctx && ctx.entries;
|
|
661
|
+
if (!entries) {
|
|
662
|
+
if (ctx && ctx.bytes) {
|
|
663
|
+
var detected = inspectMagic(Buffer.isBuffer(ctx.bytes)
|
|
664
|
+
? ctx.bytes
|
|
665
|
+
: Buffer.from(ctx.bytes));
|
|
666
|
+
if (detected) {
|
|
667
|
+
return {
|
|
668
|
+
ok: false, action: "refuse",
|
|
669
|
+
issues: [{
|
|
670
|
+
kind: "no-entry-list", severity: "high",
|
|
671
|
+
ruleId: "archive.no-entry-list",
|
|
672
|
+
snippet: "archive format " + JSON.stringify(detected.format) +
|
|
673
|
+
" detected via magic bytes; operator must enumerate " +
|
|
674
|
+
"entries via their archive library and pass via ctx.entries",
|
|
675
|
+
}],
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { ok: true, action: "serve" };
|
|
680
|
+
}
|
|
681
|
+
var rv = validateEntries(entries, opts);
|
|
682
|
+
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
683
|
+
var hasCritical = rv.issues.some(function (i) {
|
|
684
|
+
return i.severity === "critical" || i.severity === "high";
|
|
685
|
+
});
|
|
686
|
+
if (!hasCritical) return { ok: true, action: "audit-only", issues: rv.issues };
|
|
687
|
+
// Archive content has no safe sanitization — refuse.
|
|
688
|
+
return { ok: false, action: "refuse", issues: rv.issues };
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
var buildProfile = gateContract.makeProfileBuilder(PROFILES);
|
|
693
|
+
|
|
694
|
+
function compliancePosture(name) {
|
|
695
|
+
return gateContract.lookupCompliancePosture(name, COMPLIANCE_POSTURES, _err, "archive");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
var _archiveRulePacks = gateContract.makeRulePackLoader(GuardArchiveError, "archive");
|
|
699
|
+
var loadRulePack = _archiveRulePacks.load;
|
|
700
|
+
|
|
701
|
+
module.exports = {
|
|
702
|
+
// ---- guard-* family registry exports (consumed by b.guardAll) ----
|
|
703
|
+
NAME: "archive",
|
|
704
|
+
KIND: "entries", // archive-entries guard (consumes ctx.entries)
|
|
705
|
+
INTEGRATION_FIXTURES: Object.freeze({
|
|
706
|
+
kind: "entries",
|
|
707
|
+
contentType: "application/zip",
|
|
708
|
+
extension: ".zip",
|
|
709
|
+
benignEntries: [{ name: "README.txt", size: 1000, compressedSize: 500 }], // allow:raw-byte-literal — integration-fixture sample size, not byte config
|
|
710
|
+
// Hostile: zip-slip path traversal in entry name (CVE-2025-3445 class).
|
|
711
|
+
hostileEntries: [{ name: "../etc/passwd", size: 100, compressedSize: 50 }],
|
|
712
|
+
}),
|
|
713
|
+
MIME_TYPES: Object.freeze([
|
|
714
|
+
"application/zip", "application/x-zip-compressed",
|
|
715
|
+
"application/x-tar", "application/gzip", "application/x-gzip",
|
|
716
|
+
"application/x-bzip2", "application/x-xz", "application/x-7z-compressed",
|
|
717
|
+
"application/vnd.rar", "application/x-rar-compressed",
|
|
718
|
+
"application/zstd",
|
|
719
|
+
]),
|
|
720
|
+
EXTENSIONS: Object.freeze([
|
|
721
|
+
".zip", ".jar", ".war", ".tar", ".tar.gz", ".tgz",
|
|
722
|
+
".tar.bz2", ".tbz2", ".tar.xz", ".txz", ".gz", ".bz2", ".xz",
|
|
723
|
+
".7z", ".rar", ".zst", ".tar.zst", ".tzst",
|
|
724
|
+
]),
|
|
725
|
+
// ---- primitive surface ----
|
|
726
|
+
validateEntries: validateEntries,
|
|
727
|
+
inspectMagic: inspectMagic,
|
|
728
|
+
checkExtractionPath: checkExtractionPath,
|
|
729
|
+
gate: gate,
|
|
730
|
+
buildProfile: buildProfile,
|
|
731
|
+
compliancePosture: compliancePosture,
|
|
732
|
+
loadRulePack: loadRulePack,
|
|
733
|
+
PROFILES: PROFILES,
|
|
734
|
+
DEFAULTS: DEFAULTS,
|
|
735
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
736
|
+
ARCHIVE_EXTENSIONS: ARCHIVE_EXTENSIONS,
|
|
737
|
+
MAGIC_SIGNATURES: MAGIC_SIGNATURES,
|
|
738
|
+
GuardArchiveError: GuardArchiveError,
|
|
739
|
+
};
|