@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.5",
3
+ "version": "0.12.7",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:235e9ad1-2dfa-4fef-925d-f259dea69771",
5
+ "serialNumber": "urn:uuid:6ee9d122-fc29-4cdb-a4fb-bd4afab5f35d",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-23T01:36:17.867Z",
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.5",
22
+ "bom-ref": "@blamejs/core@0.12.7",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.5",
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.5",
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.5",
57
+ "ref": "@blamejs/core@0.12.7",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]