@blamejs/core 0.15.33 → 0.15.35

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.15.x
10
10
 
11
+ - v0.15.35 (2026-06-26) — **`b.metrics` shadow registry no longer lets a comma in a label value forge extra Prometheus label pairs — a label-injection that downstream tenant-scoping or authorization selectors could be tricked by.** The shadow registry serialized a metric's label set into a `name=value,name=value` string key and re-split it on `,` to build the Prometheus exposition. A label VALUE containing a comma therefore split into multiple label pairs, so a single operator-set label could forge additional label pairs in the scrape output (for example turning one `route` label into a `route` plus an attacker-named label), which downstream tenant-scoping filters, authorization selectors, recording rules, and alerting trust as a boundary; two distinct label sets could also collide into one cardinality bucket. The label key now uses canonical-JSON of the string-coerced label set (the same approach the main registry already used) and the render path emits the structured label set kept alongside that key rather than splitting or re-parsing, so a `,` or `=` inside a label value stays inside the value and a label NAMED `constructor`, `prototype`, or `__proto__` (all valid Prometheus label names) is preserved instead of being dropped. Note: the cardinality keys exposed by `shadowRegistry().snapshot()` are now canonical-JSON strings (e.g. `{"route":"/api"}`) rather than `route=/api`. **Changed:** *shadowRegistry().snapshot() cardinality keys are canonical-JSON* — As a consequence of the label-injection fix, the per-series cardinality keys in a shadow-registry snapshot are now canonical-JSON strings (e.g. `{"tenant":"a"}`) instead of the previous `tenant=a` form. Code that reads those keys directly from snapshot() should parse them as JSON; the Prometheus render output is unchanged for conforming label values. **Security:** *Label values can no longer forge extra Prometheus label pairs in the shadow registry* — b.metrics shadowRegistry built a metric's cardinality key by joining `name=value` pairs with commas, then split that string on commas when rendering the Prometheus exposition. A comma (and `=`) in a label value therefore broke one value into several forged label pairs in the scrape output, and two different label sets could collide into the same cardinality bucket. The key is now canonical-JSON of the string-coerced label set and the render emits the structured label set kept alongside that key rather than splitting or re-parsing, so a label value is emitted as a single quoted value with its commas/equals intact, distinct label sets get distinct keys, and a label NAMED `constructor`, `prototype`, or `__proto__` (each a valid Prometheus label name) is preserved rather than dropped. Deterministic regression tests assert a comma-bearing label value produces exactly one label pair and that the reserved-name labels survive render.
12
+
13
+ - v0.15.34 (2026-06-26) — **`b.mail.crypto.pgp.verify` now accepts every valid RSA OpenPGP signature — one whose value happened to have a high zero byte (about 1 in 256) was being rejected.** An OpenPGP RSA signature is an integer modulo the key's modulus, and ~1 in 256 signatures have a value that begins with a zero byte. The MPI encoding strips those leading zero bytes (RFC 9580 §3.2), but b.mail.crypto.pgp.verify passed the stripped value straight to the RSA verification, which requires a signature exactly the modulus byte length — so a perfectly valid signature was intermittently reported invalid. The signature is now left-padded back to the modulus width before verification, exactly as the Ed25519 path already pads its components. Verification of every valid RSA signature is now reliable, including signatures produced by other OpenPGP implementations. Also includes internal test-tooling: two more guard-suite suppression classes were re-verified and their tokens retired. **Fixed:** *RSA OpenPGP signatures with a high zero byte now verify reliably* — b.mail.crypto.pgp.verify read the RSA signature MPI (whose leading zero bytes the OpenPGP wire format strips) and handed it to the RSA verification without restoring the stripped bytes. Node's RSA verify requires the signature to be exactly the modulus byte length, so a signature whose value had one or more high zero bytes — about 1 in 256 of all signatures, for any key — was rejected as invalid even though it was correct. The signature is now left-padded to the modulus width before verification (the same correction the Ed25519 verification path already applied to its R/S components). This affects signatures the framework produces and signatures from other OpenPGP implementations alike. A deterministic regression test searches for a short-MPI signature and asserts it verifies. **Detectors:** *Two more suppression-marker classes re-verified and their tokens retired* — The raw-hash-compare and seal-without-aad guard classes were re-read and confirmed (a data-residency region tag compared with === — not a secret; and two intentional non-AEAD-bound seals — a non-regulated plain-mode column whose AAD is enforced by the posture seal-envelope floor where it matters, and a throwaway vault-readiness probe), then renamed to descriptive tokens with their old names added to the retired-token set. Test-suite tooling only.
14
+
11
15
  - v0.15.33 (2026-06-26) — **Internal test-suite hardening only — the published library's runtime behavior and public API are unchanged (source-comment marker text aside).** Three more suppression classes in the codebase-patterns guard suite were re-verified from scratch and renamed to descriptive tokens, with their old names recorded as retired and their in-source marker comments updated to match. No runtime code, public API, or wire format changed. **Detectors:** *Three more suppression-marker classes re-verified and their tokens retired* — Continuing the re-verification pass: each marked site for the math-random-noncrypto, raw-new-url, and dynamic-require guard classes was re-read and confirmed (non-security jitter/sampling; URL parsing for shape/origin inspection or behind the safe wrapper; operator-supplied module loads), then the class was renamed to a descriptive token and its old name added to the retired-token set. This is test-suite tooling; no shipped framework behavior changed.
12
16
 
13
17
  - v0.15.32 (2026-06-26) — **Internal test-suite hardening only — the published library's runtime behavior and public API are unchanged (source-comment marker text aside).** Three more suppression classes in the codebase-patterns guard suite were re-verified from scratch and renamed to descriptive tokens, with their old names recorded as retired so the prior approval cannot be silently resurrected and their in-source marker comments updated to match. No runtime code, public API, or wire format changed. **Detectors:** *Three more suppression-marker classes re-verified and their tokens retired* — Continuing the re-verification pass: each marked site for the process-exit, hand-rolled buffer-collect, and raw-outbound-http guard classes was re-read and confirmed (operator-opt-in exits; bounded protocol-framing / TLV assembly; framework-routed outbound calls), then the class was renamed to a descriptive token and its old name added to the retired-token set. This is test-suite tooling; no shipped framework behavior changed.
@@ -1090,7 +1090,7 @@ function sealRow(table, row, opts) {
1090
1090
  out[field] = vaultAad.seal(_encodeTyped(out[field]),
1091
1091
  _aadParts(s, table, field, out));
1092
1092
  } else {
1093
- // allow:seal-without-aad — plain-mode legacy table; operator
1093
+ // allow:seal-without-aad-by-design — plain-mode legacy table; operator
1094
1094
  // opts into AAD via registerTable({aad:true})
1095
1095
  out[field] = vault.seal(_encodeTyped(out[field]));
1096
1096
  }
@@ -1709,7 +1709,7 @@ function _crossBorderRegulated(posture) {
1709
1709
 
1710
1710
  function _residencyCompatible(primaryTag, replicaTag) {
1711
1711
  if (!primaryTag || !replicaTag) return true;
1712
- if (primaryTag === replicaTag) return true; // allow:raw-hash-compare — residency tag string, not a secret hash
1712
+ if (primaryTag === replicaTag) return true; // allow:raw-hash-compare-nonsecret-tag — residency tag string, not a secret hash
1713
1713
  if (primaryTag === "unrestricted" || replicaTag === "unrestricted") return true;
1714
1714
  return false;
1715
1715
  }
@@ -865,11 +865,22 @@ function verify(opts) {
865
865
  var ok;
866
866
  if (parsed.pubAlg === PUB_ALG_RSA) {
867
867
  var rsaMpi = _readMpi(parsed.sigMpisBytes, 0);
868
+ // The signature is an integer in [0, n); when its value has one or more
869
+ // high zero bytes (~1/256 of signatures) the OpenPGP MPI encoding strips
870
+ // them (RFC 9580 §3.2), but node's RSA verify requires a signature exactly
871
+ // the modulus byte length. Left-pad the stripped MPI back to the modulus
872
+ // width — the same correction the Ed25519 branch applies to its R/S
873
+ // components below — or a valid signature is rejected.
874
+ var rsaSigBytes = rsaMpi.value;
875
+ var modLen = rsaPub.n.length;
876
+ if (rsaSigBytes.length < modLen) {
877
+ rsaSigBytes = Buffer.concat([Buffer.alloc(modLen - rsaSigBytes.length), rsaSigBytes]);
878
+ }
868
879
  try {
869
880
  ok = nodeCrypto.verify(hashName, hashInput, {
870
881
  key: publicKey,
871
882
  padding: nodeCrypto.constants.RSA_PKCS1_PADDING,
872
- }, rsaMpi.value);
883
+ }, rsaSigBytes);
873
884
  } catch (e) {
874
885
  return _fail("mail-crypto/pgp/verify-error",
875
886
  "RSA verify threw: " + ((e && e.message) || String(e)));
package/lib/metrics.js CHANGED
@@ -1352,6 +1352,14 @@ function shadowRegistry(opts) {
1352
1352
  var counters = Object.create(null);
1353
1353
  var gauges = Object.create(null);
1354
1354
  var info = Object.create(null);
1355
+ // Maps a canonical-JSON cardinality key → the string-coerced, null-prototype
1356
+ // label object that produced it, so the render path reads the structured
1357
+ // labels directly instead of re-parsing the key. Re-parsing would both lose a
1358
+ // label NAMED `constructor` / `prototype` / `__proto__` (all valid Prometheus
1359
+ // label names) to a prototype-pollution-hardened JSON parse, and reintroduce a
1360
+ // serialize-then-split round-trip. Populated only when a value is actually
1361
+ // stored, so it stays bounded by the cardinality cap.
1362
+ var labelSets = Object.create(null);
1355
1363
  var lastCardinalityAuditMs = 0;
1356
1364
 
1357
1365
  function _cardinalityHit(metric) {
@@ -1375,19 +1383,27 @@ function shadowRegistry(opts) {
1375
1383
  }
1376
1384
  }
1377
1385
 
1378
- function _labelKey(labels) {
1379
- if (!labels || typeof labels !== "object") return "";
1380
- var keys = Object.keys(labels).sort(); // allow:bare-canonicalize-walk label-set canonicalization for cardinality keying
1381
- var parts = [];
1382
- for (var i = 0; i < keys.length; i += 1) {
1383
- parts.push(keys[i] + "=" + String(labels[keys[i]]));
1384
- }
1385
- return parts.join(",");
1386
+ function _coerceLabels(labels) {
1387
+ // { key, set }: the canonical-JSON cardinality key (collision-proof and
1388
+ // injection-proof, matching the main registry's _labelsKey) plus the
1389
+ // string-coerced, null-prototype label object it came from. The render path
1390
+ // reads `set` directly rather than re-parsing `key`, so a `,` or `=` in a
1391
+ // label VALUE can never forge extra label pairs and a label NAMED
1392
+ // `constructor` / `prototype` / `__proto__` survives verbatim. The
1393
+ // null-prototype object makes a `__proto__` label a normal own property
1394
+ // rather than a prototype assignment.
1395
+ if (!labels || typeof labels !== "object") return { key: "", set: null };
1396
+ var keys = Object.keys(labels);
1397
+ if (keys.length === 0) return { key: "", set: null };
1398
+ var coerced = Object.create(null);
1399
+ for (var i = 0; i < keys.length; i += 1) coerced[keys[i]] = String(labels[keys[i]]);
1400
+ return { key: canonicalJson.stringify(coerced), set: coerced };
1386
1401
  }
1387
1402
 
1388
1403
  function inc(name, labels) {
1389
1404
  if (!counterSet[name]) return;
1390
- var lk = _labelKey(labels);
1405
+ var ck = _coerceLabels(labels);
1406
+ var lk = ck.key;
1391
1407
  if (!counters[name]) counters[name] = Object.create(null);
1392
1408
  var current = counters[name][lk];
1393
1409
  if (current === undefined) {
@@ -1396,6 +1412,7 @@ function shadowRegistry(opts) {
1396
1412
  return;
1397
1413
  }
1398
1414
  counters[name][lk] = 1;
1415
+ if (ck.set) labelSets[lk] = ck.set;
1399
1416
  } else {
1400
1417
  counters[name][lk] = current + 1;
1401
1418
  }
@@ -1407,13 +1424,15 @@ function shadowRegistry(opts) {
1407
1424
  throw new MetricsError("metrics-shadow/bad-gauge-value",
1408
1425
  "shadowRegistry.set: '" + name + "' value must be a finite number");
1409
1426
  }
1410
- var lk = _labelKey(labels);
1427
+ var ck = _coerceLabels(labels);
1428
+ var lk = ck.key;
1411
1429
  if (!gauges[name]) gauges[name] = Object.create(null);
1412
1430
  if (gauges[name][lk] === undefined && Object.keys(gauges[name]).length >= cap) {
1413
1431
  _cardinalityHit(name);
1414
1432
  return;
1415
1433
  }
1416
1434
  gauges[name][lk] = value;
1435
+ if (ck.set) labelSets[lk] = ck.set;
1417
1436
  }
1418
1437
 
1419
1438
  function setInfo(name, value) {
@@ -1449,22 +1468,21 @@ function shadowRegistry(opts) {
1449
1468
  for (var li = 0; li < lks.length; li += 1) {
1450
1469
  var lk = lks[li];
1451
1470
  if (lk === "") { out.push(metric + " " + labelMap[lk]); continue; }
1452
- // The label-key string was assembled by `_labelKey` from a
1453
- // single shadow-registry call's `labels` object — values
1454
- // are framework-internal (operator code that supplied them
1455
- // is bounded by guards upstream); split on `,` is safe.
1456
- // Not a header-value parse (which would need a quoted-
1457
- // string aware split per RFC 9110).
1458
- var lpairs = lk.split(","); // allow:bare-split-on-quoted-header — framework-internal label-key (assembled by _labelKey), not an HTTP header parse
1471
+ // Read the structured label set kept alongside the canonical key (see
1472
+ // _coerceLabels) rather than re-parsing the key. No serialize-then-
1473
+ // split round-trip, so a `,` or `=` in a label value stays inside the
1474
+ // value and can never forge extra label pairs; and a label NAMED
1475
+ // `constructor` / `prototype` / `__proto__` survives instead of being
1476
+ // stripped by a prototype-pollution-hardened parse.
1477
+ var labelObj = labelSets[lk];
1478
+ if (!labelObj || typeof labelObj !== "object") { out.push(metric + " " + labelMap[lk]); continue; }
1479
+ var lnames = Object.keys(labelObj).sort(); // allow:bare-canonicalize-walk — deterministic label ordering in the exposition line
1459
1480
  var formatted = [];
1460
- for (var pi = 0; pi < lpairs.length; pi += 1) {
1461
- var eqIdx = lpairs[pi].indexOf("=");
1462
- if (eqIdx === -1) continue;
1463
- var lname = lpairs[pi].slice(0, eqIdx);
1464
- var lvalue = lpairs[pi].slice(eqIdx + 1);
1481
+ for (var pi = 0; pi < lnames.length; pi += 1) {
1482
+ var lname = lnames[pi];
1465
1483
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(lname)) continue; // allow:regex-no-length-cap — Prometheus label-name shape
1466
1484
  // Prometheus exposition: escape `\`, `"`, `\n` in label values.
1467
- lvalue = String(lvalue).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n"); // allow:regex-no-length-cap — fixed-char-set escape // allow:duplicate-regex — Prometheus value escape shape
1485
+ var lvalue = String(labelObj[lname]).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n"); // allow:regex-no-length-cap — fixed-char-set escape // allow:duplicate-regex — Prometheus value escape shape
1468
1486
  formatted.push(lname + '="' + lvalue + '"');
1469
1487
  }
1470
1488
  out.push(metric + "{" + formatted.join(",") + "} " + labelMap[lk]);
@@ -1516,9 +1534,10 @@ function shadowRegistry(opts) {
1516
1534
  }
1517
1535
 
1518
1536
  function reset() {
1519
- counters = Object.create(null);
1520
- gauges = Object.create(null);
1521
- info = Object.create(null);
1537
+ counters = Object.create(null);
1538
+ gauges = Object.create(null);
1539
+ info = Object.create(null);
1540
+ labelSets = Object.create(null);
1522
1541
  lastCardinalityAuditMs = 0;
1523
1542
  }
1524
1543
 
@@ -290,7 +290,7 @@ function dbStore(opts) {
290
290
  var sealEnabled = false;
291
291
  if (sealReq) {
292
292
  try {
293
- // allow:seal-without-aad — vault-readiness probe; throwaway
293
+ // allow:seal-without-aad-by-design — vault-readiness probe; throwaway
294
294
  // sentinel value, not row-bound data
295
295
  vault.seal("__idempotency_seal_probe__");
296
296
  sealEnabled = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.33",
3
+ "version": "0.15.35",
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:cfba44f6-db8e-47b3-b1dc-e142692c3a97",
5
+ "serialNumber": "urn:uuid:ecbff4b1-ce1e-42ec-93f8-46f7c036e3e8",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-26T23:00:05.686Z",
8
+ "timestamp": "2026-06-27T06:52:37.069Z",
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.15.33",
22
+ "bom-ref": "@blamejs/core@0.15.35",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.33",
25
+ "version": "0.15.35",
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.15.33",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.35",
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.15.33",
57
+ "ref": "@blamejs/core@0.15.35",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]