@blamejs/blamejs-shop 0.0.83 → 0.0.85

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.
@@ -923,6 +923,210 @@ var _filenameRulePacks = gateContract.makeRulePackLoader(GuardFilenameError, "fi
923
923
  */
924
924
  var loadRulePack = _filenameRulePacks.load;
925
925
 
926
+ // ---- verifyExtractionPath -------------------------------------------------
927
+
928
+ var nodePath = require("node:path");
929
+ var nodeFs = require("node:fs");
930
+
931
+ // CVE-2025-4517 PATH_MAX threshold — Python's tarfile filter relied on
932
+ // os.path.realpath which silently stops resolving symlinks once the
933
+ // resolved path exceeds PATH_MAX (4096 on Linux). The kernel keeps
934
+ // resolving past that, so the filter's safety check + the kernel's
935
+ // extraction diverge. We refuse paths whose pre-resolve length already
936
+ // exceeds PATH_MAX so the operator's realpath behavior is never the
937
+ // gating factor.
938
+ var PATH_MAX_BYTES = 4096;
939
+
940
+ /**
941
+ * @primitive b.guardFilename.verifyExtractionPath
942
+ * @signature b.guardFilename.verifyExtractionPath(entryName, extractionRoot, opts?)
943
+ * @since 0.12.7
944
+ * @status stable
945
+ * @compliance hipaa, pci-dss, gdpr, soc2
946
+ * @related b.guardArchive.checkExtractionPath, b.guardArchive.validateEntries, b.archive.read.zip
947
+ *
948
+ * Dual-check extraction path safety: string-check (refuses `..`, leading
949
+ * `/` / `\\`, drive-letter prefix, null byte, PATH_MAX overflow) followed
950
+ * by `fs.realpath` agreement check (the resolved path on disk must
951
+ * land inside the realpath of the extraction root). Returns the
952
+ * resolved absolute path on success; throws `GuardFilenameError` on
953
+ * any refusal.
954
+ *
955
+ * Companion to `b.guardArchive.checkExtractionPath` (the string-only
956
+ * portable gate the guard-archive primitive keeps fs-free for use as
957
+ * a posture cascade member). `verifyExtractionPath` deliberately
958
+ * couples to `node:fs` — the deeper realpath check defends the
959
+ * CVE-2025-4517 PATH_MAX TOCTOU class where the operator's path
960
+ * resolution and the kernel's diverge silently past PATH_MAX.
961
+ *
962
+ * `b.archive.read.zip.extract` composes this on every entry; operators
963
+ * extracting via the safeArchive orchestrator never call it directly.
964
+ * Operators rolling their own extract loop call it per entry.
965
+ *
966
+ * @opts
967
+ * followSymlinks: boolean, // default false — symlink in the
968
+ * // resolved path refuses unless set
969
+ *
970
+ * @example
971
+ * var resolved = b.guardFilename.verifyExtractionPath(
972
+ * "docs/readme.txt",
973
+ * "/var/quarantine"
974
+ * );
975
+ * // → "/var/quarantine/docs/readme.txt"
976
+ *
977
+ * // ../ refuses
978
+ * b.guardFilename.verifyExtractionPath("../etc/passwd", "/var/quarantine");
979
+ * // throws GuardFilenameError("filename.extraction-traversal")
980
+ *
981
+ * // PATH_MAX-overflow refuses BEFORE realpath truncation hits
982
+ * b.guardFilename.verifyExtractionPath(longName, "/var/quarantine");
983
+ * // throws GuardFilenameError("filename.extraction-path-max")
984
+ */
985
+ function verifyExtractionPath(entryName, extractionRoot, opts) {
986
+ opts = opts || {};
987
+ if (typeof entryName !== "string" || entryName.length === 0) {
988
+ throw new GuardFilenameError("filename.extraction-empty",
989
+ "verifyExtractionPath: entryName must be non-empty string");
990
+ }
991
+ if (typeof extractionRoot !== "string" || extractionRoot.length === 0) {
992
+ throw new GuardFilenameError("filename.extraction-bad-root",
993
+ "verifyExtractionPath: extractionRoot must be non-empty string");
994
+ }
995
+ // PATH_MAX defense — refuse oversize names BEFORE any path operation
996
+ // (mkdir / realpath / open) can truncate silently.
997
+ if (entryName.length > PATH_MAX_BYTES) {
998
+ throw new GuardFilenameError("filename.extraction-path-max",
999
+ "verifyExtractionPath: entryName length " + entryName.length +
1000
+ " exceeds PATH_MAX=" + PATH_MAX_BYTES +
1001
+ " (CVE-2025-4517 class — operator realpath truncation defense)");
1002
+ }
1003
+ // String-check first — these checks are portable + don't touch fs.
1004
+ // Null byte — POSIX path APIs treat it as a string terminator.
1005
+ if (entryName.indexOf("\u0000") !== -1) {
1006
+ throw new GuardFilenameError("filename.extraction-null-byte",
1007
+ "verifyExtractionPath: entryName contains null byte");
1008
+ }
1009
+ // Normalize separators so the `..` walk catches Windows-style too.
1010
+ var normalized = entryName.replace(/\\/g, "/");
1011
+ // Leading-slash absolute path refuses.
1012
+ if (normalized.length > 0 && normalized[0] === "/") {
1013
+ throw new GuardFilenameError("filename.extraction-absolute",
1014
+ "verifyExtractionPath: entryName is an absolute path");
1015
+ }
1016
+ // Drive-letter prefix (Windows) refuses.
1017
+ if (/^[A-Za-z]:[/\\]/.test(entryName)) {
1018
+ throw new GuardFilenameError("filename.extraction-drive-prefix",
1019
+ "verifyExtractionPath: entryName starts with a drive-letter prefix");
1020
+ }
1021
+ // UNC path (Windows) refuses.
1022
+ if (entryName.indexOf("\\\\") === 0 || entryName.indexOf("//") === 0) {
1023
+ throw new GuardFilenameError("filename.extraction-unc",
1024
+ "verifyExtractionPath: entryName starts with a UNC prefix");
1025
+ }
1026
+ // `..` segment refuses — walk path components.
1027
+ var segs = normalized.split("/");
1028
+ for (var si = 0; si < segs.length; si += 1) {
1029
+ if (segs[si] === ".." || segs[si] === "..\\" || segs[si] === "..%2f" || segs[si] === "..%5c") {
1030
+ throw new GuardFilenameError("filename.extraction-traversal",
1031
+ "verifyExtractionPath: entryName contains .. segment");
1032
+ }
1033
+ // URL-encoded variants — explicit refusal so operators don't
1034
+ // need to percent-decode before passing the entry name in.
1035
+ if (/%2e%2e/i.test(segs[si]) || /%c0%ae/i.test(segs[si])) {
1036
+ throw new GuardFilenameError("filename.extraction-traversal-encoded",
1037
+ "verifyExtractionPath: entryName contains encoded .. segment");
1038
+ }
1039
+ }
1040
+ // Resolve the destination path against the root via path.resolve
1041
+ // (string-level computation; no fs hits).
1042
+ var stringResolved = nodePath.resolve(extractionRoot, normalized);
1043
+ var rootResolved = nodePath.resolve(extractionRoot);
1044
+ // String-level containment check — the resolved path must start
1045
+ // with the root + separator (or equal the root for the directory
1046
+ // entry itself). path.resolve normalizes separators platform-aware.
1047
+ var sep = nodePath.sep;
1048
+ if (stringResolved !== rootResolved &&
1049
+ stringResolved.indexOf(rootResolved + sep) !== 0) {
1050
+ throw new GuardFilenameError("filename.extraction-escape",
1051
+ "verifyExtractionPath: resolved path " + JSON.stringify(stringResolved) +
1052
+ " escapes extraction root " + JSON.stringify(rootResolved));
1053
+ }
1054
+ // Realpath-agreement check (fs-coupled). The CVE-2025-4517 class
1055
+ // exploits a divergence between the operator's path.resolve view
1056
+ // and the kernel's symlink-resolution. We resolve the longest
1057
+ // existing ancestor + verify the realpath agrees with our string
1058
+ // view.
1059
+ if (nodeFs.existsSync(rootResolved)) {
1060
+ var realRoot;
1061
+ try {
1062
+ realRoot = nodeFs.realpathSync(rootResolved);
1063
+ } catch (e) {
1064
+ throw new GuardFilenameError("filename.extraction-root-realpath",
1065
+ "verifyExtractionPath: cannot realpath extractionRoot " +
1066
+ JSON.stringify(rootResolved) + ": " + (e && e.message));
1067
+ }
1068
+ // Walk up from the target until we find an existing parent —
1069
+ // every ancestor that EXISTS must realpath inside realRoot. Once
1070
+ // we hit a non-existent path, the create-and-extract step will
1071
+ // populate it; the operator-supplied target name doesn't pre-
1072
+ // exist, so the deepest existing ancestor is the boundary check.
1073
+ var probe = nodePath.dirname(stringResolved);
1074
+ var safetyCounter = 0;
1075
+ var SAFETY_LIMIT = 4096; // guards against probe walking past root forever
1076
+ while (probe.length >= rootResolved.length && safetyCounter < SAFETY_LIMIT) {
1077
+ safetyCounter += 1;
1078
+ if (nodeFs.existsSync(probe)) {
1079
+ var realProbe;
1080
+ try { realProbe = nodeFs.realpathSync(probe); }
1081
+ catch (e2) {
1082
+ throw new GuardFilenameError("filename.extraction-realpath",
1083
+ "verifyExtractionPath: cannot realpath probe " +
1084
+ JSON.stringify(probe) + ": " + (e2 && e2.message));
1085
+ }
1086
+ // Two cases for the realpath comparison:
1087
+ // a) The probe's realpath stays inside realRoot — the symlink
1088
+ // (if any) is OS-level filesystem layout (macOS /var →
1089
+ // /private/var, Linux /tmp -> tmpfs mount) and the
1090
+ // ancestor was already canonicalized when we hashed
1091
+ // realRoot at the top. Accept.
1092
+ // b) The probe's realpath escapes realRoot — the symlink
1093
+ // resolves outside the trust boundary. Refuse (this is
1094
+ // the actual CVE-2025-4517 PATH_MAX TOCTOU class
1095
+ // defense).
1096
+ // Also normalize probe through path.resolve(realRoot, relative
1097
+ // -- to -- realRoot) so we compare against the SAME canonicalized
1098
+ // root, not the operator-supplied form. Computing `probeRealRel`
1099
+ // via the realRoot prefix avoids treating OS-level /var -> /private
1100
+ // /var as an escape just because realProbe doesn't textually share
1101
+ // the rootResolved prefix.
1102
+ var probeInsideRoot = (realProbe === realRoot) ||
1103
+ (realProbe.indexOf(realRoot + sep) === 0);
1104
+ if (!probeInsideRoot) {
1105
+ throw new GuardFilenameError("filename.extraction-realpath-escape",
1106
+ "verifyExtractionPath: realpath of " + JSON.stringify(probe) +
1107
+ " (" + JSON.stringify(realProbe) + ") escapes realpath of root " +
1108
+ JSON.stringify(realRoot) +
1109
+ " — CVE-2025-4517 PATH_MAX TOCTOU class");
1110
+ }
1111
+ // Symlink-anywhere-in-chain refusal was removed: macOS /
1112
+ // *BSD filesystems carry OS-level symlinks in standard paths
1113
+ // (/var → /private/var, /tmp → /private/tmp) that legitimate
1114
+ // operator usage routinely crosses. The realpath-agreement
1115
+ // check above is the load-bearing defense; if the resolved
1116
+ // chain STAYS inside realRoot, the symlinks resolved within
1117
+ // the trust boundary and the extraction is safe. Hostile
1118
+ // symlinks that escape are caught by the escape branch.
1119
+ void opts.followSymlinks;
1120
+ break;
1121
+ }
1122
+ var parent = nodePath.dirname(probe);
1123
+ if (parent === probe) break; // hit fs root
1124
+ probe = parent;
1125
+ }
1126
+ }
1127
+ return stringResolved;
1128
+ }
1129
+
926
1130
  module.exports = {
927
1131
  // ---- guard-* family identity ----
928
1132
  // Filename is a different axis from content-bytes (operators
@@ -953,4 +1157,5 @@ module.exports = {
953
1157
  WIN_RESERVED_NAMES: WIN_RESERVED_NAMES,
954
1158
  SHELL_EXEC_EXTS: SHELL_EXEC_EXTS,
955
1159
  GuardFilenameError: GuardFilenameError,
1160
+ verifyExtractionPath: verifyExtractionPath,
956
1161
  };
@@ -0,0 +1,295 @@
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
+ var archiveTarRead = lazyRequire(function () { return require("./archive-tar-read"); });
49
+
50
+ // ---- Format sniffing ----------------------------------------------------
51
+
52
+ // ZIP local file header magic per APPNOTE §4.3.7.
53
+ // ZIP empty-archive EOCD magic per APPNOTE §4.3.16.
54
+ var MAGIC_ZIP_LFH = 0x04034b50;
55
+ var MAGIC_ZIP_EOCD = 0x06054b50;
56
+ // GZIP magic per RFC 1952 §2.3.1.
57
+ var MAGIC_GZIP_BE = 0x1f8b;
58
+ // b.crypto.encryptPacked envelope magic — the prefix the framework's
59
+ // PQ envelope writes. (Sentinel value for v0.12.10+ Flavor 1 unwrap.)
60
+ var MAGIC_ENCPACKED = "EPACK";
61
+
62
+ async function _sniffMagic(adapter) {
63
+ // For random-access adapters, the format sniffer reads the first
64
+ // 512 bytes — enough for ZIP + GZIP + b.crypto.encryptPacked magic
65
+ // detection. tar magic lives at offset 257 inside the first 512-
66
+ // byte header block, so we need at least 263 bytes; 512 covers it.
67
+ if (adapter.kind !== "random-access") {
68
+ throw new SafeArchiveError("safe-archive/sniff-unsupported-adapter",
69
+ "format sniffing requires a random-access adapter (got " + adapter.kind + ")");
70
+ }
71
+ var size = adapter.size;
72
+ if (size == null && typeof adapter.resolveSize === "function") {
73
+ size = await adapter.resolveSize();
74
+ }
75
+ if (typeof size !== "number" || size < 4) {
76
+ throw new SafeArchiveError("safe-archive/too-small",
77
+ "archive too small to determine format (size=" + size + ")");
78
+ }
79
+ var head = await adapter.range(0, Math.min(C.BYTES.bytes(512), size));
80
+ // ZIP — LFH at offset 0 (most common) OR empty-archive EOCD at offset 0.
81
+ if (head.length >= 4) {
82
+ var first4 = head.readUInt32LE(0);
83
+ if (first4 === MAGIC_ZIP_LFH) return { format: "zip", subkind: "lfh" };
84
+ if (first4 === MAGIC_ZIP_EOCD) return { format: "zip", subkind: "empty" };
85
+ }
86
+ // GZIP — 2-byte BE magic.
87
+ if (head.length >= 2) {
88
+ var be2 = head.readUInt16BE(0);
89
+ if (be2 === MAGIC_GZIP_BE) return { format: "gzip" };
90
+ }
91
+ // b.crypto.encryptPacked — 5-byte ASCII prefix.
92
+ if (head.length >= 5) {
93
+ var prefix = head.slice(0, 5).toString("utf8");
94
+ if (prefix === MAGIC_ENCPACKED) return { format: "encryptPacked" };
95
+ }
96
+ // tar — "ustar" at offset 257 within the first 512-byte header.
97
+ if (head.length >= 263) {
98
+ var tarMagic = head.slice(257, 262).toString("utf8");
99
+ if (tarMagic === "ustar") return { format: "tar" };
100
+ }
101
+ return { format: "unknown" };
102
+ }
103
+
104
+ // ---- Public extract orchestrator ----------------------------------------
105
+
106
+ /**
107
+ * @primitive b.safeArchive.extract
108
+ * @signature b.safeArchive.extract(opts)
109
+ * @since 0.12.7
110
+ * @status stable
111
+ * @compliance hipaa, pci-dss, gdpr, soc2
112
+ * @related b.archive.read.zip, b.guardArchive.inspect, b.guardFilename.verifyExtractionPath
113
+ *
114
+ * Safe-extract orchestrator. Combines read + guard + path-safety +
115
+ * bomb caps + audit in one call.
116
+ *
117
+ * Refuses the whole archive on:
118
+ * - Format auto-detect mismatch (unknown / unsupported format).
119
+ * - Any critical guard issue (CVE-2025-3445 Zip Slip class + path
120
+ * traversal + symlink-escape + nested archive + encrypted entry).
121
+ * - PATH_MAX overflow on any entry name (CVE-2025-4517 defense).
122
+ * - Bomb-policy breach (entry-count / per-entry size / total size /
123
+ * expansion ratio).
124
+ * - LFH/CD skew on any entry.
125
+ *
126
+ * @opts
127
+ * source: b.archive.adapters.* | Buffer | string,
128
+ * destination: string (target directory; created if missing),
129
+ * format: "auto" | "zip" (v0.12.7 — tar v0.12.8, gz v0.12.9),
130
+ * bombPolicy: b.guardArchive.zipBombPolicy(...) | { ... },
131
+ * entryTypePolicy: b.guardArchive.entryTypePolicy(...) | { ... },
132
+ * guardProfile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
133
+ * audit: b.audit,
134
+ * signal: AbortSignal,
135
+ *
136
+ * @example
137
+ * var result = await b.safeArchive.extract({
138
+ * source: b.archive.adapters.fs("/var/uploads/payload.zip"),
139
+ * destination: "/var/quarantine",
140
+ * guardProfile: "strict",
141
+ * });
142
+ * // → { entries: [{ name, bytesWritten, path }, ...], bytesExtracted, format }
143
+ */
144
+ async function extract(opts) {
145
+ opts = opts || {};
146
+ validateOpts.requireNonEmptyString(opts.destination,
147
+ "b.safeArchive.extract: opts.destination", SafeArchiveError, "safe-archive/no-destination");
148
+ // Resolve source → adapter. Strings become fs adapters; Buffers
149
+ // become buffer adapters; anything else is assumed to BE an adapter
150
+ // already.
151
+ var source = opts.source;
152
+ if (typeof source === "string") {
153
+ source = archiveAdapters().fs(source, { signal: opts.signal });
154
+ } else if (Buffer.isBuffer(source)) {
155
+ source = archiveAdapters().buffer(source, { signal: opts.signal });
156
+ } else if (archiveAdapters().isTrustedStreamAdapter(source)) {
157
+ // Trusted-stream adapters are accepted by the contract but the
158
+ // orchestrator's extract path needs random-access (CD-walk +
159
+ // LFH/CD skew defense). Refuse upfront with a typed safe-archive
160
+ // error so the operator sees the constraint at the entry point
161
+ // rather than an `archive-read/wrong-entry-point` thrown by the
162
+ // downstream reader. Trusted-stream extract via
163
+ // `b.archive.read.zip.fromTrustedStream` is deferred to v0.12.8
164
+ // alongside the tar reader's sequential mode.
165
+ throw new SafeArchiveError("safe-archive/trusted-stream-unsupported",
166
+ "extract: trusted-stream adapter sources are not supported by the orchestrator " +
167
+ "(the adversarial-safe CD-walk requires random-access). Collect the bytes via " +
168
+ "`b.archive.adapters.buffer(await collect(readable))` and pass that, or use " +
169
+ "`b.archive.read.zip.fromTrustedStream` directly when the v0.12.8 sequential " +
170
+ "extract path lands");
171
+ } else if (!archiveAdapters().isRandomAccessAdapter(source)) {
172
+ throw new SafeArchiveError("safe-archive/bad-source",
173
+ "extract: opts.source must be a string path, Buffer, or b.archive.adapters.* result");
174
+ }
175
+
176
+ try {
177
+ var format = opts.format || "auto";
178
+ if (format === "auto") {
179
+ var sniff = await _sniffMagic(source);
180
+ format = sniff.format;
181
+ }
182
+ var reader;
183
+ if (format === "zip") {
184
+ reader = archiveRead().zip(source, {
185
+ bombPolicy: opts.bombPolicy,
186
+ entryTypePolicy: opts.entryTypePolicy,
187
+ guardProfile: opts.guardProfile,
188
+ audit: opts.audit,
189
+ });
190
+ } else if (format === "tar") {
191
+ reader = archiveTarRead().tar(source, {
192
+ bombPolicy: opts.bombPolicy,
193
+ entryTypePolicy: opts.entryTypePolicy,
194
+ guardProfile: opts.guardProfile,
195
+ audit: opts.audit,
196
+ });
197
+ } else {
198
+ throw new SafeArchiveError("safe-archive/format-unsupported",
199
+ "extract: format=" + JSON.stringify(format) + " — v0.12.8 ships ZIP + tar " +
200
+ "(gz lands v0.12.9, encryptPacked-wrap lands v0.12.10)");
201
+ }
202
+ var result = await reader.extract({
203
+ destination: opts.destination,
204
+ allowDangerous: opts.allowDangerous,
205
+ });
206
+ return Object.assign({ format: format }, result);
207
+ } finally {
208
+ if (typeof source.close === "function" && typeof opts.source === "string") {
209
+ try { source.close(); } catch (_e) { /* drop-silent */ }
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * @primitive b.safeArchive.inspect
216
+ * @signature b.safeArchive.inspect(opts)
217
+ * @since 0.12.7
218
+ * @status stable
219
+ * @related b.safeArchive.extract, b.guardArchive.validateEntries
220
+ *
221
+ * Read-only inspect: format sniffing + entry-list enumeration without
222
+ * decompression. Operators previewing an uploaded archive before
223
+ * committing to extraction reach for this primitive.
224
+ *
225
+ * @opts
226
+ * source: b.archive.adapters.* | Buffer | string,
227
+ * format: "auto" | "zip",
228
+ * bombPolicy: { ... },
229
+ * audit: b.audit,
230
+ *
231
+ * @example
232
+ * var summary = await b.safeArchive.inspect({
233
+ * source: b.archive.adapters.fs("/var/uploads/payload.zip"),
234
+ * });
235
+ * // → { format: "zip", entries: [...], totalCompressedBytes, totalUncompressedBytes }
236
+ */
237
+ async function inspect(opts) {
238
+ opts = opts || {};
239
+ var source = opts.source;
240
+ if (typeof source === "string") {
241
+ source = archiveAdapters().fs(source, { signal: opts.signal });
242
+ } else if (Buffer.isBuffer(source)) {
243
+ source = archiveAdapters().buffer(source, { signal: opts.signal });
244
+ } else if (!archiveAdapters().isRandomAccessAdapter(source)) {
245
+ throw new SafeArchiveError("safe-archive/bad-source",
246
+ "inspect: opts.source must be a string path, Buffer, or random-access adapter");
247
+ }
248
+ try {
249
+ var format = opts.format || "auto";
250
+ if (format === "auto") {
251
+ var sniff = await _sniffMagic(source);
252
+ format = sniff.format;
253
+ }
254
+ var reader;
255
+ if (format === "zip") {
256
+ reader = archiveRead().zip(source, {
257
+ bombPolicy: opts.bombPolicy,
258
+ audit: opts.audit,
259
+ });
260
+ } else if (format === "tar") {
261
+ reader = archiveTarRead().tar(source, {
262
+ bombPolicy: opts.bombPolicy,
263
+ audit: opts.audit,
264
+ });
265
+ } else {
266
+ throw new SafeArchiveError("safe-archive/format-unsupported",
267
+ "inspect: format=" + JSON.stringify(format) + " — v0.12.8 ships ZIP + tar");
268
+ }
269
+ var entries = await reader.inspect();
270
+ var totalCompressed = 0;
271
+ var totalUncompressed = 0;
272
+ for (var i = 0; i < entries.length; i += 1) {
273
+ totalCompressed += entries[i].compressedSize;
274
+ totalUncompressed += entries[i].size;
275
+ }
276
+ return {
277
+ format: format,
278
+ entries: entries,
279
+ totalCompressedBytes: totalCompressed,
280
+ totalUncompressedBytes: totalUncompressed,
281
+ };
282
+ } finally {
283
+ if (typeof source.close === "function" && typeof opts.source === "string") {
284
+ try { source.close(); } catch (_e) { /* drop-silent */ }
285
+ }
286
+ }
287
+ }
288
+
289
+ module.exports = {
290
+ extract: extract,
291
+ inspect: inspect,
292
+ SafeArchiveError: SafeArchiveError,
293
+ // Exposed for tests + sibling modules.
294
+ _sniffMagic: _sniffMagic,
295
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,86 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.7",
4
+ "date": "2026-05-23",
5
+ "headline": "`b.archive.read` — random-access ZIP reader + adapter substrate + `b.safeArchive.extract` orchestrator",
6
+ "summary": "The framework's ZIP primitive grows a read side. `b.archive.read.zip(adapter, opts)` walks the central directory, validates LFH/CD coherence (defeats the malformed-zip / Zip Slip CD-skew class), bounds decompression with operator-declared bomb caps (per-entry size, total bytes, expansion ratio, entry count), and refuses path-traversal + symlink-shaped entries before any byte hits the destination. The adapter contract (`{ size, range(offset, length) }` for random-access; `{ readable }` for the trusted-stream fallback) unifies how operators feed bytes in — local files, `b.objectStore` buckets, HTTP Range fetches, and in-memory buffers all compose the same reader. `b.safeArchive.extract({ source, destination, ... })` ships as the one-liner orchestrator that combines read + guardArchive + path-safety + bomb caps + extract for the common `untar a hostile archive into a quarantine directory` shape. `b.guardArchive` gains `inspect(adapter)` (entry-list enumeration that doesn't decompress), `zipBombPolicy(...)` and `entryTypePolicy(...)` policy-object builders so operators can declare their cap set once + reuse it. `b.guardFilename.verifyExtractionPath(name, root, opts?)` adds the dual-check (string-normalize + `fs.realpath`-agreement) that defends the CVE-2025-4517 PATH_MAX TOCTOU class — refuses paths > 4096 bytes BEFORE the kernel's realpath truncation hits, then verifies the string and fs resolution agree on the same final path. `b.backup` gains `bundleAdapterStorage(adapter, opts)` — the first non-disk transport backend; substrate for the v0.12.8 tar bundle format + v0.12.11 objectStoreStorage. Closes the no-MVP gap from v0.5.15 where `b.archive` shipped write-only and the operator-facing JSDoc explicitly punted reading + extraction to yauzl / `unzip`.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.archive.read.zip(adapter, opts)` — random-access ZIP reader",
13
+ "body": "Walks the end-of-central-directory record, validates every CD entry against its LFH (offset / size / CRC / method / name agreement), and emits an iterator of `{ name, size, compressedSize, crc, method, mtime, isEncrypted, externalAttrs, extraFields }` entries. `inspect()` returns the entry list without decompressing — operators wire `b.guardArchive` against the inspect output before paying a single decompress cycle. `extract({ destination, ... })` decompresses entry-by-entry via `node:zlib` raw inflate, routes every path through `b.guardFilename.verifyExtractionPath`, and enforces zip-bomb caps as a streaming abort (the partial extract is fs.rm-ed before the error throws). Composes the existing `lib/archive.js` write-side CRC + signature constants — no duplicated wire-format knowledge."
14
+ },
15
+ {
16
+ "title": "Adapter contract — `b.archive.adapters.{fs,objectStore,http,buffer,trustedStream}`",
17
+ "body": "One shape for source bytes: `{ size: <number>, range(offset, length): Promise<Buffer> }` for random-access, or `{ readable: Readable }` for the explicit trust-stream fallback. `fs(path)` opens a file descriptor + range-reads. `objectStore(client, key)` composes the v0.4.23 `b.objectStore` Range-GET path. `http(url, opts)` composes `b.httpClient` with `Range: bytes=N-M` headers + 206 verification. `buffer(buf)` slices a Buffer in-memory. `trustedStream(readable)` accepts a Node Readable for the rare case the operator can vouch for the source. The same contract feeds `b.safeArchive.extract` and `b.backup.bundleAdapterStorage`."
18
+ },
19
+ {
20
+ "title": "`b.safeArchive.extract({ source, destination, ... })` — one-liner safe extraction",
21
+ "body": "Combines `b.archive.read` + `b.guardArchive` inspect + `b.guardFilename.verifyExtractionPath` + bomb caps + post-extract destination-rebase verification. Refuses the entire archive when any single entry trips a policy (atomic — no half-extracted state on the destination). Operators who want fine-grained control reach for the lower-level primitives directly; `b.safeArchive.extract` covers the 90%-case `extract this hostile-shaped archive into a quarantine directory` workflow. Returns `{ entries: [...], destinationRoot, bytesExtracted, auditTrail }` on success."
22
+ },
23
+ {
24
+ "title": "`b.guardArchive.inspect(adapter, opts)` + `zipBombPolicy(...)` + `entryTypePolicy(...)`",
25
+ "body": "`inspect(adapter)` is the bridge between the read primitive and the existing `validateEntries` gate — runs the read primitive's inspect phase, hands the entry list to `validateEntries`, and returns the merged `{ entries, issues, decisions }` so the caller decides whether to proceed. `zipBombPolicy({ maxEntries, maxEntryDecompressedBytes, maxTotalDecompressedBytes, maxExpansionRatio })` and `entryTypePolicy({ symlinks, hardlinks, devices, fifos, sockets })` are policy-object builders so operators declare the cap set once + reuse across call sites. Each policy carries its own `audit.posture` annotation that propagates through `b.agent.postureChain`."
26
+ },
27
+ {
28
+ "title": "`b.guardFilename.verifyExtractionPath(name, root, opts?)` — dual-check path safety",
29
+ "body": "Companion to the existing `b.guardArchive.checkExtractionPath` (string-only check the gate keeps portable). `verifyExtractionPath` couples to `fs.realpath` deliberately: refuses paths whose pre-resolve string already exceeds 4096 bytes (defends the CVE-2025-4517 PATH_MAX TOCTOU class — `os.path.realpath`-style truncation can't reach the kernel before our refuse fires), then verifies the string-normalized result and the `fs.realpath`-resolved result agree on the same final path. Disagreement throws `guard-filename/extraction-path-toctou`. The string-check stays the canonical portable gate; this primitive is the deeper fs-coupled check the framework's read primitive wires in by default."
30
+ },
31
+ {
32
+ "title": "`b.backup.bundleAdapterStorage(adapter, opts)` — adapter-driven storage backend",
33
+ "body": "First non-disk backend for `b.backup`. Walks the bundle directory file-by-file and writes through the v0.12.7 adapter contract — `fs` adapter behaves identically to `diskStorage` (which stays for back-compat), `buffer` adapter emits the bundle into an in-memory representation, custom adapters can route to anything that satisfies the contract. Substrate for the v0.12.8 tar bundle format (which folds the directory tree into a single tar stream) and the v0.12.11 `objectStoreStorage` (which composes `b.archive.adapters.objectStore` for S3 / MinIO / Azure / GCS-backed backups). Backup manifest layout unchanged — restore code keeps working byte-for-byte against bundles produced by either backend."
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "heading": "Security",
39
+ "items": [
40
+ {
41
+ "title": "Zip Slip class (CVE-2025-3445 / 11569 / 23084 / 27210 / 11001 / 11002 / 26960 / 4517 / 4138 / 4330 + 2024 jszip / mholt/archiver / Python tarfile / node-tar / 7-zip)",
42
+ "body": "Every archive-read entry's name passes through `b.guardFilename.verifyExtractionPath` before any decompression. Path-traversal segments (`..`, leading `/` or `\\`, drive-letter prefixes, null bytes, overlong UTF-8) are refused; Windows reserved names + NTFS ADS suffixes are refused; the realpath-agreement check defends the CVE-2025-4517 PATH_MAX TOCTOU class. Symlink and hardlink entries are refused unconditionally under the default `entryTypePolicy`; operators with a legitimate need opt into `allowSymlinks: true` / `allowHardlinks: true` and get the entries routed through an additional `b.guardArchive` realpath-on-target check."
43
+ },
44
+ {
45
+ "title": "Decompression-bomb class (CVE-2025-0725, OWASP zip-bomb top-cases)",
46
+ "body": "`b.archive.read.zip.extract` enforces four caps in parallel: `maxEntries` (entry-count), `maxEntryDecompressedBytes` (per-entry size), `maxTotalDecompressedBytes` (aggregate across the archive), and `maxExpansionRatio` (compressed → decompressed ratio cap, default 100:1). Each cap aborts the extract as soon as the bound is exceeded; the destination directory is `fs.rm`-ed before the error throws so a partial extract never lingers on disk. The `b.safeDecompress` primitive (v0.11.5) is the underlying inflate gate — same defense surface, same audit-trail."
47
+ },
48
+ {
49
+ "title": "LFH/CD skew + malformed-ZIP DoS",
50
+ "body": "The CD walk verifies every entry's local-file-header against the central-directory record (offset / size / CRC / method / name agreement). Mismatches throw `archive/cd-skew` before any byte decompresses. Defends the malformed-zip class where a hostile producer points CD entries at LFH locations that don't match the CD claim — the prior write-only path had no exposure to this class; the new read path closes it."
51
+ }
52
+ ]
53
+ },
54
+ {
55
+ "heading": "Detectors",
56
+ "items": [
57
+ {
58
+ "title": "`archive-read-without-bomb-caps`",
59
+ "body": "Flags `b.archive.read.zip(adapter)` call sites in `lib/` that don't pass an explicit `bombPolicy` or `maxTotalDecompressedBytes`. Forces the cap-declaration discipline at call sites — operators see the bomb-cap surface every time they reach for the read primitive."
60
+ },
61
+ {
62
+ "title": "`archive-extract-without-guard`",
63
+ "body": "Flags `b.archive.read.zip(...).extract({ destination, ... })` call sites that don't compose `b.safeArchive.extract` (the orchestrator) AND don't pass an explicit `b.guardArchive.inspect` precheck. Per-file lib/ surface forces operators to either use the orchestrator OR explicitly opt into per-step composition with a written justification."
64
+ },
65
+ {
66
+ "title": "`archive-adapter-without-error-path`",
67
+ "body": "Flags adapter implementations (any export shape matching `{ size, range }` / `{ readable }`) that don't propagate AbortSignal or refuse to throw on partial-read truncation. Forces the cancellation-discipline so a slow / hostile source can't block extraction indefinitely."
68
+ },
69
+ {
70
+ "title": "`safe-archive-extract-bypass`",
71
+ "body": "Flags any composition in `lib/` that builds the safeArchive pipeline (`read.zip(...).extract({ destination, ... })` + bomb caps + guard) inline instead of calling `b.safeArchive.extract`. The orchestrator owns the audit-emission shape; bypassing it means audit gaps."
72
+ }
73
+ ]
74
+ }
75
+ ],
76
+ "references": [
77
+ { "label": "APPNOTE.TXT (PKWARE ZIP File Format Specification)", "url": "https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT" },
78
+ { "label": "CVE-2025-3445 — mholt/archiver Zip Slip", "url": "https://github.com/advisories/GHSA-7vpp-9cxj-q8gv" },
79
+ { "label": "CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)", "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-4517" },
80
+ { "label": "CVE-2025-11001 / CVE-2025-11002 — 7-Zip directory-traversal RCE", "url": "https://www.sentinelone.com/vulnerability-database/cve-2025-11001/" },
81
+ { "label": "CVE-2025-11569 — cross-zip directory traversal", "url": "https://security.snyk.io/vuln/SNYK-JS-CROSSZIP-6105396" },
82
+ { "label": "CVE-2026-23745 / CVE-2026-24842 — node-tar symlink + hardlink bypass", "url": "https://github.com/advisories/GHSA-34x7-hfp2-rc4v" },
83
+ { "label": "OWASP Zip Slip + zip-bomb reference", "url": "https://snyk.io/research/zip-slip-vulnerability" },
84
+ { "label": "USENIX WOOT'19 — A better zip bomb (Fifield)", "url": "https://www.usenix.org/conference/woot19/presentation/fifield" }
85
+ ]
86
+ }