@blamejs/core 0.8.41 → 0.8.43
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 +2 -0
- package/index.js +2 -0
- package/lib/audit-tools.js +23 -5
- package/lib/audit.js +1 -0
- package/lib/crypto-field.js +17 -5
- package/lib/db.js +91 -2
- package/lib/external-db.js +74 -31
- package/lib/process-spawn.js +122 -0
- package/lib/vault/index.js +41 -3
- package/lib/vault/seal-pem-file.js +38 -2
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- v0.8.43 (2026-05-07) — `examples/wiki/Dockerfile` declares an explicit `USER 65532:65532` directive in the runtime stage. Chainguard's `cgr.dev/chainguard/node:latest` already runs as `nonroot` (UID 65532) by default, but Trivy's static Dockerfile checker (DS-0002) flags any image without a literal `USER` line regardless of base-image default. Behavior unchanged.
|
|
12
|
+
- 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
13
|
- 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
14
|
- 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
15
|
- 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,
|
package/lib/audit-tools.js
CHANGED
|
@@ -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:
|
|
738
|
-
exportSlice:
|
|
739
|
-
forensicSnapshot:
|
|
740
|
-
verifyBundle:
|
|
741
|
-
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
|
|
package/lib/crypto-field.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/lib/external-db.js
CHANGED
|
@@ -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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
+
};
|
package/lib/vault/index.js
CHANGED
|
@@ -66,12 +66,49 @@ var log = boot("vault");
|
|
|
66
66
|
|
|
67
67
|
function resolvePaths(dataDir) {
|
|
68
68
|
return {
|
|
69
|
-
dataDir:
|
|
70
|
-
plaintext:
|
|
71
|
-
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
|
-
|
|
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
|
-
|
|
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
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:ee1628bb-0575-4a66-b601-38962996ca75",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-07T20:54:17.715Z",
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.43",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.43",
|
|
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.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.43",
|
|
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.
|
|
57
|
+
"ref": "@blamejs/core@0.8.43",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|