@blamejs/core 0.16.17 → 0.16.19

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - v0.16.19 (2026-07-12) — **Close a family of entity- and whitespace-hidden dangerous-URL-scheme and CSS-injection bypasses across b.guardHtml / b.guardSvg / b.guardMarkdown behind one shared normalizer, bound a DNS decompression-pointer name bomb, refuse an ambiguous audit-anchor canonicalization, and stop an empty-string sealed field from crashing an AAD-table write.** Covering untested adversarial branches across the content guards, a DNS wire parser, the audit-log anchor, and the sealed-field codec surfaced a family of fail-open defects, fixed at the root. The most serious span the content-guard URL-scheme and CSS-injection denylists: a browser removes tab/lf/cr from anywhere in a URL and trims a leading/trailing control-or-space run before resolving the scheme, and character-reference-decodes a style attribute before the CSS parser sees it, so an entity-encoded tab, an entity-encoded leading space, an HTML named entity, or an entity-encoded CSS token could all navigate/execute while reading past a denylist that matched the raw bytes. b.guardHtml missed the whitespace normalization entirely and matched CSS against raw bytes; b.guardMarkdown decoded only numeric entities; b.guardSvg had a partial fix. All three now route their scheme and CSS-token checks through one pair of shared codepoint-class normalizers, so no guard can strip a different set of encodings than another. A DNS compression-pointer chain could decompress a single name past the RFC 1035 255-octet / label caps (a decompression-amplification DoS); the audit-log anchor's newline-delimited signed bytes let one signature validate for several different tip/previous-tip splits (defeating the linkage tamper-evidence); an SVG animation element permitted under a permissive profile lost its open tag; and an empty-string sealed column crashed an AAD-table insert. **Fixed:** *b.guardSvg preserves a profile-permitted safe animation element* — Under a permissive profile that allows animation, a safe animation element (for example an <animate> whose attributeName targets a visual property) had its open tag dropped while its end tag was still emitted, leaving an orphan close tag and silently stripping an element the profile permits. The sanitize path now affirmatively re-permits the safe-target case; an unsafe-target animation (attributeName=href and similar) is still dropped and neutralized. · *b.cryptoField.sealRow seals an empty-string field as a tamper-evident envelope* — sealRow dispatched every non-null value -- including an empty string -- to one of three envelope-seal branches that disagreed on empty plaintext: the plain and per-row-key branches handled it, but the AAD branch is fail-closed and threw, so a write to an AAD-sealed table whose sealed column held an empty string crashed the insert. An empty string now encodes to a non-empty typed marker before sealing, so it becomes a real authenticated envelope (never a bare plaintext empty string) that round-trips to an empty string on read. On the read side, a bare empty string found in an AAD-bound or per-row-key sealed column -- which after this change can only be an envelope-downgrade by a DB-write attacker -- fails closed (the cell is nulled and audited) instead of being accepted as a valid empty value, so the sealed-column tamper-evidence guarantee holds for empty values too. **Security:** *b.guardHtml / b.guardSvg / b.guardMarkdown refuse entity- and whitespace-hidden dangerous URL schemes* — The URL-scheme denylist on every URL-bearing attribute (href / xlink:href / markdown link, image, autolink, reference-definition) is resolved after normalizing the value the way a browser does: character-reference decoding (numeric AND the HTML5 named-entity ASCII subset browsers honor, e.g. &Tab; / &colon;), removing tab/lf/cr from anywhere, and trimming a leading/trailing C0-control-or-space run. Previously b.guardHtml stripped neither tab/lf/cr nor an entity-encoded leading space, and b.guardMarkdown decoded only numeric entities, so payloads such as java&#9;script:, java&Tab;script:, and &#32;javascript: resolved to an empty scheme and were served/rendered as safe while a browser executed them as javascript:. The normalization now lives in two shared codepoint-class primitives (b.codepointClass.decodeMarkupEntities and b.codepointClass.stripUrlSchemeWhitespace) that all three guards compose, so no guard can fold away a different set of encodings than another. · *b.guardHtml / b.guardSvg detect entity-encoded and whitespace-hidden CSS injection in style attributes* — A style attribute is HTML/XML character-reference-decoded before the CSS parser sees it, so width:ex&#x70;ression(, background:url(&#x6A;avascript:...), and behavior&colon;url(...) reach CSS as expression( / url(javascript:...) / behavior: with no literal danger token in the raw bytes. The CSS-danger check matched the raw attribute value, so these bypassed the denylist and were served verbatim (a stored CSS-injection XSS). Both guards now match the entity-decoded value AND fold the URL-scheme whitespace a browser strips inside url(...) (tab / lf / cr), so an entity-hidden tab in a CSS URL scheme (url(java&Tab;script:) reaching CSS as url(java<TAB>script:), which navigates as javascript:) can no longer defeat the contiguous javascript: pattern either. A plain (unencoded) dangerous style is still caught and a benign style is untouched. · *b.safeDns bounds a decompressed name across the whole compression-pointer chain* — readName tracked its per-name octet and label budgets per stack frame, so each compression-pointer jump restarted a fresh RFC 1035 255-octet / label budget and a pointer was charged as only its 2 on-wire bytes. A crafted pointer chain therefore decompressed a single name well past the advertised 255-octet / label caps (a decompression-amplification DoS on untrusted DNS responses). The octet and label accountants now thread through the whole pointer chain, so the composite decompressed name is bounded by the same caps that bound a single in-line name; independent names are still measured independently and no name that decompresses within the caps is newly rejected. · *b.auditSign refuses an anchor whose fields carry the record delimiter* — The chain-anchor signed bytes are a newline-delimited layout of format / counter / tipHash / prevTipHash / createdAt. A literal newline inside any operator-influenced string field (format / tipHash / prevTipHash) let content migrate across a field boundary without changing the signed bytes, so one signature was valid for several different { tipHash, prevTipHash } splits -- an attacker could rewrite a stored anchor's apparent tip/linkage while keeping verify() happy, defeating the prevTipHash linkage tamper-evidence. anchor() now refuses a delimiter-bearing field at sign time and verifyAnchor refuses one at verify time, so an ambiguous anchor can neither be minted nor accepted; every legitimate (delimiter-free) anchor is byte-identical and unaffected. **Detectors:** *Guard scheme extractors and CSS-danger checks must compose the shared normalizers* — Two structural checks assert that a content guard which resolves a URL scheme for a denylist routes the decoded value through b.codepointClass.stripUrlSchemeWhitespace, and that a guard's CSS-danger check matches the entity-decoded style value via b.codepointClass.decodeMarkupEntities -- so a future guard cannot silently fold away a narrower set of encodings than the browser and re-open the bypass class.
12
+
13
+ - v0.16.18 (2026-07-12) — **Refuse a stale FIDO metadata BLOB on the operator-fetch path, harden the GraphQL DoS-shape guard against escaped strings and malformed queries, parse POSIX leading-space octal in tar headers, and fix the FIDO certified-level, self-update 304, and swap-maxBytes paths — surfaced by covering previously-untested error and adversarial branches.** Covering untested error and adversarial branches across four security-relevant primitives surfaced seven defects, fixed at the root. The most serious: b.auth.fidoMds3.fetch() reimplemented the metadata-BLOB validation inline and omitted the stale-BLOB refusal the internal path enforced — so a signed-but-expired BLOB (nextUpdate in the past) was accepted and cached, letting an attacker who serves an ancient correctly-signed BLOB freeze an operator's revoked/compromised-authenticator list at a time of their choosing. Both paths now route through one _verifyBlobWithRoots source of truth, so no check can be present on one and missing on the other. The GraphQL query-shape guard (the pre-schema depth/alias DoS walker) miscounted on a string literal containing an escaped quote and on a brace-unbalanced malformed query, weakening the depth/alias limits it exists to enforce. The FIDO certified-level resolver lexically compared status reports and let an undated later report (a decertification or downgrade) lose to an earlier dated one, so a now-decertified authenticator could still read as certified. A tar reader misparsed POSIX leading-space-padded octal header fields to 0 (a size misparse desynced the block walker and rejected archives other tars extract). self-update's documented 304 If-None-Match fast-path was dead (it threw on every unchanged conditional poll), and selfUpdate.swap read an opts.maxBytes it never declared, so it could refuse a large binary that selfUpdate.verify accepted. **Fixed:** *b.auth.fidoMds3 certified-level honors an undated later decertification or downgrade* — _certifiedLevel compared status reports lexically and coerced a missing effectiveDate to an empty string, which sorts before any real date — so a later report with no effectiveDate (a NOT_FIDO_CERTIFIED decertification, or a downgrade/upgrade) always lost to an earlier dated grant, and certifiedLevel froze at the stale, higher historical value. A step-up policy reading certifiedLevel >= N could therefore accept a now-decertified authenticator. Reports without a comparable date now fall back to array order (append order = chronological), so a later decertification/downgrade wins. · *b.archive.read.tar parses POSIX leading-space-padded octal header fields* — A tar numeric header field (mode / uid / gid / size / mtime / checksum) that is left-padded with ASCII spaces — POSIX-legal, and emitted by star / BSD / Java / Perl tars — was misparsed to 0 because the octal reader treated a leading space as a field terminator. A misparsed size desynced the 512-byte block walker (it read the file body as the next header) and rejected an archive other tars extract cleanly. The reader now skips leading-space padding before reading octal digits. · *b.selfUpdate.poll returns a no-update result on a 304 Not Modified* — The documented If-None-Match / ETag fast-path was unreachable: poll delegated HTTP status handling to the HTTP client, which rejects every non-2xx (304 included) as an error before poll could inspect the status. A conditional poll that correctly received a 304 threw selfupdate/poll-failed instead of returning { available: false, statusCode: 304 }. The 304 (and the non-2xx) branch is now reached, so ETag-conditional polling works as documented. · *b.selfUpdate.swap accepts the maxBytes it re-reads under* — swap re-reads the newly-installed bytes to re-hash them (closing the verify→swap window) under an opts.maxBytes cap, but its option schema never declared maxBytes — so a caller who passed it was refused with selfupdate/bad-opts and swap always used the fixed 1 GiB default, which could refuse a large binary that selfUpdate.verify (which does declare maxBytes) accepted. swap now declares maxBytes (validated like verify's), so the two caps match; rollback, which re-reads nothing, still does not accept it. **Security:** *b.auth.fidoMds3.fetch refuses a stale (expired) metadata BLOB* — The operator-facing fetch path (which trusts caCertificate roots) reimplemented the metadata-BLOB parse and verify inline and omitted the stale-BLOB refusal that the default-roots path enforced, so a BLOB whose nextUpdate is already in the past was accepted and cached even though FIDO MDS3 §3.1.7 says it must not be trusted. An attacker serving an ancient, correctly-signed BLOB could pin an operator to a revoked/compromised-authenticator list frozen at that time. The JWS + x5c-chain verify, payload-shape checks, and stale-BLOB refusal now live in one _verifyBlobWithRoots helper that both the operator-fetch and default-roots paths route through, so the checks can never drift apart again. · *b.guardGraphql hardens the pre-schema DoS-shape walker against escaped strings and malformed queries* — The query-shape walker that enforces depth and alias limits before the query reaches a schema miscounted in two ways: it treated an escaped quote inside a valid GraphQL string literal as the string's terminator (desyncing its in-string state), and it popped its depth-indexed alias counter on every closing brace even for a brace-unbalanced malformed query (underflowing the counter). Both weakened the depth/alias caps the guard exists to enforce against a DoS-shaped query. The walker now tracks string escapes correctly and guards the alias-counter stack against imbalance.
14
+
11
15
  - v0.16.17 (2026-07-12) — **Reject INI float literals that overflow to Infinity, return a verdict (not an exception) when a reverse-DNS lookup faults, wrap an async redirect-hook rejection like a sync throw, and refuse a fractional --max-rows — four defects surfaced by covering previously-untested error branches.** Covering previously-untested error and adversarial branches across four primitives surfaced four genuine defects, each fixed at the root. b.parsers.ini.parse coerced a value like `x = 1e999` straight to ±Infinity — its integer and hex branches already reject out-of-range numbers, but the float branch had no finiteness guard, so an overflowing float slipped through and could poison a downstream size cap or timeout; it is now rejected with ini/value-out-of-range. b.mail.iprev.verify threw an unhandled exception when the forward-confirm DNS lookup returned an error code outside the handful it enumerated (EREFUSED / ENOTIMP / …), even though its reverse-lookup path and every sibling (SPF/DKIM/DMARC/ARC) return a verdict for such faults; it now returns a temperror verdict, and — like those siblings — accepts an operator opts.dnsLookup resolver so the confirm path is resolvable offline. b.httpClient wrapped a synchronous onRedirect hook throw into a REDIRECT_ABORTED error but let an async hook rejection escape unwrapped; both now abort the redirect identically. And the blamejs audit verify-chain --max-rows flag accepted a fractional value (2.5), which truncated the chain walk mid-row and reported a nonsensical fractional count; it now requires a whole positive integer, matching its own error message. **Fixed:** *b.parsers.ini.parse rejects an overflowing float instead of coercing to Infinity* — A float literal that exceeds the representable range (e.g. `x = 1e999`) coerced to ±Infinity. The integer and hex coercion branches already reject out-of-range numbers via Number.isSafeInteger, but the float branch returned Number(raw) with no finiteness check — so an Infinity could flow into a downstream size cap or timeout, a denial-of-service vector. The float branch now rejects a non-finite result with ini/value-out-of-range; a large-but-finite float (1e308) and underflow (1e-999 → 0) still parse. · *b.mail.iprev.verify returns a temperror verdict on an un-enumerated reverse/forward DNS fault* — The forward-confirm DNS lookup's error handler enumerated a few transient codes and threw for anything else, so a resolver returning EREFUSED / ENOTIMP / EBADRESP produced an unhandled exception from the public API rather than a verdict. The reverse-lookup path and every sibling result type (SPF / DKIM / DMARC / ARC) return a verdict for a DNS-derived fault; the forward path now does too (temperror). iprev.verify also gains an operator opts.dnsLookup resolver, matching the dnsLookup contract the other types already honor, so the forward-confirm path is resolvable offline. · *b.httpClient aborts a redirect on an async onRedirect hook rejection* — A synchronous throw from the onRedirect hook was wrapped into a REDIRECT_ABORTED error, but an async hook that rejected let the rejection escape unwrapped — inconsistent handling for the same operator control point. An async onRedirect rejection now aborts the redirect with REDIRECT_ABORTED, identical to the synchronous throw. · *blamejs audit verify-chain --max-rows requires a whole positive integer* — The --max-rows flag validated only that the value was finite and >= 1, so a fractional value (2.5) was accepted and passed to the chain walk, where it truncated the verification mid-row and reported a fractional rowsVerified count — despite the flag's own error message promising a positive integer. It now rejects a non-integer value, matching the sibling --steps flag.
12
16
 
13
17
  - v0.16.16 (2026-07-12) — **Build the data-subject-request ticket store's SQL through the shared b.sql query builder instead of hand-assembled statements, and add a static check that keeps db-handle primitives composing b.sql.** A maintainability change with no behavior difference for operators. The b.dsr ticket store built its reads and writes by concatenating table and column names into SQL strings passed to db.prepare, re-implementing the identifier quoting and sealed-field handling that b.sql — the same builder b.db.from() uses — already provides. That hand-rolled shape is how b.tenant.quota's storage query drifted from the query builder and accrued a run of parity defects fixed in 0.16.15 (reserved-word names, schema-qualified names, sealed-column filtering). The store's DML now composes b.sql (its schema DDL, which is not a b.sql concern, stays as direct statements), so its SQL cannot diverge from the builder. A new codebase-patterns check flags any db-handle primitive that passes an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql, directing it to compose b.sql instead, so this class of drift cannot recur. **Changed:** *b.dsr ticket store composes b.sql for its reads and writes* — The data-subject-request ticket store (insert / get / list / update / delete / purge and the legacy re-seal backfill) now builds its DML with the b.sql query builder — sql.select / insert / update / delete(table, { dialect, quoteName }).…toSql() — and prepares the resulting statement, rather than concatenating identifiers into SQL strings by hand. This removes a hand-rolled identifier-quoting surface that could drift from what b.db.from() accepts. Schema provisioning (CREATE TABLE / INDEX, ALTER, PRAGMA) is not a b.sql concern and remains as direct statements. One behavior change: on a store backed by a vault, a ticket payload is AEAD-sealed and base64-encoded (~4/3 expansion) before it is bound, and the bound cell must fit the query builder's 64 MiB per-value ceiling — so the payload is now capped at an expansion-safe plaintext size (~48 MiB) and a larger ticket is refused with dsr/ticket-too-large (route large access/portability exports through chunked storage rather than one giant sealed cell). Plaintext stores keep the full 64 MiB limit. When a vault is first enabled on a table that already holds an over-cap legacy plaintext row, the one-time re-seal backfill still migrates that row's subject columns and derived hashes — so it stays findable by subject lookup and erasable by the data-subject erasure purge — and leaves only the over-cap payload plaintext (still under the read ceiling, DB-encrypted at rest, and removed when the row is erased), rather than failing provisioning with a query-builder error. **Detectors:** *Static check: db-handle primitives must compose b.sql for DML* — A new codebase-patterns check flags any primitive holding a db handle that runs DML by passing an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql — the shape that lets a query drift from b.sql's identifier quoting and sealed-field rewrite (the b.tenant.quota storage defect class). It directs authors to build the query with b.sql and prepare the resulting string. DDL and PRAGMA (not b.sql verbs) and queries already built through a b.sql variable are out of scope.
@@ -175,10 +175,20 @@ function _writeString(buf, value, offset, width) {
175
175
 
176
176
  function _readOctal(buf, offset, width) {
177
177
  // Read an octal-encoded field. Terminator may be space or NUL.
178
+ //
179
+ // POSIX permits numeric fields to be LEFT-padded with spaces (as well as
180
+ // zeros) — star, BSD tar, and several Java/Perl tar libraries emit them,
181
+ // and GNU tar / libarchive skip them. Treating a leading space as a
182
+ // terminator silently misreads such a field as 0 (a parser-differential:
183
+ // the reader would disagree with every other tar on the same bytes, and a
184
+ // misread size desyncs the block walker). Skip leading-space padding first;
185
+ // a space or NUL AFTER the digits still terminates the field.
186
+ var i = 0;
187
+ while (i < width && buf[offset + i] === 0x20) i += 1; // ASCII space (0x20) leading padding per POSIX numeric field
178
188
  var s = "";
179
- for (var i = 0; i < width; i += 1) {
189
+ for (; i < width; i += 1) {
180
190
  var c = buf[offset + i];
181
- if (c === 0x20 || c === 0) break; // ASCII space (0x20) + NUL (0x00) field terminators
191
+ if (c === 0x20 || c === 0) break; // ASCII space (0x20) + NUL (0x00) field terminators after digits
182
192
  if (c < 0x30 || c > 0x37) { // ASCII '0' (0x30) .. '7' (0x37) octal digits
183
193
  throw new TarError("archive-tar/bad-octal",
184
194
  "non-octal byte 0x" + c.toString(16) + " at offset " + (offset + i)); // radix=16 for diagnostic hex format
package/lib/audit-sign.js CHANGED
@@ -204,6 +204,24 @@ function _computeFingerprint(publicKeyPem) {
204
204
  // untouched). Changing this invalidates every prior anchor.
205
205
  var ANCHOR_FORMAT = "blamejs-chain-anchor-v1";
206
206
 
207
+ // The anchor signed-bytes layout below is newline-delimited, so a literal
208
+ // newline inside any operator-influenced string field (format / tipHash /
209
+ // prevTipHash) would let content migrate across a field boundary WITHOUT
210
+ // changing the signed bytes: two different { tipHash, prevTipHash } splits
211
+ // collide on one signature, so a signature minted for one split verifies for a
212
+ // different split. That defeats the "prevTipHash is bound into the signature"
213
+ // linkage guarantee — an attacker could rewrite the apparent tip / linkage of a
214
+ // stored anchor while keeping verify() happy. counter + createdAt are numbers
215
+ // (String() of a finite number can never contain the delimiter), so only the
216
+ // three string fields need guarding. Reject the delimiter at BOTH sign time
217
+ // (so no ambiguous signature is ever minted) and verify time (so a pre-existing
218
+ // or hand-crafted ambiguous anchor is refused, not accepted). The wire format
219
+ // stays byte-identical for every legitimate (delimiter-free) anchor.
220
+ var ANCHOR_FIELD_DELIMITER = "\n";
221
+ function _containsAnchorDelimiter(s) {
222
+ return typeof s === "string" && s.indexOf(ANCHOR_FIELD_DELIMITER) !== -1;
223
+ }
224
+
207
225
  // Build the canonical signed bytes for one anchor. Fixed multi-line layout (no
208
226
  // JSON serializer quirks). prevTipHash IS part of the signed payload, so the
209
227
  // link between consecutive anchors is tamper-evident — an attacker cannot
@@ -878,10 +896,20 @@ function _normalizeTip(tip, fnLabel) {
878
896
  throw _err("ANCHOR_BAD_TIPHASH",
879
897
  "auditSign." + fnLabel + ": tip.tipHash must be a non-empty string");
880
898
  }
899
+ if (_containsAnchorDelimiter(tip.tipHash)) {
900
+ throw _err("ANCHOR_BAD_TIPHASH",
901
+ "auditSign." + fnLabel + ": tip.tipHash must not contain a newline (the anchor " +
902
+ "record delimiter — it would make the signed bytes ambiguous)");
903
+ }
881
904
  if (tip.prevTipHash != null && typeof tip.prevTipHash !== "string") {
882
905
  throw _err("ANCHOR_BAD_PREV",
883
906
  "auditSign." + fnLabel + ": tip.prevTipHash, when present, must be a string");
884
907
  }
908
+ if (_containsAnchorDelimiter(tip.prevTipHash)) {
909
+ throw _err("ANCHOR_BAD_PREV",
910
+ "auditSign." + fnLabel + ": tip.prevTipHash must not contain a newline (the anchor " +
911
+ "record delimiter — it would make the signed bytes ambiguous)");
912
+ }
885
913
  return {
886
914
  counter: counter,
887
915
  tipHash: tip.tipHash,
@@ -916,7 +944,9 @@ function _normalizeTip(tip, fnLabel) {
916
944
  * that key store (not fully store-free) — keep the history with the anchors.
917
945
  *
918
946
  * Throws `AuditSignError` (`ANCHOR_BAD_TIP` / `ANCHOR_BAD_COUNTER` /
919
- * `ANCHOR_BAD_TIPHASH` / `ANCHOR_BAD_PREV`) on a malformed tip;
947
+ * `ANCHOR_BAD_TIPHASH` / `ANCHOR_BAD_PREV` / `ANCHOR_BAD_FORMAT`) on a malformed
948
+ * tip — including any `format` / `tipHash` / `prevTipHash` that carries a
949
+ * newline, which would make the signed bytes ambiguous;
920
950
  * `audit-sign/not-initialized` when `init()` has not been awaited.
921
951
  *
922
952
  * @opts
@@ -934,6 +964,11 @@ function anchor(tip, opts) {
934
964
  _requireInit();
935
965
  opts = opts || {};
936
966
  var t = _normalizeTip(tip, "anchor");
967
+ if (_containsAnchorDelimiter(opts.format)) {
968
+ throw _err("ANCHOR_BAD_FORMAT",
969
+ "auditSign.anchor: opts.format must not contain a newline (the anchor " +
970
+ "record delimiter — it would make the signed bytes ambiguous)");
971
+ }
937
972
  var format = (typeof opts.format === "string" && opts.format.length > 0) ? opts.format : ANCHOR_FORMAT;
938
973
  var createdAt = (typeof opts.createdAt === "number" && isFinite(opts.createdAt)) ? opts.createdAt : Date.now();
939
974
  var payload = anchorPayload(t.counter, t.tipHash, t.prevTipHash, createdAt, format);
@@ -962,6 +997,17 @@ function _verifyOneAnchor(a) {
962
997
  if (typeof a.counter !== "number" || !isFinite(a.counter)) {
963
998
  return { ok: false, reason: "anchor counter is not a finite number" };
964
999
  }
1000
+ // Fail closed on a delimiter-bearing field: the newline-delimited signed
1001
+ // bytes are ambiguous when format / tipHash / prevTipHash carries the
1002
+ // delimiter, so ONE signature can be valid for several different logical
1003
+ // { tipHash, prevTipHash } splits. Refuse rather than accept the ambiguity —
1004
+ // a legitimate anchor is delimiter-free (anchor() rejects a newline field at
1005
+ // sign time), so this only ever rejects a hand-crafted / boundary-shifted one.
1006
+ if (_containsAnchorDelimiter(a.tipHash) ||
1007
+ _containsAnchorDelimiter(a.format) ||
1008
+ _containsAnchorDelimiter(a.prevTipHash)) {
1009
+ return { ok: false, reason: "anchor field contains the record delimiter (ambiguous canonicalization)" };
1010
+ }
965
1011
  var pub = getPublicKeyByFingerprint(a.publicKeyFingerprint);
966
1012
  if (!pub) {
967
1013
  return { ok: false, reason: "no audit-signing key on record for this anchor's fingerprint" };
@@ -344,12 +344,16 @@ function _parseNextUpdate(s) {
344
344
  return d;
345
345
  }
346
346
 
347
- // Internal verify-blob helper used by both fetch (live HTTP) and the
348
- // fetch-with-injected-body test path. Operator-facing surface goes
349
- // through fetch().
350
- function _verifyAndParseBlob(token) {
347
+ // Single source of truth for the BLOB-trust decision: JWS + x5c-chain verify
348
+ // against the supplied trust roots, payload-shape validation, AND the
349
+ // stale-BLOB refusal. fetch() (operator-supplied roots via caCertificate) and
350
+ // _verifyAndParseBlob (default vendored roots) BOTH route here so no check can
351
+ // ever be present on one path but missing on the other. The stale-refusal used
352
+ // to live only in this helper's caller-inlined twin, so the operator-facing
353
+ // fetch path silently accepted (and cached) a signed-but-expired BLOB; keeping
354
+ // one body closes that class.
355
+ function _verifyBlobWithRoots(token, rootPems) {
351
356
  var jws = _parseJws(token);
352
- var rootPems = _resolveRoots(undefined);
353
357
  var chain = _validateChain(jws.header.x5c, rootPems);
354
358
  _verifyJws(jws, chain[0]);
355
359
  var payload = jws.payload;
@@ -366,19 +370,19 @@ function _verifyAndParseBlob(token) {
366
370
  throw new FidoMds3Error("fido-mds3/bad-payload",
367
371
  "BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
368
372
  }
369
- // Stale-BLOB refusal FIDO MDS3 §3.1.7 says clients SHOULD refresh
370
- // by nextUpdate; a BLOB whose nextUpdate is already in the past is
371
- // not safe to trust even though its cert chain still validates.
372
- // Pre-v0.9.2 the staleness was floored to MIN_CACHE_TTL_MS in
373
- // _ttlFromNextUpdate but the BLOB itself was still served from
374
- // cache; an attacker serving an ancient signed-but-expired BLOB
375
- // could keep operators on a revoked-authenticator-list-frozen-at-X.
376
- // Refuse at parse time so neither fetch nor cache lookup honors it.
373
+ // Stale-BLOB refusal (FIDO MDS3 section 3.1.7): clients SHOULD refresh by
374
+ // nextUpdate; a BLOB whose nextUpdate is already in the past is not safe to
375
+ // trust even though its cert chain still validates. Flooring the staleness to
376
+ // MIN_CACHE_TTL_MS in _ttlFromNextUpdate is not enough on its own -- the BLOB
377
+ // itself must be refused, or an attacker serving an ancient signed-but-
378
+ // expired BLOB keeps operators pinned to a revoked-authenticator list frozen
379
+ // at that time. Refuse at parse time so neither fetch nor a cache lookup ever
380
+ // honors it.
377
381
  if (nextUpdate.getTime() < Date.now()) {
378
382
  throw new FidoMds3Error("fido-mds3/blob-stale",
379
383
  "BLOB payload nextUpdate \"" + payload.nextUpdate +
380
- "\" is in the past refusing to trust a stale metadata BLOB " +
381
- "(FIDO MDS3 §3.1.7)");
384
+ "\" is in the past -- refusing to trust a stale metadata BLOB " +
385
+ "(FIDO MDS3 section 3.1.7)");
382
386
  }
383
387
  return {
384
388
  entries: payload.entries,
@@ -388,6 +392,13 @@ function _verifyAndParseBlob(token) {
388
392
  };
389
393
  }
390
394
 
395
+ // Internal verify-blob helper used by tests to exercise the verifier against
396
+ // the DEFAULT vendored MDS3 trust roots without standing up a real HTTPS
397
+ // endpoint. Operator-facing surface goes through fetch().
398
+ function _verifyAndParseBlob(token) {
399
+ return _verifyBlobWithRoots(token, _resolveRoots(undefined));
400
+ }
401
+
391
402
  // ---- public surface ----
392
403
 
393
404
  /**
@@ -481,39 +492,22 @@ async function fetch(opts) { // allow:raw-outbound-http-framework-internal —
481
492
  }
482
493
  var token = rsp.body.toString("ascii").trim();
483
494
 
484
- var jws = _parseJws(token);
485
- var chain = _validateChain(jws.header.x5c, rootPems);
486
- _verifyJws(jws, chain[0]);
487
- var payload = jws.payload;
488
- if (!payload || !Array.isArray(payload.entries)) {
489
- throw new FidoMds3Error("fido-mds3/bad-payload",
490
- "BLOB payload missing 'entries' array");
491
- }
492
- if (typeof payload.no !== "number" || !isFinite(payload.no)) {
493
- throw new FidoMds3Error("fido-mds3/bad-payload",
494
- "BLOB payload missing or non-numeric 'no'");
495
- }
496
- var nextUpdate = _parseNextUpdate(payload.nextUpdate);
497
- if (!nextUpdate) {
498
- throw new FidoMds3Error("fido-mds3/bad-payload",
499
- "BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
500
- }
501
- var record = {
502
- entries: payload.entries,
503
- no: payload.no,
504
- nextUpdate: nextUpdate,
505
- url: url,
506
- legalHeader: payload.legalHeader,
507
- };
495
+ // Route the trust decision through the shared verifier so this operator-
496
+ // facing path enforces the identical checks (chain verify, payload shape,
497
+ // AND the stale-BLOB refusal) as the internal helper -- fetch previously
498
+ // reimplemented the parse inline and omitted the stale check, silently
499
+ // accepting and caching an ancient signed-but-expired BLOB.
500
+ var record = _verifyBlobWithRoots(token, rootPems);
501
+ record.url = url;
508
502
  // Re-assert TTL based on the BLOB's nextUpdate (overrides the
509
503
  // wrap-call's safe-minimum seed).
510
- try { await c.set(cacheKey, record, _ttlFromNextUpdate(nextUpdate)); }
504
+ try { await c.set(cacheKey, record, _ttlFromNextUpdate(record.nextUpdate)); }
511
505
  catch (_e) { /* cache.set best-effort */ }
512
506
  try { audit().safeEmit({
513
507
  action: "auth.fido_mds3.fetch",
514
508
  outcome: "success",
515
- metadata: { url: url, no: payload.no, entries: payload.entries.length,
516
- nextUpdate: payload.nextUpdate },
509
+ metadata: { url: url, no: record.no, entries: record.entries.length,
510
+ nextUpdate: record.nextUpdate.toISOString().slice(0, 10) },
517
511
  }); } catch (_e) { /* audit best-effort */ }
518
512
  return record;
519
513
  }, MIN_CACHE_TTL_MS);
@@ -577,18 +571,35 @@ function lookupAaguid(blob, aaguid) {
577
571
  function _certifiedLevel(statusReports) {
578
572
  if (!Array.isArray(statusReports)) return { level: 0, plus: false };
579
573
  var latest = null;
580
- var latestDate = null;
574
+ var latestDate = null; // null == no comparable date (missing / malformed)
581
575
  for (var i = 0; i < statusReports.length; i++) {
582
576
  var sr = statusReports[i];
583
577
  // Only certification-status reports move the level: a level grant, or its
584
578
  // explicit revocation (NOT_FIDO_CERTIFIED). Other statuses (UPDATE_AVAILABLE,
585
- // REVOKED, ) are handled elsewhere and must not be read as a level. The
586
- // status length is bounded before the regex test below FIDO status tokens
579
+ // REVOKED, etc.) are handled elsewhere and must not be read as a level. The
580
+ // status length is bounded before the regex test below -- FIDO status tokens
587
581
  // are short enums; bounding input before any .test() is the convention.
588
582
  if (!sr || typeof sr.status !== "string" || sr.status.length > 64) continue;
589
583
  if (!CERT_LEVEL_RE.test(sr.status) && sr.status !== "NOT_FIDO_CERTIFIED") continue;
590
- var d = typeof sr.effectiveDate === "string" ? sr.effectiveDate : "";
591
- if (latest === null || d >= latestDate) {
584
+ // Only a well-formed calendar date is comparable. Reuse _parseNextUpdate
585
+ // (which already validates YYYY-MM-DD AND rejects impossible dates such as
586
+ // 2026-02-31) and compare epoch ms. A missing / malformed effectiveDate is
587
+ // "no comparable date" (null) and falls back to array order (append order,
588
+ // which the spec defines as chronological): the report later in the array
589
+ // is the more recent one. Coercing a missing date to "" and running it
590
+ // through a lexical compare made an undated report lose to EVERY earlier
591
+ // dated report, so a later decertification / downgrade / upgrade whose
592
+ // effectiveDate was absent never superseded the earlier grant -- freezing
593
+ // certifiedLevel at a stale value (an authenticator-assurance bypass: a
594
+ // decertified or downgraded authenticator kept reporting its prior level to
595
+ // step-up / risk policy).
596
+ var parsed = typeof sr.effectiveDate === "string" ? _parseNextUpdate(sr.effectiveDate) : null;
597
+ var d = parsed ? parsed.getTime() : null;
598
+ if (latest === null) { latest = sr; latestDate = d; continue; }
599
+ // sr is later in array order than the current latest. A missing/malformed
600
+ // date on EITHER side means array order decides -> sr (later) wins. Both
601
+ // dated -> the newer-or-equal date wins (sr, being later, takes ties).
602
+ if (d === null || latestDate === null || d >= latestDate) {
592
603
  latest = sr; latestDate = d;
593
604
  }
594
605
  }
@@ -581,10 +581,95 @@ function decodeNumericEntities(s) {
581
581
  });
582
582
  }
583
583
 
584
+ // The HTML5 named-entity ASCII subset a browser resolves inside URL and CSS
585
+ // attribute contexts — the scheme/whitespace-significant characters an attacker
586
+ // hides a payload behind (`java&Tab;script:` / `behavior&colon;`). One table so
587
+ // guard-html / guard-svg / guard-markdown decode the SAME set and cannot drift
588
+ // (guard-markdown shipped without named-entity decoding at all, so `&Tab;` /
589
+ // `&NewLine;` slipped past its scheme denylist).
590
+ var NAMED_ENTITY_ASCII = {
591
+ // Whitespace + control chars browsers strip inside URL schemes
592
+ Tab: "\t", NewLine: "\n",
593
+ // Scheme-significant punctuation
594
+ colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
595
+ num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
596
+ lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
597
+ // Quotes / brackets
598
+ quot: "\"", apos: "'", lt: "<", gt: ">",
599
+ // Misc ASCII
600
+ amp: "&", commat: "@", dollar: "$", percnt: "%",
601
+ ast: "*", plus: "+", lowbar: "_", hyphen: "-",
602
+ // Latin-1 space browsers treat as URL-strippable
603
+ nbsp: " ",
604
+ };
605
+ var NAMED_ENTITY_RE_G = /&([A-Za-z][A-Za-z0-9]+);/g;
606
+
607
+ /**
608
+ * @primitive b.codepointClass.decodeMarkupEntities
609
+ * @signature b.codepointClass.decodeMarkupEntities(value)
610
+ * @since 0.16.19
611
+ * @status stable
612
+ * @related b.codepointClass.decodeNumericEntities, b.codepointClass.stripUrlSchemeWhitespace
613
+ *
614
+ * Decode the character references a browser resolves inside an attribute value
615
+ * -- numeric (hex/decimal, semicolon OPTIONAL) then the named-entity ASCII
616
+ * subset browsers honor in URL/CSS contexts -- and drop the C0 controls and
617
+ * zero-widths a payload hides behind. The single decoder every content guard
618
+ * routes a scheme / CSS-token danger check through, so a threat cannot slip
619
+ * past the guard that forgot to decode an encoding a sibling strips. Pair with
620
+ * `stripUrlSchemeWhitespace` for a URL-scheme check.
621
+ *
622
+ * @example
623
+ * b.codepointClass.decodeMarkupEntities("ex&#x70;ression("); // "expression("
624
+ * b.codepointClass.decodeMarkupEntities("behavior&colon;"); // "behavior:"
625
+ */
626
+ function decodeMarkupEntities(value) {
627
+ var s = decodeNumericEntities(String(value == null ? "" : value));
628
+ s = s.replace(NAMED_ENTITY_RE_G, function (m, name) {
629
+ if (Object.prototype.hasOwnProperty.call(NAMED_ENTITY_ASCII, name)) {
630
+ return NAMED_ENTITY_ASCII[name];
631
+ }
632
+ return m;
633
+ });
634
+ return s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
635
+ }
636
+
637
+ var URL_TAB_NEWLINE_RE_G = /[\t\n\r]/g; // URL tab/newline strip, not byte size
638
+ // allow:dynamic-regex -- codepoints from a literal [0x00, 0x20] range (C0 controls + space)
639
+ var URL_C0_SPACE_CC = charClass([[0x0000, 0x0020]]);
640
+ var URL_C0_SPACE_TRIM_RE = new RegExp("^(?:[" + URL_C0_SPACE_CC + "])+|(?:[" + URL_C0_SPACE_CC + "])+$", "g");
641
+ /**
642
+ * @primitive b.codepointClass.stripUrlSchemeWhitespace
643
+ * @signature b.codepointClass.stripUrlSchemeWhitespace(s)
644
+ * @since 0.16.19
645
+ * @status stable
646
+ * @related b.codepointClass.decodeMarkupEntities, b.codepointClass.decodeNumericEntities
647
+ *
648
+ * Fold away exactly the whitespace the WHATWG URL parser removes before it
649
+ * resolves a scheme: ASCII tab / LF / CR from ANYWHERE, plus a leading/trailing
650
+ * C0-control-or-space run. tab/lf/cr are excluded from the C0-control catalog
651
+ * and space is not a control, so a danger check that strips only C0/zero-width
652
+ * still lets `java<TAB>script:` or an entity-encoded leading space
653
+ * (`&#32;javascript:`) read as scheme-less. Run AFTER entity decoding; every
654
+ * guard that extracts a URL scheme for a denylist routes the decoded value
655
+ * through this.
656
+ *
657
+ * @example
658
+ * b.codepointClass.stripUrlSchemeWhitespace(" javascript:x"); // "javascript:x"
659
+ */
660
+ function stripUrlSchemeWhitespace(s) {
661
+ return String(s == null ? "" : s)
662
+ .replace(URL_TAB_NEWLINE_RE_G, "")
663
+ .replace(URL_C0_SPACE_TRIM_RE, "");
664
+ }
665
+
584
666
  module.exports = {
585
667
  isForbiddenControlChar: isForbiddenControlChar,
586
668
  firstControlCharOffset: firstControlCharOffset,
587
669
  decodeNumericEntities: decodeNumericEntities,
670
+ decodeMarkupEntities: decodeMarkupEntities,
671
+ NAMED_ENTITY_ASCII: NAMED_ENTITY_ASCII,
672
+ stripUrlSchemeWhitespace: stripUrlSchemeWhitespace,
588
673
  isAsciiAlnum: isAsciiAlnum,
589
674
  isUnreserved: isUnreserved,
590
675
  hex4: hex4,
@@ -89,6 +89,11 @@ var TYPED_SENTINEL = String.fromCharCode(0) + "bjsv1:";
89
89
 
90
90
  function _encodeTyped(value) {
91
91
  if (typeof value === "string") {
92
+ // "" encodes to a NON-empty typed marker so a sealed empty string is a real
93
+ // authenticated envelope, never a bare plaintext "": vault.aad.seal refuses
94
+ // empty plaintext, and a bare "" in a sealed cell is indistinguishable from a
95
+ // ciphertext a DB-write attacker downgraded to "".
96
+ if (value === "") return TYPED_SENTINEL + "E:";
92
97
  return value.indexOf(TYPED_SENTINEL) === 0 ? TYPED_SENTINEL + "S:" + value : value;
93
98
  }
94
99
  if (Buffer.isBuffer(value)) return TYPED_SENTINEL + "B:" + value.toString("base64");
@@ -102,6 +107,7 @@ function _decodeTyped(str) {
102
107
  var body = str.slice(TYPED_SENTINEL.length);
103
108
  var tag = body.slice(0, 2);
104
109
  var payload = body.slice(2);
110
+ if (tag === "E:") return "";
105
111
  if (tag === "B:") return Buffer.from(payload, "base64");
106
112
  if (tag === "J:") return safeJson.parse(payload); // plaintext is AEAD-verified; safeJson blocks proto-pollution defensively
107
113
  if (tag === "S:") return payload;
@@ -1071,6 +1077,11 @@ function sealRow(table, row, opts) {
1071
1077
  // - plain mode: vault.seal (idempotent — already-sealed pass through).
1072
1078
  for (var i = 0; i < s.sealedFields.length; i++) {
1073
1079
  var field = s.sealedFields[i];
1080
+ // Skip only null / undefined (nothing to seal). An empty string IS sealed:
1081
+ // _encodeTyped("") yields a non-empty typed marker (E:), so every branch
1082
+ // produces a real authenticated envelope -- never a bare plaintext "" that a
1083
+ // DB-write attacker could forge, or that a ciphertext could be silently
1084
+ // downgraded to (unsealRow fails closed on a bare "" in an AAD/keyed cell).
1074
1085
  if (out[field] === undefined || out[field] === null) continue;
1075
1086
  if (kRow && field === residencyCol) continue; // residency tag stays plaintext
1076
1087
  if (kRow) {
@@ -1216,6 +1227,27 @@ function unsealRow(table, row, actor, dbHandle) {
1216
1227
 
1217
1228
  for (var i = 0; i < s.sealedFields.length; i++) {
1218
1229
  var field = s.sealedFields[i];
1230
+ // Post-seal, a legitimately-empty sealed value is a NON-empty authenticated
1231
+ // envelope (see _encodeTyped "" -> E:), so a bare "" present in an AAD-bound
1232
+ // or per-row-key sealed column is an envelope-downgrade tamper -- a DB-write
1233
+ // attacker replacing a ciphertext with "". Fail closed (null the cell + audit),
1234
+ // mirroring the plain-vault aad-downgrade refusal below; never surface it as a
1235
+ // valid empty value. Plain-mode tables keep the lenient pass-through.
1236
+ if (out[field] === "" && (s.aad || keyedTable)) {
1237
+ out[field] = null;
1238
+ try {
1239
+ audit().safeEmit({
1240
+ action: "system.crypto.unseal_failed",
1241
+ outcome: "failure",
1242
+ metadata: {
1243
+ table: table, field: field, rowId: out[s.rowIdField] || out._id || null,
1244
+ shape: (s.aad ? "aad" : "row"),
1245
+ reason: "empty-string cell in a sealed AAD/keyed column (envelope downgrade)",
1246
+ },
1247
+ });
1248
+ } catch (_e) { /* drop-silent */ }
1249
+ continue;
1250
+ }
1219
1251
  if (out[field]) {
1220
1252
  // Per-cell envelope shape for audit metadata (operators write alert
1221
1253
  // rules off it): "row" = K_row cell, "aad" = vault.aad: cell on an
@@ -165,7 +165,8 @@ function _measureQueryShape(query) {
165
165
  var depth = 0;
166
166
  var inString = false;
167
167
  var inComment = false;
168
- var aliasCounts = [0]; // per-depth alias counter
168
+ var escapeNext = false; // inside a string, the previous char was an unescaped backslash
169
+ var aliasStack = [0]; // per-selection-set alias counter (top of stack = current set)
169
170
  for (var i = 0; i < query.length; i += 1) {
170
171
  var c = query.charAt(i);
171
172
  if (inComment) {
@@ -173,7 +174,18 @@ function _measureQueryShape(query) {
173
174
  continue;
174
175
  }
175
176
  if (inString) {
176
- if (c === '"' && query.charAt(i - 1) !== "\\") inString = false;
177
+ // Escaped-quote handling must track the backslash RUN, not just the
178
+ // single preceding char. `"\\"` is a complete string (its backslash is
179
+ // itself escaped), so a naive `charAt(i - 1) !== "\\"` test reads the
180
+ // closing quote as escaped, leaves the walker stuck in-string, and
181
+ // blinds it to every following brace / colon — silently under-counting
182
+ // depth and alias breadth so a depth-bomb or alias-bomb rides through as
183
+ // shape-clean (a fail-open DoS-measurement bypass on valid GraphQL).
184
+ // Toggle on each backslash: a closing quote ends the string only after
185
+ // an EVEN run (escapeNext false).
186
+ if (escapeNext) { escapeNext = false; continue; }
187
+ if (c === "\\") { escapeNext = true; continue; }
188
+ if (c === '"') inString = false;
177
189
  continue;
178
190
  }
179
191
  if (c === '"') { inString = true; continue; }
@@ -181,28 +193,35 @@ function _measureQueryShape(query) {
181
193
  if (c === "{") {
182
194
  depth += 1;
183
195
  if (depth > maxDepth) maxDepth = depth;
184
- aliasCounts.push(0);
196
+ aliasStack.push(0);
185
197
  } else if (c === "}") {
186
198
  // Capture the current selection-set's alias count before popping
187
199
  // — otherwise we lose the per-block max when the block closes.
188
- var current = aliasCounts[aliasCounts.length - 1] || 0;
200
+ var current = aliasStack[aliasStack.length - 1] || 0;
189
201
  if (current > maxAliases) maxAliases = current;
190
202
  depth -= 1;
191
- aliasCounts.pop();
203
+ // Never pop the base counter: an unbalanced leading `}` would otherwise
204
+ // desync the stack from `depth`, and a subsequent `depth`-indexed
205
+ // increment lands on an absent slot (`undefined + 1 === NaN`), poisoning
206
+ // every later comparison so an alias-bomb reads as clean.
207
+ if (aliasStack.length > 1) aliasStack.pop();
192
208
  if (depth < 0) depth = 0;
193
209
  } else if (c === ":") {
194
- // Alias indicator — `alias: field`. Increment the current depth's
195
- // counter when the char before `:` looks like an identifier.
210
+ // Alias / argument indicator — `alias: field` / `field(arg: value)`.
211
+ // Increment the CURRENT selection-set's counter (top of stack) when the
212
+ // char before `:` looks like an identifier. Using the stack top rather
213
+ // than a `depth` index keeps counting correct even when a malformed
214
+ // brace run has decoupled `depth` from the stack length.
196
215
  var prev = i > 0 ? query.charAt(i - 1) : "";
197
- if (/[A-Za-z0-9_]/.test(prev) && depth > 0) {
198
- aliasCounts[depth] += 1;
216
+ if (/[A-Za-z0-9_]/.test(prev) && aliasStack.length > 1) {
217
+ aliasStack[aliasStack.length - 1] += 1;
199
218
  }
200
219
  }
201
220
  }
202
221
  // Final sweep covers any unclosed selection-sets (operator-supplied
203
222
  // syntactically-invalid queries).
204
- for (var ai = 0; ai < aliasCounts.length; ai += 1) {
205
- if (aliasCounts[ai] > maxAliases) maxAliases = aliasCounts[ai];
223
+ for (var ai = 0; ai < aliasStack.length; ai += 1) {
224
+ if (aliasStack[ai] > maxAliases) maxAliases = aliasStack[ai];
206
225
  }
207
226
  return { maxDepth: maxDepth, maxAliases: maxAliases };
208
227
  }
package/lib/guard-html.js CHANGED
@@ -110,11 +110,6 @@ void observability;
110
110
 
111
111
  var _err = GuardHtmlError.factory;
112
112
 
113
- // ---- Codepoint catalog (shared via lib/codepoint-class) ----
114
-
115
- var C0_CTRL_RE_G = codepointClass.C0_CTRL_RE_G;
116
- var ZW_RE_G = codepointClass.ZW_RE_G;
117
-
118
113
  // ---- Tag denylists / allowlists ----
119
114
 
120
115
  // Always-dangerous tags. Active scripts, plugin embeds, form elements,
@@ -362,54 +357,21 @@ function escapeAttr(value) {
362
357
  .replace(/=/g, "&#61;");
363
358
  }
364
359
 
365
- // HTML5 named entities that decode to ASCII codepoints focused on
366
- // the entries browsers honor inside URL contexts (whitespace, control
367
- // chars, scheme-significant punctuation). The full WHATWG named-
368
- // character-reference table is ~2,231 entries; this is the
369
- // security-load-bearing subset documented in scheme-bypass writeups
370
- // (CVE-2026-30838 class). High-codepoint named entities (e.g. mathematical
371
- // symbols) don't affect URL scheme parsing, so they're omitted.
372
- var NAMED_ENTITY_ASCII = {
373
- // Whitespace + control chars browsers strip inside URL schemes
374
- Tab: "\t", NewLine: "\n",
375
- // Scheme-significant punctuation
376
- colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
377
- num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
378
- lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
379
- // Quotes / brackets
380
- quot: "\"", apos: "'", lt: "<", gt: ">",
381
- // Misc ASCII
382
- amp: "&", commat: "@", dollar: "$", percnt: "%",
383
- ast: "*", plus: "+", lowbar: "_", hyphen: "-",
384
- // Whitespace markers (codepoints in the ASCII / Latin-1 range that
385
- // browsers treat as URL-strippable)
386
- nbsp: " ",
387
- };
388
-
389
- // _normalizeUrl — peel off entity-encoded leading whitespace and
390
- // HTML/URL-encoded scheme prefix tricks, then return the lowercased
391
- // scheme. Returns "" if no scheme.
360
+ // The named-entity ASCII table + entity decoder now live in codepoint-class
361
+ // (NAMED_ENTITY_ASCII / decodeMarkupEntities), shared with guard-svg /
362
+ // guard-markdown so all three decode the SAME browser-honored set and cannot
363
+ // drift on which encoding a scheme / CSS-token danger check must fold away.
364
+ // _extractScheme decode entity-hidden scheme-prefix tricks, fold away the
365
+ // whitespace the WHATWG URL parser would (tab/lf/cr anywhere + a leading/
366
+ // trailing C0-control-or-space run none of which the C0/zero-width strip
367
+ // covers: tab/lf/cr are excluded from C0_CTRL_RANGES and space is not a
368
+ // control), then return the lowercased scheme ("" if none). The shared
369
+ // codepoint-class primitive keeps guard-html / guard-svg from drifting on which
370
+ // whitespace to strip, so neither `java<TAB>script:` nor `&#32;javascript:` can
371
+ // read as scheme-less.
392
372
  function _extractScheme(rawUrl) {
393
- var s = String(rawUrl || "").trim();
394
- // Decode HTML numeric entities (hex &#x..; and decimal &#..;, semicolon
395
- // OPTIONAL) just enough to expose hidden schemes like &#x6A;avascript: or
396
- // the browser-decoded no-semicolon form &#106avascript:. Shared decoder so
397
- // guard-html / guard-svg / guard-markdown can't drift (see codepoint-class).
398
- s = codepointClass.decodeNumericEntities(s);
399
- // Decode HTML5 named entities that browsers honor inside URL
400
- // contexts. Without this, payloads like `java&Tab;script:alert(1)`
401
- // bypass the scheme allowlist (the literal `&Tab;` between `java`
402
- // and `script:` doesn't match any denied scheme; the browser then
403
- // decodes the entity, strips the tab, and executes javascript:).
404
- s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
405
- if (Object.prototype.hasOwnProperty.call(NAMED_ENTITY_ASCII, name)) {
406
- return NAMED_ENTITY_ASCII[name];
407
- }
408
- return m;
409
- });
410
- // Strip embedded whitespace + control chars + zero-widths the
411
- // URL parser would tolerate.
412
- s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
373
+ var s = codepointClass.stripUrlSchemeWhitespace(
374
+ codepointClass.decodeMarkupEntities(String(rawUrl || "").trim()));
413
375
  var m = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
414
376
  return m ? m[1].toLowerCase() : "";
415
377
  }
@@ -443,8 +405,19 @@ function _isClobberGlobal(name) {
443
405
  }
444
406
 
445
407
  function _isCssDangerous(value) {
408
+ // A style attribute is HTML/XML character-reference-decoded before the CSS
409
+ // parser sees it, so `ex&#x70;ression(` reaches CSS as `expression(` and
410
+ // `behavior&colon;` as `behavior:`. Match against the decoded value — the same
411
+ // normalization the URL-scheme check performs — so an entity-encoded CSS
412
+ // payload can't be served verbatim past the danger patterns.
413
+ // Also fold the URL-scheme whitespace a browser strips inside url(...) -- tab /
414
+ // lf / cr -- so an entity-hidden tab in a CSS URL scheme (url(java&Tab;script:))
415
+ // cannot defeat the contiguous `javascript:` danger pattern, the same bypass the
416
+ // URL-scheme check folds away for href.
417
+ var decoded = codepointClass.stripUrlSchemeWhitespace(
418
+ codepointClass.decodeMarkupEntities(value));
446
419
  for (var i = 0; i < CSS_DANGEROUS_PATTERNS.length; i += 1) {
447
- if (CSS_DANGEROUS_PATTERNS[i].test(value)) return true;
420
+ if (CSS_DANGEROUS_PATTERNS[i].test(decoded)) return true;
448
421
  }
449
422
  return false;
450
423
  }
@@ -87,8 +87,6 @@ var INLINE_LINK_RE = /(!?)\[([^\]\n]*)\]\(\s*([^)\s]+)\s*(?:"[^"]*")?\s*\)/g;
87
87
  var AUTOLINK_RE = /<((?:[a-zA-Z][a-zA-Z0-9+.-]{0,32}):[^\s>]+)>/g;
88
88
  // Reference-link definition `[label]: url "title"`.
89
89
  var REF_DEF_RE = /^\s{0,3}\[([^\]\n]+)\]:\s*([^\s]+)/gm;
90
- // HTML entity hex / decimal scheme bypass — decode and re-test.
91
- var HTML_ENTITY_NUM_RE = /&#(?:x([0-9a-f]+)|(\d+));?/gi;
92
90
 
93
91
  // Front-matter block (YAML triple-dash or TOML triple-plus).
94
92
  var FRONT_MATTER_YAML_RE = /^---\s*\n[\s\S]+?\n---\s*\n?/;
@@ -99,31 +97,20 @@ var DOCTYPE_INLINE_RE = /<!DOCTYPE\b/i;
99
97
  var CODE_FENCE_LANG_RE = /^(?:```|~~~)([^\n]*)\n/gm;
100
98
  var EMPH_RUN_RE = /[*_]{20,}/; // allow:regex-no-length-cap — character-class repeat is linear in input length
101
99
 
102
- function _decodeHtmlEntities(s) {
103
- return s.replace(HTML_ENTITY_NUM_RE, function (match, hex, dec) {
104
- var code = hex !== undefined ? parseInt(hex, 16) : parseInt(dec, 10); // parseInt radix args (16 hex / 10 decimal)
105
- if (!isFinite(code) || code < 0 || code > 0x10ffff) return match; // Unicode codepoint range
106
- try { return String.fromCodePoint(code); } catch (_e) { return match; }
107
- });
108
- }
109
100
 
110
101
  function _isDangerousUrl(url, opts) {
111
102
  if (typeof url !== "string") return null;
112
- var s = url.trim();
113
- s = _decodeHtmlEntities(s);
114
- // Strip null + ASCII control chars from the URL ` javascript:` works
115
- // in some browsers because the leading control bytes are tolerated.
116
- // Char-by-char filter avoids the no-control-regex lint surface; the
117
- // codepoint catalog (< 0x20 or 0x7F) is the same shape as the
118
- // codepointClass tables.
119
- var stripped = "";
120
- for (var ci = 0; ci < s.length; ci += 1) {
121
- var cc = s.charCodeAt(ci);
122
- if (cc > 0x1f && cc !== 0x7f) stripped += s.charAt(ci); // ASCII control range thresholds
123
- }
124
- s = stripped;
125
- if (DANGEROUS_SCHEME_RE.test(s)) return s.match(/^[a-z]+/i)[0].toLowerCase(); // allow:regex-no-length-cap — `s` is a markdown URL token already bounded by the inline-link / autolink / ref-def matchers (which themselves run on input bounded by maxBytes)
126
- if (FILE_SCHEME_RE.test(s) && opts.filePolicy !== "allow") return "file"; // allow:regex-no-length-cap — same bounded-URL-token reasoning
103
+ // Decode the entity-hidden scheme tricks a browser resolves -- numeric AND the
104
+ // named-entity ASCII subset (guard-markdown previously decoded numeric only, so
105
+ // `java&Tab;script:` / a `&colon;`-hidden scheme slipped past) -- then fold away
106
+ // the whitespace the WHATWG URL parser strips (tab/lf/cr anywhere + a leading/
107
+ // trailing C0-control-or-space run, so `&#32;javascript:` -> " javascript:" can't
108
+ // defeat the `^scheme:` anchor). Shared codepoint-class primitives keep
109
+ // guard-markdown / guard-html / guard-svg from drifting on which encodings to fold.
110
+ var s = codepointClass.stripUrlSchemeWhitespace(
111
+ codepointClass.decodeMarkupEntities(url.trim()));
112
+ if (DANGEROUS_SCHEME_RE.test(s)) return s.match(/^[a-z]+/i)[0].toLowerCase(); // allow:regex-no-length-cap -- `s` is a markdown URL token already bounded by the inline-link / autolink / ref-def matchers (which themselves run on input bounded by maxBytes)
113
+ if (FILE_SCHEME_RE.test(s) && opts.filePolicy !== "allow") return "file"; // allow:regex-no-length-cap -- same bounded-URL-token reasoning
127
114
  return null;
128
115
  }
129
116
 
package/lib/guard-svg.js CHANGED
@@ -122,11 +122,6 @@ void observability;
122
122
 
123
123
  var _err = GuardSvgError.factory;
124
124
 
125
- // ---- Codepoint catalog (shared via lib/codepoint-class) ----
126
-
127
- var C0_CTRL_RE_G = codepointClass.C0_CTRL_RE_G;
128
- var ZW_RE_G = codepointClass.ZW_RE_G;
129
-
130
125
  // ---- Tag classification ----
131
126
 
132
127
  // Always-dangerous SVG tags. Active scripts, namespace-shift escape
@@ -314,32 +309,20 @@ var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256
314
309
  // ---- Internal helpers ----
315
310
 
316
311
 
317
- // HTML5 named-entity ASCII subset same shape as guard-html.
318
- // Browsers honor these inside URL contexts; without decoding them,
319
- // `java&Tab;script:` and friends bypass the scheme allowlist.
320
- var SVG_NAMED_ENTITY_ASCII = {
321
- Tab: "\t", NewLine: "\n",
322
- colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
323
- num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
324
- lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
325
- quot: "\"", apos: "'", lt: "<", gt: ">",
326
- amp: "&", commat: "@", dollar: "$", percnt: "%",
327
- ast: "*", plus: "+", lowbar: "_", hyphen: "-",
328
- nbsp: " ",
329
- };
312
+ // The named-entity ASCII table + entity decoder live in codepoint-class
313
+ // (NAMED_ENTITY_ASCII / decodeMarkupEntities), shared with guard-html /
314
+ // guard-markdown so all three decode the SAME browser-honored set and cannot
315
+ // drift on which encoding a scheme / CSS-token danger check must fold away.
330
316
 
331
317
  function _extractScheme(rawUrl) {
332
- var s = String(rawUrl || "").trim();
333
- // Numeric entities (hex/decimal, semicolon OPTIONAL) via the shared decoder
334
- // so guard-html / guard-svg / guard-markdown can't drift (see codepoint-class).
335
- s = codepointClass.decodeNumericEntities(s);
336
- s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
337
- if (Object.prototype.hasOwnProperty.call(SVG_NAMED_ENTITY_ASCII, name)) {
338
- return SVG_NAMED_ENTITY_ASCII[name];
339
- }
340
- return m;
341
- });
342
- s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
318
+ // Decode entities, then fold away exactly the whitespace the WHATWG URL parser
319
+ // would (tab/lf/cr anywhere + a leading/trailing C0-control-or-space run) via
320
+ // the shared codepoint-class primitive, so an entity-hidden tab or leading
321
+ // space (`java&Tab;script:` / `&#32;javascript:`) can't push the scheme past
322
+ // the `^[A-Za-z]` anchor and read as scheme-less. Shared with guard-html so
323
+ // the two can't drift on which whitespace to strip.
324
+ var s = codepointClass.stripUrlSchemeWhitespace(
325
+ codepointClass.decodeMarkupEntities(String(rawUrl || "").trim()));
343
326
  var m = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
344
327
  return m ? m[1].toLowerCase() : "";
345
328
  }
@@ -355,8 +338,19 @@ function _isFragmentRef(rawUrl) {
355
338
  }
356
339
 
357
340
  function _isCssDangerous(value) {
341
+ // A style attribute is HTML/XML character-reference-decoded before the CSS
342
+ // parser sees it, so `ex&#x70;ression(` reaches CSS as `expression(` and
343
+ // `behavior&colon;` as `behavior:`. Match against the decoded value — the
344
+ // same normalization the URL-scheme check performs — so an entity-encoded
345
+ // CSS payload can't be served verbatim past the danger patterns.
346
+ // Also fold the URL-scheme whitespace a browser strips inside url(...) -- tab /
347
+ // lf / cr -- so an entity-hidden tab in a CSS URL scheme (url(java&Tab;script:))
348
+ // cannot defeat the contiguous `javascript:` danger pattern, the same bypass the
349
+ // URL-scheme check folds away for href.
350
+ var decoded = codepointClass.stripUrlSchemeWhitespace(
351
+ codepointClass.decodeMarkupEntities(value));
358
352
  for (var i = 0; i < CSS_DANGEROUS_PATTERNS.length; i += 1) {
359
- if (CSS_DANGEROUS_PATTERNS[i].test(value)) return true;
353
+ if (CSS_DANGEROUS_PATTERNS[i].test(decoded)) return true;
360
354
  }
361
355
  return false;
362
356
  }
@@ -782,7 +776,12 @@ function _sanitize(input, opts) {
782
776
  }
783
777
  var allowed = !dangerousTags[tok.name] && allowedTags[tok.name];
784
778
  if (animationTags[tok.name] && opts.allowAnimation && allowedTags[tok.name]) {
785
- // Animation element re-check attributeName.
779
+ // Animation element under a profile that permits animation: allowed iff
780
+ // its attributeName is in the safe-targets allowlist. Every animation tag
781
+ // is in DANGEROUS_TAGS, so `allowed` is already false above — this branch
782
+ // must AFFIRMATIVELY re-permit the safe case, else the open tag is dropped
783
+ // while its (still-allowlisted) end tag is emitted, leaving an orphan
784
+ // close and silently stripping a profile-permitted element.
786
785
  var safeAnimation = true;
787
786
  (tok.attrs || []).forEach(function (a) {
788
787
  if (a.name.toLowerCase() === "attributename" &&
@@ -790,7 +789,7 @@ function _sanitize(input, opts) {
790
789
  safeAnimation = false;
791
790
  }
792
791
  });
793
- if (!safeAnimation) allowed = false;
792
+ allowed = safeAnimation;
794
793
  }
795
794
  if (!allowed) {
796
795
  if (BODY_DROP[tok.name] && !tok.selfClosing) {
package/lib/safe-dns.js CHANGED
@@ -357,14 +357,24 @@ function checkCnameChainDepth(depth, opts) {
357
357
  }
358
358
  }
359
359
 
360
- function _readName(state, pointerDepth) {
360
+ function _readName(state, pointerDepth, acc) {
361
361
  if (pointerDepth > state.caps.maxPointerDepth) {
362
362
  throw new SafeDnsError("safe-dns/oversize-pointer-depth",
363
363
  "safeDns.readName: compression-pointer chain depth=" + pointerDepth +
364
364
  " exceeds maxPointerDepth=" + state.caps.maxPointerDepth + " (RFC 1035 §4.1.4 loop defense)");
365
365
  }
366
+ // Both per-name accountants — the decompressed octet count and the label
367
+ // count — thread through the WHOLE compression-pointer chain via `acc`. A
368
+ // pointer jump continues the same budget instead of restarting it, so the
369
+ // composite name a chain expands to is bounded by the same RFC 1035 §3.1
370
+ // 255-octet / §2.3.4 maxLabels caps that bound a single in-line name. When
371
+ // the accountants were frame-local, each jump began a fresh 255-octet /
372
+ // maxLabels budget, so a chain of N pointers decompressed to ~N x 255 octets
373
+ // and ~N x maxLabels labels past the advertised per-name caps — a
374
+ // decompression-amplification seam. `acc` is undefined for a top-level name
375
+ // (each is measured independently) and shared with every sub-read.
376
+ if (!acc) acc = { bytes: 0, labels: 0 };
366
377
  var labels = [];
367
- var totalBytes = 0;
368
378
  var jumped = false;
369
379
  var afterPointerOff = -1;
370
380
  var off = state.off;
@@ -376,10 +386,10 @@ function _readName(state, pointerDepth) {
376
386
  var byte = state.buf[off];
377
387
  if (byte === 0) {
378
388
  off += 1;
379
- totalBytes += 1;
380
- if (totalBytes > DNS_MAX_NAME_BYTES) {
389
+ acc.bytes += 1;
390
+ if (acc.bytes > DNS_MAX_NAME_BYTES) {
381
391
  throw new SafeDnsError("safe-dns/oversize-name",
382
- "safeDns.readName: wire-name=" + totalBytes + " bytes exceeds RFC 1035 cap=" +
392
+ "safeDns.readName: wire-name=" + acc.bytes + " bytes exceeds RFC 1035 cap=" +
383
393
  DNS_MAX_NAME_BYTES);
384
394
  }
385
395
  break;
@@ -394,20 +404,14 @@ function _readName(state, pointerDepth) {
394
404
  throw new SafeDnsError("safe-dns/truncated-name",
395
405
  "safeDns.readName: compression pointer offset past message end");
396
406
  }
397
- // First compression pointer ends the in-line label walk
398
- // (line break below). `jumped` can never already be true here;
399
- // assign unconditionally per Codex code-quality review.
407
+ // First compression pointer ends the in-line label walk. The pointed-to
408
+ // tail continues accumulating into the SAME `acc`, so the composite
409
+ // decompressed name stays bounded by the per-name octet + label caps.
400
410
  afterPointerOff = off + 2; // RFC 1035 §4.1.4 2-byte pointer width
401
411
  jumped = true;
402
412
  var subState = { off: ptrOff, buf: state.buf, caps: state.caps };
403
- var tailName = _readName(subState, pointerDepth + 1);
413
+ var tailName = _readName(subState, pointerDepth + 1, acc);
404
414
  if (tailName.length) labels.push(tailName);
405
- totalBytes += 2; // RFC 1035 §4.1.4 2-byte pointer width
406
- if (totalBytes > DNS_MAX_NAME_BYTES) {
407
- throw new SafeDnsError("safe-dns/oversize-name",
408
- "safeDns.readName: composite name=" + totalBytes + " bytes exceeds RFC 1035 cap=" +
409
- DNS_MAX_NAME_BYTES);
410
- }
411
415
  break;
412
416
  }
413
417
  if (byte > DNS_MAX_LABEL_BYTES) {
@@ -419,15 +423,16 @@ function _readName(state, pointerDepth) {
419
423
  "safeDns.readName: label content past message end");
420
424
  }
421
425
  labels.push(state.buf.toString("ascii", off + 1, off + 1 + byte));
422
- if (labels.length > state.caps.maxLabels) {
426
+ acc.labels += 1;
427
+ if (acc.labels > state.caps.maxLabels) {
423
428
  throw new SafeDnsError("safe-dns/oversize-labels",
424
- "safeDns.readName: label count=" + labels.length + " exceeds maxLabels=" + state.caps.maxLabels);
429
+ "safeDns.readName: label count=" + acc.labels + " exceeds maxLabels=" + state.caps.maxLabels);
425
430
  }
426
431
  off += 1 + byte;
427
- totalBytes += 1 + byte;
428
- if (totalBytes > DNS_MAX_NAME_BYTES) {
432
+ acc.bytes += 1 + byte;
433
+ if (acc.bytes > DNS_MAX_NAME_BYTES) {
429
434
  throw new SafeDnsError("safe-dns/oversize-name",
430
- "safeDns.readName: wire-name=" + totalBytes + " bytes exceeds RFC 1035 cap=" +
435
+ "safeDns.readName: wire-name=" + acc.bytes + " bytes exceeds RFC 1035 cap=" +
431
436
  DNS_MAX_NAME_BYTES);
432
437
  }
433
438
  }
@@ -378,6 +378,15 @@ async function poll(opts) {
378
378
  allowedHosts: opts.allowedHosts,
379
379
  allowedProtocols: opts.allowedProtocols,
380
380
  allowInternal: opts.allowInternal,
381
+ // poll() owns status handling — the branches below distinguish a 304
382
+ // If-None-Match "fast no-update" hit from a real non-2xx refusal and a
383
+ // 2xx feed to parse. Without always-resolve, httpClient.request rejects
384
+ // EVERY non-2xx (304 included) as HTTP_ERROR before poll can inspect
385
+ // res.statusCode, which made the documented conditional-poll fast-path
386
+ // and the selfupdate/poll-non-2xx branch dead code — a conditional poll
387
+ // that correctly received a 304 threw selfupdate/poll-failed instead of
388
+ // reporting "no update".
389
+ responseMode: "always-resolve",
381
390
  errorClass: SelfUpdateError,
382
391
  });
383
392
  } catch (e) {
@@ -630,6 +639,15 @@ function _validateSwapOpts(opts, label) {
630
639
  "selfUpdate.swap: opts.hashAlgo must be one of " + ALLOWED_HASH_ALGS.join(", "));
631
640
  }
632
641
  };
642
+ // swap re-reads the from-bytes to re-hash them (closing the verify->swap
643
+ // window); its cap must be declarable so it matches the maxBytes an
644
+ // operator passed to selfUpdate.verify for the same asset — otherwise swap
645
+ // would refuse a large binary that verify accepted. Optional; defaults to
646
+ // the same C.BYTES.gib(1) cap the body applies.
647
+ schema.maxBytes = function (value) {
648
+ numericBounds.requirePositiveFiniteIntIfPresent(value,
649
+ "selfUpdate.swap: opts.maxBytes", SelfUpdateError, "selfupdate/bad-max-bytes");
650
+ };
633
651
  }
634
652
  schema.to = { rule: "required-string", code: "selfupdate/bad-to",
635
653
  label: "selfUpdate." + label + ": opts.to" };
@@ -696,6 +714,8 @@ async function _safeRollback(backupTo, to, hadOriginal) {
696
714
  * backupTo: string, // required — backup path for the existing `to`
697
715
  * expectedHash: string, // required — the hash selfUpdate.verify returned
698
716
  * hashAlgo: string, // sha3-512 (default) | sha-256 | sha-512 | shake256
717
+ * maxBytes: number, // from-bytes re-hash cap (default 1 GiB) — set to
718
+ * // the same value passed to selfUpdate.verify
699
719
  *
700
720
  * @example
701
721
  * var v = await b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.17",
3
+ "version": "0.16.19",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:3e3c1217-5f8b-4f16-b9f5-bea246e83437",
5
+ "serialNumber": "urn:uuid:eb1100d2-6fe5-42ed-8c45-8ecc67a610fa",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-12T06:26:29.484Z",
8
+ "timestamp": "2026-07-12T11:39:25.071Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.16.17",
22
+ "bom-ref": "@blamejs/core@0.16.19",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.17",
25
+ "version": "0.16.19",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.16.17",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.19",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.16.17",
57
+ "ref": "@blamejs/core@0.16.19",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]