@blamejs/core 0.7.4 → 0.7.18

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