@blamejs/core 0.7.4 → 0.7.18
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 +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/config-drift.js
CHANGED
|
@@ -1,301 +1,301 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* config-drift — boot-time config-baseline capture + signed sidecar +
|
|
4
|
-
* next-boot drift detection.
|
|
5
|
-
*
|
|
6
|
-
* The framework's audit chain captures DATA writes (every row, every
|
|
7
|
-
* key change). It does NOT capture RUNTIME CONFIG (the operator
|
|
8
|
-
* silently changed `allowedOrigins` 3 weeks ago and we have no
|
|
9
|
-
* signal). config-drift fills that gap: at every boot, the operator
|
|
10
|
-
* passes the baseline config snapshot they want tracked. The
|
|
11
|
-
* primitive hashes it with SHA3-512, signs the digest with the audit-
|
|
12
|
-
* signing key, and writes the result to a sidecar at
|
|
13
|
-
* `<dataDir>/config-baseline.sig`. On the next boot, the sidecar is
|
|
14
|
-
* loaded + verified + diffed against the new snapshot. Drift surfaces
|
|
15
|
-
* as an audit event (`config.drift.detected`); no boot block — the
|
|
16
|
-
* operator may have a legitimate reason to change config and the
|
|
17
|
-
* framework's job is to make the change auditable, not to refuse to
|
|
18
|
-
* start.
|
|
19
|
-
*
|
|
20
|
-
* var configDrift = b.configDrift.create({
|
|
21
|
-
* dataDir: "/data",
|
|
22
|
-
* audit: b.audit,
|
|
23
|
-
* });
|
|
24
|
-
*
|
|
25
|
-
* await configDrift.checkpoint({
|
|
26
|
-
* // operator decides what's tracked. JSON-stringifiable.
|
|
27
|
-
* allowedOrigins: ["https://app.example.com"],
|
|
28
|
-
* csp: "default-src 'self'",
|
|
29
|
-
* auditMode: b.audit.getMode(),
|
|
30
|
-
* vaultMode: b.vault.getMode(),
|
|
31
|
-
* dbAtRest: b.db.getAtRestMode(),
|
|
32
|
-
* });
|
|
33
|
-
* // → { signed: true, drifted: false, previousAt: 1730000000000 }
|
|
34
|
-
*
|
|
35
|
-
* The signed sidecar uses b.auditSign — same SLH-DSA-SHAKE-256f keypair
|
|
36
|
-
* the audit chain anchors on. An attacker who flips a config value
|
|
37
|
-
* would also need to forge the signing key to update the sidecar
|
|
38
|
-
* cleanly; otherwise next-boot verify catches the tamper.
|
|
39
|
-
*
|
|
40
|
-
* Validation:
|
|
41
|
-
* - create() opts: throw at boot on bad shape
|
|
42
|
-
* - checkpoint() snapshot: must be a JSON-serialisable object
|
|
43
|
-
* - sidecar verify failure (tampered, key rotated, missing pubkey)
|
|
44
|
-
* surfaces as `config.baseline.tamper` audit event AND the call
|
|
45
|
-
* returns { tamper: true } so the operator can decide whether
|
|
46
|
-
* to refuse boot
|
|
47
|
-
*/
|
|
48
|
-
var fs = require("node:fs");
|
|
49
|
-
var path = require("node:path");
|
|
50
|
-
var auditSign = require("./audit-sign");
|
|
51
|
-
var canonicalJson = require("./canonical-json");
|
|
52
|
-
var crypto = require("./crypto");
|
|
53
|
-
var lazyRequire = require("./lazy-require");
|
|
54
|
-
var safeJson = require("./safe-json");
|
|
55
|
-
var validateOpts = require("./validate-opts");
|
|
56
|
-
var { defineClass } = require("./framework-error");
|
|
57
|
-
|
|
58
|
-
var audit = lazyRequire(function () { return require("./audit"); });
|
|
59
|
-
|
|
60
|
-
var ConfigDriftError = defineClass("ConfigDriftError", { alwaysPermanent: true });
|
|
61
|
-
var _err = ConfigDriftError.factory;
|
|
62
|
-
|
|
63
|
-
var SIDECAR_NAME = "config-baseline.sig";
|
|
64
|
-
var SIDECAR_VERSION = 1;
|
|
65
|
-
|
|
66
|
-
// Stable JSON serialization via the shared lib/canonical-json walker.
|
|
67
|
-
// Deterministic key order so the same snapshot always hashes to the same
|
|
68
|
-
// digest. Pre-v0.6.67 the in-line implementation silently lost Date /
|
|
69
|
-
// Map / Set / Buffer / BigInt content; the shared walker handles all
|
|
70
|
-
// of those + circular refs. Same bytes as audit-chain / audit-tools /
|
|
71
|
-
// pagination would produce for the same input.
|
|
72
|
-
function _stableStringify(value) { return canonicalJson.stringify(value); }
|
|
73
|
-
|
|
74
|
-
function _hashSnapshot(snapshot) {
|
|
75
|
-
return crypto.sha3Hash(_stableStringify(snapshot));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function _diffShallow(prev, next) {
|
|
79
|
-
// For drift reporting only — names the keys that changed without
|
|
80
|
-
// dumping the full snapshot (which may carry sensitive values like
|
|
81
|
-
// a CSP nonce-derivation key). Operators reading the drift event
|
|
82
|
-
// get "these keys changed" + a hash on each side.
|
|
83
|
-
var changed = [];
|
|
84
|
-
var added = [];
|
|
85
|
-
var removed = [];
|
|
86
|
-
var allKeys = {};
|
|
87
|
-
Object.keys(prev || {}).forEach(function (k) { allKeys[k] = true; });
|
|
88
|
-
Object.keys(next || {}).forEach(function (k) { allKeys[k] = true; });
|
|
89
|
-
Object.keys(allKeys).forEach(function (k) {
|
|
90
|
-
var inPrev = Object.prototype.hasOwnProperty.call(prev || {}, k);
|
|
91
|
-
var inNext = Object.prototype.hasOwnProperty.call(next || {}, k);
|
|
92
|
-
if (inPrev && !inNext) { removed.push(k); return; }
|
|
93
|
-
if (!inPrev && inNext) { added.push(k); return; }
|
|
94
|
-
if (_stableStringify(prev[k]) !== _stableStringify(next[k])) changed.push(k);
|
|
95
|
-
});
|
|
96
|
-
return { changed: changed, added: added, removed: removed };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function create(opts) {
|
|
100
|
-
opts = opts || {};
|
|
101
|
-
validateOpts(opts, [
|
|
102
|
-
"dataDir", "audit", "baseline", "criticalKeys", "ignoreKeys",
|
|
103
|
-
], "configDrift");
|
|
104
|
-
validateOpts.requireNonEmptyString(opts.dataDir, "create: opts.dataDir", ConfigDriftError, "BAD_OPT");
|
|
105
|
-
var dataDir = opts.dataDir;
|
|
106
|
-
var auditOn = opts.audit !== false;
|
|
107
|
-
var auditInstance = (opts.audit && opts.audit !== true) ? opts.audit : null;
|
|
108
|
-
// Multi-baseline support — each operator-named baseline lives in its
|
|
109
|
-
// own sidecar so production / staging / disaster-recovery deploys
|
|
110
|
-
// each track their own drift independently.
|
|
111
|
-
var baselineName = (typeof opts.baseline === "string" && opts.baseline.length > 0)
|
|
112
|
-
? opts.baseline : "default";
|
|
113
|
-
// Critical-keys allowlist: drift in these keys raises severity to
|
|
114
|
-
// "high" in the audit emission so SIEM rules can page on them
|
|
115
|
-
// separately from cosmetic drift. null = every key is treated as
|
|
116
|
-
// high severity (the safer default).
|
|
117
|
-
var criticalKeys = Array.isArray(opts.criticalKeys) ? opts.criticalKeys.slice() : null;
|
|
118
|
-
// Ignore-keys: drift in these keys is excluded from drift detection
|
|
119
|
-
// (e.g. operator-tracked metadata that legitimately changes per
|
|
120
|
-
// boot). Captured in the snapshot but never flagged.
|
|
121
|
-
var ignoreKeys = Array.isArray(opts.ignoreKeys) ? opts.ignoreKeys.slice() : [];
|
|
122
|
-
var sidecarPath = path.join(dataDir,
|
|
123
|
-
baselineName === "default" ? SIDECAR_NAME : ("config-baseline-" + baselineName + ".sig"));
|
|
124
|
-
|
|
125
|
-
function _emit(action, info, outcome) {
|
|
126
|
-
if (!auditOn) return;
|
|
127
|
-
var sink = auditInstance || audit();
|
|
128
|
-
try {
|
|
129
|
-
sink.safeEmit({
|
|
130
|
-
action: action,
|
|
131
|
-
outcome: outcome,
|
|
132
|
-
metadata: info || {},
|
|
133
|
-
reason: info && info.reason ? info.reason : null,
|
|
134
|
-
});
|
|
135
|
-
} catch (_e) { /* audit best-effort */ }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function _readSidecar() {
|
|
139
|
-
if (!fs.existsSync(sidecarPath)) return null;
|
|
140
|
-
var raw;
|
|
141
|
-
try { raw = fs.readFileSync(sidecarPath, "utf8"); }
|
|
142
|
-
catch (_e) { return null; }
|
|
143
|
-
var parsed;
|
|
144
|
-
try { parsed = safeJson.parse(raw); }
|
|
145
|
-
catch (_e) { return { unreadable: true }; }
|
|
146
|
-
if (!parsed || parsed.version !== SIDECAR_VERSION) return { unreadable: true };
|
|
147
|
-
if (typeof parsed.digestHex !== "string" || typeof parsed.signatureBase64 !== "string" ||
|
|
148
|
-
typeof parsed.publicKeyPem !== "string" || typeof parsed.snapshot !== "object") {
|
|
149
|
-
return { unreadable: true };
|
|
150
|
-
}
|
|
151
|
-
return parsed;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function _writeSidecar(snapshot, digestHex) {
|
|
155
|
-
// Sign over the digest (not the snapshot bytes directly) so the
|
|
156
|
-
// sidecar stays small even when the snapshot is large.
|
|
157
|
-
var signature = auditSign.sign(digestHex);
|
|
158
|
-
var payload = {
|
|
159
|
-
version: SIDECAR_VERSION,
|
|
160
|
-
capturedAt: Date.now(),
|
|
161
|
-
digestHex: digestHex,
|
|
162
|
-
signatureBase64: Buffer.from(signature).toString("base64"),
|
|
163
|
-
publicKeyPem: auditSign.getPublicKey(),
|
|
164
|
-
snapshot: snapshot,
|
|
165
|
-
};
|
|
166
|
-
var tmp = sidecarPath + ".tmp";
|
|
167
|
-
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
168
|
-
fs.renameSync(tmp, sidecarPath);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function _verifySidecar(parsed) {
|
|
172
|
-
// Verify the recorded signature against the recorded public key
|
|
173
|
-
// first — this catches tampering with the digest+sig pair on its
|
|
174
|
-
// own. Then verify the digest matches a re-hash of the recorded
|
|
175
|
-
// snapshot — catches tampering with the snapshot field.
|
|
176
|
-
var sigBuf = Buffer.from(parsed.signatureBase64, "base64");
|
|
177
|
-
if (!auditSign.verify(parsed.digestHex, sigBuf, parsed.publicKeyPem)) {
|
|
178
|
-
return { ok: false, reason: "signature-invalid" };
|
|
179
|
-
}
|
|
180
|
-
if (_hashSnapshot(parsed.snapshot) !== parsed.digestHex) {
|
|
181
|
-
return { ok: false, reason: "digest-mismatch" };
|
|
182
|
-
}
|
|
183
|
-
return { ok: true };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async function checkpoint(snapshot) {
|
|
187
|
-
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
188
|
-
throw _err("BAD_OPT", "checkpoint: snapshot must be a plain object");
|
|
189
|
-
}
|
|
190
|
-
var newDigest = _hashSnapshot(snapshot);
|
|
191
|
-
|
|
192
|
-
var existing = _readSidecar();
|
|
193
|
-
if (existing && existing.unreadable) {
|
|
194
|
-
_emit("config.baseline.unreadable",
|
|
195
|
-
{ sidecar: sidecarPath, reason: "sidecar present but malformed or wrong version" },
|
|
196
|
-
"warning");
|
|
197
|
-
_writeSidecar(snapshot, newDigest);
|
|
198
|
-
return { signed: true, drifted: false, tamper: false, previousAt: null, reason: "sidecar-unreadable-rewritten" };
|
|
199
|
-
}
|
|
200
|
-
if (!existing) {
|
|
201
|
-
_writeSidecar(snapshot, newDigest);
|
|
202
|
-
_emit("config.baseline.captured",
|
|
203
|
-
{ digestHex: newDigest, capturedAt: Date.now() }, "success");
|
|
204
|
-
return { signed: true, drifted: false, tamper: false, previousAt: null };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
var verified = _verifySidecar(existing);
|
|
208
|
-
if (!verified.ok) {
|
|
209
|
-
_emit("config.baseline.tamper",
|
|
210
|
-
{ sidecar: sidecarPath, reason: verified.reason, previousAt: existing.capturedAt },
|
|
211
|
-
"failure");
|
|
212
|
-
// DO NOT auto-rewrite on tamper — let the operator inspect first.
|
|
213
|
-
return { signed: false, drifted: false, tamper: true, reason: verified.reason, previousAt: existing.capturedAt };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (existing.digestHex === newDigest) {
|
|
217
|
-
// No drift; refresh capturedAt so the sidecar timestamp moves
|
|
218
|
-
// forward each successful boot (operator can see "last verified
|
|
219
|
-
// clean at <T>" in the file).
|
|
220
|
-
_writeSidecar(snapshot, newDigest);
|
|
221
|
-
return { signed: true, drifted: false, tamper: false, previousAt: existing.capturedAt };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
var diff = _diffShallow(existing.snapshot, snapshot);
|
|
225
|
-
// Filter ignore-keys out of the drift report.
|
|
226
|
-
function _stripIgnored(arr) {
|
|
227
|
-
return arr.filter(function (k) { return ignoreKeys.indexOf(k) === -1; });
|
|
228
|
-
}
|
|
229
|
-
diff.changed = _stripIgnored(diff.changed);
|
|
230
|
-
diff.added = _stripIgnored(diff.added);
|
|
231
|
-
diff.removed = _stripIgnored(diff.removed);
|
|
232
|
-
if (diff.changed.length === 0 && diff.added.length === 0 && diff.removed.length === 0) {
|
|
233
|
-
// All drift was in ignore-keys — refresh sidecar without
|
|
234
|
-
// emitting a drift event.
|
|
235
|
-
_writeSidecar(snapshot, newDigest);
|
|
236
|
-
return { signed: true, drifted: false, tamper: false,
|
|
237
|
-
previousAt: existing.capturedAt, ignoredOnly: true };
|
|
238
|
-
}
|
|
239
|
-
// Severity classification: HIGH when any drifted key is in
|
|
240
|
-
// criticalKeys (or no allowlist is configured — every key is
|
|
241
|
-
// critical by default). LOW when criticalKeys is set and none
|
|
242
|
-
// of the drifted keys are in it.
|
|
243
|
-
var severity = "high";
|
|
244
|
-
if (criticalKeys !== null) {
|
|
245
|
-
var anyCritical = false;
|
|
246
|
-
var allDrifted = diff.changed.concat(diff.added, diff.removed);
|
|
247
|
-
for (var di = 0; di < allDrifted.length; di++) {
|
|
248
|
-
if (criticalKeys.indexOf(allDrifted[di]) !== -1) { anyCritical = true; break; }
|
|
249
|
-
}
|
|
250
|
-
severity = anyCritical ? "high" : "low";
|
|
251
|
-
}
|
|
252
|
-
_emit("config.drift.detected",
|
|
253
|
-
{
|
|
254
|
-
baseline: baselineName,
|
|
255
|
-
previousDigestHex: existing.digestHex,
|
|
256
|
-
currentDigestHex: newDigest,
|
|
257
|
-
previousAt: existing.capturedAt,
|
|
258
|
-
keysChanged: diff.changed,
|
|
259
|
-
keysAdded: diff.added,
|
|
260
|
-
keysRemoved: diff.removed,
|
|
261
|
-
severity: severity,
|
|
262
|
-
},
|
|
263
|
-
severity === "high" ? "failure" : "warning");
|
|
264
|
-
_writeSidecar(snapshot, newDigest);
|
|
265
|
-
return {
|
|
266
|
-
signed: true,
|
|
267
|
-
drifted: true,
|
|
268
|
-
tamper: false,
|
|
269
|
-
severity: severity,
|
|
270
|
-
previousAt: existing.capturedAt,
|
|
271
|
-
diff: diff,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function read() {
|
|
276
|
-
var existing = _readSidecar();
|
|
277
|
-
if (!existing || existing.unreadable) return null;
|
|
278
|
-
var verified = _verifySidecar(existing);
|
|
279
|
-
return {
|
|
280
|
-
capturedAt: existing.capturedAt,
|
|
281
|
-
digestHex: existing.digestHex,
|
|
282
|
-
snapshot: existing.snapshot,
|
|
283
|
-
verified: verified.ok,
|
|
284
|
-
tamperReason: verified.ok ? null : verified.reason,
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return {
|
|
289
|
-
checkpoint: checkpoint,
|
|
290
|
-
read: read,
|
|
291
|
-
sidecarPath: sidecarPath,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
module.exports = {
|
|
296
|
-
create: create,
|
|
297
|
-
ConfigDriftError: ConfigDriftError,
|
|
298
|
-
// Test-only export for hashing — operators don't need this directly.
|
|
299
|
-
_hashSnapshot: _hashSnapshot,
|
|
300
|
-
_stableStringify: _stableStringify,
|
|
301
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* config-drift — boot-time config-baseline capture + signed sidecar +
|
|
4
|
+
* next-boot drift detection.
|
|
5
|
+
*
|
|
6
|
+
* The framework's audit chain captures DATA writes (every row, every
|
|
7
|
+
* key change). It does NOT capture RUNTIME CONFIG (the operator
|
|
8
|
+
* silently changed `allowedOrigins` 3 weeks ago and we have no
|
|
9
|
+
* signal). config-drift fills that gap: at every boot, the operator
|
|
10
|
+
* passes the baseline config snapshot they want tracked. The
|
|
11
|
+
* primitive hashes it with SHA3-512, signs the digest with the audit-
|
|
12
|
+
* signing key, and writes the result to a sidecar at
|
|
13
|
+
* `<dataDir>/config-baseline.sig`. On the next boot, the sidecar is
|
|
14
|
+
* loaded + verified + diffed against the new snapshot. Drift surfaces
|
|
15
|
+
* as an audit event (`config.drift.detected`); no boot block — the
|
|
16
|
+
* operator may have a legitimate reason to change config and the
|
|
17
|
+
* framework's job is to make the change auditable, not to refuse to
|
|
18
|
+
* start.
|
|
19
|
+
*
|
|
20
|
+
* var configDrift = b.configDrift.create({
|
|
21
|
+
* dataDir: "/data",
|
|
22
|
+
* audit: b.audit,
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* await configDrift.checkpoint({
|
|
26
|
+
* // operator decides what's tracked. JSON-stringifiable.
|
|
27
|
+
* allowedOrigins: ["https://app.example.com"],
|
|
28
|
+
* csp: "default-src 'self'",
|
|
29
|
+
* auditMode: b.audit.getMode(),
|
|
30
|
+
* vaultMode: b.vault.getMode(),
|
|
31
|
+
* dbAtRest: b.db.getAtRestMode(),
|
|
32
|
+
* });
|
|
33
|
+
* // → { signed: true, drifted: false, previousAt: 1730000000000 }
|
|
34
|
+
*
|
|
35
|
+
* The signed sidecar uses b.auditSign — same SLH-DSA-SHAKE-256f keypair
|
|
36
|
+
* the audit chain anchors on. An attacker who flips a config value
|
|
37
|
+
* would also need to forge the signing key to update the sidecar
|
|
38
|
+
* cleanly; otherwise next-boot verify catches the tamper.
|
|
39
|
+
*
|
|
40
|
+
* Validation:
|
|
41
|
+
* - create() opts: throw at boot on bad shape
|
|
42
|
+
* - checkpoint() snapshot: must be a JSON-serialisable object
|
|
43
|
+
* - sidecar verify failure (tampered, key rotated, missing pubkey)
|
|
44
|
+
* surfaces as `config.baseline.tamper` audit event AND the call
|
|
45
|
+
* returns { tamper: true } so the operator can decide whether
|
|
46
|
+
* to refuse boot
|
|
47
|
+
*/
|
|
48
|
+
var fs = require("node:fs");
|
|
49
|
+
var path = require("node:path");
|
|
50
|
+
var auditSign = require("./audit-sign");
|
|
51
|
+
var canonicalJson = require("./canonical-json");
|
|
52
|
+
var crypto = require("./crypto");
|
|
53
|
+
var lazyRequire = require("./lazy-require");
|
|
54
|
+
var safeJson = require("./safe-json");
|
|
55
|
+
var validateOpts = require("./validate-opts");
|
|
56
|
+
var { defineClass } = require("./framework-error");
|
|
57
|
+
|
|
58
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
59
|
+
|
|
60
|
+
var ConfigDriftError = defineClass("ConfigDriftError", { alwaysPermanent: true });
|
|
61
|
+
var _err = ConfigDriftError.factory;
|
|
62
|
+
|
|
63
|
+
var SIDECAR_NAME = "config-baseline.sig";
|
|
64
|
+
var SIDECAR_VERSION = 1;
|
|
65
|
+
|
|
66
|
+
// Stable JSON serialization via the shared lib/canonical-json walker.
|
|
67
|
+
// Deterministic key order so the same snapshot always hashes to the same
|
|
68
|
+
// digest. Pre-v0.6.67 the in-line implementation silently lost Date /
|
|
69
|
+
// Map / Set / Buffer / BigInt content; the shared walker handles all
|
|
70
|
+
// of those + circular refs. Same bytes as audit-chain / audit-tools /
|
|
71
|
+
// pagination would produce for the same input.
|
|
72
|
+
function _stableStringify(value) { return canonicalJson.stringify(value); }
|
|
73
|
+
|
|
74
|
+
function _hashSnapshot(snapshot) {
|
|
75
|
+
return crypto.sha3Hash(_stableStringify(snapshot));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _diffShallow(prev, next) {
|
|
79
|
+
// For drift reporting only — names the keys that changed without
|
|
80
|
+
// dumping the full snapshot (which may carry sensitive values like
|
|
81
|
+
// a CSP nonce-derivation key). Operators reading the drift event
|
|
82
|
+
// get "these keys changed" + a hash on each side.
|
|
83
|
+
var changed = [];
|
|
84
|
+
var added = [];
|
|
85
|
+
var removed = [];
|
|
86
|
+
var allKeys = {};
|
|
87
|
+
Object.keys(prev || {}).forEach(function (k) { allKeys[k] = true; });
|
|
88
|
+
Object.keys(next || {}).forEach(function (k) { allKeys[k] = true; });
|
|
89
|
+
Object.keys(allKeys).forEach(function (k) {
|
|
90
|
+
var inPrev = Object.prototype.hasOwnProperty.call(prev || {}, k);
|
|
91
|
+
var inNext = Object.prototype.hasOwnProperty.call(next || {}, k);
|
|
92
|
+
if (inPrev && !inNext) { removed.push(k); return; }
|
|
93
|
+
if (!inPrev && inNext) { added.push(k); return; }
|
|
94
|
+
if (_stableStringify(prev[k]) !== _stableStringify(next[k])) changed.push(k);
|
|
95
|
+
});
|
|
96
|
+
return { changed: changed, added: added, removed: removed };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function create(opts) {
|
|
100
|
+
opts = opts || {};
|
|
101
|
+
validateOpts(opts, [
|
|
102
|
+
"dataDir", "audit", "baseline", "criticalKeys", "ignoreKeys",
|
|
103
|
+
], "configDrift");
|
|
104
|
+
validateOpts.requireNonEmptyString(opts.dataDir, "create: opts.dataDir", ConfigDriftError, "BAD_OPT");
|
|
105
|
+
var dataDir = opts.dataDir;
|
|
106
|
+
var auditOn = opts.audit !== false;
|
|
107
|
+
var auditInstance = (opts.audit && opts.audit !== true) ? opts.audit : null;
|
|
108
|
+
// Multi-baseline support — each operator-named baseline lives in its
|
|
109
|
+
// own sidecar so production / staging / disaster-recovery deploys
|
|
110
|
+
// each track their own drift independently.
|
|
111
|
+
var baselineName = (typeof opts.baseline === "string" && opts.baseline.length > 0)
|
|
112
|
+
? opts.baseline : "default";
|
|
113
|
+
// Critical-keys allowlist: drift in these keys raises severity to
|
|
114
|
+
// "high" in the audit emission so SIEM rules can page on them
|
|
115
|
+
// separately from cosmetic drift. null = every key is treated as
|
|
116
|
+
// high severity (the safer default).
|
|
117
|
+
var criticalKeys = Array.isArray(opts.criticalKeys) ? opts.criticalKeys.slice() : null;
|
|
118
|
+
// Ignore-keys: drift in these keys is excluded from drift detection
|
|
119
|
+
// (e.g. operator-tracked metadata that legitimately changes per
|
|
120
|
+
// boot). Captured in the snapshot but never flagged.
|
|
121
|
+
var ignoreKeys = Array.isArray(opts.ignoreKeys) ? opts.ignoreKeys.slice() : [];
|
|
122
|
+
var sidecarPath = path.join(dataDir,
|
|
123
|
+
baselineName === "default" ? SIDECAR_NAME : ("config-baseline-" + baselineName + ".sig"));
|
|
124
|
+
|
|
125
|
+
function _emit(action, info, outcome) {
|
|
126
|
+
if (!auditOn) return;
|
|
127
|
+
var sink = auditInstance || audit();
|
|
128
|
+
try {
|
|
129
|
+
sink.safeEmit({
|
|
130
|
+
action: action,
|
|
131
|
+
outcome: outcome,
|
|
132
|
+
metadata: info || {},
|
|
133
|
+
reason: info && info.reason ? info.reason : null,
|
|
134
|
+
});
|
|
135
|
+
} catch (_e) { /* audit best-effort */ }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _readSidecar() {
|
|
139
|
+
if (!fs.existsSync(sidecarPath)) return null;
|
|
140
|
+
var raw;
|
|
141
|
+
try { raw = fs.readFileSync(sidecarPath, "utf8"); }
|
|
142
|
+
catch (_e) { return null; }
|
|
143
|
+
var parsed;
|
|
144
|
+
try { parsed = safeJson.parse(raw); }
|
|
145
|
+
catch (_e) { return { unreadable: true }; }
|
|
146
|
+
if (!parsed || parsed.version !== SIDECAR_VERSION) return { unreadable: true };
|
|
147
|
+
if (typeof parsed.digestHex !== "string" || typeof parsed.signatureBase64 !== "string" ||
|
|
148
|
+
typeof parsed.publicKeyPem !== "string" || typeof parsed.snapshot !== "object") {
|
|
149
|
+
return { unreadable: true };
|
|
150
|
+
}
|
|
151
|
+
return parsed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _writeSidecar(snapshot, digestHex) {
|
|
155
|
+
// Sign over the digest (not the snapshot bytes directly) so the
|
|
156
|
+
// sidecar stays small even when the snapshot is large.
|
|
157
|
+
var signature = auditSign.sign(digestHex);
|
|
158
|
+
var payload = {
|
|
159
|
+
version: SIDECAR_VERSION,
|
|
160
|
+
capturedAt: Date.now(),
|
|
161
|
+
digestHex: digestHex,
|
|
162
|
+
signatureBase64: Buffer.from(signature).toString("base64"),
|
|
163
|
+
publicKeyPem: auditSign.getPublicKey(),
|
|
164
|
+
snapshot: snapshot,
|
|
165
|
+
};
|
|
166
|
+
var tmp = sidecarPath + ".tmp";
|
|
167
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
168
|
+
fs.renameSync(tmp, sidecarPath);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _verifySidecar(parsed) {
|
|
172
|
+
// Verify the recorded signature against the recorded public key
|
|
173
|
+
// first — this catches tampering with the digest+sig pair on its
|
|
174
|
+
// own. Then verify the digest matches a re-hash of the recorded
|
|
175
|
+
// snapshot — catches tampering with the snapshot field.
|
|
176
|
+
var sigBuf = Buffer.from(parsed.signatureBase64, "base64");
|
|
177
|
+
if (!auditSign.verify(parsed.digestHex, sigBuf, parsed.publicKeyPem)) {
|
|
178
|
+
return { ok: false, reason: "signature-invalid" };
|
|
179
|
+
}
|
|
180
|
+
if (_hashSnapshot(parsed.snapshot) !== parsed.digestHex) {
|
|
181
|
+
return { ok: false, reason: "digest-mismatch" };
|
|
182
|
+
}
|
|
183
|
+
return { ok: true };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function checkpoint(snapshot) {
|
|
187
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
188
|
+
throw _err("BAD_OPT", "checkpoint: snapshot must be a plain object");
|
|
189
|
+
}
|
|
190
|
+
var newDigest = _hashSnapshot(snapshot);
|
|
191
|
+
|
|
192
|
+
var existing = _readSidecar();
|
|
193
|
+
if (existing && existing.unreadable) {
|
|
194
|
+
_emit("config.baseline.unreadable",
|
|
195
|
+
{ sidecar: sidecarPath, reason: "sidecar present but malformed or wrong version" },
|
|
196
|
+
"warning");
|
|
197
|
+
_writeSidecar(snapshot, newDigest);
|
|
198
|
+
return { signed: true, drifted: false, tamper: false, previousAt: null, reason: "sidecar-unreadable-rewritten" };
|
|
199
|
+
}
|
|
200
|
+
if (!existing) {
|
|
201
|
+
_writeSidecar(snapshot, newDigest);
|
|
202
|
+
_emit("config.baseline.captured",
|
|
203
|
+
{ digestHex: newDigest, capturedAt: Date.now() }, "success");
|
|
204
|
+
return { signed: true, drifted: false, tamper: false, previousAt: null };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
var verified = _verifySidecar(existing);
|
|
208
|
+
if (!verified.ok) {
|
|
209
|
+
_emit("config.baseline.tamper",
|
|
210
|
+
{ sidecar: sidecarPath, reason: verified.reason, previousAt: existing.capturedAt },
|
|
211
|
+
"failure");
|
|
212
|
+
// DO NOT auto-rewrite on tamper — let the operator inspect first.
|
|
213
|
+
return { signed: false, drifted: false, tamper: true, reason: verified.reason, previousAt: existing.capturedAt };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (existing.digestHex === newDigest) {
|
|
217
|
+
// No drift; refresh capturedAt so the sidecar timestamp moves
|
|
218
|
+
// forward each successful boot (operator can see "last verified
|
|
219
|
+
// clean at <T>" in the file).
|
|
220
|
+
_writeSidecar(snapshot, newDigest);
|
|
221
|
+
return { signed: true, drifted: false, tamper: false, previousAt: existing.capturedAt };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
var diff = _diffShallow(existing.snapshot, snapshot);
|
|
225
|
+
// Filter ignore-keys out of the drift report.
|
|
226
|
+
function _stripIgnored(arr) {
|
|
227
|
+
return arr.filter(function (k) { return ignoreKeys.indexOf(k) === -1; });
|
|
228
|
+
}
|
|
229
|
+
diff.changed = _stripIgnored(diff.changed);
|
|
230
|
+
diff.added = _stripIgnored(diff.added);
|
|
231
|
+
diff.removed = _stripIgnored(diff.removed);
|
|
232
|
+
if (diff.changed.length === 0 && diff.added.length === 0 && diff.removed.length === 0) {
|
|
233
|
+
// All drift was in ignore-keys — refresh sidecar without
|
|
234
|
+
// emitting a drift event.
|
|
235
|
+
_writeSidecar(snapshot, newDigest);
|
|
236
|
+
return { signed: true, drifted: false, tamper: false,
|
|
237
|
+
previousAt: existing.capturedAt, ignoredOnly: true };
|
|
238
|
+
}
|
|
239
|
+
// Severity classification: HIGH when any drifted key is in
|
|
240
|
+
// criticalKeys (or no allowlist is configured — every key is
|
|
241
|
+
// critical by default). LOW when criticalKeys is set and none
|
|
242
|
+
// of the drifted keys are in it.
|
|
243
|
+
var severity = "high";
|
|
244
|
+
if (criticalKeys !== null) {
|
|
245
|
+
var anyCritical = false;
|
|
246
|
+
var allDrifted = diff.changed.concat(diff.added, diff.removed);
|
|
247
|
+
for (var di = 0; di < allDrifted.length; di++) {
|
|
248
|
+
if (criticalKeys.indexOf(allDrifted[di]) !== -1) { anyCritical = true; break; }
|
|
249
|
+
}
|
|
250
|
+
severity = anyCritical ? "high" : "low";
|
|
251
|
+
}
|
|
252
|
+
_emit("config.drift.detected",
|
|
253
|
+
{
|
|
254
|
+
baseline: baselineName,
|
|
255
|
+
previousDigestHex: existing.digestHex,
|
|
256
|
+
currentDigestHex: newDigest,
|
|
257
|
+
previousAt: existing.capturedAt,
|
|
258
|
+
keysChanged: diff.changed,
|
|
259
|
+
keysAdded: diff.added,
|
|
260
|
+
keysRemoved: diff.removed,
|
|
261
|
+
severity: severity,
|
|
262
|
+
},
|
|
263
|
+
severity === "high" ? "failure" : "warning");
|
|
264
|
+
_writeSidecar(snapshot, newDigest);
|
|
265
|
+
return {
|
|
266
|
+
signed: true,
|
|
267
|
+
drifted: true,
|
|
268
|
+
tamper: false,
|
|
269
|
+
severity: severity,
|
|
270
|
+
previousAt: existing.capturedAt,
|
|
271
|
+
diff: diff,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function read() {
|
|
276
|
+
var existing = _readSidecar();
|
|
277
|
+
if (!existing || existing.unreadable) return null;
|
|
278
|
+
var verified = _verifySidecar(existing);
|
|
279
|
+
return {
|
|
280
|
+
capturedAt: existing.capturedAt,
|
|
281
|
+
digestHex: existing.digestHex,
|
|
282
|
+
snapshot: existing.snapshot,
|
|
283
|
+
verified: verified.ok,
|
|
284
|
+
tamperReason: verified.ok ? null : verified.reason,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
checkpoint: checkpoint,
|
|
290
|
+
read: read,
|
|
291
|
+
sidecarPath: sidecarPath,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = {
|
|
296
|
+
create: create,
|
|
297
|
+
ConfigDriftError: ConfigDriftError,
|
|
298
|
+
// Test-only export for hashing — operators don't need this directly.
|
|
299
|
+
_hashSnapshot: _hashSnapshot,
|
|
300
|
+
_stableStringify: _stableStringify,
|
|
301
|
+
};
|