@blamejs/core 0.14.25 → 0.14.27

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/lib/compliance.js CHANGED
@@ -50,6 +50,18 @@ var audit = lazyRequire(function () { return require("./audit"); });
50
50
  var retentionMod = lazyRequire(function () { return require("./retention"); });
51
51
  var db = lazyRequire(function () { return require("./db"); });
52
52
  var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
53
+ var redact = lazyRequire(function () { return require("./redact"); });
54
+
55
+ // Postures whose floor implies an outbound-DLP gate (b.redact's
56
+ // classifier presets cover exactly these regimes). Pinning one of these
57
+ // does NOT auto-install outbound DLP — the compliance coordinator holds
58
+ // no httpClient / mail / webhook handles — so set() emits a one-time
59
+ // `compliance.posture.outbound_dlp_unwired` warning when none is wired,
60
+ // so the gap is grep-able in the audit chain instead of a silent paper-
61
+ // compliance hole (CWE-200 / CWE-201 outbound data exposure).
62
+ var OUTBOUND_DLP_FLOOR_POSTURES = Object.freeze([
63
+ "hipaa", "pci-dss", "gdpr", "soc2", "fapi-2.0", "fapi-2.0-message-signing",
64
+ ]);
53
65
 
54
66
  // Recognised posture names. Aligns with the compliance-posture
55
67
  // vocabulary every guard / retention floor / etc. accepts. Operators
@@ -445,6 +457,24 @@ function set(posture) {
445
457
  "warning");
446
458
  }
447
459
  }
460
+
461
+ // Outbound-DLP wiring signal. A posture whose floor implies an
462
+ // outbound-DLP gate is being pinned, but set() cannot install the
463
+ // interceptors itself (no httpClient / mail / webhook handles). Warn
464
+ // once when nothing is wired so the gap is visible in the audit chain
465
+ // rather than a silent paper-compliance hole. Fires at most once per
466
+ // pin (set() is idempotent for the same posture).
467
+ if (OUTBOUND_DLP_FLOOR_POSTURES.indexOf(posture) !== -1) {
468
+ var dlpInstalled = false;
469
+ try { dlpInstalled = redact().isOutboundDlpInstalled() === true; }
470
+ catch (_e) { dlpInstalled = false; }
471
+ if (!dlpInstalled) {
472
+ _emitAudit("compliance.posture.outbound_dlp_unwired",
473
+ { posture: posture,
474
+ recommendation: "compliance.set does not auto-install outbound DLP — it holds no httpClient / mail / webhook handles. Call b.redact.installForPosture('" + posture + "', { httpClient, mail, webhook }) with your primitive instances so outbound payloads are classified (CWE-200 / CWE-201)." },
475
+ "warning");
476
+ }
477
+ }
448
478
  }
449
479
 
450
480
  // _applyPostureCascade — walks every primitive that
@@ -948,6 +978,25 @@ function describe(posture) {
948
978
  // + DPDP §12 + LGPD-BR Art. 18 + PIPL-CN
949
979
  // Art. 47 all require effective erasure;
950
980
  // leftover index residue defeats it.
981
+ // sealEnvelopeFloor — minimum field-level seal envelope a
982
+ // sealed-column table may declare under
983
+ // this posture: "plain" (vault.seal, no
984
+ // AAD), "aad" (AEAD-bound to table/row/
985
+ // column via b.vault.aad), or "per-row-key"
986
+ // (K_row crypto-shred). cryptoField.
987
+ // registerTable refuses a table whose
988
+ // declared envelope is below the floor when
989
+ // this posture is the globally-pinned one.
990
+ // PCI-DSS Req. 3.5/3.6 (PAN render
991
+ // unreadable, key-management binding) and
992
+ // HIPAA 45 CFR 164.312(a)(2)(iv) +
993
+ // 164.312(e)(2)(ii) (encryption that
994
+ // resists ciphertext relocation, CWE-311 /
995
+ // CWE-326) need an AAD-bound envelope at
996
+ // minimum so a DB-write attacker cannot
997
+ // copy a sealed cell between rows. Absent
998
+ // on a posture → no floor (back-compat;
999
+ // plain envelopes keep registering).
951
1000
  //
952
1001
  // This table is the single source-of-truth — duplicating values into
953
1002
  // per-primitive defaults would drift the moment a regulator updates.
@@ -957,12 +1006,22 @@ var POSTURE_DEFAULTS = Object.freeze({
957
1006
  auditChainSignedRequired: true,
958
1007
  tlsMinVersion: "TLSv1.3",
959
1008
  requireVacuumAfterErase: true,
1009
+ // 45 CFR 164.312(a)(2)(iv) + (e)(2)(ii) — ePHI encryption must
1010
+ // resist ciphertext relocation; a plain vault.seal cell can be
1011
+ // copied between rows undetected (CWE-311 / CWE-326). AAD-bound
1012
+ // envelope is the floor.
1013
+ sealEnvelopeFloor: "aad",
960
1014
  }),
961
1015
  "pci-dss": Object.freeze({
962
1016
  backupEncryptionRequired: true,
963
1017
  auditChainSignedRequired: true,
964
1018
  tlsMinVersion: "TLSv1.3",
965
1019
  requireVacuumAfterErase: false,
1020
+ // PCI-DSS v4 Req. 3.5 (PAN unreadable) + Req. 3.6 (key-management
1021
+ // binding) — the seal must bind cardholder data to its storage
1022
+ // location so a relocated ciphertext fails to verify. AAD-bound
1023
+ // envelope is the floor.
1024
+ sealEnvelopeFloor: "aad",
966
1025
  }),
967
1026
  "gdpr": Object.freeze({
968
1027
  backupEncryptionRequired: false, // GDPR Art. 32 says "appropriate" — not mandatory floor
@@ -1357,10 +1416,13 @@ var POSTURE_DEFAULTS = Object.freeze({
1357
1416
  * where `set()` would over-pin the process.
1358
1417
  *
1359
1418
  * Recognised keys per posture include `backupEncryptionRequired`,
1360
- * `auditChainSignedRequired`, `tlsMinVersion`, and
1361
- * `requireVacuumAfterErase` — the floors enforced by `b.backup`,
1362
- * `b.audit`, the TLS minimum-version gate, and `b.cryptoField`'s
1363
- * residual-erasure pass.
1419
+ * `auditChainSignedRequired`, `tlsMinVersion`,
1420
+ * `requireVacuumAfterErase`, and `sealEnvelopeFloor` — the floors
1421
+ * enforced by `b.backup`, `b.audit`, the TLS minimum-version gate,
1422
+ * `b.cryptoField`'s residual-erasure pass, and `b.cryptoField`'s
1423
+ * field-level seal-envelope gate. Keys not declared for a posture
1424
+ * return `null` (no floor), so reading `sealEnvelopeFloor` for a
1425
+ * posture that doesn't pin one is the back-compat no-op signal.
1364
1426
  *
1365
1427
  * @example
1366
1428
  * b.compliance.postureDefault("hipaa", "tlsMinVersion");
@@ -1615,10 +1677,91 @@ function isCrossBorderRegulated(posture) {
1615
1677
  return CROSS_BORDER_REGULATED_POSTURES.indexOf(posture) !== -1;
1616
1678
  }
1617
1679
 
1680
+ // Region-tag wildcards. Both spellings mean "no residency constraint"
1681
+ // across the framework — the external-db gate uses "unrestricted" as
1682
+ // its default + wildcard, while the local db-query / external-db row
1683
+ // gates also accept "global" as the region-neutral row tag. Normalizing
1684
+ // folds both to "unrestricted" so callers reason about one wildcard.
1685
+ var _REGION_WILDCARDS = Object.freeze(["global", "unrestricted", "any", "*"]);
1686
+
1687
+ /**
1688
+ * @primitive b.compliance.normalizeRegionTag
1689
+ * @signature b.compliance.normalizeRegionTag(tag)
1690
+ * @since 0.14.27
1691
+ * @compliance gdpr
1692
+ * @related b.compliance.isRegionCompatible, b.compliance.isCrossBorderRegulated
1693
+ *
1694
+ * Canonicalize an operator-supplied residency region tag so the same
1695
+ * region declared as `"EU"`, `"eu"`, or `" Eu "` compares equal. Lower-
1696
+ * cases and trims the tag; folds the no-constraint wildcards
1697
+ * (`"global"` / `"unrestricted"` / `"any"` / `"*"`) to `"unrestricted"`.
1698
+ * Returns `null` for non-string / empty input.
1699
+ *
1700
+ * This is an ADDITIVE helper composed OVER the residency write gates
1701
+ * (`b.db.from` local, `b.externalDb.query` backend/replica) — it does
1702
+ * not change the gate internals. Callers normalize their tags with it
1703
+ * BEFORE handing them to the gate so case / wildcard drift (`"EU"` vs
1704
+ * `"eu"` vs `"global"`) doesn't read as a region mismatch.
1705
+ *
1706
+ * @example
1707
+ * b.compliance.normalizeRegionTag("EU"); // → "eu"
1708
+ * b.compliance.normalizeRegionTag(" eu "); // → "eu"
1709
+ * b.compliance.normalizeRegionTag("global"); // → "unrestricted"
1710
+ * b.compliance.normalizeRegionTag("unrestricted"); // → "unrestricted"
1711
+ * b.compliance.normalizeRegionTag(null); // → null
1712
+ */
1713
+ function normalizeRegionTag(tag) {
1714
+ if (typeof tag !== "string") return null;
1715
+ var t = tag.trim().toLowerCase();
1716
+ if (t.length === 0) return null;
1717
+ if (_REGION_WILDCARDS.indexOf(t) !== -1) return "unrestricted";
1718
+ return t;
1719
+ }
1720
+
1721
+ /**
1722
+ * @primitive b.compliance.isRegionCompatible
1723
+ * @signature b.compliance.isRegionCompatible(a, b)
1724
+ * @since 0.14.27
1725
+ * @compliance gdpr
1726
+ * @related b.compliance.normalizeRegionTag, b.compliance.isCrossBorderRegulated
1727
+ *
1728
+ * Returns `true` when two residency region tags are compatible for a
1729
+ * same-region write/replication after normalization: identical
1730
+ * normalized regions are compatible, and a wildcard (`"global"` /
1731
+ * `"unrestricted"`) on EITHER side is compatible. Different concrete
1732
+ * regions (`"eu"` vs `"us"`) are NOT compatible — a cross-border
1733
+ * transfer the operator must opt into explicitly at the gate.
1734
+ *
1735
+ * Mirrors the residency gate's compatibility rule (identical-or-
1736
+ * wildcard) but over NORMALIZED tags, so it is case- and wildcard-drift
1737
+ * insensitive. ADDITIVE helper composed over the gate — it does not
1738
+ * change `_residencyCompatible` in db-query.js / external-db.js.
1739
+ * Missing/non-string tags on either side normalize to `null`, treated
1740
+ * as "no constraint" → compatible (matches the gate's
1741
+ * `!primaryTag || !replicaTag` short-circuit).
1742
+ *
1743
+ * @example
1744
+ * b.compliance.isRegionCompatible("EU", "eu"); // → true
1745
+ * b.compliance.isRegionCompatible("eu", "global"); // → true
1746
+ * b.compliance.isRegionCompatible("unrestricted", "us"); // → true
1747
+ * b.compliance.isRegionCompatible("eu", "us"); // → false
1748
+ * b.compliance.isRegionCompatible("EU", null); // → true
1749
+ */
1750
+ function isRegionCompatible(a, b) {
1751
+ var na = normalizeRegionTag(a);
1752
+ var nb = normalizeRegionTag(b);
1753
+ if (na === null || nb === null) return true; // no constraint either side
1754
+ if (na === nb) return true; // identical region (post-normalize)
1755
+ if (na === "unrestricted" || nb === "unrestricted") return true; // wildcard either side
1756
+ return false;
1757
+ }
1758
+
1618
1759
  module.exports = {
1619
1760
  set: set,
1620
1761
  current: current,
1621
1762
  isCrossBorderRegulated: isCrossBorderRegulated,
1763
+ normalizeRegionTag: normalizeRegionTag,
1764
+ isRegionCompatible: isRegionCompatible,
1622
1765
  CROSS_BORDER_REGULATED_POSTURES: CROSS_BORDER_REGULATED_POSTURES,
1623
1766
  assert: assert,
1624
1767
  clear: clear,
@@ -155,6 +155,25 @@ var perRowResidency = Object.create(null);
155
155
  // { tableName: { keySize, info } }
156
156
  var perRowKeyTables = Object.create(null);
157
157
 
158
+ // Seal-envelope strength ranking. A regulated posture can declare a
159
+ // sealEnvelopeFloor in b.compliance POSTURE_DEFAULTS; registerTable
160
+ // refuses a table that seals columns under a weaker envelope than the
161
+ // floor when that posture is the globally-pinned one. Higher rank =
162
+ // stronger binding:
163
+ // plain — vault.seal: XChaCha20-Poly1305 under the vault root,
164
+ // no AAD; a DB-write attacker can copy a cell to another
165
+ // row undetected (CWE-311 / CWE-326).
166
+ // aad — vault.aad.seal: AEAD-bound to (table,row,column,
167
+ // schemaVersion); a relocated cell fails Poly1305.
168
+ // per-row-key — K_row crypto-shred: aad binding PLUS a per-row key,
169
+ // so destroying the row-secret renders residue
170
+ // mathematically undecryptable.
171
+ var SEAL_ENVELOPE_RANK = Object.freeze({
172
+ "plain": 0,
173
+ "aad": 1,
174
+ "per-row-key": 2,
175
+ });
176
+
158
177
  // The framework registry table that holds each row's AAD-sealed
159
178
  // row-secret. Named once so the seal-side AAD (materializePerRowKey),
160
179
  // the read-side AAD (unsealRow's K_row fetch), and rotate's reseal all
@@ -232,6 +251,14 @@ function isRowSealed(value) {
232
251
  * hash namespaces. Subsequent `sealRow` / `unsealRow` / `eraseRow`
233
252
  * calls dispatch through this registry.
234
253
  *
254
+ * Seal-envelope floor: when a compliance posture that declares a
255
+ * `sealEnvelopeFloor` is globally pinned (`b.compliance.set` — today
256
+ * `hipaa` / `pci-dss` require at least an AAD-bound envelope), a table
257
+ * that seals columns under a weaker envelope throws
258
+ * `crypto-field/seal-envelope-below-floor` here at registration so the
259
+ * operator catches the under-protected schema at boot. Unpinned and
260
+ * non-regulated deployments register unchanged.
261
+ *
235
262
  * @opts
236
263
  * sealedFields: string[], // column names sealed via vault.seal
237
264
  * derivedHashes: { [hashCol]: { from: string, normalize?: fn } },
@@ -289,8 +316,25 @@ function registerTable(name, opts) {
289
316
  "'salted-sha3' or 'hmac-shake256', got " + JSON.stringify(colMode));
290
317
  }
291
318
  }
319
+ var sealedFields = Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [];
320
+ // Seal-envelope floor gate. Only fires when ALL hold:
321
+ // (1) a posture is globally pinned (b.compliance.set) — read via
322
+ // compliance().current(), the same source the residency write
323
+ // gates read; an UNPINNED deployment is untouched (back-compat),
324
+ // (2) that posture declares a sealEnvelopeFloor in POSTURE_DEFAULTS
325
+ // (only regulated regimes do — hipaa / pci-dss), and
326
+ // (3) the table actually seals columns under an envelope WEAKER than
327
+ // the floor.
328
+ // A non-sealing table, an unpinned deployment, or a posture without a
329
+ // floor all pass through exactly as before. Config-time / entry-point
330
+ // tier: THROW so the operator catches the under-protected schema at
331
+ // boot rather than shipping PHI/PCI under a relocatable plain seal
332
+ // (CWE-311 / CWE-326).
333
+ if (sealedFields.length > 0) {
334
+ _assertSealEnvelopeFloor(name, aadOn);
335
+ }
292
336
  schemas[name] = {
293
- sealedFields: Array.isArray(opts.sealedFields) ? opts.sealedFields.slice() : [],
337
+ sealedFields: sealedFields,
294
338
  derivedHashes: derivedHashes,
295
339
  hashNamespaces: Object.assign({}, opts.hashNamespaces || {}),
296
340
  aad: aadOn,
@@ -300,6 +344,48 @@ function registerTable(name, opts) {
300
344
  };
301
345
  }
302
346
 
347
+ // _assertSealEnvelopeFloor — config-time guard for registerTable. Reads
348
+ // the globally-pinned posture (compliance().current()) and its declared
349
+ // sealEnvelopeFloor; throws when `table` seals columns under a weaker
350
+ // envelope. No-op when no posture is pinned, the posture declares no
351
+ // floor, or compliance isn't loaded — so unpinned/non-regulated
352
+ // deployments register exactly as before.
353
+ function _assertSealEnvelopeFloor(table, aadOn) {
354
+ var posture;
355
+ var floor;
356
+ try {
357
+ var c = compliance();
358
+ posture = c.current();
359
+ if (typeof posture !== "string" || posture.length === 0) return;
360
+ floor = c.postureDefault(posture, "sealEnvelopeFloor");
361
+ } catch (_e) {
362
+ // compliance not loaded / unavailable — record nothing, gate nothing.
363
+ return;
364
+ }
365
+ if (typeof floor !== "string" || !Object.prototype.hasOwnProperty.call(SEAL_ENVELOPE_RANK, floor)) {
366
+ return; // posture pins no recognised floor → back-compat pass-through
367
+ }
368
+ // Declared envelope for this table: per-row-key beats aad beats plain.
369
+ // declarePerRowKey may run before or after registerTable; honour it
370
+ // when it ran first.
371
+ var declared = perRowKeyTables[table] ? "per-row-key" : (aadOn ? "aad" : "plain");
372
+ if (SEAL_ENVELOPE_RANK[declared] < SEAL_ENVELOPE_RANK[floor]) {
373
+ throw new CryptoFieldError("crypto-field/seal-envelope-below-floor",
374
+ "registerTable: table '" + table + "' seals columns under the '" +
375
+ declared + "' envelope, but the pinned compliance posture '" +
376
+ posture + "' requires at least '" + floor + "'. " +
377
+ (floor === "aad"
378
+ ? "Pass registerTable({ aad: true, rowIdField: <pk> }) so each " +
379
+ "cell is AEAD-bound to (table, row, column) and cannot be " +
380
+ "relocated between rows"
381
+ : "Call b.cryptoField.declarePerRowKey('" + table + "', ...) " +
382
+ "before registerTable so each row gets a crypto-shred K_row") +
383
+ " (CWE-311 / CWE-326). Unpinned or non-regulated deployments are " +
384
+ "unaffected; this gate fires only under a posture that declares a " +
385
+ "sealEnvelopeFloor.");
386
+ }
387
+ }
388
+
303
389
  // Derived-hash digest width for the keyed (hmac-shake256) mode: 32
304
390
  // bytes -> 64 hex chars.
305
391
  var DERIVED_HASH_BYTES = 32;