@blamejs/core 0.14.19 → 0.14.21
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 +4 -0
- package/README.md +1 -1
- package/lib/auth/oauth.js +736 -1
- package/lib/auth/oid4vci.js +124 -5
- package/lib/auth/oid4vp.js +14 -4
- package/lib/auth/sd-jwt-vc-holder.js +46 -1
- package/lib/break-glass.js +1 -2
- package/lib/config.js +28 -31
- package/lib/crypto-field.js +274 -17
- package/lib/dora.js +8 -5
- package/lib/dsr.js +2 -2
- package/lib/flag-evaluation-context.js +7 -0
- package/lib/guard-html-wcag-aria.js +4 -2
- package/lib/guard-html-wcag-forms.js +4 -2
- package/lib/guard-html-wcag-tables.js +4 -2
- package/lib/guard-html-wcag-tagwalk.js +20 -0
- package/lib/guard-html-wcag.js +1 -1
- package/lib/honeytoken.js +27 -20
- package/lib/mail-auth.js +333 -0
- package/lib/mail-deploy.js +1 -1
- package/lib/mail-send-deliver.js +13 -4
- package/lib/middleware/api-encrypt.js +140 -13
- package/lib/middleware/asyncapi-serve.js +3 -0
- package/lib/middleware/csp-report.js +13 -9
- package/lib/middleware/fetch-metadata.js +115 -14
- package/lib/middleware/openapi-serve.js +3 -0
- package/lib/middleware/scim-server.js +297 -19
- package/lib/middleware/security-headers.js +47 -0
- package/lib/middleware/security-txt.js +1 -2
- package/lib/middleware/trace-log-correlation.js +1 -2
- package/lib/network-smtp-policy.js +4 -4
- package/lib/object-store/sigv4-bucket-ops.js +11 -2
- package/lib/observability-tracer.js +1 -1
- package/lib/observability.js +39 -1
- package/lib/problem-details.js +56 -11
- package/lib/pubsub-cluster.js +16 -3
- package/lib/queue-sqs.js +20 -2
- package/lib/redis-client.js +32 -4
- package/lib/safe-redirect.js +16 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/crypto-field.js
CHANGED
|
@@ -45,9 +45,19 @@
|
|
|
45
45
|
var lazyRequire = require("./lazy-require");
|
|
46
46
|
var vault = require("./vault");
|
|
47
47
|
var vaultAad = require("./vault-aad");
|
|
48
|
+
var validateOpts = require("./validate-opts");
|
|
49
|
+
var numericBounds = require("./numeric-bounds");
|
|
50
|
+
var { defineClass } = require("./framework-error");
|
|
48
51
|
var { sha3Hash, kdf } = require("./crypto");
|
|
49
52
|
var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
|
|
50
53
|
|
|
54
|
+
// Typed refusal raised when a (actor, table, column) tuple exceeds the
|
|
55
|
+
// opt-in unseal-failure rate cap and is in cooldown. alwaysPermanent —
|
|
56
|
+
// the caller does not retry; the cooldown is a deliberate, time-bounded
|
|
57
|
+
// circuit-breaker, not a transient backend hiccup.
|
|
58
|
+
var CryptoFieldRateError = defineClass("CryptoFieldRateError", { alwaysPermanent: true });
|
|
59
|
+
var CryptoFieldError = defineClass("CryptoFieldError", { alwaysPermanent: true });
|
|
60
|
+
|
|
51
61
|
var compliance = lazyRequire(function () { return require("./compliance"); });
|
|
52
62
|
var db = lazyRequire(function () { return require("./db"); });
|
|
53
63
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
@@ -187,7 +197,8 @@ function registerTable(name, opts) {
|
|
|
187
197
|
var schemaVersion = opts.schemaVersion != null ? String(opts.schemaVersion) : "1";
|
|
188
198
|
var derivedHashMode = opts.derivedHashMode || "salted-sha3";
|
|
189
199
|
if (derivedHashMode !== "salted-sha3" && derivedHashMode !== "hmac-shake256") {
|
|
190
|
-
throw new
|
|
200
|
+
throw new CryptoFieldError("crypto-field/bad-derived-hash-mode",
|
|
201
|
+
"registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
|
|
191
202
|
"'hmac-shake256', got " + JSON.stringify(derivedHashMode));
|
|
192
203
|
}
|
|
193
204
|
var derivedHashes = Object.assign({}, opts.derivedHashes || {});
|
|
@@ -195,7 +206,8 @@ function registerTable(name, opts) {
|
|
|
195
206
|
if (!Object.prototype.hasOwnProperty.call(derivedHashes, col)) continue;
|
|
196
207
|
var colMode = derivedHashes[col] && derivedHashes[col].mode;
|
|
197
208
|
if (colMode !== undefined && colMode !== "salted-sha3" && colMode !== "hmac-shake256") {
|
|
198
|
-
throw new
|
|
209
|
+
throw new CryptoFieldError("crypto-field/bad-derived-hash-col-mode",
|
|
210
|
+
"registerTable: derivedHashes." + col + ".mode must be " +
|
|
199
211
|
"'salted-sha3' or 'hmac-shake256', got " + JSON.stringify(colMode));
|
|
200
212
|
}
|
|
201
213
|
}
|
|
@@ -285,14 +297,16 @@ function computeNamespacedHash(ns, value, opts) {
|
|
|
285
297
|
opts = opts || {};
|
|
286
298
|
var mode = opts.mode || "salted-sha3";
|
|
287
299
|
if (mode !== "salted-sha3" && mode !== "hmac-shake256") {
|
|
288
|
-
throw new
|
|
300
|
+
throw new CryptoFieldError("crypto-field/bad-namespaced-hash-mode",
|
|
301
|
+
"computeNamespacedHash: opts.mode must be 'salted-sha3' " +
|
|
289
302
|
"(default) or 'hmac-shake256', got " + JSON.stringify(mode));
|
|
290
303
|
}
|
|
291
304
|
var truncateBytes = opts.truncateBytes;
|
|
292
305
|
if (truncateBytes !== undefined) {
|
|
293
306
|
if (typeof truncateBytes !== "number" || !isFinite(truncateBytes) ||
|
|
294
307
|
truncateBytes <= 0 || Math.floor(truncateBytes) !== truncateBytes) {
|
|
295
|
-
throw new
|
|
308
|
+
throw new CryptoFieldError("crypto-field/bad-truncate-bytes",
|
|
309
|
+
"computeNamespacedHash: opts.truncateBytes must be a " +
|
|
296
310
|
"positive integer (bytes), got " + JSON.stringify(truncateBytes));
|
|
297
311
|
}
|
|
298
312
|
}
|
|
@@ -422,6 +436,202 @@ function computeDerived(table, sourceField, sourceValue) {
|
|
|
422
436
|
return null;
|
|
423
437
|
}
|
|
424
438
|
|
|
439
|
+
// ---- Unseal-failure rate cap (CWE-307) ----
|
|
440
|
+
//
|
|
441
|
+
// Opt-in brute-force / decryption-oracle throttle for the unsealRow read
|
|
442
|
+
// path. A DB-write attacker who can write `vault:<crafted>` /
|
|
443
|
+
// `vault.aad:<crafted>` payloads to sealed columns can force KEM
|
|
444
|
+
// decapsulation / AEAD verify on attacker-controlled bytes on every read.
|
|
445
|
+
// unsealRow already nulls the field + emits system.crypto.unseal_failed,
|
|
446
|
+
// but absent a cap the attacker can hammer the oracle indefinitely and
|
|
447
|
+
// only an off-band operator alert rule catches the burst. This adds an
|
|
448
|
+
// in-process per-(actor, table, column) sliding-window failure cap: past
|
|
449
|
+
// `threshold` failures inside `windowMs`, further unseal attempts for that
|
|
450
|
+
// tuple are refused for `cooldownMs` with a typed CryptoFieldRateError and
|
|
451
|
+
// a distinct system.crypto.unseal_rate_exceeded audit row.
|
|
452
|
+
//
|
|
453
|
+
// Default OFF — when no cap is configured, unsealRow behaves exactly as
|
|
454
|
+
// before (null-the-field + audit-only). Composes the same timestamp-array
|
|
455
|
+
// sliding-window shape used by b.mail.server.rateLimit (_pruneWindow):
|
|
456
|
+
// count-based, lazily pruned on read, no background timer.
|
|
457
|
+
//
|
|
458
|
+
// CWE-307 (Improper Restriction of Excessive Authentication Attempts —
|
|
459
|
+
// generalized here to excessive decryption-oracle attempts); OWASP ASVS
|
|
460
|
+
// v5 §2.2.1 (anti-automation); NIST SP 800-63B §5.2.2 (rate limiting).
|
|
461
|
+
var _rateCap = null; // null = disabled
|
|
462
|
+
var _rateFailWindows = new Map(); // "actor\x00table\x00column" → [tsMs, ...]
|
|
463
|
+
var _rateCooldowns = new Map(); // same key → cooldownUntilMs
|
|
464
|
+
|
|
465
|
+
// Tuple key. \x00 is not a legal column / table identifier byte and is
|
|
466
|
+
// vanishingly unlikely in an actor id, so the join is unambiguous; the
|
|
467
|
+
// composite is only ever a Map key (never an object property), so no
|
|
468
|
+
// prototype-pollution surface.
|
|
469
|
+
function _rateKey(actor, table, column) {
|
|
470
|
+
return String(actor) + "\x00" + table + "\x00" + column;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* @primitive b.cryptoField.configureUnsealRateCap
|
|
475
|
+
* @signature b.cryptoField.configureUnsealRateCap(opts)
|
|
476
|
+
* @since 0.14.20
|
|
477
|
+
* @compliance hipaa, gdpr, pci-dss
|
|
478
|
+
* @related b.cryptoField.unsealRow, b.cryptoField.clearRateCapForTest
|
|
479
|
+
*
|
|
480
|
+
* Opt into a per-(actor, table, column) cap on sealed-column unseal
|
|
481
|
+
* FAILURES. By default (unconfigured) `unsealRow` only nulls the field
|
|
482
|
+
* and emits `system.crypto.unseal_failed` on a forged-ciphertext read —
|
|
483
|
+
* an attacker who can write `vault:<crafted>` payloads can hammer the
|
|
484
|
+
* KEM-decapsulation / AEAD-verify oracle indefinitely, and only an
|
|
485
|
+
* off-band operator alert rule catches the burst. With a cap configured,
|
|
486
|
+
* once a single tuple accrues `threshold` failures inside `windowMs`,
|
|
487
|
+
* every subsequent `unsealRow` touching that tuple is REFUSED for
|
|
488
|
+
* `cooldownMs` with a `CryptoFieldRateError` and a distinct
|
|
489
|
+
* `system.crypto.unseal_rate_exceeded` audit row, bounding the oracle.
|
|
490
|
+
*
|
|
491
|
+
* Pass `null` (or `{ disabled: true }`) to turn the cap back off. This is
|
|
492
|
+
* a behaviour-changing refusal gate, so it is opt-in: unconfigured
|
|
493
|
+
* deployments keep today's audit-only behaviour with full back-compat.
|
|
494
|
+
* Validation is config-time / entry-point tier — bad `threshold` /
|
|
495
|
+
* `windowMs` / `cooldownMs` THROW so an operator catches the typo at
|
|
496
|
+
* boot rather than silently disabling the cap.
|
|
497
|
+
*
|
|
498
|
+
* CWE-307 (excessive-attempt restriction); OWASP ASVS v5 §2.2.1;
|
|
499
|
+
* NIST SP 800-63B §5.2.2.
|
|
500
|
+
*
|
|
501
|
+
* @opts
|
|
502
|
+
* threshold: number, // failures within the window before refusal kicks in (positive int)
|
|
503
|
+
* windowMs: number, // sliding-window width in ms (positive int; default 60000)
|
|
504
|
+
* cooldownMs: number, // refusal duration once tripped (positive int; default windowMs)
|
|
505
|
+
* disabled: boolean, // pass true to turn the cap off (same as configureUnsealRateCap(null))
|
|
506
|
+
* now: function, // injected clock returning epoch ms; default Date.now (test seam)
|
|
507
|
+
* onAudit: function, // optional sink({ action, outcome, metadata }) for the rate audit (test seam)
|
|
508
|
+
*
|
|
509
|
+
* @example
|
|
510
|
+
* b.cryptoField.configureUnsealRateCap({ threshold: 5, windowMs: 60000, cooldownMs: 300000 });
|
|
511
|
+
* // ...after 5 forged-ciphertext unseal failures for one (actor, table, column):
|
|
512
|
+
* try { b.cryptoField.unsealRow("patients", forgedRow, "actor-42"); }
|
|
513
|
+
* catch (e) { e.code; } // → "crypto-field/unseal-rate-exceeded"
|
|
514
|
+
*
|
|
515
|
+
* b.cryptoField.configureUnsealRateCap(null); // → disable again
|
|
516
|
+
*/
|
|
517
|
+
function configureUnsealRateCap(opts) {
|
|
518
|
+
if (opts === null || opts === undefined || opts.disabled === true) {
|
|
519
|
+
_rateCap = null;
|
|
520
|
+
_rateFailWindows.clear();
|
|
521
|
+
_rateCooldowns.clear();
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
validateOpts(opts, ["threshold", "windowMs", "cooldownMs", "disabled", "now", "onAudit"],
|
|
525
|
+
"cryptoField.configureUnsealRateCap");
|
|
526
|
+
// threshold is required (no default); presence-guard first, then validate
|
|
527
|
+
// the three positive-int bounds through the shared batch helper so the
|
|
528
|
+
// get-or-default and the bound checks each live in one place.
|
|
529
|
+
if (opts.threshold === undefined) {
|
|
530
|
+
throw new CryptoFieldRateError("crypto-field/bad-threshold",
|
|
531
|
+
"cryptoField.configureUnsealRateCap: opts.threshold is required and must be a positive finite integer");
|
|
532
|
+
}
|
|
533
|
+
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
534
|
+
["threshold", "windowMs", "cooldownMs"],
|
|
535
|
+
"cryptoField.configureUnsealRateCap", CryptoFieldRateError, "crypto-field/bad-rate-cap-opt");
|
|
536
|
+
validateOpts.optionalFunction(opts.now,
|
|
537
|
+
"cryptoField.configureUnsealRateCap: opts.now", CryptoFieldRateError,
|
|
538
|
+
"crypto-field/bad-now");
|
|
539
|
+
validateOpts.optionalFunction(opts.onAudit,
|
|
540
|
+
"cryptoField.configureUnsealRateCap: opts.onAudit", CryptoFieldRateError,
|
|
541
|
+
"crypto-field/bad-on-audit");
|
|
542
|
+
|
|
543
|
+
var windowMs = opts.windowMs === undefined ? TIME.minutes(1) : opts.windowMs;
|
|
544
|
+
_rateCap = {
|
|
545
|
+
threshold: opts.threshold,
|
|
546
|
+
windowMs: windowMs,
|
|
547
|
+
cooldownMs: opts.cooldownMs === undefined ? windowMs : opts.cooldownMs,
|
|
548
|
+
now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
|
|
549
|
+
onAudit: typeof opts.onAudit === "function" ? opts.onAudit : null,
|
|
550
|
+
};
|
|
551
|
+
// Re-arming with a fresh config drops any in-flight windows/cooldowns
|
|
552
|
+
// so a config change can't leave a tuple stuck in an old cooldown.
|
|
553
|
+
_rateFailWindows.clear();
|
|
554
|
+
_rateCooldowns.clear();
|
|
555
|
+
return { threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Emit the distinct rate-exceeded audit. Drop-silent (hot-path sink): a
|
|
559
|
+
// throwing audit on the read path must not crash the request that
|
|
560
|
+
// triggered it. Honors the injected onAudit test sink when present,
|
|
561
|
+
// otherwise routes through the framework audit chain.
|
|
562
|
+
function _emitRateAudit(metadata) {
|
|
563
|
+
try {
|
|
564
|
+
if (_rateCap && _rateCap.onAudit) {
|
|
565
|
+
_rateCap.onAudit({ action: "system.crypto.unseal_rate_exceeded", outcome: "denied", metadata: metadata });
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
audit().safeEmit({ action: "system.crypto.unseal_rate_exceeded", outcome: "denied", metadata: metadata });
|
|
569
|
+
} catch (_e) { /* drop-silent — audit best-effort */ }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Pre-unseal gate. Returns true when the tuple is currently in cooldown
|
|
573
|
+
// (caller must refuse). No-op (returns false) when the cap is disabled.
|
|
574
|
+
// Prunes an expired cooldown lazily so the Map can't grow unbounded.
|
|
575
|
+
function _rateInCooldown(actor, table, column) {
|
|
576
|
+
if (!_rateCap) return false;
|
|
577
|
+
var key = _rateKey(actor, table, column);
|
|
578
|
+
var until = _rateCooldowns.get(key);
|
|
579
|
+
if (until === undefined) return false;
|
|
580
|
+
if (_rateCap.now() >= until) {
|
|
581
|
+
_rateCooldowns.delete(key);
|
|
582
|
+
_rateFailWindows.delete(key);
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Post-failure accounting. Records one failure timestamp for the tuple,
|
|
589
|
+
// prunes the sliding window, and arms a cooldown when the count reaches
|
|
590
|
+
// the threshold. Returns true when this failure tripped the cap (so the
|
|
591
|
+
// caller can emit the rate audit exactly once on the transition).
|
|
592
|
+
function _rateNoteFailure(actor, table, column) {
|
|
593
|
+
if (!_rateCap) return false;
|
|
594
|
+
var nowMs = _rateCap.now();
|
|
595
|
+
var key = _rateKey(actor, table, column);
|
|
596
|
+
var arr = _rateFailWindows.get(key);
|
|
597
|
+
if (!arr) { arr = []; _rateFailWindows.set(key, arr); }
|
|
598
|
+
// Prune entries older than the window (sliding-window via timestamp
|
|
599
|
+
// array — same shape as b.mail.server.rateLimit._pruneWindow).
|
|
600
|
+
var cutoff = nowMs - _rateCap.windowMs;
|
|
601
|
+
var drop = 0;
|
|
602
|
+
while (drop < arr.length && arr[drop] < cutoff) drop += 1;
|
|
603
|
+
if (drop > 0) arr.splice(0, drop);
|
|
604
|
+
arr.push(nowMs);
|
|
605
|
+
if (arr.length >= _rateCap.threshold) {
|
|
606
|
+
_rateCooldowns.set(key, nowMs + _rateCap.cooldownMs);
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* @primitive b.cryptoField.clearRateCapForTest
|
|
614
|
+
* @signature b.cryptoField.clearRateCapForTest()
|
|
615
|
+
* @since 0.14.20
|
|
616
|
+
* @status experimental
|
|
617
|
+
* @related b.cryptoField.configureUnsealRateCap
|
|
618
|
+
*
|
|
619
|
+
* Test-only helper. Disables the unseal-failure rate cap and drops every
|
|
620
|
+
* in-flight sliding-window + cooldown entry so a fixture can re-configure
|
|
621
|
+
* the cap between cases. Operator code never calls this — production
|
|
622
|
+
* deployments configure the cap once at boot.
|
|
623
|
+
*
|
|
624
|
+
* @example
|
|
625
|
+
* b.cryptoField.configureUnsealRateCap({ threshold: 3 });
|
|
626
|
+
* b.cryptoField.clearRateCapForTest();
|
|
627
|
+
* // cap is off again; windows + cooldowns cleared
|
|
628
|
+
*/
|
|
629
|
+
function clearRateCapForTest() {
|
|
630
|
+
_rateCap = null;
|
|
631
|
+
_rateFailWindows.clear();
|
|
632
|
+
_rateCooldowns.clear();
|
|
633
|
+
}
|
|
634
|
+
|
|
425
635
|
// ---- Row sealing / unsealing ----
|
|
426
636
|
|
|
427
637
|
/**
|
|
@@ -485,7 +695,8 @@ function sealRow(table, row) {
|
|
|
485
695
|
if (s.aad) {
|
|
486
696
|
var rowId = out[s.rowIdField];
|
|
487
697
|
if (rowId === undefined || rowId === null || String(rowId).length === 0) {
|
|
488
|
-
throw new
|
|
698
|
+
throw new CryptoFieldError("crypto-field/seal-row-aad-rowid-missing",
|
|
699
|
+
"cryptoField.sealRow: table '" + table +
|
|
489
700
|
"' is AAD-bound (registerTable({aad:true})); the row's identity " +
|
|
490
701
|
"column '" + s.rowIdField + "' must be populated BEFORE sealRow. " +
|
|
491
702
|
"Generate the primary key first (e.g. uuid / sequence INSERT … RETURNING), " +
|
|
@@ -533,10 +744,10 @@ function _aadParts(schema, table, column, row) {
|
|
|
533
744
|
|
|
534
745
|
/**
|
|
535
746
|
* @primitive b.cryptoField.unsealRow
|
|
536
|
-
* @signature b.cryptoField.unsealRow(table, row)
|
|
747
|
+
* @signature b.cryptoField.unsealRow(table, row, actor?)
|
|
537
748
|
* @since 0.4.0
|
|
538
749
|
* @compliance hipaa, gdpr, pci-dss
|
|
539
|
-
* @related b.cryptoField.sealRow, b.vault.unseal
|
|
750
|
+
* @related b.cryptoField.sealRow, b.vault.unseal, b.cryptoField.configureUnsealRateCap
|
|
540
751
|
*
|
|
541
752
|
* Returns a copy of `row` with every sealed column unwrapped via
|
|
542
753
|
* `vault.unseal()`. Round-trips with `sealRow`. When `vault.unseal`
|
|
@@ -547,21 +758,45 @@ function _aadParts(schema, table, column, row) {
|
|
|
547
758
|
* so downstream code sees "no value" instead of crashing the request.
|
|
548
759
|
* The input row is never mutated.
|
|
549
760
|
*
|
|
761
|
+
* When an unseal-failure rate cap is configured via
|
|
762
|
+
* `configureUnsealRateCap` (default off), repeated forged-ciphertext
|
|
763
|
+
* failures for a single `(actor, table, column)` tuple trip a cooldown:
|
|
764
|
+
* once tripped, this call THROWS `CryptoFieldRateError` and emits a
|
|
765
|
+
* distinct `system.crypto.unseal_rate_exceeded` audit instead of
|
|
766
|
+
* exercising the decryption oracle again (CWE-307). `actor` identifies
|
|
767
|
+
* the caller for that tuple (e.g. session subject / API key id); it
|
|
768
|
+
* defaults to an anonymous bucket when omitted, and is ignored entirely
|
|
769
|
+
* when no cap is configured (full back-compat for the 2-arg call).
|
|
770
|
+
*
|
|
550
771
|
* @example
|
|
551
772
|
* b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
|
|
552
773
|
* var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
|
|
553
774
|
* var clear = b.cryptoField.unsealRow("patients", sealed);
|
|
554
775
|
* clear.ssn; // → "123-45-6789"
|
|
555
776
|
*/
|
|
556
|
-
function unsealRow(table, row) {
|
|
777
|
+
function unsealRow(table, row, actor) {
|
|
557
778
|
if (!row) return row;
|
|
558
779
|
var s = schemas[table];
|
|
559
780
|
if (!s || s.sealedFields.length === 0) return row;
|
|
560
781
|
var out = Object.assign({}, row);
|
|
782
|
+
var capActor = (actor === undefined || actor === null || String(actor).length === 0)
|
|
783
|
+
? "_anon" : String(actor);
|
|
561
784
|
|
|
562
785
|
for (var i = 0; i < s.sealedFields.length; i++) {
|
|
563
786
|
var field = s.sealedFields[i];
|
|
564
787
|
if (out[field]) {
|
|
788
|
+
// Opt-in cap: if this (actor, table, column) tuple is in cooldown
|
|
789
|
+
// from prior forged-ciphertext failures, refuse before touching the
|
|
790
|
+
// decryption oracle again (CWE-307). No-op when the cap is disabled.
|
|
791
|
+
if (_rateInCooldown(capActor, table, field)) {
|
|
792
|
+
_emitRateAudit({
|
|
793
|
+
table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
|
|
794
|
+
threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
|
|
795
|
+
});
|
|
796
|
+
throw new CryptoFieldRateError("crypto-field/unseal-rate-exceeded",
|
|
797
|
+
"cryptoField.unsealRow: unseal-failure rate cap tripped for (actor, '" + table +
|
|
798
|
+
"', '" + field + "') — refusing further unseal attempts during cooldown");
|
|
799
|
+
}
|
|
565
800
|
var unsealed;
|
|
566
801
|
try {
|
|
567
802
|
// Auto-detect the envelope shape so an AAD-bound table that
|
|
@@ -599,6 +834,16 @@ function unsealRow(table, row) {
|
|
|
599
834
|
},
|
|
600
835
|
});
|
|
601
836
|
} catch (_e) { /* drop-silent */ }
|
|
837
|
+
// Opt-in rate cap: account this failure against the (actor,
|
|
838
|
+
// table, column) tuple. When it trips the threshold, arm the
|
|
839
|
+
// cooldown + emit the distinct rate-exceeded audit once on the
|
|
840
|
+
// transition. No-op when the cap is disabled.
|
|
841
|
+
if (_rateNoteFailure(capActor, table, field)) {
|
|
842
|
+
_emitRateAudit({
|
|
843
|
+
table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
|
|
844
|
+
threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
602
847
|
unsealed = null;
|
|
603
848
|
}
|
|
604
849
|
// Assign unconditionally. `unsealed` already carries the right value
|
|
@@ -806,21 +1051,25 @@ function lookupHash(table, field, value) {
|
|
|
806
1051
|
*/
|
|
807
1052
|
function declareColumnResidency(table, opts) {
|
|
808
1053
|
if (typeof table !== "string" || table.length === 0) {
|
|
809
|
-
throw new
|
|
1054
|
+
throw new CryptoFieldError("crypto-field/residency-table-empty",
|
|
1055
|
+
"declareColumnResidency: table must be a non-empty string");
|
|
810
1056
|
}
|
|
811
1057
|
if (opts === null || opts === undefined || typeof opts !== "object" || Array.isArray(opts)) {
|
|
812
|
-
throw new
|
|
1058
|
+
throw new CryptoFieldError("crypto-field/residency-opts-not-object",
|
|
1059
|
+
"declareColumnResidency: opts must be a plain object");
|
|
813
1060
|
}
|
|
814
1061
|
var map = opts.columnResidency;
|
|
815
1062
|
if (!map || typeof map !== "object" || Array.isArray(map)) {
|
|
816
|
-
throw new
|
|
1063
|
+
throw new CryptoFieldError("crypto-field/residency-map-not-object",
|
|
1064
|
+
"declareColumnResidency: opts.columnResidency must be an object");
|
|
817
1065
|
}
|
|
818
1066
|
var entry = Object.create(null);
|
|
819
1067
|
for (var col in map) {
|
|
820
1068
|
if (!Object.prototype.hasOwnProperty.call(map, col)) continue;
|
|
821
1069
|
var tag = map[col];
|
|
822
1070
|
if (typeof tag !== "string" || tag.length === 0) {
|
|
823
|
-
throw new
|
|
1071
|
+
throw new CryptoFieldError("crypto-field/residency-tag-empty",
|
|
1072
|
+
"declareColumnResidency: column '" + col +
|
|
824
1073
|
"' residency tag must be a non-empty string");
|
|
825
1074
|
}
|
|
826
1075
|
entry[col] = tag;
|
|
@@ -943,17 +1192,20 @@ function assertColumnResidency(table, row, args) {
|
|
|
943
1192
|
*/
|
|
944
1193
|
function declarePerRowKey(table, opts) {
|
|
945
1194
|
if (typeof table !== "string" || table.length === 0) {
|
|
946
|
-
throw new
|
|
1195
|
+
throw new CryptoFieldError("crypto-field/per-row-key-table-empty",
|
|
1196
|
+
"declarePerRowKey: table must be a non-empty string");
|
|
947
1197
|
}
|
|
948
1198
|
opts = opts || {};
|
|
949
1199
|
var keySize = opts.keySize === undefined ? 32 : opts.keySize; // XChaCha20-Poly1305 key length in bytes
|
|
950
1200
|
if (typeof keySize !== "number" || !isFinite(keySize) ||
|
|
951
1201
|
keySize < 16 || Math.floor(keySize) !== keySize) { // minimum AES-128 key length in bytes
|
|
952
|
-
throw new
|
|
1202
|
+
throw new CryptoFieldError("crypto-field/per-row-key-bad-size",
|
|
1203
|
+
"declarePerRowKey: opts.keySize must be an integer >= 16 (bytes)");
|
|
953
1204
|
}
|
|
954
1205
|
var info = opts.info || ("blamejs-per-row-key:" + table);
|
|
955
1206
|
if (typeof info !== "string" || info.length === 0) {
|
|
956
|
-
throw new
|
|
1207
|
+
throw new CryptoFieldError("crypto-field/per-row-key-info-empty",
|
|
1208
|
+
"declarePerRowKey: opts.info must be a non-empty string");
|
|
957
1209
|
}
|
|
958
1210
|
perRowKeyTables[table] = { keySize: keySize, info: info };
|
|
959
1211
|
return { table: table, keySize: keySize, info: info };
|
|
@@ -1010,7 +1262,8 @@ function materializePerRowKey(table, rowId, dbHandle) {
|
|
|
1010
1262
|
var spec = perRowKeyTables[table];
|
|
1011
1263
|
if (!spec) return null;
|
|
1012
1264
|
if (!dbHandle || typeof dbHandle.prepare !== "function") {
|
|
1013
|
-
throw new
|
|
1265
|
+
throw new CryptoFieldError("crypto-field/materialize-per-row-key-no-db",
|
|
1266
|
+
"materializePerRowKey: dbHandle (b.db) is required");
|
|
1014
1267
|
}
|
|
1015
1268
|
// Existing key? Re-use to support idempotent UPSERTs.
|
|
1016
1269
|
var existing = dbHandle.prepare(
|
|
@@ -1066,7 +1319,8 @@ function materializePerRowKey(table, rowId, dbHandle) {
|
|
|
1066
1319
|
function destroyPerRowKey(table, rowId, dbHandle) {
|
|
1067
1320
|
if (!perRowKeyTables[table]) return { destroyed: 0 };
|
|
1068
1321
|
if (!dbHandle || typeof dbHandle.prepare !== "function") {
|
|
1069
|
-
throw new
|
|
1322
|
+
throw new CryptoFieldError("crypto-field/destroy-per-row-key-no-db",
|
|
1323
|
+
"destroyPerRowKey: dbHandle (b.db) is required");
|
|
1070
1324
|
}
|
|
1071
1325
|
var result = dbHandle.prepare(
|
|
1072
1326
|
'DELETE FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
|
|
@@ -1105,6 +1359,9 @@ module.exports = {
|
|
|
1105
1359
|
getSealedFields: getSealedFields,
|
|
1106
1360
|
sealRow: sealRow,
|
|
1107
1361
|
unsealRow: unsealRow,
|
|
1362
|
+
configureUnsealRateCap: configureUnsealRateCap,
|
|
1363
|
+
clearRateCapForTest: clearRateCapForTest,
|
|
1364
|
+
CryptoFieldRateError: CryptoFieldRateError,
|
|
1108
1365
|
// _aadParts — the column-AAD builder the seal/unseal path uses. Exported
|
|
1109
1366
|
// (internal) so the vault-key rotation pipeline reconstructs the IDENTICAL
|
|
1110
1367
|
// AAD tuple a cell was sealed under — one source of truth, no drift
|
package/lib/dora.js
CHANGED
|
@@ -248,8 +248,8 @@ function _validateReportInput(input) {
|
|
|
248
248
|
*
|
|
249
249
|
* @opts
|
|
250
250
|
* audit: boolean (default true; set false to skip audit emits),
|
|
251
|
-
* observability: boolean (
|
|
252
|
-
* best-effort
|
|
251
|
+
* observability: boolean (default true; set false to skip the
|
|
252
|
+
* best-effort observability counter on report),
|
|
253
253
|
*
|
|
254
254
|
* @example
|
|
255
255
|
* var dora = b.dora.create({ audit: true });
|
|
@@ -276,6 +276,7 @@ function create(opts) {
|
|
|
276
276
|
opts = opts || {};
|
|
277
277
|
validateOpts(opts, ["audit", "observability"], "dora.create");
|
|
278
278
|
var auditOn = opts.audit !== false;
|
|
279
|
+
var obsOn = opts.observability !== false;
|
|
279
280
|
|
|
280
281
|
function _emit(action, info) {
|
|
281
282
|
if (!auditOn) return;
|
|
@@ -354,9 +355,11 @@ function create(opts) {
|
|
|
354
355
|
stage: record.stage,
|
|
355
356
|
},
|
|
356
357
|
});
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
358
|
+
if (obsOn) {
|
|
359
|
+
observability().safeEvent("dora.incident.reported", 1, {
|
|
360
|
+
classification: record.classification, stage: record.stage,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
360
363
|
return record;
|
|
361
364
|
}
|
|
362
365
|
|
package/lib/dsr.js
CHANGED
|
@@ -279,8 +279,8 @@ function create(opts) {
|
|
|
279
279
|
validateOpts(opts, [
|
|
280
280
|
"ticketStore", "posture", "identityResolver",
|
|
281
281
|
"sources", "audit", "retentionFloorMs",
|
|
282
|
-
"deadlineMs",
|
|
283
|
-
"verificationLevel",
|
|
282
|
+
"deadlineMs",
|
|
283
|
+
"verificationLevel",
|
|
284
284
|
"receiptSigner", "minVerificationByType",
|
|
285
285
|
], "dsr.create");
|
|
286
286
|
|
|
@@ -77,6 +77,13 @@ function fromRequest(req, opts) {
|
|
|
77
77
|
if (typeof req.user.email === "string") ctx.email = req.user.email;
|
|
78
78
|
if (req.user.tenantId != null) ctx.tenantId = req.user.tenantId;
|
|
79
79
|
}
|
|
80
|
+
// Explicit tenantKey overrides the tenant id derived from req.user —
|
|
81
|
+
// the sibling of userKey for the tenant axis. Operators behind a
|
|
82
|
+
// gateway that resolves tenancy out-of-band (subdomain, mTLS SAN,
|
|
83
|
+
// signed header) pass it directly rather than depending on req.user.
|
|
84
|
+
if (typeof opts.tenantKey === "string" && opts.tenantKey.length > 0) {
|
|
85
|
+
ctx.tenantId = opts.tenantKey;
|
|
86
|
+
}
|
|
80
87
|
var headers = req.headers || {};
|
|
81
88
|
if (typeof headers["accept-language"] === "string") {
|
|
82
89
|
ctx.locale = headers["accept-language"].split(",")[0].split(";")[0].trim();
|
|
@@ -58,8 +58,10 @@ function audit(html, opts) {
|
|
|
58
58
|
? KNOWN_ROLES.concat(opts.allowedRoles)
|
|
59
59
|
: KNOWN_ROLES;
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Per-finding scopeUrl stamping — shared collector in tagwalk.
|
|
62
|
+
var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
|
|
63
|
+
var findings = collector.findings;
|
|
64
|
+
var _add = collector.add;
|
|
63
65
|
|
|
64
66
|
var declaredIds = Object.create(null);
|
|
65
67
|
var idRe = /\bid\s*=\s*["']([^"']+)["']/gi;
|
|
@@ -55,8 +55,10 @@ function audit(html, opts) {
|
|
|
55
55
|
? AUTOCOMPLETE_TOKENS.concat(opts.allowedAutocomplete)
|
|
56
56
|
: AUTOCOMPLETE_TOKENS;
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// Per-finding scopeUrl stamping — shared collector in tagwalk.
|
|
59
|
+
var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
|
|
60
|
+
var findings = collector.findings;
|
|
61
|
+
var _add = collector.add;
|
|
60
62
|
|
|
61
63
|
// Pre-scan: is there a <legend> inside any <fieldset>?
|
|
62
64
|
// We track fieldset → has-legend by forward-scanning each fieldset.
|
|
@@ -29,8 +29,10 @@ function audit(html, opts) {
|
|
|
29
29
|
throw new TypeError("tables.audit: html must be a string");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// Per-finding scopeUrl stamping — shared collector in tagwalk.
|
|
33
|
+
var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
|
|
34
|
+
var findings = collector.findings;
|
|
35
|
+
var _add = collector.add;
|
|
34
36
|
|
|
35
37
|
// Walk the tag stream, tracking nesting state for tables + their
|
|
36
38
|
// children. We don't build a full DOM; we track the open-tag stack
|
|
@@ -36,9 +36,29 @@ function lineColAt(html, offset) {
|
|
|
36
36
|
return { line: line, column: offset - lastNl };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Shared findings collector for the sub-scanners' audit(html, opts)
|
|
40
|
+
// entry points. scopeUrl annotates every finding with the page it came
|
|
41
|
+
// from so a direct caller of a sub-scanner (aria/forms/tables) can
|
|
42
|
+
// correlate a finding back to its source document; the parent
|
|
43
|
+
// wcag.audit also records scopeUrl at report level, but stamping
|
|
44
|
+
// per-finding keeps the value useful when a sub-scanner is invoked on
|
|
45
|
+
// its own. Returns { findings, add } — push findings through add() so
|
|
46
|
+
// the stamp applies uniformly.
|
|
47
|
+
function makeScopedFindings(scopeUrlOpt) {
|
|
48
|
+
var scopeUrl = (typeof scopeUrlOpt === "string" && scopeUrlOpt.length > 0)
|
|
49
|
+
? scopeUrlOpt : null;
|
|
50
|
+
var findings = [];
|
|
51
|
+
function add(f) {
|
|
52
|
+
if (scopeUrl !== null) f.scopeUrl = scopeUrl;
|
|
53
|
+
findings.push(f);
|
|
54
|
+
}
|
|
55
|
+
return { findings: findings, add: add };
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
module.exports = {
|
|
40
59
|
TAG_RE: TAG_RE,
|
|
41
60
|
ATTR_RE: ATTR_RE,
|
|
42
61
|
parseAttrs: parseAttrs,
|
|
43
62
|
lineColAt: lineColAt,
|
|
63
|
+
makeScopedFindings: makeScopedFindings,
|
|
44
64
|
};
|
package/lib/guard-html-wcag.js
CHANGED
|
@@ -345,7 +345,7 @@ function _checkAnchors(html, scheduled, report) {
|
|
|
345
345
|
function audit(html, opts) {
|
|
346
346
|
opts = opts || {};
|
|
347
347
|
validateOpts(opts, [
|
|
348
|
-
"level", "ignore", "
|
|
348
|
+
"level", "ignore", "scopeUrl",
|
|
349
349
|
"skipAria", "allowedRoles", "skipTables",
|
|
350
350
|
"skipForms", "allowedAutocomplete",
|
|
351
351
|
], "guardHtml.wcag.audit");
|
package/lib/honeytoken.js
CHANGED
|
@@ -89,6 +89,17 @@ function create(opts) {
|
|
|
89
89
|
opts = opts || {};
|
|
90
90
|
validateOpts(opts, ["audit"], "honeytoken.create");
|
|
91
91
|
|
|
92
|
+
// Honor the operator-supplied audit sink when present (the documented
|
|
93
|
+
// `audit: b.audit` injection); fall back to the module's lazyRequire so
|
|
94
|
+
// a caller that omits the sink still emits to the default audit log.
|
|
95
|
+
var auditSink = (opts.audit && typeof opts.audit.safeEmit === "function")
|
|
96
|
+
? opts.audit : null;
|
|
97
|
+
function _emit(record) {
|
|
98
|
+
var sink = auditSink || audit();
|
|
99
|
+
try { sink.safeEmit(record); }
|
|
100
|
+
catch (_e) { /* audit best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
92
103
|
var registry = new Map(); // value → { id, kind, metadata, issuedAt }
|
|
93
104
|
|
|
94
105
|
function issue(spec) {
|
|
@@ -110,13 +121,11 @@ function create(opts) {
|
|
|
110
121
|
issuedAt: Date.now(),
|
|
111
122
|
});
|
|
112
123
|
registry.set(value, record);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
119
|
-
} catch (_e) { /* audit best-effort */ }
|
|
124
|
+
_emit({
|
|
125
|
+
action: "honeytoken.issued",
|
|
126
|
+
outcome: "success",
|
|
127
|
+
metadata: { id: id, kind: kind },
|
|
128
|
+
});
|
|
120
129
|
return { id: id, value: value };
|
|
121
130
|
}
|
|
122
131
|
|
|
@@ -124,19 +133,17 @@ function create(opts) {
|
|
|
124
133
|
if (typeof value !== "string" || value.length === 0) return null;
|
|
125
134
|
var record = registry.get(value);
|
|
126
135
|
if (!record) return null;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
});
|
|
139
|
-
} catch (_e) { /* audit best-effort */ }
|
|
136
|
+
_emit({
|
|
137
|
+
action: "honeytoken.tripped",
|
|
138
|
+
outcome: "failure",
|
|
139
|
+
metadata: {
|
|
140
|
+
id: record.id,
|
|
141
|
+
kind: record.kind,
|
|
142
|
+
metadata: record.metadata,
|
|
143
|
+
observedAt: Date.now(),
|
|
144
|
+
observedActor: observedActor || null,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
140
147
|
return record;
|
|
141
148
|
}
|
|
142
149
|
|