@blamejs/core 0.12.6 → 0.12.8
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-tar-read.js +418 -0
- package/lib/archive-tar.js +557 -0
- package/lib/archive.js +17 -0
- package/lib/audit.js +22 -7
- package/lib/backup/index.js +429 -0
- package/lib/guard-archive.js +180 -0
- package/lib/guard-filename.js +205 -0
- package/lib/safe-archive.js +295 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/guard-filename.js
CHANGED
|
@@ -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
|
+
};
|
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:4e14c3b7-7b58-4756-ba67-d2bcef61b25b",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-23T16:36:21.469Z",
|
|
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.8",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.12.
|
|
25
|
+
"version": "0.12.8",
|
|
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.8",
|
|
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.8",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|