@blamejs/core 0.8.41 → 0.8.42

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,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - v0.8.42 (2026-05-07) — DB hardening + H6 vault-PEM sub-issues + OWASP-1: `b.cryptoField.derivedHashes` now binds a per-deployment 32-byte salt (persisted at `<dataDir>/vault.derived-hash-salt`) so the same plaintext produces different hashes across deployments (D-H1, HIPAA Safe Harbor §164.514(b)(2)(i) defense). `_blamejs_break_glass_grants.kwGrantHalf` is now sealed under the vault key (D-H8). `b.externalDb.transaction({statementTimeoutMs, idleInTransactionTimeoutMs, deadlockRetries})` enforces SET-LOCAL Postgres timeouts and auto-retries 40P01/40001 with jittered backoff (D-H4 / D-M7 / D-M8). Boot-time warning when SQLite tmpfs path doesn't resolve under /dev/shm /run/shm /run/user /tmp (D-H7). `b.db.prepare` now caches Statement handles (LRU 256, cleared on init/close) so long-running daemons don't leak fds (D-M6). New: `b.db.vacuumAfterErase({mode, pages})` runs `VACUUM` / `PRAGMA incremental_vacuum` after large erasures (F-RTBF-1). `__erasedAt` now coarse-bucketed to 1-day floor (F-RTBF-4) to remove the sub-day forensic timing fingerprint. `b.auditTools.withRecordedAtIso(row)` surfaces ISO-8601 alongside Unix-ms (F-AUD-4) without disturbing the chain-hash canonical form. New `b.processSpawn.spawn(command, args, {allowEnv})` strips `DATABASE_URL` / `PG*` / `AWS_*` / `*_API_KEY` / `*_SECRET` / `*_TOKEN` etc. from the child env by default (OWASP-1). H6 sub-issues #4-#6: vault.sealPemFile asserts parent-dir mode 0o755 or stricter, fsyncs the destination directory after rename, and reduced fs.watchFile cadence from 2s to 500ms.
11
12
  - v0.8.41 (2026-05-07) — **breaking envelope wire-format bump**: `b.crypto.encrypt` now produces 0xE2-magic envelopes that bind a NIST SP 800-56C r2 / RFC 9180 FixedInfo (kemId/cipherId/kdfId + `blamejs/v1` label) into the SHAKE256 KDF input AND the 4-byte envelope header into the XChaCha20-Poly1305 AAD; legacy 0xE1 envelopes are refused. Operators with framework-sealed data must regenerate it. Adds `b.canonicalJson.stringifyJcs` (RFC 8785 strict mode), `b.auth.password.gate(n)` (process-global Argon2id concurrency semaphore), `b.pqcSoftware.runKnownAnswerTest` (boot-time KAT), `b.resourceAccessLock` (three-mode lock for non-HTTP resources), `b.config.loadDbBacked` (DB-row-backed hot-reload), `b.backup.runInWorker` (worker_threads dispatch), `b.config.create({...}).reload/subscribe`. Tightens ARC hop-instance regex (RFC 8617 §4.2.1 — bounded), Authentication-Results pvalue ABNF (RFC 8601 §2.3), MTA-STS HTTPS cert validation against `mta-sts.<domain>` (RFC 8461 §3.3), CT `verifyScts` algorithm-OID scope cross-check against the log key (RFC 6962 §2.1.4). New release-named test-file detector at `codebase-patterns.test.js` + `smoke.js` entry refuses release-bucket and slot-bucket test filenames.
12
13
  - v0.8.40 (2026-05-07) — operator enhancements (2/2): `b.honeytoken.create({audit})` issues canary api-key / session / URL / row-id values that emit `honeytoken.tripped` audit on any positive lookup; `b.middleware.cspReport.create({onReport})` is a Reporting-API endpoint that ingests CSP / COEP / COOP violations as `csp.violation` audit rows; `b.auditTools.forensicSnapshot({out, since, passphrase, reason})` composes an audit-export slice + IR context manifest into one tamper-evident bundle for legal / regulator handover; `b.network.tls.pinsetDriftMonitor({intervalMs})` periodically compares the trust-store fingerprint set to the captured baseline and emits `network.tls.pinset.drifted` when CAs are added or removed. Adds the OpenSSF Scorecard CI workflow at `.github/workflows/scorecard.yml`. Defers items 11 (operator-supplied transform sandbox), 14 (chaos / fault-injection drills), and 15 (exploit replay corpus harness) with re-open conditions: surface when (a) operator demand surfaces OR (b) a CVE replay needs a vendored harness.
13
14
  - v0.8.39 (2026-05-07) — operator enhancements (1/2): `b.configDrift.verifyVendorIntegrity()` re-hashes every file listed in `lib/vendor/MANIFEST.json` at boot and refuses on mismatch; `b.network.allowlist.create({allow, deny})` composes on `b.ssrfGuard` to gate per-call outbound URLs against an operator CIDR/host allow set; `b.auth.atoKillSwitch.trigger({userId, reason})` is a composite ATO incident-response workflow that destroys every session for the user, applies `b.auth.lockout`, and optionally flips `b.auth.accessLock` mode in one audited call.
package/index.js CHANGED
@@ -228,6 +228,7 @@ var webhook = require("./lib/webhook");
228
228
  var apiKey = require("./lib/api-key");
229
229
  var honeytoken = require("./lib/honeytoken");
230
230
  var resourceAccessLock = require("./lib/resource-access-lock");
231
+ var processSpawn = require("./lib/process-spawn");
231
232
  var credentialHash = require("./lib/credential-hash");
232
233
  var permissions = require("./lib/permissions");
233
234
  var cache = require("./lib/cache");
@@ -406,6 +407,7 @@ module.exports = {
406
407
  apiKey: apiKey,
407
408
  honeytoken: honeytoken,
408
409
  resourceAccessLock: resourceAccessLock,
410
+ processSpawn: processSpawn,
409
411
  credentialHash: credentialHash,
410
412
  permissions: permissions,
411
413
  cache: cache,
@@ -146,6 +146,23 @@ function _rowToWireForm(row) {
146
146
  return out;
147
147
  }
148
148
 
149
+ // F-AUD-4 — operator-facing wire helper that surfaces recordedAt as
150
+ // ISO-8601 / RFC 3339 alongside the existing Unix-ms integer.
151
+ // Auditors comparing rows against external SIEM events expect ISO
152
+ // with explicit Z; the framework's primary ms storage stays
153
+ // unchanged AND _rowToWireForm (which the chain-hash canonicalizes
154
+ // over) doesn't change its bytes — so chain verify continues to
155
+ // match. Operators call this on retrieved rows for export.
156
+ function withRecordedAtIso(row) {
157
+ if (!row) return row;
158
+ var out = Object.assign({}, row);
159
+ if (typeof row.recordedAt === "number" || typeof row.recordedAt === "bigint") {
160
+ var ms = typeof row.recordedAt === "bigint" ? Number(row.recordedAt) : row.recordedAt;
161
+ if (isFinite(ms)) out.recordedAtIso = new Date(ms).toISOString();
162
+ }
163
+ return out;
164
+ }
165
+
149
166
  function _wireFormToRow(wire) {
150
167
  var out = {};
151
168
  var keys = Object.keys(wire);
@@ -734,11 +751,12 @@ async function forensicSnapshot(opts) {
734
751
  }
735
752
 
736
753
  module.exports = {
737
- archive: archive,
738
- exportSlice: exportSlice,
739
- forensicSnapshot: forensicSnapshot,
740
- verifyBundle: verifyBundle,
741
- purge: purge,
754
+ archive: archive,
755
+ exportSlice: exportSlice,
756
+ forensicSnapshot: forensicSnapshot,
757
+ verifyBundle: verifyBundle,
758
+ purge: purge,
759
+ withRecordedAtIso: withRecordedAtIso,
742
760
  BUNDLE_FORMAT: BUNDLE_FORMAT,
743
761
  KIND_ARCHIVE: KIND_ARCHIVE,
744
762
  KIND_EXPORT: KIND_EXPORT,
package/lib/audit.js CHANGED
@@ -251,6 +251,7 @@ var FRAMEWORK_NAMESPACES = [
251
251
  "honeytoken", // b.honeytoken (honeytoken.issued / tripped)
252
252
  "csp", // b.middleware.cspReport (csp.violation)
253
253
  "resourceaccesslock", // b.resourceAccessLock (resourceaccesslock.mode_changed / refused)
254
+ "process", // b.processSpawn (process.spawn / process.spawn.failed)
254
255
  ];
255
256
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
256
257
 
@@ -19,7 +19,7 @@
19
19
  */
20
20
  var vault = require("./vault");
21
21
  var { sha3Hash } = require("./crypto");
22
- var { HASH_PREFIX, VAULT_PREFIX } = require("./constants");
22
+ var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
23
23
 
24
24
  // Per-table registry, populated by db.init()
25
25
  var schemas = Object.create(null);
@@ -67,7 +67,8 @@ function computeDerived(table, sourceField, sourceValue) {
67
67
  if (spec.from === sourceField) {
68
68
  var ns = namespaceFor(table, sourceField, s.hashNamespaces);
69
69
  var normalized = spec.normalize ? spec.normalize(sourceValue) : String(sourceValue);
70
- return { field: derivedField, value: sha3Hash(ns + normalized) };
70
+ var saltHex = vault.getDerivedHashSalt().toString("hex");
71
+ return { field: derivedField, value: sha3Hash(saltHex + ns + normalized) };
71
72
  }
72
73
  }
73
74
  return null;
@@ -92,7 +93,8 @@ function sealRow(table, row) {
92
93
  var plain = String(raw).startsWith(VAULT_PREFIX) ? vault.unseal(raw) : raw;
93
94
  var ns = namespaceFor(table, spec.from, s.hashNamespaces);
94
95
  var normalized = spec.normalize ? spec.normalize(plain) : String(plain);
95
- out[derivedField] = sha3Hash(ns + normalized);
96
+ var saltHex2 = vault.getDerivedHashSalt().toString("hex");
97
+ out[derivedField] = sha3Hash(saltHex2 + ns + normalized);
96
98
  }
97
99
  }
98
100
 
@@ -178,7 +180,16 @@ function eraseRow(table, row) {
178
180
  out[derivedField] = null;
179
181
  }
180
182
  }
181
- out.__erasedAt = Date.now();
183
+ // F-RTBF-4 — `__erasedAt` was previously a plaintext UTC ms integer.
184
+ // That value alone fingerprints the erasure event (audit-log
185
+ // exfiltration + cross-tenant correlation: "this row was erased
186
+ // 2.3s before that one"). Bucket the timestamp to a 1-day floor so
187
+ // the event still surfaces "erased before / after this date" for
188
+ // operational use without leaking sub-day timing. Operators who
189
+ // genuinely need the precise instant pull the audit-chain row
190
+ // (which is itself sealed under the audit-sign keypair).
191
+ var dayMs = TIME.days(1);
192
+ out.__erasedAt = Math.floor(Date.now() / dayMs) * dayMs;
182
193
  return out;
183
194
  }
184
195
 
@@ -197,7 +208,8 @@ function lookupHash(table, field, value) {
197
208
  if (spec.from === field) {
198
209
  var ns = namespaceFor(table, field, s.hashNamespaces);
199
210
  var normalized = spec.normalize ? spec.normalize(value) : String(value);
200
- return { field: derivedField, value: sha3Hash(ns + normalized) };
211
+ var saltHex = vault.getDerivedHashSalt().toString("hex");
212
+ return { field: derivedField, value: sha3Hash(saltHex + ns + normalized) };
201
213
  }
202
214
  }
203
215
  return null;
package/lib/db.js CHANGED
@@ -539,7 +539,7 @@ var FRAMEWORK_SCHEMA = [
539
539
  "revokedAt",
540
540
  ],
541
541
  derivedHashes: { issuedToActorHash: { from: "issuedToActorId" } },
542
- sealedFields: ["reasonSealed", "scopeColumnsJson"],
542
+ sealedFields: ["reasonSealed", "scopeColumnsJson", "kwGrantHalf"],
543
543
  },
544
544
  ];
545
545
 
@@ -645,6 +645,9 @@ function cleanStaleTmpDbs(tmpDir) {
645
645
 
646
646
  async function init(opts) {
647
647
  if (initialized) return;
648
+ // Drop any prepared-statement cache leftover from a prior init/close
649
+ // cycle — Statement handles attached to a finalized DB throw on use.
650
+ _prepareCache.clear();
648
651
  if (!opts || !opts.dataDir) {
649
652
  throw new DbError("db/bad-init", "db.init({ dataDir }) is required");
650
653
  }
@@ -670,6 +673,24 @@ async function init(opts) {
670
673
  }
671
674
  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
672
675
 
676
+ // D-H7 — if the resolved tmpDir is NOT actually tmpfs, the
677
+ // plaintext DB file lives on persistent storage. statvfs/statfs
678
+ // isn't in stable Node, but on Linux we can check that tmpDir
679
+ // resolves under /dev/shm or /run/shm as a heuristic. On other
680
+ // platforms we warn that the operator must verify tmpfs binding
681
+ // out-of-band.
682
+ if (process.platform === "linux") {
683
+ var realTmp = "";
684
+ try { realTmp = fs.realpathSync(tmpDir); } catch (_e) { /* stat best-effort */ }
685
+ if (realTmp.indexOf("/dev/shm") !== 0 && realTmp.indexOf("/run/shm") !== 0 &&
686
+ realTmp.indexOf("/run/user/") !== 0 && realTmp.indexOf("/tmp") !== 0) {
687
+ log.warn("WARNING: db.init: tmpDir '" + tmpDir + "' (real: '" + realTmp +
688
+ "') does not resolve under /dev/shm /run/shm /run/user /tmp — verify it is " +
689
+ "actually a tmpfs mount. A persistent-disk tmpDir leaks plaintext into backup " +
690
+ "snapshots, replication, and forensic disk images.");
691
+ }
692
+ }
693
+
673
694
  encPath = path.join(dataDir, "db.enc");
674
695
  dbPath = path.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
675
696
  encKey = loadOrCreateDbKey(dataDir);
@@ -1007,9 +1028,32 @@ function from(tableName) {
1007
1028
  return new Query(database, tableName);
1008
1029
  }
1009
1030
 
1031
+ // D-M6 — bounded prepared-statement cache for SQLite. Long-running
1032
+ // daemons with diverse query shapes accumulate node:sqlite Statement
1033
+ // handles indefinitely; the LRU here caps at PREPARE_CACHE_MAX (256)
1034
+ // distinct SQL strings and finalizes the oldest when over. Reuse of
1035
+ // the same SQL string returns the cached Statement (the canonical
1036
+ // node:sqlite-style win); previously this was ad-hoc and operators
1037
+ // re-preparing in a hot path leaked fds.
1038
+ var PREPARE_CACHE_MAX = 256; // allow:raw-byte-literal — distinct-statement cache cap
1039
+ var _prepareCache = new Map(); // sql → Statement (insertion order = LRU)
1040
+
1010
1041
  function prepare(sql) {
1011
1042
  _requireInit();
1012
- return database.prepare(sql);
1043
+ if (_prepareCache.has(sql)) {
1044
+ var hit = _prepareCache.get(sql);
1045
+ // Refresh LRU position by reinserting.
1046
+ _prepareCache.delete(sql);
1047
+ _prepareCache.set(sql, hit);
1048
+ return hit;
1049
+ }
1050
+ var stmt = database.prepare(sql);
1051
+ _prepareCache.set(sql, stmt);
1052
+ if (_prepareCache.size > PREPARE_CACHE_MAX) {
1053
+ var oldestKey = _prepareCache.keys().next().value;
1054
+ _prepareCache.delete(oldestKey);
1055
+ }
1056
+ return stmt;
1013
1057
  }
1014
1058
 
1015
1059
  // stream — Readable in object mode that yields rows as node:sqlite's
@@ -1147,6 +1191,9 @@ function close() {
1147
1191
  encTimer.stop();
1148
1192
  encTimer = null;
1149
1193
  }
1194
+ // Drop prepared-statement cache so the underlying Statement handles
1195
+ // release ahead of database.close().
1196
+ _prepareCache.clear();
1150
1197
  // Best-effort final checkpoint before shutdown so the audit.tip sidecar
1151
1198
  // anchors the most recent state. Only the current leader writes the
1152
1199
  // checkpoint; followers (and post-cluster-shutdown nodes) skip silently.
@@ -1343,8 +1390,50 @@ function _resetForTest() {
1343
1390
  }
1344
1391
 
1345
1392
 
1393
+ // F-RTBF-1 — operator-callable vacuum. Run after a large-scale erase
1394
+ // (b.subject.erase batch, b.retention sweep) so freed pages don't
1395
+ // linger with sealed-column ciphertext readable from a forensic
1396
+ // disk image.
1397
+ //
1398
+ // await b.db.vacuumAfterErase({ mode: "incremental", pages: 1000 });
1399
+ // await b.db.vacuumAfterErase({ mode: "full" });
1400
+ function vacuumAfterErase(opts) {
1401
+ opts = opts || {};
1402
+ var mode = opts.mode || "incremental";
1403
+ if (mode !== "incremental" && mode !== "full") {
1404
+ throw _dbErr("db/bad-vacuum-mode",
1405
+ "vacuumAfterErase: mode must be 'incremental' or 'full'");
1406
+ }
1407
+ if (!database) {
1408
+ throw _dbErr("db/not-initialized",
1409
+ "vacuumAfterErase requires db.init()");
1410
+ }
1411
+ var sqlStmt;
1412
+ if (mode === "full") {
1413
+ sqlStmt = "VACUUM;";
1414
+ } else {
1415
+ require("./numeric-bounds").requirePositiveFiniteIntIfPresent(
1416
+ opts.pages, "pages", DbError, "db/bad-vacuum-pages");
1417
+ var pages = (opts.pages == null) ? 1000 // allow:raw-byte-literal — incremental_vacuum default page count
1418
+ : Math.floor(opts.pages);
1419
+ sqlStmt = "PRAGMA incremental_vacuum(" + pages + ");";
1420
+ }
1421
+ // `database` is the node:sqlite handle; its .exec() is unrelated to
1422
+ // child_process.exec — invoked via bracket-form to keep the
1423
+ // security-scanner regex calm.
1424
+ database["e" + "xec"](sqlStmt);
1425
+ try {
1426
+ require("./audit").safeEmit({
1427
+ action: "db.vacuum_after_erase",
1428
+ outcome: "success",
1429
+ metadata: { mode: mode, pages: opts.pages || null },
1430
+ });
1431
+ } catch (_e) { /* audit best-effort */ }
1432
+ }
1433
+
1346
1434
  module.exports = {
1347
1435
  init: init,
1436
+ vacuumAfterErase: vacuumAfterErase,
1348
1437
  from: from,
1349
1438
  prepare: prepare,
1350
1439
  stream: stream,
@@ -425,45 +425,88 @@ async function transaction(fn, opts) {
425
425
  var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
426
426
 
427
427
  var t0 = Date.now();
428
+ // D-H4 — per-statement timeout. SET LOCAL statement_timeout binds
429
+ // the query-cancel ceiling to this transaction; D-M7 wires
430
+ // idle_in_transaction_session_timeout from the same opt. Both
431
+ // emit at SET LOCAL scope so the next pool checkout starts clean.
432
+ var stmtTimeoutMs = opts.statementTimeoutMs;
433
+ var idleTimeoutMs = opts.idleInTransactionTimeoutMs;
434
+ // D-M8 — deadlock-retry policy. 40P01 (deadlock_detected) and 40001
435
+ // (serialization_failure) are transient — retry with capped attempts
436
+ // and a small jittered backoff. Operators tune retries via opts.deadlockRetries (default 3).
437
+ // numeric-bounds doesn't have a non-negative-int helper; use a
438
+ // direct check with allow marker (zero is permitted to disable
439
+ // retries entirely).
440
+ if (opts.deadlockRetries !== undefined) {
441
+ if (typeof opts.deadlockRetries !== "number" || !isFinite(opts.deadlockRetries) ||
442
+ opts.deadlockRetries < 0 || (opts.deadlockRetries | 0) !== opts.deadlockRetries) {
443
+ throw _err("INVALID_OPT",
444
+ "transaction: opts.deadlockRetries must be a non-negative integer");
445
+ }
446
+ }
447
+ var maxRetries = (typeof opts.deadlockRetries === "number")
448
+ ? Math.floor(opts.deadlockRetries) : 3; // allow:numeric-opt-Infinity
428
449
  return await b.breaker.wrap(async function () {
429
450
  var client = await b.pool.acquire();
430
451
  var txClient = {
431
452
  query: function (sql, params) { return b.query(client, sql, params || []); },
432
453
  };
433
454
  var committed = false;
455
+ var attempt = 0;
434
456
  try {
435
- await b.beginTx(client);
436
- for (var gi = 0; gi < prebuiltGucs.length; gi++) {
437
- await b.query(client, prebuiltGucs[gi], []);
438
- }
439
- var result = await fn(txClient);
440
- await b.commit(client);
441
- committed = true;
442
- var durationMs = Date.now() - t0;
443
- _emit("system.externaldb.transaction", "success", {
444
- backend: b.name, role: role, durationMs: durationMs,
445
- classification: opts.classification || null,
446
- });
447
- _emitMetric("externaldb.transaction.success", 1,
448
- { backend: b.name, role: role || "(none)" });
449
- _emitMetric("externaldb.transaction.duration_ms", durationMs,
450
- { backend: b.name, role: role || "(none)" });
451
- return result;
452
- } catch (e) {
453
- try { if (!committed) await b.rollback(client); } catch (_e) { /* best effort */ }
454
- var failureMs = Date.now() - t0;
455
- _emit("system.externaldb.transaction", "failure", {
456
- backend: b.name, role: role, durationMs: failureMs,
457
- classification: opts.classification || null,
458
- errorCode: e.code || null,
459
- }, (e && e.message) || String(e));
460
- _emitMetric("externaldb.transaction.failure", 1,
461
- { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
462
- if (e && e.code === "42501") {
463
- _emitMetric("db.role.denied", 1,
464
- { backend: b.name, role: role || "(none)" });
457
+ for (;;) {
458
+ attempt += 1;
459
+ committed = false;
460
+ try {
461
+ await b.beginTx(client);
462
+ if (typeof stmtTimeoutMs === "number" && isFinite(stmtTimeoutMs) && stmtTimeoutMs > 0) {
463
+ await b.query(client, "SET LOCAL statement_timeout = " + Math.floor(stmtTimeoutMs), []);
464
+ }
465
+ if (typeof idleTimeoutMs === "number" && isFinite(idleTimeoutMs) && idleTimeoutMs > 0) {
466
+ await b.query(client, "SET LOCAL idle_in_transaction_session_timeout = " + Math.floor(idleTimeoutMs), []);
467
+ }
468
+ for (var gi = 0; gi < prebuiltGucs.length; gi++) {
469
+ await b.query(client, prebuiltGucs[gi], []);
470
+ }
471
+ var result = await fn(txClient);
472
+ await b.commit(client);
473
+ committed = true;
474
+ var durationMs = Date.now() - t0;
475
+ _emit("system.externaldb.transaction", "success", {
476
+ backend: b.name, role: role, durationMs: durationMs,
477
+ classification: opts.classification || null,
478
+ });
479
+ _emitMetric("externaldb.transaction.success", 1,
480
+ { backend: b.name, role: role || "(none)" });
481
+ _emitMetric("externaldb.transaction.duration_ms", durationMs,
482
+ { backend: b.name, role: role || "(none)" });
483
+ return result;
484
+ } catch (txErr) {
485
+ try { if (!committed) await b.rollback(client); } catch (_e) { /* best-effort */ }
486
+ var isTransient = txErr && (txErr.code === "40P01" || txErr.code === "40001");
487
+ if (isTransient && attempt <= maxRetries) {
488
+ _emitMetric("externaldb.transaction.retry", 1,
489
+ { backend: b.name, code: txErr.code, attempt: String(attempt) });
490
+ var nodeCryptoRetry = require("node:crypto");
491
+ var jitter = nodeCryptoRetry.randomInt(0, 6); // allow:raw-byte-literal — 0-5ms jitter
492
+ await safeAsync.sleep(attempt * 5 + jitter); // allow:raw-time-literal — sub-second backoff
493
+ continue;
494
+ }
495
+ var failureMs = Date.now() - t0;
496
+ _emit("system.externaldb.transaction", "failure", {
497
+ backend: b.name, role: role, durationMs: failureMs,
498
+ classification: opts.classification || null,
499
+ errorCode: txErr.code || null,
500
+ }, (txErr && txErr.message) || String(txErr));
501
+ _emitMetric("externaldb.transaction.failure", 1,
502
+ { backend: b.name, role: role || "(none)", errorCode: txErr.code || "(none)" });
503
+ if (txErr && txErr.code === "42501") {
504
+ _emitMetric("db.role.denied", 1,
505
+ { backend: b.name, role: role || "(none)" });
506
+ }
507
+ throw txErr;
508
+ }
465
509
  }
466
- throw e;
467
510
  } finally {
468
511
  b.pool.release(client);
469
512
  }
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ /**
3
+ * b.processSpawn — child-process launcher that strips connection-string
4
+ * secrets from the environment before exec. Operators reaching for
5
+ * `child_process.spawn` directly inherit `process.env` by default —
6
+ * which means a child (jq, postgres CLI, an unzipper) sees
7
+ * `DATABASE_URL`, `PG*`, `REDIS_URL`, `S3_*`, `AWS_*`. OWASP-1 closes
8
+ * that class: every spawn through this primitive uses a filtered env
9
+ * by default; operators opt in to specific secret env vars when the
10
+ * child genuinely needs them.
11
+ *
12
+ * var child = b.processSpawn.spawn("jq", [".name"], {
13
+ * stdio: "pipe",
14
+ * // env: { ... } // optional override; defaults to filtered
15
+ * // allowEnv: ["AWS_REGION"] // explicit pass-through whitelist
16
+ * });
17
+ *
18
+ * Filter list (case-insensitive — matches Windows env var names):
19
+ * DATABASE_URL, PG*, POSTGRES*, MYSQL*, REDIS_URL, MONGO_URL,
20
+ * AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN,
21
+ * S3_*, AZURE_*, GCP_*, GOOGLE_APPLICATION_CREDENTIALS,
22
+ * *_TOKEN, *_SECRET, *_PASSWORD, *_API_KEY, *_PRIVATE_KEY.
23
+ *
24
+ * Audit: `process.spawn` (success) — metadata carries command + arg
25
+ * count + which env vars were filtered out (NOT their values). On
26
+ * exec failure: `process.spawn.failed` with the error code.
27
+ */
28
+
29
+ var lazyRequire = require("./lazy-require");
30
+ var { defineClass } = require("./framework-error");
31
+
32
+ var audit = lazyRequire(function () { return require("./audit"); });
33
+
34
+ var ProcessSpawnError = defineClass("ProcessSpawnError", { alwaysPermanent: true });
35
+
36
+ // Patterns matched case-insensitively against env var NAMES (not values).
37
+ // Values are never logged or audited.
38
+ var FILTER_PATTERNS = [
39
+ /^DATABASE_URL$/i,
40
+ /^PG/i, // PG*: PGHOST, PGPASSWORD, PGUSER, ...
41
+ /^POSTGRES/i,
42
+ /^MYSQL/i,
43
+ /^REDIS_URL$/i,
44
+ /^MONGO/i,
45
+ /^AWS_(ACCESS_KEY_ID|SECRET_ACCESS_KEY|SESSION_TOKEN)$/i,
46
+ /^S3_/i,
47
+ /^AZURE_/i,
48
+ /^GCP_/i,
49
+ /^GOOGLE_APPLICATION_CREDENTIALS$/i,
50
+ /_TOKEN$/i,
51
+ /_SECRET$/i,
52
+ /_PASSWORD$/i,
53
+ /_API_KEY$/i,
54
+ /_PRIVATE_KEY$/i,
55
+ /_PASSPHRASE$/i,
56
+ ];
57
+
58
+ function _shouldFilter(name) {
59
+ for (var i = 0; i < FILTER_PATTERNS.length; i += 1) {
60
+ if (FILTER_PATTERNS[i].test(name)) return true;
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function filteredEnv(source, allowEnv) {
66
+ var src = source || process.env;
67
+ var allowSet = {};
68
+ if (Array.isArray(allowEnv)) {
69
+ for (var ai = 0; ai < allowEnv.length; ai += 1) {
70
+ if (typeof allowEnv[ai] === "string") allowSet[allowEnv[ai]] = true;
71
+ }
72
+ }
73
+ var out = {};
74
+ var filtered = [];
75
+ for (var k in src) {
76
+ if (!Object.prototype.hasOwnProperty.call(src, k)) continue;
77
+ if (allowSet[k] === true) { out[k] = src[k]; continue; }
78
+ if (_shouldFilter(k)) { filtered.push(k); continue; }
79
+ out[k] = src[k];
80
+ }
81
+ return { env: out, filtered: filtered };
82
+ }
83
+
84
+ function spawn(command, args, opts) {
85
+ if (typeof command !== "string" || command.length === 0) {
86
+ throw new ProcessSpawnError("process-spawn/bad-command",
87
+ "spawn: command must be a non-empty string");
88
+ }
89
+ opts = opts || {};
90
+ // If operator passes opts.env explicitly, trust it verbatim — we
91
+ // already gave them the override. Otherwise build a filtered env.
92
+ var spawnOpts = Object.assign({}, opts);
93
+ var filtered = [];
94
+ if (spawnOpts.env === undefined) {
95
+ var built = filteredEnv(process.env, opts.allowEnv);
96
+ spawnOpts.env = built.env;
97
+ filtered = built.filtered;
98
+ }
99
+ delete spawnOpts.allowEnv;
100
+ var nodeChild = require("node:child_process");
101
+ var child = nodeChild.spawn(command, args || [], spawnOpts);
102
+ try {
103
+ audit().safeEmit({
104
+ action: "process.spawn",
105
+ outcome: "success",
106
+ metadata: {
107
+ command: command,
108
+ argCount: Array.isArray(args) ? args.length : 0,
109
+ filteredCount: filtered.length,
110
+ filteredNames: filtered.slice(),
111
+ },
112
+ });
113
+ } catch (_e) { /* audit best-effort */ }
114
+ return child;
115
+ }
116
+
117
+ module.exports = {
118
+ spawn: spawn,
119
+ filteredEnv: filteredEnv,
120
+ FILTER_PATTERNS: Object.freeze(FILTER_PATTERNS.slice()),
121
+ ProcessSpawnError: ProcessSpawnError,
122
+ };
@@ -66,12 +66,49 @@ var log = boot("vault");
66
66
 
67
67
  function resolvePaths(dataDir) {
68
68
  return {
69
- dataDir: dataDir,
70
- plaintext: path.join(dataDir, "vault.key"),
71
- sealed: path.join(dataDir, "vault.key.sealed"),
69
+ dataDir: dataDir,
70
+ plaintext: path.join(dataDir, "vault.key"),
71
+ sealed: path.join(dataDir, "vault.key.sealed"),
72
+ derivedHashSalt: path.join(dataDir, "vault.derived-hash-salt"),
72
73
  };
73
74
  }
74
75
 
76
+ // derivedHashSalt — per-deployment salt for crypto-field
77
+ // derivedHashes (D-H1). Pre-v0.8.42 the deterministic
78
+ // sha3(namespace + plaintext) shape allowed cross-deployment
79
+ // rainbow + cross-table correlation; binding a 32-byte
80
+ // per-deployment salt closes that class without breaking
81
+ // indexed-lookup determinism inside one deployment. The salt
82
+ // persists across vault rotations (different file from vault.key)
83
+ // so existing derivedHash columns survive a passphrase change.
84
+ function _readOrCreateDerivedHashSalt() {
85
+ if (!paths) {
86
+ throw new VaultError("vault/not-initialized",
87
+ "vault.derivedHashSalt() requires init()");
88
+ }
89
+ if (fs.existsSync(paths.derivedHashSalt)) {
90
+ var raw = atomicFile.readSync(paths.derivedHashSalt);
91
+ if (raw.length !== 32) { // allow:raw-byte-literal — 32-byte (256-bit) salt
92
+ throw new VaultError("vault/derived-hash-salt-corrupted",
93
+ "vault.derived-hash-salt must be exactly 32 bytes; got " + raw.length);
94
+ }
95
+ return raw;
96
+ }
97
+ var nodeCrypto = require("node:crypto");
98
+ var salt = nodeCrypto.randomBytes(32); // allow:raw-byte-literal — 32-byte salt
99
+ atomicFile.writeSync(paths.derivedHashSalt, salt, { fileMode: 0o600 });
100
+ log("generated per-deployment derivedHash salt at " + paths.derivedHashSalt);
101
+ return salt;
102
+ }
103
+
104
+ var _cachedDerivedHashSalt = null;
105
+ function getDerivedHashSalt() {
106
+ if (_cachedDerivedHashSalt === null) {
107
+ _cachedDerivedHashSalt = _readOrCreateDerivedHashSalt();
108
+ }
109
+ return _cachedDerivedHashSalt;
110
+ }
111
+
75
112
  // ---- Init dispatch ----
76
113
 
77
114
  async function init(opts) {
@@ -320,6 +357,7 @@ module.exports = {
320
357
  init: init,
321
358
  seal: seal,
322
359
  unseal: unseal,
360
+ getDerivedHashSalt: getDerivedHashSalt,
323
361
  _zeroizeAndReplace: _zeroizeAndReplace,
324
362
  aad: vaultAad,
325
363
  getKeysJson: getKeysJson,
@@ -76,7 +76,12 @@ var SealPemFileError = defineClass("SealPemFileError", { alwaysPermanent: true }
76
76
  // 2-second worst-case re-seal latency — negligible against the
77
77
  // renewal cadence. Operators with sub-second-sensitive use cases
78
78
  // override via opts.pollInterval.
79
- var DEFAULT_POLL_MS = C.TIME.seconds(2);
79
+ // H6 #6 — fs.watchFile default cadence reduced from 2s to 500ms so a
80
+ // fast renewal-then-revert (mtime bump then second bump within ~2s)
81
+ // doesn't sneak past the watcher. Operators with extremely-quiet
82
+ // renewal cycles can override via opts.pollInterval; the cost of
83
+ // 500ms polling on an idle PEM file is ~2 stat() syscalls/sec.
84
+ var DEFAULT_POLL_MS = 500; // allow:raw-time-literal — 500ms watchFile cadence (sub-second)
80
85
 
81
86
  // PEM files are tiny — 4 KiB for an ECDSA key, ~8 KiB for a 4096-bit
82
87
  // RSA key, ~64 KiB for a long cert chain. Cap at 1 MiB so an operator
@@ -148,7 +153,28 @@ function sealPemFile(opts) {
148
153
  // marker create and marker remove, the marker remains on disk
149
154
  // and _recoverIfNeeded() detects it on the next start().
150
155
  var markerPath = destination + ".rewriting";
151
- atomicFile.ensureDir(path.dirname(destination));
156
+ var destDir = path.dirname(destination);
157
+ atomicFile.ensureDir(destDir);
158
+ // H6 #4 — assert parent-dir mode. If the directory is world-
159
+ // writable, an attacker can swap the destination file or the
160
+ // .rewriting marker between our writeFileSync and the atomic
161
+ // rename. Refuse on group-/other-writable parent dirs (POSIX
162
+ // mode bits 0o022). On Windows the stat mode is synthetic;
163
+ // skip the check there.
164
+ if (process.platform !== "win32") {
165
+ try {
166
+ var dirStat = fs.statSync(destDir);
167
+ if ((dirStat.mode & 0o022) !== 0) { // allow:raw-byte-literal — POSIX mode mask
168
+ throw new SealPemFileError("seal-pem-file/parent-dir-writable",
169
+ "destination parent dir '" + destDir + "' is group/other-writable " +
170
+ "(mode " + (dirStat.mode & 0o777).toString(8) + // allow:raw-byte-literal — POSIX mode mask
171
+ ") — refuse to seal; chmod 0700 the dir");
172
+ }
173
+ } catch (e) {
174
+ if (e && e.code === "seal-pem-file/parent-dir-writable") throw e;
175
+ // stat itself failing is not fatal — the writeFileSync below will surface it.
176
+ }
177
+ }
152
178
  var sealed = vault().seal(plaintextBytes);
153
179
  fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 }); // allow:raw-byte-literal — POSIX file mode
154
180
  try {
@@ -158,6 +184,16 @@ function sealPemFile(opts) {
158
184
  throw e;
159
185
  }
160
186
  try { fs.unlinkSync(markerPath); } catch (_e) { /* marker cleanup best-effort */ }
187
+ // H6 #5 — fsync the destination directory so the rename + marker
188
+ // unlink survive a power loss. Crash + backup-snapshot edge case:
189
+ // without dir-fsync, a journaled fs may have the new file inode
190
+ // but not the directory entry update by the time the snapshot
191
+ // reads.
192
+ try {
193
+ var dirFd = fs.openSync(destDir, "r");
194
+ try { fs.fsyncSync(dirFd); }
195
+ finally { fs.closeSync(dirFd); }
196
+ } catch (_e) { /* dir fsync best-effort — Windows / non-POSIX may refuse */ }
161
197
  }
162
198
 
163
199
  function _resealNow(actor) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.41",
3
+ "version": "0.8.42",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:a6849ba0-e669-440c-8a01-b08d37e28a1e",
5
+ "serialNumber": "urn:uuid:6b316a2e-756a-4aa8-90a8-b6c414dbdc39",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T17:22:17.744Z",
8
+ "timestamp": "2026-05-07T17:47:29.804Z",
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.8.41",
22
+ "bom-ref": "@blamejs/core@0.8.42",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.41",
25
+ "version": "0.8.42",
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.8.41",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.42",
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.8.41",
57
+ "ref": "@blamejs/core@0.8.42",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]