@blamejs/core 0.12.5 → 0.12.7
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 +4 -0
- package/README.md +1 -1
- package/index.js +15 -1
- package/lib/archive-adapters.js +629 -0
- package/lib/archive-read.js +781 -0
- package/lib/archive.js +12 -0
- package/lib/backup/index.js +245 -0
- package/lib/guard-archive.js +140 -0
- package/lib/guard-filename.js +205 -0
- package/lib/observability-otlp-exporter.js +245 -3
- package/lib/protobuf-encoder.js +87 -9
- package/lib/safe-archive.js +275 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.safeArchive
|
|
4
|
+
* @nav Tools
|
|
5
|
+
* @title Safe Archive
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* One-liner safe-extract orchestrator for adversarial archives.
|
|
9
|
+
* Combines `b.archive.read` + `b.guardArchive.inspect` + `b.guardFilename.
|
|
10
|
+
* verifyExtractionPath` + zip-bomb + entry-type policy + audit chain
|
|
11
|
+
* into a single call.
|
|
12
|
+
*
|
|
13
|
+
* The 90%-case workflow — operator receives a hostile-shaped archive
|
|
14
|
+
* from an upload / external system / pipeline, wants to extract it
|
|
15
|
+
* into a quarantine directory with every defense default-on, and
|
|
16
|
+
* doesn't want to learn the read/guard/safeDecompress composition
|
|
17
|
+
* surface to do it.
|
|
18
|
+
*
|
|
19
|
+
* `b.safeArchive.extract({ source, destination, ... })` does the
|
|
20
|
+
* composition for them. Operators with fine-grained control needs
|
|
21
|
+
* reach for `b.archive.read.zip(adapter)` directly + assemble the
|
|
22
|
+
* pipeline manually.
|
|
23
|
+
*
|
|
24
|
+
* Format auto-detection sniffs the first ~512 bytes for magic
|
|
25
|
+
* signatures. v0.12.7 ships ZIP detection (LFH magic `0x04034b50`
|
|
26
|
+
* + EOCD magic `0x06054b50`); tar / gz / ae2 / `b.crypto.encryptPacked`-
|
|
27
|
+
* wrapped formats are flagged as `safe-archive/format-unsupported`
|
|
28
|
+
* in this patch — tar lands v0.12.8, gz v0.12.9, encryption v0.12.10/11.
|
|
29
|
+
*
|
|
30
|
+
* The orchestrator refuses the WHOLE archive on any single critical
|
|
31
|
+
* guard issue — no partial extraction. Cleanup is `fs.rm`-recursive
|
|
32
|
+
* on the destination if extraction was interrupted, so a failed
|
|
33
|
+
* extract leaves no half-state on disk.
|
|
34
|
+
*
|
|
35
|
+
* @card
|
|
36
|
+
* One-liner safe-extract orchestrator — read + guard + path-safety + bomb caps + audit.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
var lazyRequire = require("./lazy-require");
|
|
40
|
+
var validateOpts = require("./validate-opts");
|
|
41
|
+
var C = require("./constants");
|
|
42
|
+
var { defineClass } = require("./framework-error");
|
|
43
|
+
|
|
44
|
+
var SafeArchiveError = defineClass("SafeArchiveError", { alwaysPermanent: true });
|
|
45
|
+
|
|
46
|
+
var archiveRead = lazyRequire(function () { return require("./archive-read"); });
|
|
47
|
+
var archiveAdapters = lazyRequire(function () { return require("./archive-adapters"); });
|
|
48
|
+
|
|
49
|
+
// ---- Format sniffing ----------------------------------------------------
|
|
50
|
+
|
|
51
|
+
// ZIP local file header magic per APPNOTE §4.3.7.
|
|
52
|
+
// ZIP empty-archive EOCD magic per APPNOTE §4.3.16.
|
|
53
|
+
var MAGIC_ZIP_LFH = 0x04034b50;
|
|
54
|
+
var MAGIC_ZIP_EOCD = 0x06054b50;
|
|
55
|
+
// GZIP magic per RFC 1952 §2.3.1.
|
|
56
|
+
var MAGIC_GZIP_BE = 0x1f8b;
|
|
57
|
+
// b.crypto.encryptPacked envelope magic — the prefix the framework's
|
|
58
|
+
// PQ envelope writes. (Sentinel value for v0.12.10+ Flavor 1 unwrap.)
|
|
59
|
+
var MAGIC_ENCPACKED = "EPACK";
|
|
60
|
+
|
|
61
|
+
async function _sniffMagic(adapter) {
|
|
62
|
+
// For random-access adapters, the format sniffer reads the first
|
|
63
|
+
// 512 bytes — enough for ZIP + GZIP + b.crypto.encryptPacked magic
|
|
64
|
+
// detection. tar magic lives at offset 257 inside the first 512-
|
|
65
|
+
// byte header block, so we need at least 263 bytes; 512 covers it.
|
|
66
|
+
if (adapter.kind !== "random-access") {
|
|
67
|
+
throw new SafeArchiveError("safe-archive/sniff-unsupported-adapter",
|
|
68
|
+
"format sniffing requires a random-access adapter (got " + adapter.kind + ")");
|
|
69
|
+
}
|
|
70
|
+
var size = adapter.size;
|
|
71
|
+
if (size == null && typeof adapter.resolveSize === "function") {
|
|
72
|
+
size = await adapter.resolveSize();
|
|
73
|
+
}
|
|
74
|
+
if (typeof size !== "number" || size < 4) {
|
|
75
|
+
throw new SafeArchiveError("safe-archive/too-small",
|
|
76
|
+
"archive too small to determine format (size=" + size + ")");
|
|
77
|
+
}
|
|
78
|
+
var head = await adapter.range(0, Math.min(C.BYTES.bytes(512), size));
|
|
79
|
+
// ZIP — LFH at offset 0 (most common) OR empty-archive EOCD at offset 0.
|
|
80
|
+
if (head.length >= 4) {
|
|
81
|
+
var first4 = head.readUInt32LE(0);
|
|
82
|
+
if (first4 === MAGIC_ZIP_LFH) return { format: "zip", subkind: "lfh" };
|
|
83
|
+
if (first4 === MAGIC_ZIP_EOCD) return { format: "zip", subkind: "empty" };
|
|
84
|
+
}
|
|
85
|
+
// GZIP — 2-byte BE magic.
|
|
86
|
+
if (head.length >= 2) {
|
|
87
|
+
var be2 = head.readUInt16BE(0);
|
|
88
|
+
if (be2 === MAGIC_GZIP_BE) return { format: "gzip" };
|
|
89
|
+
}
|
|
90
|
+
// b.crypto.encryptPacked — 5-byte ASCII prefix.
|
|
91
|
+
if (head.length >= 5) {
|
|
92
|
+
var prefix = head.slice(0, 5).toString("utf8");
|
|
93
|
+
if (prefix === MAGIC_ENCPACKED) return { format: "encryptPacked" };
|
|
94
|
+
}
|
|
95
|
+
// tar — "ustar" at offset 257 within the first 512-byte header.
|
|
96
|
+
if (head.length >= 263) {
|
|
97
|
+
var tarMagic = head.slice(257, 262).toString("utf8");
|
|
98
|
+
if (tarMagic === "ustar") return { format: "tar" };
|
|
99
|
+
}
|
|
100
|
+
return { format: "unknown" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- Public extract orchestrator ----------------------------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @primitive b.safeArchive.extract
|
|
107
|
+
* @signature b.safeArchive.extract(opts)
|
|
108
|
+
* @since 0.12.7
|
|
109
|
+
* @status stable
|
|
110
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
111
|
+
* @related b.archive.read.zip, b.guardArchive.inspect, b.guardFilename.verifyExtractionPath
|
|
112
|
+
*
|
|
113
|
+
* Safe-extract orchestrator. Combines read + guard + path-safety +
|
|
114
|
+
* bomb caps + audit in one call.
|
|
115
|
+
*
|
|
116
|
+
* Refuses the whole archive on:
|
|
117
|
+
* - Format auto-detect mismatch (unknown / unsupported format).
|
|
118
|
+
* - Any critical guard issue (CVE-2025-3445 Zip Slip class + path
|
|
119
|
+
* traversal + symlink-escape + nested archive + encrypted entry).
|
|
120
|
+
* - PATH_MAX overflow on any entry name (CVE-2025-4517 defense).
|
|
121
|
+
* - Bomb-policy breach (entry-count / per-entry size / total size /
|
|
122
|
+
* expansion ratio).
|
|
123
|
+
* - LFH/CD skew on any entry.
|
|
124
|
+
*
|
|
125
|
+
* @opts
|
|
126
|
+
* source: b.archive.adapters.* | Buffer | string,
|
|
127
|
+
* destination: string (target directory; created if missing),
|
|
128
|
+
* format: "auto" | "zip" (v0.12.7 — tar v0.12.8, gz v0.12.9),
|
|
129
|
+
* bombPolicy: b.guardArchive.zipBombPolicy(...) | { ... },
|
|
130
|
+
* entryTypePolicy: b.guardArchive.entryTypePolicy(...) | { ... },
|
|
131
|
+
* guardProfile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
|
|
132
|
+
* audit: b.audit,
|
|
133
|
+
* signal: AbortSignal,
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* var result = await b.safeArchive.extract({
|
|
137
|
+
* source: b.archive.adapters.fs("/var/uploads/payload.zip"),
|
|
138
|
+
* destination: "/var/quarantine",
|
|
139
|
+
* guardProfile: "strict",
|
|
140
|
+
* });
|
|
141
|
+
* // → { entries: [{ name, bytesWritten, path }, ...], bytesExtracted, format }
|
|
142
|
+
*/
|
|
143
|
+
async function extract(opts) {
|
|
144
|
+
opts = opts || {};
|
|
145
|
+
validateOpts.requireNonEmptyString(opts.destination,
|
|
146
|
+
"b.safeArchive.extract: opts.destination", SafeArchiveError, "safe-archive/no-destination");
|
|
147
|
+
// Resolve source → adapter. Strings become fs adapters; Buffers
|
|
148
|
+
// become buffer adapters; anything else is assumed to BE an adapter
|
|
149
|
+
// already.
|
|
150
|
+
var source = opts.source;
|
|
151
|
+
if (typeof source === "string") {
|
|
152
|
+
source = archiveAdapters().fs(source, { signal: opts.signal });
|
|
153
|
+
} else if (Buffer.isBuffer(source)) {
|
|
154
|
+
source = archiveAdapters().buffer(source, { signal: opts.signal });
|
|
155
|
+
} else if (archiveAdapters().isTrustedStreamAdapter(source)) {
|
|
156
|
+
// Trusted-stream adapters are accepted by the contract but the
|
|
157
|
+
// orchestrator's extract path needs random-access (CD-walk +
|
|
158
|
+
// LFH/CD skew defense). Refuse upfront with a typed safe-archive
|
|
159
|
+
// error so the operator sees the constraint at the entry point
|
|
160
|
+
// rather than an `archive-read/wrong-entry-point` thrown by the
|
|
161
|
+
// downstream reader. Trusted-stream extract via
|
|
162
|
+
// `b.archive.read.zip.fromTrustedStream` is deferred to v0.12.8
|
|
163
|
+
// alongside the tar reader's sequential mode.
|
|
164
|
+
throw new SafeArchiveError("safe-archive/trusted-stream-unsupported",
|
|
165
|
+
"extract: trusted-stream adapter sources are not supported by the orchestrator " +
|
|
166
|
+
"(the adversarial-safe CD-walk requires random-access). Collect the bytes via " +
|
|
167
|
+
"`b.archive.adapters.buffer(await collect(readable))` and pass that, or use " +
|
|
168
|
+
"`b.archive.read.zip.fromTrustedStream` directly when the v0.12.8 sequential " +
|
|
169
|
+
"extract path lands");
|
|
170
|
+
} else if (!archiveAdapters().isRandomAccessAdapter(source)) {
|
|
171
|
+
throw new SafeArchiveError("safe-archive/bad-source",
|
|
172
|
+
"extract: opts.source must be a string path, Buffer, or b.archive.adapters.* result");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
var format = opts.format || "auto";
|
|
177
|
+
if (format === "auto") {
|
|
178
|
+
var sniff = await _sniffMagic(source);
|
|
179
|
+
format = sniff.format;
|
|
180
|
+
}
|
|
181
|
+
if (format !== "zip") {
|
|
182
|
+
throw new SafeArchiveError("safe-archive/format-unsupported",
|
|
183
|
+
"extract: format=" + JSON.stringify(format) + " — v0.12.7 ships ZIP only " +
|
|
184
|
+
"(tar lands v0.12.8, gz lands v0.12.9, encryptPacked-wrap lands v0.12.10)");
|
|
185
|
+
}
|
|
186
|
+
var reader = archiveRead().zip(source, {
|
|
187
|
+
bombPolicy: opts.bombPolicy,
|
|
188
|
+
entryTypePolicy: opts.entryTypePolicy,
|
|
189
|
+
guardProfile: opts.guardProfile,
|
|
190
|
+
audit: opts.audit,
|
|
191
|
+
});
|
|
192
|
+
var result = await reader.extract({ destination: opts.destination });
|
|
193
|
+
return Object.assign({ format: format }, result);
|
|
194
|
+
} finally {
|
|
195
|
+
if (typeof source.close === "function" && typeof opts.source === "string") {
|
|
196
|
+
try { source.close(); } catch (_e) { /* drop-silent */ }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @primitive b.safeArchive.inspect
|
|
203
|
+
* @signature b.safeArchive.inspect(opts)
|
|
204
|
+
* @since 0.12.7
|
|
205
|
+
* @status stable
|
|
206
|
+
* @related b.safeArchive.extract, b.guardArchive.validateEntries
|
|
207
|
+
*
|
|
208
|
+
* Read-only inspect: format sniffing + entry-list enumeration without
|
|
209
|
+
* decompression. Operators previewing an uploaded archive before
|
|
210
|
+
* committing to extraction reach for this primitive.
|
|
211
|
+
*
|
|
212
|
+
* @opts
|
|
213
|
+
* source: b.archive.adapters.* | Buffer | string,
|
|
214
|
+
* format: "auto" | "zip",
|
|
215
|
+
* bombPolicy: { ... },
|
|
216
|
+
* audit: b.audit,
|
|
217
|
+
*
|
|
218
|
+
* @example
|
|
219
|
+
* var summary = await b.safeArchive.inspect({
|
|
220
|
+
* source: b.archive.adapters.fs("/var/uploads/payload.zip"),
|
|
221
|
+
* });
|
|
222
|
+
* // → { format: "zip", entries: [...], totalCompressedBytes, totalUncompressedBytes }
|
|
223
|
+
*/
|
|
224
|
+
async function inspect(opts) {
|
|
225
|
+
opts = opts || {};
|
|
226
|
+
var source = opts.source;
|
|
227
|
+
if (typeof source === "string") {
|
|
228
|
+
source = archiveAdapters().fs(source, { signal: opts.signal });
|
|
229
|
+
} else if (Buffer.isBuffer(source)) {
|
|
230
|
+
source = archiveAdapters().buffer(source, { signal: opts.signal });
|
|
231
|
+
} else if (!archiveAdapters().isRandomAccessAdapter(source)) {
|
|
232
|
+
throw new SafeArchiveError("safe-archive/bad-source",
|
|
233
|
+
"inspect: opts.source must be a string path, Buffer, or random-access adapter");
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
var format = opts.format || "auto";
|
|
237
|
+
if (format === "auto") {
|
|
238
|
+
var sniff = await _sniffMagic(source);
|
|
239
|
+
format = sniff.format;
|
|
240
|
+
}
|
|
241
|
+
if (format !== "zip") {
|
|
242
|
+
throw new SafeArchiveError("safe-archive/format-unsupported",
|
|
243
|
+
"inspect: format=" + JSON.stringify(format) + " — v0.12.7 ships ZIP only");
|
|
244
|
+
}
|
|
245
|
+
var reader = archiveRead().zip(source, {
|
|
246
|
+
bombPolicy: opts.bombPolicy,
|
|
247
|
+
audit: opts.audit,
|
|
248
|
+
});
|
|
249
|
+
var entries = await reader.inspect();
|
|
250
|
+
var totalCompressed = 0;
|
|
251
|
+
var totalUncompressed = 0;
|
|
252
|
+
for (var i = 0; i < entries.length; i += 1) {
|
|
253
|
+
totalCompressed += entries[i].compressedSize;
|
|
254
|
+
totalUncompressed += entries[i].size;
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
format: format,
|
|
258
|
+
entries: entries,
|
|
259
|
+
totalCompressedBytes: totalCompressed,
|
|
260
|
+
totalUncompressedBytes: totalUncompressed,
|
|
261
|
+
};
|
|
262
|
+
} finally {
|
|
263
|
+
if (typeof source.close === "function" && typeof opts.source === "string") {
|
|
264
|
+
try { source.close(); } catch (_e) { /* drop-silent */ }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
extract: extract,
|
|
271
|
+
inspect: inspect,
|
|
272
|
+
SafeArchiveError: SafeArchiveError,
|
|
273
|
+
// Exposed for tests + sibling modules.
|
|
274
|
+
_sniffMagic: _sniffMagic,
|
|
275
|
+
};
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:6ee9d122-fc29-4cdb-a4fb-bd4afab5f35d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-23T15:14:39.191Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.12.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.12.7",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.12.
|
|
25
|
+
"version": "0.12.7",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.12.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.12.7",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.12.
|
|
57
|
+
"ref": "@blamejs/core@0.12.7",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|