@blamejs/blamejs-shop 0.0.82 → 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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.0.x
10
10
 
11
+ - v0.0.85 (2026-05-23) — **FSM-name shape detector + email-campaigns rename + /SECURITY.md redirect (auto-deploy fix) + vendor v0.12.8 (audit drop-silent contract restored).** The `email-campaigns` primitive named its FSM `emailCampaign` — camelCase. The framework's audit-action validator at `lib/vendor/blamejs/lib/audit.js:401` enforces `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$` per segment, so every transition fired `flush dropped event: audit action must be 'namespace.verb[.qualifier...]' — got: fsm.emailCampaign.transition` and the event was dropped silently. Renamed to `email_campaign`. A new `codebase-patterns` detector `fsm-name-not-audit-action-safe` catches this class of bug going forward — it scans `fsm.define({ ... name: "..." ... })` calls (using a new multi-line scan mode) and refuses any name that doesn't match the per-segment shape. Auto-deploy is also unblocked: the `/SECURITY.md` route now redirects to the GitHub mirror (same shape as `/CHANGELOG.md`) — bundling the file via wrangler's text rule was failing under `wrangler versions upload`. Bundled vendor refresh to blamejs v0.12.8 — upstream restored the `safeEmit` drop-silent contract in the flush handler, eliminating the `flush dropped event: db.init() must be awaited` log spam that layer-1 tests were producing whenever a primitive composed `b.fsm.transition` without the framework's storage layer being initialized. The npm `homepage` field also moves from the GitHub mirror to `https://blamejs.shop` so the registry page links at the live deployment rather than the source repo. **Added:** *`fsm-name-not-audit-action-safe` codebase-patterns detector* — Scans `fsm.define({ ... name: "..." ... })` calls in `lib/` and flags any name that doesn't match the per-segment audit-action shape (`[a-z][a-z0-9_]*`). The pattern spans lines so multi-line `fsm.define({ \n name: "..." \n })` shapes are caught; a new `multiline: true` flag on the detector entry routes the scan through a whole-file regex pass instead of the per-line one. Description points at the canonical audit-action validator + the framework's PQC-first naming convention. · *Multi-line scan mode in `codebase-patterns.test.js`* — The scanner now accepts `{ multiline: true }` per detector. When set, the whole-file content is scanned with the global regex via `matchAll`; line numbers are derived from the byte offset by counting preceding newlines. Per-line scans remain the default — only detectors that need cross-line context (FSM `name:` field validation, future shape checks) opt in. **Changed:** *Vendored blamejs refreshed from v0.12.6 to v0.12.8* — Upstream restored the `b.audit.safeEmit` drop-silent contract in the flush handler at `lib/vendor/blamejs/lib/audit.js:1129` — events queued before `b.db.init()` is awaited (or where storage rejected the write) now drop without the `log.error("flush dropped event: ...")` spam that prior versions emitted. The framework's chain integrity guarantee is unchanged; only the flush-side log behaviour. Layer-1 tests in this codebase wire `query` directly via `node:sqlite` (the framework's storage layer is intentionally not initialized in unit tests), so every primitive that composes `b.fsm.transition` previously caused dozens of dropped-event logs per smoke run. Local smoke goes from ~100 dropped-event logs to zero. · *`package.json` homepage now points at the live deployment* — The `homepage` field was `https://github.com/blamejs/blamejs.shop` so the npm registry page linked at the source repo. Operators landing on `npmjs.com/package/@blamejs/blamejs-shop` were one click away from the reference deployment but the link sent them to the README. Field now reads `https://blamejs.shop` so the registry entry points at the live storefront; the `repository` field still references the GitHub mirror for source. **Fixed:** *`email-campaigns` FSM renamed from `emailCampaign` to `email_campaign`* — The FSM name composes into audit actions as `fsm.<name>.transition`. The framework's audit-action validator (`lib/vendor/blamejs/lib/audit.js:401`) enforces `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$` per segment — `emailCampaign` failed validation because of the uppercase `C`, and the audit module dropped every campaign-state transition silently with an error log. Snake_case rename closes the gap. No data shape change — the FSM `name` is an identifier in the framework, not a column value. · *`/SECURITY.md` redirects to the GitHub mirror — unblocks Cloudflare auto-deploy* — 0.0.84's `wrangler [[rules]] type = "Text"` bundling of `SECURITY.md` worked under local `wrangler deploy` but failed under `wrangler versions upload` (the command Cloudflare Workers Builds runs): `✘ [ERROR] No loader is configured for ".md" files`. The route now serves a 302 to `https://github.com/blamejs/blamejs.shop/blob/main/SECURITY.md` — same pattern as `/CHANGELOG.md`. Both files stay readable to operators; neither bloats the Worker bundle.
12
+
13
+ - v0.0.84 (2026-05-23) — **`/privacy`, `/terms`, `/SECURITY.md`, `/CHANGELOG.md` served from the edge — footer links no longer 404.** The storefront footer has always linked to `/privacy`, `/terms`, `/SECURITY.md`, and `/CHANGELOG.md`, but the container never owned those routes — every footer click landed on a 403 / 404. This patch wires each route into the Worker: `/privacy` and `/terms` render minimal policy pages composed off a stripped-down layout; `/SECURITY.md` serves the in-tree file as `text/markdown` (bundled via a new wrangler `[[rules]]` Text rule); `/CHANGELOG.md` redirects (302) to the GitHub mirror since the file at 303 KB would inflate the gzipped Worker by ~60 KB. All four pages cache 24h `must-revalidate` so the browser + Cloudflare's zone cache hold them across visits. **Added:** *Edge handlers for `/privacy`, `/terms`, `/SECURITY.md`, `/CHANGELOG.md`* — `worker/render/policy.js` carries `renderPrivacy(opts)` and `renderTerms(opts)` — minimal layout (no marquee / hero / catalog chrome) with the policy text inlined. Both compose `renderTemplate` from `worker/render/_lib.js` so the substitution + HTML escape semantics match the rest of the Worker. `worker/index.js` routes `/privacy` and `/terms` to those renderers, `/SECURITY.md` to the wrangler-text-bundled in-tree SECURITY.md, and `/CHANGELOG.md` to a 302 redirect at `https://github.com/blamejs/blamejs.shop/blob/main/CHANGELOG.md`. All four respond with `cache-control: public, max-age=86400, must-revalidate` so the browser and Cloudflare's zone cache hold them between visits. · *`wrangler.toml` text-rule for `SECURITY.md` bundling* — `[[rules]] type = "Text" globs = ["SECURITY.md"] fallthrough = true` — wrangler's esbuild loader now treats `SECURITY.md` as a text-imported string. `import SECURITY_MD from "../SECURITY.md"` in `worker/index.js` resolves to the file's content. `CHANGELOG.md` is excluded by glob (303 KB → 60+ KB gzipped Worker bloat); that route uses an HTTP redirect to the GitHub mirror instead.
14
+
15
+ - v0.0.83 (2026-05-23) — **Cache-warmer cron + cache-key normalization for referral traffic.** A once-a-minute scheduled handler now warms the edge cache for `/` and `/search` so the 60-second cache TTL never lapses at PoPs that have seen at least one real visitor — repeat visitors get the cached response even after a brief idle gap. Cache-key normalization strips common tracking parameters (`utm_*`, `fbclid`, `gclid`, `gbraid`, `wbraid`, `msclkid`, `mc_eid`, `mc_cid`, `_ga`, `igshid`, `ref`, `fb_action_*`, `trk_*`, `yclid`) before computing the `caches.default` key, so two visitors arriving with different referral params share the same cache entry. The original request URL is preserved end-to-end — the strip only affects which cache bucket the response lives in. **Added:** *Once-a-minute cache-warmer cron — keeps every active PoP's `/` and `/search` entries fresh* — `wrangler.toml` gains `[triggers] crons = ["* * * * *"]` and the Worker now exports a `scheduled(event, env, ctx)` handler that GETs each storefront read route with a browser-shaped header set. The cache TTL is 60s — the once-a-minute warm keeps the cache populated across the gap so a visitor arriving 30 seconds after the previous hit still sees a cache hit instead of a renderer miss. Gated by `env.EDGE_RENDER === "on"`. Auth-cookie-bearing visitors continue to bypass the cache (per-session content stays correct); only the unauthenticated default fetches get warmed. · *Cache-key normalization — referral traffic shares cache entries with direct traffic* — The cache lookup now uses a derived key that strips `utm_*`, `fbclid`, `gclid`, `gbraid`, `wbraid`, `msclkid`, `mc_eid`, `mc_cid`, `_ga`, `igshid`, `ref`, `fb_action_*`, `trk_*`, and `yclid` query parameters before computing the `caches.default` lookup. The original request URL stays intact end-to-end so analytics and the rendered output still see the unmodified params; the strip only affects which cache bucket the response is stored / retrieved from. A visitor arriving at `/?utm_source=newsletter` now hits the same cache entry as one arriving at `/` directly.
16
+
11
17
  - v0.0.82 (2026-05-23) — **`vendor-update.sh --check` skips gracefully when upstream is unreachable (Cloudflare Workers Builds fix).** The vendor-drift gate inside the container smoke test was failing in build environments that can't reach `api.github.com` — `_latest_tag()` returned empty and the script reported a phantom drift against an empty version string. The committed `lib/vendor/blamejs/` tree is already the source of truth at build time; freshness can only meaningfully be checked when the upstream tag is reachable. The gate now skips with a warning to stderr when the upstream lookup returns empty, instead of failing the build. **Fixed:** *`scripts/vendor-update.sh --check` no longer fails the build when upstream is unreachable* — When `_latest_tag()` returns an empty string (sandboxed CI runner, rate-limited anonymous GitHub API request, air-gapped image), the gate emits `[vendor-check] SKIPPED — could not resolve upstream tag (offline / rate-limited); committed v<X> is the source of truth` to stderr and exits 0. The next operator-run smoke against a network-reachable environment re-verifies freshness. Online behavior is unchanged — when the upstream tag resolves, the gate compares as before and fails on actual drift.
12
18
 
13
19
  - v0.0.81 (2026-05-23) — **Comprehensive `codebase-patterns` detector catalog for primitive composition + shape alignment with the vendored framework's catalog.** Extends the `codebase-patterns` detector with five additional reinvention catchers (`manual-random-uuid`, `manual-random-bytes`, `weak-hash-sha2`, `manual-createhmac`, `worker-direct-vendor-import`) and aligns every entry's data shape with the vendored framework's canonical catalog at `lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js` — `id` / `primitive` (one-line replacement) / `regex` / `allowlist` / `reason`. Existing `console-direct`, `math-random`, `todo-fixme-hack-xxx`, `empty-catch-swallow` detectors expand from the `lib`-only scope to a new `shop` scope (lib/ + worker/) so the Worker substrate gets the same hygiene gates. The runner prints both the canonical primitive and the deeper reason on failure so the operator-facing fail message points directly at the b.* call that should have been composed. **Added:** *Five additional `codebase-patterns` detectors for blamejs primitive composition* — `manual-random-uuid` (`crypto.randomUUID()` → `b.uuid.v7()` or `b.uuid.v4()`), `manual-random-bytes` (`crypto.randomBytes(n)` → `b.crypto.generateBytes(n)`), `weak-hash-sha2` (`createHash("sha256"|"sha384"|"sha512")` → `b.crypto.sha3Hash(data)` outside explicit protocol exceptions), `manual-createhmac` (`createHmac(...)` → `b.crypto.hmacSha3` / `b.crypto.hmacSha256`), `worker-direct-vendor-import` (Worker code reaching for `lib/vendor/blamejs/lib/*.js` leaf modules outside `worker/b.js` → use the adapter). The four detectors that already existed for `lib`-only enforcement (`console-direct`, `math-random`, `todo-fixme-hack-xxx`, `empty-catch-swallow`) expand to the new `shop` scope covering both `lib/` and `worker/`. **Changed:** *Detector entry shape aligned with the vendored framework's catalog* — Every entry now carries `id`, `primitive` (the canonical one-line replacement), `regex`, `allowlist`, and `reason` — matching the shape blamejs's own `codebase-patterns.test.js` uses for its 95 internal detectors. The runner prints both the primitive line and the deeper reason on failure so operators see what to compose AND why, not just the regex match. · *Allow markers on the documented exceptions (`worker/index.js` console.*, `lib/pixel-events.js` SHA-256)* — `worker/index.js` carries per-line `allow:console-direct` markers on every `console.log/error` call — Workers have no framework observability sink; `console.*` IS the structured log emission point auto-routed to wrangler tail / Logpush. `lib/pixel-events.js#_sha256Hex` carries inline `allow:weak-hash-sha2` (and the existing `allow:non-shop-require`) markers — Meta CAPI / Google EC / TikTok / Pinterest / Snap CAPI mandate SHA-256 of the normalised identifier on the wire and `b.crypto.sha3Hash` is not a valid substitute.
@@ -126,7 +126,7 @@ function _getCampaignFsm() {
126
126
  // the operator's audit sink instead of dropping with a warning.
127
127
  try { _b().audit.registerNamespace("fsm"); } catch (_e) { /* idempotent */ }
128
128
  _campaignFsm = _b().fsm.define({
129
- name: "emailCampaign",
129
+ name: "email_campaign",
130
130
  initial: "draft",
131
131
  states: {
132
132
  draft: {},
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.12.6",
7
- "tag": "v0.12.6",
6
+ "version": "0.12.8",
7
+ "tag": "v0.12.8",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.8 (2026-05-23) — **`b.archive.tar` + `b.archive.read.tar` — POSIX pax tar format end-to-end + `b.guardArchive.tarEntryPolicy` + `b.backup` tar bundle default.** Tar lands as the second format in the archive family. `b.archive.tar()` builds POSIX pax archives (ustar magic + pax extended headers for >100-char names, >8 GiB sizes, nanosecond mtime); `b.archive.read.tar(adapter)` walks the 512-byte block sequence with the same bomb-cap + path-traversal + entry-type defenses that ZIP read shipped at v0.12.7. Tar's natively-streamable shape means `b.archive.adapters.trustedStream(readable)` is a first-class extract path here (no CD-walk required since tar has no central directory; sequential header-by-header is the canonical adversarial-safe path). `b.guardArchive.tarEntryPolicy` ships as the tar-specific entry-shape policy beyond `entryTypePolicy` — handles typeflag 0/5 (regular/directory) by default, refuses 1/2 (hardlink/symlink) unless `allowDangerous` is set with the realpath-on-link-target dual-check, and refuses 3/4/6/7 (char-device/block-device/FIFO/contiguous-file) unconditionally. `b.backup.bundleAdapterStorage({ format: "tar" })` becomes the default for new bundles — directory-tree format stays available via `format: "directory"` for back-compat with v0.12.7 bundles. `b.backup.migrate(from, to)` one-shot helper converts v0.12.7 directory bundles to v0.12.8 tar bundles transparently. `b.safeArchive.extract({ source, destination, format: "auto" })` now sniffs ustar magic at offset 257 inside the first 512-byte block and dispatches to the tar reader automatically. CVE coverage extends to the tar class: CVE-2026-23745 / 2026-24842 (node-tar symlink+hardlink path resolution), CVE-2025-4517 PATH_MAX TOCTOU (the v0.12.7 dual-check carries through), CVE-2025-11001/11002 (symlink TOCTOU on extract), CVE-2024-12905 / 2025-48387 (tar-fs traversal), CVE-2025-4138/4330 (Python tarfile data filter bypass). **Added:** *`b.archive.tar()` — POSIX pax write builder* — Mirrors `b.archive.zip()`'s contract: `addFile(name, content, opts?)` + `addDirectory(name, opts?)` + `toBuffer()` + `toStream(writable)` + `toAdapter(adapter)` + `digest()`. Emits ustar-magic 512-byte header blocks with the standard 11-field prefix (name / mode / uid / gid / size / mtime / chksum / typeflag / linkname / magic / version / uname / gname / devmajor / devminor / prefix). Names >100 chars + sizes >8 GiB + mtime with nanosecond precision get a pax extended header (typeflag=x) preceding the entry; the extended header records (per POSIX.1-2001 §4.18) carry the `path` / `size` / `mtime` / `atime` / `ctime` fields that overflow ustar's fixed widths. Determinism opts: `{ fixedMtime: 0, ignoreOrder: false }` for reproducible builds (matches the ZIP write side). · *`b.archive.read.tar(adapter, opts)` — sequential + random-access tar reader* — Walks 512-byte header blocks in order. `inspect()` enumerates entries without decompressing; `extract({ destination })` decompresses entry-by-entry with the same bomb-cap + path-traversal + entry-type defenses as ZIP read. Trusted-stream adapters are first-class here — tar has no central directory, so sequential header-by-header walk IS the canonical adversarial-safe path (`b.archive.adapters.trustedStream(readable)` and `b.archive.adapters.fs/buffer/objectStore/http` all flow through the same reader). Per-entry path safety routes through `b.guardFilename.verifyExtractionPath` (the v0.12.7 dual-check). Refuses to overwrite pre-existing destination files (carries the v0.12.7 atomic-rollback contract). · *`b.guardArchive.tarEntryPolicy(opts)` — tar-specific entry-type policy* — Defaults: typeflag 0 (regular file) + 5 (directory) extract; typeflag 1 (hardlink) + 2 (symlink) refused unless `allowDangerous: { symlinks: true, hardlinks: true }` is set; typeflag 3 (char-device) + 4 (block-device) + 6 (FIFO) + 7 (contiguous-file) refused unconditionally. When `allowDangerous` is set, link target is routed through `b.guardFilename.verifyExtractionPath` against the extraction root — the realpath-on-link-target check defends the CVE-2026-23745 / 24842 node-tar class where the safety check and creation logic diverged on path resolution. Pax extended-header (x) + global-header (g) entries consumed by the reader (merged into the following entry's metadata); operators never see them as standalone entries. · *`b.backup.bundleAdapterStorage({ format: "tar" })` — tar bundle becomes default* — New bundles ship as a single tar archive instead of a directory tree. Restore via `b.archive.read.tar` (with the operator-supplied adapter routing the bytes). `format: "directory"` opts back into the v0.12.7 layout for operators with existing bundles. `format: "tar"` is the new default; `b.backup.diskStorage` stays back-compat at the legacy directory-tree format. · *`b.backup.migrate(opts)` — directory → tar bundle migration* — One-shot helper that walks an operator's directory-tree-format bundle (v0.12.7 layout) and writes the same content as a tar-format bundle via the v0.12.8 bundleAdapterStorage. Idempotent: re-running on an already-migrated bundle is a no-op. Source bundle stays in place until the migrate succeeds; operators with explicit transition windows pass `{ deleteSourceOnSuccess: true }` to opt into the inline replace. · *`b.safeArchive.extract({ format: "auto" })` recognizes tar* — Format auto-sniff now dispatches `ustar` magic at offset 257 inside the first 512-byte header block to the tar reader. ZIP magic + tar magic + GZIP magic (v0.12.9) live in the same sniff path; operators with mixed-format pipelines pass `format: "auto"` once + the orchestrator picks the right reader. **Security:** *Symlink + hardlink path resolution (CVE-2026-23745 / CVE-2026-24842 node-tar class)* — node-tar < 7.5.7 / ≤ 7.5.2 shipped a divergence between its hardlink safety check (which used one path resolution) and its hardlink creation logic (which used another). When `allowDangerous: { hardlinks: true }` is set, blamejs routes the link target through `b.guardFilename.verifyExtractionPath` — the SAME primitive that the eventual `link()` call resolves against — so check + create agree by construction. Symlink targets same shape. · *Path traversal (CVE-2024-12905 / CVE-2025-48387 tar-fs + CVE-2025-4138 / 4330 Python tarfile data filter bypass)* — Every entry name passes through `b.guardFilename.verifyExtractionPath` — the v0.12.7 dual-check that refuses pre-resolve names > PATH_MAX (4096 bytes) AND verifies the string-normalize + `fs.realpath` resolutions agree on the same final path. Defends the CVE-2025-4517 / 4138 / 4330 class where the operator's path resolution and the kernel's diverge silently past PATH_MAX. · *Symlink TOCTOU on extract (CVE-2025-11001 / CVE-2025-11002 7-Zip class)* — When `allowDangerous: { symlinks: true }` opts symlinks in, the reader resolves the link target via `verifyExtractionPath` against the extraction root BEFORE calling `fs.symlink` — so the resolved target is inside the trust boundary by construction. The v0.12.7 atomic-rollback contract carries through: any single entry failure aborts the whole extract + cleans up only newly-created files (pre-existing destination files refused at the pre-write check). **Detectors:** *`tar-extract-allow-dangerous-without-link-target-check`* — Flags any `b.archive.read.tar(adapter).extract({ allowDangerous: ... })` call site in `lib/` that doesn't route the link target through `b.guardFilename.verifyExtractionPath` against the extraction root. Forces the dual-check discipline at every allow-dangerous opt-in — operators with hardlink / symlink extract needs see the realpath check at the call site. · *`tar-entry-typeflag-without-policy`* — Flags `lib/archive-tar.js` extract code paths that switch on typeflag without composing `b.guardArchive.tarEntryPolicy` for the type-allowlist decision. Locks the shape: every typeflag dispatch goes through the policy, never inline. · *`backup-migrate-without-source-preserve`* — Flags `b.backup.migrate(opts)` call sites that pass `deleteSourceOnSuccess: true` without an operator-stated justification comment. Default is preserve-source; deletes need an explicit reason. **References:** [POSIX.1-2001 pax extended format (IEEE 1003.1)](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html) · [CVE-2026-23745 — node-tar symlink+hardlink path resolution](https://www.sentinelone.com/vulnerability-database/cve-2026-23745/) · [CVE-2026-24842 — node-tar hardlink path resolution](https://github.com/advisories/GHSA-34x7-hfp2-rc4v) · [CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)](https://nvd.nist.gov/vuln/detail/CVE-2025-4517) · [CVE-2025-4138 / CVE-2025-4330 — Python tarfile data filter](https://github.com/0xDTC/CVE-2025-4138-4517-POC) · [CVE-2025-11001 / CVE-2025-11002 — 7-Zip symlink TOCTOU on extract](https://www.sentinelone.com/vulnerability-database/cve-2025-11001/) · [CVE-2024-12905 / CVE-2025-48387 — node-tar-fs path traversal](https://vulert.com/vuln-db/debian-11-node-tar-fs-193050)
12
+
13
+ - v0.12.7 (2026-05-23) — **`b.archive.read` — random-access ZIP reader + adapter substrate + `b.safeArchive.extract` orchestrator.** 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`. **Added:** *`b.archive.read.zip(adapter, opts)` — random-access ZIP reader* — 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. · *Adapter contract — `b.archive.adapters.{fs,objectStore,http,buffer,trustedStream}`* — 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`. · *`b.safeArchive.extract({ source, destination, ... })` — one-liner safe extraction* — 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. · *`b.guardArchive.inspect(adapter, opts)` + `zipBombPolicy(...)` + `entryTypePolicy(...)`* — `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`. · *`b.guardFilename.verifyExtractionPath(name, root, opts?)` — dual-check path safety* — 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. · *`b.backup.bundleAdapterStorage(adapter, opts)` — adapter-driven storage backend* — 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. **Security:** *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)* — 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. · *Decompression-bomb class (CVE-2025-0725, OWASP zip-bomb top-cases)* — `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. · *LFH/CD skew + malformed-ZIP DoS* — 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. **Detectors:** *`archive-read-without-bomb-caps`* — 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. · *`archive-extract-without-guard`* — 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. · *`archive-adapter-without-error-path`* — 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. · *`safe-archive-extract-bypass`* — 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. **References:** [APPNOTE.TXT (PKWARE ZIP File Format Specification)](https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT) · [CVE-2025-3445 — mholt/archiver Zip Slip](https://github.com/advisories/GHSA-7vpp-9cxj-q8gv) · [CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)](https://nvd.nist.gov/vuln/detail/CVE-2025-4517) · [CVE-2025-11001 / CVE-2025-11002 — 7-Zip directory-traversal RCE](https://www.sentinelone.com/vulnerability-database/cve-2025-11001/) · [CVE-2025-11569 — cross-zip directory traversal](https://security.snyk.io/vuln/SNYK-JS-CROSSZIP-6105396) · [CVE-2026-23745 / CVE-2026-24842 — node-tar symlink + hardlink bypass](https://github.com/advisories/GHSA-34x7-hfp2-rc4v) · [OWASP Zip Slip + zip-bomb reference](https://snyk.io/research/zip-slip-vulnerability) · [USENIX WOOT'19 — A better zip bomb (Fifield)](https://www.usenix.org/conference/woot19/presentation/fifield)
14
+
11
15
  - v0.12.6 (2026-05-22) — **`b.observability.otlpExporter` adds OTLP/protobuf-HTTP encoding (`opts.encoding: "protobuf"`).** The OTLP trace exporter now speaks `application/x-protobuf` end-to-end. Operators with high-volume telemetry opt into binary encoding via `opts.encoding: "protobuf"` (`"http/protobuf"` is accepted as a spec-name alias). The protobuf wire format encodes the same `ExportTraceServiceRequest` envelope as the existing JSON path — `ResourceSpans` → `ScopeSpans` → `Span` → `Status` / `Event` / `KeyValue` / `AnyValue` per the opentelemetry-proto repo — but emits 30-50% smaller bodies than the JSON shape on real-world workloads and avoids the JSON-parse cost on the collector side. Default stays `"json"`; collectors that don't speak protobuf keep working unchanged. Composes the existing `lib/protobuf-encoder.js` infrastructure. **Added:** *`opts.encoding: "json" | "protobuf"` on `b.observability.otlpExporter.create`* — When `"protobuf"` (or the spec-name alias `"http/protobuf"`), the exporter encodes batches as binary OTLP `ExportTraceServiceRequest` bytes and POSTs with `Content-Type: application/x-protobuf`. The retry / queue / drop-counter / audit machinery is shared with the JSON path so operators get the same operational primitives across both encodings. Default stays `"json"` — existing collectors keep working without configuration changes. · *Full OTLP trace schema encoded via `lib/protobuf-encoder.js`* — ResourceSpans / ScopeSpans / Span / Event / Status / KeyValue / AnyValue / ArrayValue are emitted per the opentelemetry-proto repo's field numbers + wire types. `trace_id` and `span_id` round-trip as fixed-length bytes (16 + 8 octets respectively). `start_time_unix_nano` / `end_time_unix_nano` use `fixed64` for the nanosecond precision the JSON path's number type lossily encoded. SpanKind enum mapping covers unspecified / internal / server / client / producer / consumer. · *`pb.int64` / `pb.sint64` — signed-integer varint shapes on `lib/protobuf-encoder.js`* — Negative integer attribute values in OTLP `AnyValue` (e.g. retry-after offsets, signed metric deltas) emit as proto3 `int64` — wire-type 0 varint, 10-byte two's-complement reinterpret per the spec. `pb.sint64` adds ZigZag-encoded varint for cases where small negatives dominate. Both accept Number / BigInt / digit-string inputs with explicit `[-2^63, 2^63 - 1]` range validation. · *`pb.fixed64` accepts string-form uint64 values* — OTLP/JSON encodes uint64 as a JSON string (per the proto3 JSON mapping) — the framework's tracer emits `start_time_unix_nano` / `end_time_unix_nano` as digit-string BigInt-to-string conversions so the JSON path stays lossless. `pb.fixed64` now accepts that same digit-string shape on the protobuf path so a single timestamp representation flows through both encodings without a separate coercion step. Refuses non-digit strings and silently-rounded Numbers above `Number.MAX_SAFE_INTEGER`. **Security:** *AnyValue recursion capped at 100 levels (CVE-2024-7254 / CVE-2025-4565 class)* — Both protobufjs (CVE-2024-7254) and protobuf-python (CVE-2025-4565) shipped DoS-via-unbounded-nested-group decoding. The OTLP `AnyValue` type permits a nested `ArrayValue { repeated AnyValue values = 1 }` that an adversarial collector-response could exploit during a future receive path. The encoder caps `_anyValueToProto` recursion at 100 levels — beyond which it emits an empty AnyValue rather than continuing to descend. Today's framework only EMITS (never receives) OTLP — but the cap is in the right place when the receive path lands. **References:** [OTLP §3 — Body encodings (JSON + protobuf)](https://opentelemetry.io/docs/specs/otlp/) · [opentelemetry-proto repo — trace/v1/trace.proto](https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/trace/v1/trace.proto) · [CVE-2024-7254 (protobufjs unbounded nesting DoS)](https://nvd.nist.gov/vuln/detail/CVE-2024-7254) · [CVE-2025-4565 (protobuf-python unbounded nesting DoS)](https://nvd.nist.gov/vuln/detail/CVE-2025-4565)
12
16
 
13
17
  - v0.12.5 (2026-05-22) — **`b.metrics` content-negotiates OpenMetrics 1.0 + auto-attaches trace exemplars on request histograms.** The `/metrics` scrape endpoint now serves `application/openmetrics-text; version=1.0.0; charset=utf-8` when the scraper requests it via the `Accept` header (Prometheus 2.x strict mode, OpenObservability tooling). Legacy scrapers still get `text/plain; version=0.0.4` — no operator with the default Prometheus client sees a content-type change. Separately, the framework's request-duration histogram middleware now auto-attaches the active sampled trace's `trace_id` + `span_id` as the OpenMetrics §6.2 exemplar on every bucket sample, so Grafana / Tempo / Jaeger scrapers can pivot from a slow-bucket histogram to the exact trace that produced the sample. The wiring is composition-only — `b.middleware.tracePropagate` populates `req.trace.{traceId,parentId,sampled}`, the metrics middleware reads it, no operator opt-in needed. **Added:** *`Accept` content-negotiation on `b.metrics.expositionHandler()`* — When the scraper's `Accept` header includes `application/openmetrics-text`, the handler renders the OpenMetrics 1.0 wire format (`# UNIT` lines, `_total` suffix on counters, `# EOF` terminator, exemplar shape) and serves `application/openmetrics-text; version=1.0.0; charset=utf-8`. Otherwise serves Prometheus 0.0.4 `text/plain` as before. Operators relying on the legacy Prometheus content-type see no change. · *Auto-attached trace exemplars on request-duration histograms* — When `b.middleware.spanHttpServer` populates `req.span.{traceId, spanId, sampled}` on the inbound request and the span is sampled, the framework's built-in `requestDuration` histogram middleware attaches `{ labels: { trace_id, span_id }, value: <duration>, timestamp: <unix-sec> }` as the OpenMetrics §6.2 exemplar on the corresponding bucket. The exemplar's `span_id` is the server-handling span, not the upstream `traceparent`'s parent-id, so the metric-to-trace pivot in Grafana / Tempo / Jaeger lands on the work the metric measured. Operators wiring `tracePropagate` without `spanHttpServer` fall back to `req.trace.spanId` when populated; the framework never invents a span_id from the upstream parent. **Fixed:** *Accept-header weighted negotiation (Codex P1)* — The first pass treated any `Accept` header containing `application/openmetrics-text` as an unconditional OpenMetrics request — clients sending `Accept: text/plain;q=1.0, application/openmetrics-text;q=0.5` got OpenMetrics back instead of their preferred Prometheus 0.0.4. Fix: parse Accept via `b.requestHelpers.parseQualityList` and compare q-values for `application/openmetrics-text` vs `text/plain` (wildcards `*/*`, `application/*`, `text/*` honored). Defaults to Prometheus when both q-values are equal or zero (backward compatibility with the legacy default content-type). · *Exemplar span_id sources the active server span, not the upstream parent (Codex P2)* — The first pass used `req.trace.parentId` for the exemplar's `span_id` label — but `parentId` is the upstream caller's span (or empty for root requests), not the server-handling span. Fix: prefer `req.span.spanId` (set by `b.middleware.spanHttpServer`), falling back to `req.trace.spanId` for operators wiring `tracePropagate` without `spanHttpServer`. Never synthesises a span_id from `parentId`. **References:** [OpenMetrics 1.0 §1.2 (content negotiation)](https://prometheus.io/docs/specs/om/open_metrics_spec/) · [OpenMetrics 1.0 §6.2 (exemplars)](https://prometheus.io/docs/specs/om/open_metrics_spec/) · [W3C Trace Context (traceparent header)](https://www.w3.org/TR/trace-context/)
@@ -198,7 +198,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
198
198
  - **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`)
199
199
  - **CSV** — RFC 4180 with Excel formula-injection prevention (`b.csv`)
200
200
  - **IDs + slugs** — RFC 9562 UUID v4 + v7 (`b.uuid`); URL-safe slugs (`b.slug`)
201
- - **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation (`b.archive`)
201
+ - **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`)
202
202
  - **Pagination + forms** — HMAC-signed cursor pagination (`b.pagination`); HTML form rendering + validation + CSRF (`b.forms`)
203
203
 
204
204
  ### Production
@@ -332,6 +332,7 @@ This is the minimum-viable security posture for a production deployment. The fra
332
332
  - [ ] For routes that accept email (inbound webhooks from mail providers, .eml uploads, mailbox imports, message-archival flows, customer-support-ticket-by-email — ANY operator-supplied RFC 822/5322 message the server processes): `b.guardEmail.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.17. For inbound message bytes that don't go through those primitives, wire `b.guardEmail.validateMessage(bytes, { profile: "strict" })` BEFORE the parser sees the message — strict refuses SMTP smuggling (bare CR / bare LF outside CRLF pairs combined with embedded SMTP verbs `MAIL FROM`/`RCPT TO`/`DATA`/`EHLO`/`HELO`/`RSET`/`QUIT` — defends CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim / CVE-2026-32178 .NET System.Net.Mail class), CRLF header injection in single-line headers (defends From/Bcc/body smuggling), IDN homograph mixed-script domains in address-bearing headers (Cyrillic / Greek / Armenian / Cherokee codepoints overlapping Latin — operator opts in to legitimate non-Latin via `allowedScripts: ["latin", "cyrillic"]`), Punycode `xn--` labels, display-name spoofing (`"support@apple.com" <attacker@evil>` — display contains @-address that doesn't match envelope domain), IP-literal addresses (`user@[1.2.3.4]` — bypasses DNS/DMARC alignment), RFC 5322 comment syntax in addresses, multiple @ characters, RFC 5321 length caps (local-part 64 / domain 255 / address 320), RFC 5322 line cap (998), BOM injection, bidi/null/control chars in addresses + headers. For per-address validation outside a full message context (form-submitted email, signup, MX-host validation), wire `b.guardEmail.validateAddress(addr, { profile: "strict" })`. Pair with operator's DMARC / SPF / DKIM verifier for envelope-alignment checks — guardEmail is the source-level gate, not the authentication-result interpreter
333
333
  - [ ] For routes that accept markdown (rich-text editors, comment systems, README rendering, documentation submission, GitHub-style wikis, mail-rendered markdown, document-import flows — ANY operator-supplied markdown the server renders): `b.guardMarkdown.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.16. For inbound markdown bodies that don't go through those primitives, wire `b.guardMarkdown.validate(body, { profile: "strict" })` BEFORE passing the source to any markdown renderer (marked / markdown-it / commonmark / remark / parsedown — all of them) — strict refuses dangerous URL schemes in inline links + images + autolinks + reference-link definitions (defends CVE-2025-9540 Markup Markdown class, CVE-2025-24981 MDC class, NuGetGallery GHSA-gwjh-c548-f787, Joplin GHSA-hff8-hjwv-j9q7), whitespace-tolerant dangerous-tag matching (`<script\n>` / `<script\t>` — defends CVE-2026-30838 CommonMark DisallowedRawHtml bypass class), HTML-entity scheme bypass (`&#x6A;avascript:` / `&#106;avascript:` decoded BEFORE scheme matching), reference-link smuggling (`[label]: javascript:...`), front-matter YAML/TOML blocks, HTML comments, code-fence language injection (language tag containing `<>"' `` blocks attribute breakout), catastrophic emphasis runs (CVE-2025-6493 CodeMirror Markdown class, CVE-2025-7969 markdown-it class), inline DOCTYPE, bidi/null/control chars, total-bytes + line + link + image + autolink + ref-def + list-depth + blockquote-depth caps. **Layer with `b.guardHtml`**: source-level guardMarkdown then render then output-level guardHtml together close the residual bypass surface that either alone misses (markdown engines surprise; sanitizers also surprise — defense in depth)
334
334
  - [ ] For routes that accept XML (SOAP endpoints, sitemap submissions, RSS / Atom feeds, OAI-PMH harvesters, SAML / WS-Federation receivers, document-import flows — ANY operator-supplied XML the server parses): `b.guardXml.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.15. For inbound XML bodies that don't go through those primitives, wire `b.guardXml.validate(body, { profile: "strict" })` before passing the document to any XML parser — strict refuses DOCTYPE declarations unconditionally (XXE + billion-laughs vector — defends CVE-2026-24400 AssertJ class, CVE-2024-8176 libexpat recursive-entity stack-overflow class), `<!ENTITY>` declarations including parameter entities (out-of-band exfiltration vector), external entity references (SYSTEM / PUBLIC with file:// / http:// / ftp:// schemes — local file read + SSRF), `<xi:include>` remote inclusion (CVE-2024-25062 libxml2 use-after-free class), `xsi:schemaLocation` operator-controlled schema fetch, processing instructions (`<?xml-stylesheet ?>` CSS-injection vector), CDATA sections (often used to hide payloads from naive scanners), XML signature wrapping (xmldsig surface), bidi/null/control chars in element text + attribute values, and applies depth + element + per-attribute-value caps. DOCTYPE remains refused at every profile level (strict / balanced / permissive) because billion-laughs is universal. Operators integrating with legacy SOAP that requires DTDs must instead route through a separately-firewalled XML processor with explicit allowlist — the gate has no knob to relax DOCTYPE
335
+ - [ ] **For ZIP-shaped uploads specifically**, reach for `b.safeArchive.extract({ source, destination, guardProfile: "strict" })` — the one-liner composes `b.archive.read.zip`'s random-access reader (LFH/CD skew defense + CD-walk validation), `b.guardArchive.zipBombPolicy` defaults (per-entry + per-archive + ratio caps), `b.guardArchive.entryTypePolicy` defaults (symlink / hardlink / device / fifo / socket entries refused), and `b.guardFilename.verifyExtractionPath`'s dual-check (string-normalize + `fs.realpath`-agreement; refuses pre-resolve names exceeding PATH_MAX=4096 to defend the CVE-2025-4517 TOCTOU class). The fs-coupled realpath check is the depth above `b.guardArchive.checkExtractionPath`'s portable string-only gate; operators with their own extract loop call both. Default refuses ZIP encrypted entries (v0.12.10/11 add the encryption read paths). For tar / gzip / 7z / rar / zstd, the read-side primitives land in v0.12.8 / v0.12.9; until then use the legacy guard-only path below
335
336
  - [ ] For routes that accept archives (zip / tar / gzip / 7z / rar / zstd / etc. — ANY upload that downstream code will extract): use the operator's archive library to enumerate entries, then validate via `b.guardArchive.validateEntries(entries, { profile: "strict" })` BEFORE extracting any file. Strict profile defends against zip-slip path traversal (CVE-2025-3445 / 32779 / 62156 / 66945 / 45582 / 11002 class), symlink + hardlink escape (CVE-2026-26960 class), per-entry + aggregate compression-ratio bombs (zip-bomb defense), total-size + entry-count caps, nested-archive recursion DoS, duplicate entry names (silent-overwrite vector), case-insensitive collisions on Windows / macOS, and per-entry filename safety (composes `b.guardFilename` for path traversal / null-byte / Windows reserved names / NTFS ADS / RTLO bidi / overlong UTF-8 / shell-exec / double-extension detection). Additionally call `b.guardArchive.checkExtractionPath(entryName, extractionRoot)` per entry at extract time AND `path.resolve(extractionRoot, entryName).startsWith(path.resolve(extractionRoot))` after path-resolve to catch any traversal that survived metadata validation
336
337
  - [ ] For ANY file-upload route — wire `b.guardFilename.gate({ profile: "strict" })` to validate the filename string before it touches the filesystem. Strict profile rejects path traversal (raw + percent-encoded + UTF-8 overlong), null-byte truncation (defends extension-allowlist bypass), Windows reserved device names (CON / PRN / AUX / ... — even with extensions), NTFS alternate data streams, leading/trailing whitespace + trailing dots (Windows silently strips), Unicode bidi / RTLO file-name spoofing (CVE-2021-42574 in filename context — `Photo01By‮gpj.SCR` displays as `RCS.jpg` while OS opens `.SCR`), reserved characters, UNC paths, shell-shortcut + executable extensions (.exe / .bat / .vbs / .lnk / .scr / .dll / .so / etc.), and double-extension bypass (`invoice.pdf.exe`). Operators with non-ASCII filename requirements use `profile: "balanced"`. Operators with multi-component path-shape needs use `profile: "permissive"` and explicitly opt in to `pathSeparatorsPolicy: "allow"`
337
338
  - [ ] For routes that accept SVG (avatar uploads, illustration / icon assets, mail attachments, file-upload widgets that allow image/svg+xml): wire `b.guardSvg.gate({ profile: "strict" })` — strict profile rejects every dangerous tag (script / foreignObject / animation family), denies cross-origin `<use>` references (defends server-side rasterization SSRF), refuses every DOCTYPE (defends billion-laughs entity expansion + XXE per CVE-2026-29074 class), refuses SVGZ payloads (operator must ungzip first), and enforces the SMIL animation attributeName allowlist (defends the recent CVE class where `<animate attributeName="href" to="javascript:..."/>` retroactively hijacks an element's href). For uploaded SVGs that need to be rendered, additionally serve under a strict CSP and consider rasterizing server-side to PNG before display
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.12.6",
4
- "createdAt": "2026-05-23T01:41:26.382Z",
3
+ "frameworkVersion": "0.12.8",
4
+ "createdAt": "2026-05-23T15:51:43.409Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -1706,6 +1706,110 @@
1706
1706
  "type": "function",
1707
1707
  "arity": 4
1708
1708
  },
1709
+ "TarError": {
1710
+ "type": "function",
1711
+ "arity": 4
1712
+ },
1713
+ "adapters": {
1714
+ "type": "object",
1715
+ "members": {
1716
+ "buffer": {
1717
+ "type": "function",
1718
+ "arity": 2
1719
+ },
1720
+ "fs": {
1721
+ "type": "function",
1722
+ "arity": 2
1723
+ },
1724
+ "http": {
1725
+ "type": "function",
1726
+ "arity": 2
1727
+ },
1728
+ "isRandomAccessAdapter": {
1729
+ "type": "function",
1730
+ "arity": 1
1731
+ },
1732
+ "isTrustedStreamAdapter": {
1733
+ "type": "function",
1734
+ "arity": 1
1735
+ },
1736
+ "objectStore": {
1737
+ "type": "function",
1738
+ "arity": 3
1739
+ },
1740
+ "trustedStream": {
1741
+ "type": "function",
1742
+ "arity": 2
1743
+ }
1744
+ }
1745
+ },
1746
+ "read": {
1747
+ "type": "object",
1748
+ "members": {
1749
+ "ArchiveReadError": {
1750
+ "type": "function",
1751
+ "arity": 4
1752
+ },
1753
+ "DEFAULT_BOMB_POLICY": {
1754
+ "type": "object",
1755
+ "members": {
1756
+ "maxEntries": {
1757
+ "type": "primitive",
1758
+ "valueType": "number"
1759
+ },
1760
+ "maxEntryDecompressedBytes": {
1761
+ "type": "primitive",
1762
+ "valueType": "number"
1763
+ },
1764
+ "maxExpansionRatio": {
1765
+ "type": "primitive",
1766
+ "valueType": "number"
1767
+ },
1768
+ "maxTotalDecompressedBytes": {
1769
+ "type": "primitive",
1770
+ "valueType": "number"
1771
+ }
1772
+ }
1773
+ },
1774
+ "DEFAULT_ENTRY_TYPE_POLICY": {
1775
+ "type": "object",
1776
+ "members": {
1777
+ "devices": {
1778
+ "type": "primitive",
1779
+ "valueType": "boolean"
1780
+ },
1781
+ "fifos": {
1782
+ "type": "primitive",
1783
+ "valueType": "boolean"
1784
+ },
1785
+ "hardlinks": {
1786
+ "type": "primitive",
1787
+ "valueType": "boolean"
1788
+ },
1789
+ "sockets": {
1790
+ "type": "primitive",
1791
+ "valueType": "boolean"
1792
+ },
1793
+ "symlinks": {
1794
+ "type": "primitive",
1795
+ "valueType": "boolean"
1796
+ }
1797
+ }
1798
+ },
1799
+ "tar": {
1800
+ "type": "function",
1801
+ "arity": 2
1802
+ },
1803
+ "zip": {
1804
+ "type": "function",
1805
+ "arity": 2
1806
+ }
1807
+ }
1808
+ },
1809
+ "tar": {
1810
+ "type": "function",
1811
+ "arity": 0
1812
+ },
1709
1813
  "zip": {
1710
1814
  "type": "function",
1711
1815
  "arity": 0
@@ -4349,6 +4453,10 @@
4349
4453
  "type": "function",
4350
4454
  "arity": 4
4351
4455
  },
4456
+ "bundleAdapterStorage": {
4457
+ "type": "function",
4458
+ "arity": 1
4459
+ },
4352
4460
  "create": {
4353
4461
  "type": "function",
4354
4462
  "arity": 1
@@ -4357,6 +4465,10 @@
4357
4465
  "type": "function",
4358
4466
  "arity": 1
4359
4467
  },
4468
+ "migrate": {
4469
+ "type": "function",
4470
+ "arity": 1
4471
+ },
4360
4472
  "recommendedFiles": {
4361
4473
  "type": "function",
4362
4474
  "arity": 1
@@ -16323,10 +16435,18 @@
16323
16435
  "type": "function",
16324
16436
  "arity": 1
16325
16437
  },
16438
+ "entryTypePolicy": {
16439
+ "type": "function",
16440
+ "arity": 1
16441
+ },
16326
16442
  "gate": {
16327
16443
  "type": "function",
16328
16444
  "arity": 1
16329
16445
  },
16446
+ "inspect": {
16447
+ "type": "function",
16448
+ "arity": 2
16449
+ },
16330
16450
  "inspectMagic": {
16331
16451
  "type": "function",
16332
16452
  "arity": 1
@@ -16335,9 +16455,17 @@
16335
16455
  "type": "function",
16336
16456
  "arity": 1
16337
16457
  },
16458
+ "tarEntryPolicy": {
16459
+ "type": "function",
16460
+ "arity": 1
16461
+ },
16338
16462
  "validateEntries": {
16339
16463
  "type": "function",
16340
16464
  "arity": 2
16465
+ },
16466
+ "zipBombPolicy": {
16467
+ "type": "function",
16468
+ "arity": 1
16341
16469
  }
16342
16470
  }
16343
16471
  },
@@ -20426,6 +20554,10 @@
20426
20554
  "validate": {
20427
20555
  "type": "function",
20428
20556
  "arity": 2
20557
+ },
20558
+ "verifyExtractionPath": {
20559
+ "type": "function",
20560
+ "arity": 3
20429
20561
  }
20430
20562
  }
20431
20563
  },
@@ -44782,6 +44914,23 @@
44782
44914
  }
44783
44915
  }
44784
44916
  },
44917
+ "safeArchive": {
44918
+ "type": "object",
44919
+ "members": {
44920
+ "SafeArchiveError": {
44921
+ "type": "function",
44922
+ "arity": 4
44923
+ },
44924
+ "extract": {
44925
+ "type": "function",
44926
+ "arity": 1
44927
+ },
44928
+ "inspect": {
44929
+ "type": "function",
44930
+ "arity": 1
44931
+ }
44932
+ }
44933
+ },
44785
44934
  "safeAsync": {
44786
44935
  "type": "object",
44787
44936
  "members": {
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ /**
3
+ * Fuzz target: b.safeArchive
4
+ *
5
+ * Feeds adversarial bytes into `b.safeArchive.inspect` (the read-only
6
+ * path that walks EOCD + CD + LFH skew checks). The fuzzer probes
7
+ * the malformed-ZIP class — EOCD pointers past EOF, CD-claimed sizes
8
+ * that don't match LFH, ZIP64 sentinels (refused in v0.12.7),
9
+ * truncated headers, comments containing fake EOCD signatures,
10
+ * negative byte offsets — and asserts the primitive surfaces typed
11
+ * `archive-read/* | safe-archive/* | filename.extraction-*` codes
12
+ * for every refusal: no OOM, no hang, no uncaught error class outside
13
+ * the documented surface.
14
+ *
15
+ * Seed corpus: a single valid ZIP produced by `b.archive.zip()` so
16
+ * the fuzzer mutates around a known-good baseline rather than starting
17
+ * from random noise.
18
+ */
19
+
20
+ var b = require("..");
21
+ var expected = require("./_expected");
22
+
23
+ module.exports.fuzz = function (data) {
24
+ if (!Buffer.isBuffer(data) || data.length === 0) return;
25
+ // The fuzzer can't usefully drive an async function under jazzer.js
26
+ // without an await; we lean on the format sniffer + EOCD locator which
27
+ // are the parsing-heavy phases. Both run inside b.safeArchive.inspect.
28
+ return b.safeArchive.inspect({
29
+ source: data,
30
+ }).then(
31
+ function () { /* legitimate ZIP-shaped input → no finding */ },
32
+ function (err) {
33
+ if (expected.isExpected(err)) return;
34
+ throw err; // re-throw unexpected error class so libFuzzer records it
35
+ }
36
+ );
37
+ };
@@ -271,6 +271,8 @@ var forms = require("./lib/forms");
271
271
  var app = require("./lib/app");
272
272
  var jobs = require("./lib/jobs");
273
273
  var archive = require("./lib/archive");
274
+ var archiveAdapters = require("./lib/archive-adapters");
275
+ var safeArchive = require("./lib/safe-archive");
274
276
  var breakGlass = require("./lib/break-glass");
275
277
  var config = require("./lib/config");
276
278
  var csv = require("./lib/csv");
@@ -571,7 +573,19 @@ module.exports = {
571
573
  forms: forms,
572
574
  createApp: app.createApp,
573
575
  jobs: jobs,
574
- archive: archive,
576
+ archive: Object.assign({}, archive, {
577
+ adapters: {
578
+ fs: archiveAdapters.fs,
579
+ buffer: archiveAdapters.buffer,
580
+ objectStore: archiveAdapters.objectStore,
581
+ http: archiveAdapters.http,
582
+ trustedStream: archiveAdapters.trustedStream,
583
+ isRandomAccessAdapter: archiveAdapters.isRandomAccessAdapter,
584
+ isTrustedStreamAdapter: archiveAdapters.isTrustedStreamAdapter,
585
+ },
586
+ read: archive.read,
587
+ }),
588
+ safeArchive: safeArchive,
575
589
  breakGlass: breakGlass,
576
590
  config: config,
577
591
  csv: csv,