@blamejs/core 0.9.49 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
@@ -3048,6 +3048,51 @@ function _checkServerIdentityStrict(host, cert) {
3048
3048
  return checkServerIdentity9525(host, cert);
3049
3049
  }
3050
3050
 
3051
+ // CVE-2026-21637 — Node propagates a synchronous throw from an
3052
+ // operator-supplied SNICallback up through the TLS handshake listener;
3053
+ // the unhandled throw on an unexpected servername crashes the
3054
+ // listener. RFC 6066 §3 expects the server to abort the handshake on a
3055
+ // failed callback, NOT crash the process.
3056
+ //
3057
+ // `wrapSNICallback(operatorCb)` returns a wrapper that:
3058
+ //
3059
+ // - Calls the operator callback in a try/catch.
3060
+ // - Surface a synchronous throw via the async (err, null) callback so
3061
+ // the TLS handshake aborts cleanly. Cb is best-effort: an operator
3062
+ // callback that throws AFTER invoking the callback already (double
3063
+ // invoke) gets the throw caught here without double-invoking again.
3064
+ // - Emit an audit event so a burst of crashes-that-weren't surfaces
3065
+ // in operator review.
3066
+ // - Returns the operator's original callback unchanged if it's not a
3067
+ // function (lets the caller pass undefined through without
3068
+ // special-casing).
3069
+ //
3070
+ // router.js routes its operator-supplied tlsOptions.SNICallback through
3071
+ // this helper before handing the options off to https.createServer.
3072
+ // Any future framework primitive that takes operator SNICallback
3073
+ // values does the same.
3074
+ function wrapSNICallback(operatorCb) {
3075
+ if (typeof operatorCb !== "function") return operatorCb;
3076
+ return function _wrappedSNICallback(servername, cb) {
3077
+ try {
3078
+ operatorCb(servername, cb);
3079
+ } catch (err) {
3080
+ try {
3081
+ audit().safeEmit({
3082
+ action: "network.tls.sni_callback_threw",
3083
+ outcome: "failure",
3084
+ metadata: {
3085
+ servername: typeof servername === "string" ? servername : null,
3086
+ reason: (err && err.message) ? err.message : String(err),
3087
+ },
3088
+ });
3089
+ } catch (_auditErr) { /* drop-silent — audit best-effort */ }
3090
+ try { cb(err, null); }
3091
+ catch (_cbErr) { /* cb already invoked or unavailable */ }
3092
+ }
3093
+ };
3094
+ }
3095
+
3051
3096
  module.exports = {
3052
3097
  addCa: addCa,
3053
3098
  addCaBundle: addCaBundle,
@@ -3073,6 +3118,7 @@ module.exports = {
3073
3118
  parseEchConfigList: parseEchConfigList,
3074
3119
  connectWithEch: connectWithEch,
3075
3120
  checkServerIdentity9525: checkServerIdentity9525,
3121
+ wrapSNICallback: wrapSNICallback,
3076
3122
  TlsTrustError: TlsTrustError,
3077
3123
  NetworkTlsError: NetworkTlsError,
3078
3124
  _resetForTest: _resetForTest,
@@ -50,6 +50,13 @@ var OtelExportError = defineClass("OtelExportError", { alwaysPermanent: false })
50
50
 
51
51
  var DEFAULT_INTERVAL_MS = C.TIME.seconds(15);
52
52
 
53
+ // OTLP collector response is `Empty` (zero protobuf bytes) on success
54
+ // or a short ExportPartialSuccess message on partial accept. A response
55
+ // past this cap is a hostile / misbehaving collector; refusing the
56
+ // body keeps the exporter from buffering megabytes per flush (CVE-2026-
57
+ // 40891 / CVE-2026-40182 OTLP class).
58
+ var MAX_RESPONSE_BYTES = C.BYTES.mib(1);
59
+
53
60
  // OTLP aggregation temporality:
54
61
  // 1 = DELTA — counters report deltas since last export
55
62
  // 2 = CUMULATIVE — counters report running totals
@@ -221,10 +228,12 @@ function create(opts) {
221
228
  var body = JSON.stringify(payload);
222
229
  try {
223
230
  var res = await effectiveHttpClient.request({
224
- method: "POST",
225
- url: endpoint,
226
- headers: Object.assign({ "Content-Type": "application/json" }, headers),
227
- body: body,
231
+ method: "POST",
232
+ url: endpoint,
233
+ headers: Object.assign({ "Content-Type": "application/json" }, headers),
234
+ body: body,
235
+ maxResponseBytes: MAX_RESPONSE_BYTES,
236
+ errorClass: OtelExportError,
228
237
  });
229
238
  if (res.statusCode < 200 || res.statusCode >= 300) {
230
239
  throw new OtelExportError("otel-export/upstream-rejected",
package/lib/outbox.js CHANGED
@@ -342,27 +342,77 @@ function create(opts) {
342
342
  var stopping = false;
343
343
  var inFlight = null;
344
344
 
345
+ // SUBSTRATE-23 — `FOR UPDATE SKIP LOCKED` is Postgres / MySQL 8+ only.
346
+ // SQLite (single-writer at the DB level, but WAL mode lets multiple
347
+ // processes share the file with concurrent SELECTs) doesn't support
348
+ // SKIP LOCKED — feeding it Postgres syntax silently double-publishes
349
+ // every row when two processes poll in parallel. Detect the dialect
350
+ // at runtime; only emit FOR UPDATE SKIP LOCKED when the backend
351
+ // declares postgres / mysql.
352
+ //
353
+ // Operator-visible: dialect comes from `externalDb.dialect` (set at
354
+ // `b.externalDb.create({ dialect: "postgres" | "mysql" | "sqlite" }`).
355
+ // Other backends fall back to the conservative "mark-then-update"
356
+ // path that works on every SQL dialect at the cost of a tiny race
357
+ // window between the SELECT + UPDATE (mitigated by status='in-flight'
358
+ // marker — duplicate publishes still bounded by retry visibility).
359
+ function _supportsForUpdateSkipLocked() {
360
+ var d = externalDb.dialect;
361
+ return d === "postgres" || d === "mysql";
362
+ }
363
+
345
364
  async function _claimBatch() {
365
+ var supportsSkipLocked = _supportsForUpdateSkipLocked();
346
366
  return await externalDb.transaction(async function (xdb) {
347
367
  var nowExpr = _utcNowExpr(externalDb);
348
- var rows = await xdb.query(
368
+ var selectSql =
349
369
  "SELECT id, topic, payload, key, headers, attempts" +
350
370
  " FROM " + quotedTable +
351
371
  " WHERE status = 'pending' AND next_attempt_at <= $1" +
352
372
  " ORDER BY next_attempt_at" +
353
- " LIMIT $2" +
354
- " FOR UPDATE SKIP LOCKED",
355
- [nowExpr, batchSize]
356
- );
373
+ " LIMIT $2";
374
+ if (supportsSkipLocked) {
375
+ selectSql += " FOR UPDATE SKIP LOCKED";
376
+ }
377
+ var rows = await xdb.query(selectSql, [nowExpr, batchSize]);
357
378
  if (!rows || !rows.rows || rows.rows.length === 0) return [];
358
379
  var ids = rows.rows.map(function (r) { return r.id; });
359
- // Mark as 'in-flight' so a parallel publisher won't re-claim them
360
- // when the row lock releases (after the SELECT-for-update txn).
361
- await xdb.query(
362
- "UPDATE " + quotedTable + " SET status = 'in-flight' WHERE id = ANY($1)",
363
- [ids]
364
- );
365
- return rows.rows.map(function (r) {
380
+ // Atomic claim: when the dialect lacks SKIP LOCKED, the UPDATE
381
+ // WHERE status='pending' AND id IN (...) ensures only ONE publisher
382
+ // sees each row transition from 'pending' to 'in-flight' — the
383
+ // other publisher's UPDATE matches zero rows and its batch shrinks.
384
+ // We re-select after the UPDATE to know which IDs we actually
385
+ // claimed (sqlite UPDATE doesn't return affected rows the same
386
+ // way Postgres does).
387
+ var actuallyClaimed;
388
+ if (supportsSkipLocked) {
389
+ // Postgres/MySQL: row lock held; ANY($1) update is safe.
390
+ await xdb.query(
391
+ "UPDATE " + quotedTable + " SET status = 'in-flight' WHERE id = ANY($1)",
392
+ [ids]
393
+ );
394
+ actuallyClaimed = rows.rows;
395
+ } else {
396
+ // SQLite (or "other") path: emit a portable UPDATE that
397
+ // refuses overlap by gating on status='pending'. After the
398
+ // update we re-read the in-flight rows we own; rows that
399
+ // another publisher beat us to are skipped.
400
+ // Use placeholders so the SQL stays parameterized regardless
401
+ // of dialect array semantics.
402
+ var placeholders = ids.map(function (_, i) { return "$" + (i + 1); }).join(",");
403
+ await xdb.query(
404
+ "UPDATE " + quotedTable +
405
+ " SET status = 'in-flight' WHERE status = 'pending' AND id IN (" + placeholders + ")",
406
+ ids
407
+ );
408
+ var afterRows = await xdb.query(
409
+ "SELECT id, topic, payload, key, headers, attempts FROM " + quotedTable +
410
+ " WHERE status = 'in-flight' AND id IN (" + placeholders + ")",
411
+ ids
412
+ );
413
+ actuallyClaimed = (afterRows && afterRows.rows) || [];
414
+ }
415
+ return actuallyClaimed.map(function (r) {
366
416
  return {
367
417
  id: r.id,
368
418
  topic: r.topic,
package/lib/pqc-agent.js CHANGED
@@ -253,13 +253,21 @@ function _getDefaultAgent() {
253
253
  * logger.info("pqc-agent reloaded", res);
254
254
  */
255
255
  function reload() {
256
- var hadAgent = _defaultAgent !== null;
257
- if (hadAgent) {
258
- try { _defaultAgent.destroy(); }
256
+ // CRYPTO-9 null the cached agent BEFORE calling destroy. The
257
+ // previous order let a concurrent _getDefaultAgent() see the
258
+ // destroyed-not-null agent and hand it to a caller; the caller
259
+ // then tries to issue a request through a torn-down keep-alive
260
+ // pool and surfaces a "socket destroyed" error. Null-first means
261
+ // every concurrent _getDefaultAgent() either sees the live agent
262
+ // (request lands on the about-to-be-torn-down pool — natural
263
+ // graceful drain) or the null sentinel (builds fresh).
264
+ var prior = _defaultAgent;
265
+ _defaultAgent = null;
266
+ if (prior) {
267
+ try { prior.destroy(); }
259
268
  catch (_e) { /* destroy is best-effort */ }
260
- _defaultAgent = null;
261
269
  }
262
- return { destroyed: hadAgent };
270
+ return { destroyed: prior !== null };
263
271
  }
264
272
 
265
273
  module.exports = {
package/lib/retry.js CHANGED
@@ -39,7 +39,6 @@
39
39
 
40
40
  var C = require("./constants");
41
41
  var lazyRequire = require("./lazy-require");
42
- var nodeCrypto = require("node:crypto");
43
42
  var numericChecks = require("./numeric-checks");
44
43
  // safe-async re-exports withRetry + CircuitBreaker from this module, so a
45
44
  // direct top-level require would create a cycle. Lazy-require defers the
@@ -239,10 +238,19 @@ function isRetryable(err) {
239
238
  *
240
239
  * Compute the backoff in milliseconds for a given (1-based) `attempt`
241
240
  * number. Exponential growth `baseDelayMs * 2^(attempt-1)` capped at
242
- * `maxDelayMs`, then subtract a cryptographic jitter sample scaled by
243
- * `jitterFactor` so retrying clients do not realign on the same
244
- * boundary. Throws TypeError when `attempt` is not a positive integer.
245
- * `opts` defaults to `b.retry.DEFAULT_RETRY` when absent.
241
+ * `maxDelayMs`, then subtract a Math.random-sourced jitter sample
242
+ * scaled by `jitterFactor` so retrying clients spread across the
243
+ * millisecond window instead of realigning on the same boundary
244
+ * (thundering-herd avoidance). Throws TypeError when `attempt` is
245
+ * not a positive integer. `opts` defaults to `b.retry.DEFAULT_RETRY`
246
+ * when absent.
247
+ *
248
+ * Jitter is intentionally NOT a CSPRNG sample — the per-request delay
249
+ * is observable to every peer client by construction (the request
250
+ * that comes in carries its own arrival timing), so there is no
251
+ * confidentiality property a stronger random source would protect.
252
+ * Math.random is the right tool for thundering-herd avoidance and
253
+ * costs ~50x less than a CSPRNG randomInt() under a retry storm.
246
254
  *
247
255
  * @opts
248
256
  * baseDelayMs: number, // initial backoff (default 100)
@@ -263,10 +271,16 @@ function backoffDelay(attempt, opts) {
263
271
  opts = opts || DEFAULT_RETRY;
264
272
  var base = opts.baseDelayMs * Math.pow(2, attempt - 1);
265
273
  var capped = Math.min(base, opts.maxDelayMs);
266
- // Cryptographically-strong jitter so a timing-attack mitigation isn't
267
- // undermined by Math.random's predictable PRNG.
268
- var jitterDenom = 1_000_000;
269
- var jitter = capped * opts.jitterFactor * (nodeCrypto.randomInt(0, jitterDenom) / jitterDenom);
274
+ // CRYPTO-12 jitter exists to spread retry storms across the
275
+ // millisecond window so N peer clients waking from the same
276
+ // upstream outage don't all hit the recovering service at the same
277
+ // tick. The value is observable to every client by construction
278
+ // (the request that comes in carries its own timing); there's no
279
+ // confidentiality property to protect, so a CSPRNG would burn
280
+ // 30-50K randomInt/sec under a retry storm without any security
281
+ // payoff. Math.random's PRNG is the right tool for
282
+ // thundering-herd avoidance.
283
+ var jitter = capped * opts.jitterFactor * Math.random(); // allow:math-random-noncrypto — jitter for thundering-herd, not a confidentiality primitive
270
284
  return Math.floor(capped - jitter);
271
285
  }
272
286
 
package/lib/router.js CHANGED
@@ -463,6 +463,14 @@ class Router {
463
463
  }
464
464
 
465
465
  _registerRoute(method, pattern, args) {
466
+ // CVE-2026-4923 — refuse pattern with more than 3 consecutive `*`
467
+ // metacharacters. The framework's segment matcher doesn't compile
468
+ // regex from operator input, but the policy stays crisp at the
469
+ // registration boundary.
470
+ if (typeof pattern === "string" && /\*{4,}/.test(pattern)) {
471
+ throw new Error(method + " " + pattern + ": route pattern refused " +
472
+ "(CVE-2026-4923) — more than 3 consecutive '*' metacharacters");
473
+ }
466
474
  var split = this._splitArgs(args);
467
475
  if (split.spec) _validateRouteSpec(split.spec, method, pattern);
468
476
  var handlers = split.handlers;
@@ -656,7 +664,21 @@ class Router {
656
664
  allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
657
665
  });
658
666
  req.pathname = parsed.pathname;
659
- req.query = Object.fromEntries(parsed.searchParams);
667
+ // CVE-2026-21717 V8 HashDoS defense — cap distinct query keys
668
+ // before forming the dense object. Integer-shaped keys past 1000
669
+ // entries degrade V8 hidden-class transitions to O(n²).
670
+ var queryEntries = [];
671
+ var queryKeyCount = 0;
672
+ for (var pair of parsed.searchParams) {
673
+ queryKeyCount += 1;
674
+ if (queryKeyCount > 1000) { // allow:raw-byte-literal — CVE-2026-21717 V8 HashDoS query-key cap
675
+ res.statusCode = 400;
676
+ res.end("400 Bad Request: too many query keys");
677
+ return;
678
+ }
679
+ queryEntries.push(pair);
680
+ }
681
+ req.query = Object.fromEntries(queryEntries);
660
682
 
661
683
  // Run middleware
662
684
  for (var mw of this.middleware) {