@blamejs/core 0.8.43 → 0.8.49
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 +92 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
package/lib/audit.js
CHANGED
|
@@ -1,39 +1,52 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* audit
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
3
|
+
* @module b.audit
|
|
4
|
+
* @featured true
|
|
5
|
+
* @nav Observability
|
|
6
|
+
* @title Audit
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Tamper-evident, append-only record of every privileged action — the
|
|
10
|
+
* forensic surface every compliance posture (HIPAA / PCI-DSS / SOC 2 /
|
|
11
|
+
* GDPR / SOX / DORA) bottoms out on. The `audit_log` table is baked
|
|
12
|
+
* into db.js's schema runner so apps cannot opt out; the chain is
|
|
13
|
+
* verified at boot and a break refuses-to-boot.
|
|
14
|
+
*
|
|
15
|
+
* Hash chain: every row carries `prevHash` + `rowHash` computed over
|
|
16
|
+
* the SEALED form of the row plus a nonce. Verification recomputes
|
|
17
|
+
* directly from disk without unsealing — auditors can confirm
|
|
18
|
+
* integrity without holding the vault key. Periodic SLH-DSA-SHAKE-256f
|
|
19
|
+
* checkpoints (post-quantum signatures over the chain tip) anchor the
|
|
20
|
+
* chain to off-line evidence; tampering that recomputes hashes still
|
|
21
|
+
* fails checkpoint verification.
|
|
22
|
+
*
|
|
23
|
+
* Namespaces: framework owns `auth.*` / `system.*` / `audit.*` /
|
|
24
|
+
* `consent.*` / `subject.*`; apps call `registerNamespace("orders")`
|
|
25
|
+
* at boot before emitting `orders.created`. Unregistered namespaces
|
|
26
|
+
* are rejected so typos don't become silent unobservable events.
|
|
27
|
+
*
|
|
28
|
+
* Action shape — the 5W form: WHO (`actor.userId` / sessionId / ip /
|
|
29
|
+
* userAgent), WHAT (`action` = "namespace.verb[.qualifier]"), WHEN
|
|
30
|
+
* (`recordedAt` ms epoch + monotonic counter), WHERE (`resource.kind`
|
|
31
|
+
* / id), HOW (`outcome` ∈ {success, failure, denied} + `reason` +
|
|
32
|
+
* `metadata`).
|
|
33
|
+
*
|
|
34
|
+
* Two emit paths:
|
|
35
|
+
* - `record(event)` — async, throws on bad input, awaits the chain
|
|
36
|
+
* append. Use when the caller needs durability before continuing.
|
|
37
|
+
* - `emit(event)` / `safeEmit(event)` — synchronous fire-and-forget;
|
|
38
|
+
* events buffer in an AsyncHandler and drain serially through
|
|
39
|
+
* record(). `safeEmit` is drop-silent on malformed input by
|
|
40
|
+
* design: it runs in request hot paths where throwing would crash
|
|
41
|
+
* the request that triggered the audit attempt.
|
|
42
|
+
*
|
|
43
|
+
* Reserved metadata keys: `traceId` (cross-request correlation,
|
|
44
|
+
* `beginTrace()` mints), `parentEventId`, `before` / `after` (state
|
|
45
|
+
* diff for change events), `evidenceRef` (pointer to signed PDF /
|
|
46
|
+
* ticket).
|
|
47
|
+
*
|
|
48
|
+
* @card
|
|
49
|
+
* Tamper-evident, append-only record of every privileged action — the forensic surface every compliance posture (HIPAA / PCI-DSS / SOC 2 / GDPR / SOX / DORA) bottoms out on.
|
|
37
50
|
*/
|
|
38
51
|
var auditChain = require("./audit-chain");
|
|
39
52
|
var auditSign = require("./audit-sign");
|
|
@@ -42,6 +55,7 @@ var cluster = require("./cluster");
|
|
|
42
55
|
var clusterStorage = require("./cluster-storage");
|
|
43
56
|
var { generateToken } = require("./crypto");
|
|
44
57
|
var cryptoField = require("./crypto-field");
|
|
58
|
+
var dbRoleContext = require("./db-role-context");
|
|
45
59
|
var handlers = require("./handlers");
|
|
46
60
|
var { boot } = require("./log");
|
|
47
61
|
var redact = require("./redact");
|
|
@@ -49,7 +63,7 @@ var safeAsync = require("./safe-async");
|
|
|
49
63
|
var C = require("./constants");
|
|
50
64
|
var lazyRequire = require("./lazy-require");
|
|
51
65
|
var observability = require("./observability");
|
|
52
|
-
var { ClusterError } = require("./framework-error");
|
|
66
|
+
var { AuditSegregationError, ClusterError } = require("./framework-error");
|
|
53
67
|
|
|
54
68
|
var log = boot("audit");
|
|
55
69
|
|
|
@@ -226,6 +240,7 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
226
240
|
"inbox", // b.inbox (inbox.received / handled / handle_failed / swept)
|
|
227
241
|
"flag", // b.flag (flag.evaluated / flag.evaluation.error / flag.cache.bust)
|
|
228
242
|
"permissions", // b.permissions
|
|
243
|
+
"pqcagent", // b.pqcAgent (pqcagent.operator_group.accepted)
|
|
229
244
|
"restore", // b.restore
|
|
230
245
|
"retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
|
|
231
246
|
"scheduler", // b.scheduler (lifecycle: scheduler.start / scheduler.stop;
|
|
@@ -252,6 +267,27 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
252
267
|
"csp", // b.middleware.cspReport (csp.violation)
|
|
253
268
|
"resourceaccesslock", // b.resourceAccessLock (resourceaccesslock.mode_changed / refused)
|
|
254
269
|
"process", // b.processSpawn (process.spawn / process.spawn.failed)
|
|
270
|
+
"keychain", // b.keychain (keychain.stored / keychain.retrieved / keychain.removed)
|
|
271
|
+
"fda21cfr11", // b.fda21cfr11 (signature.created / verified / gxp.assert_failed / audit.refused / posture.installed)
|
|
272
|
+
"ddl", // b.ddlChangeControl (ddl.change.proposed / approved / rejected / applied / apply_refused)
|
|
273
|
+
"migrations", // b.migrations + b.externalDb.migrate (migrations.history.appended / verified / tampered)
|
|
274
|
+
"dlp", // b.redact.installOutboundDlp (dlp.outbound.refused / redacted / scanned / installed)
|
|
275
|
+
"session", // b.sessionDeviceBinding (session.device.bound / drift / refused)
|
|
276
|
+
"sandbox", // b.sandbox (sandbox.run / sandbox.run.refused — operator-supplied transform isolation)
|
|
277
|
+
"safeurl", // b.safeUrl.parse (safeurl.idn_homograph.refused — UTS #39 mixed-script host-label refusal)
|
|
278
|
+
"http", // b.middleware.bodyParser (http.chunked.malformed.refused — RFC 9112 §7.1 chunked-decode failure with Connection: close) // allow:raw-byte-literal — RFC number in prose
|
|
279
|
+
"cryptofield", // b.cryptoField.eraseRow (cryptofield.vacuum.skipped — F-RTBF-2 vacuum-after-erase signal when DB not initialized at erase time)
|
|
280
|
+
"acme", // b.acme (acme.account.registered / order.* / cert.issued / cert.renewed / cert.renew.skipped — RFC 8555 + RFC 9773 ARI workflow)
|
|
281
|
+
"tls", // b.router 0-RTT posture (tls.0rtt.refused / tls.0rtt.replayed) — RFC 8446 §8 anti-replay surface // allow:raw-byte-literal — RFC number in prose
|
|
282
|
+
"workerpool", // b.workerPool (workerpool.created / terminated / task.completed / task.failed / task.timeout / spawn.failed — generic worker_threads pool)
|
|
283
|
+
"jwt", // b.auth.jwt-external (jwt.jwe.refused — RFC 7516 5-segment JWE refusal)
|
|
284
|
+
"dr", // b.drRunbook (dr.runbook.emitted)
|
|
285
|
+
"guardfilename", // b.guardFilename (guardfilename.sanitize.stripped)
|
|
286
|
+
"legalhold", // b.legalHold (legalhold.placed / released / place_rejected / release_rejected)
|
|
287
|
+
"networkheartbeat", // b.network.heartbeat.passive (networkheartbeat.passive.timeout)
|
|
288
|
+
"router", // b.router (router.redirect.cross_origin.refused / allowed)
|
|
289
|
+
"http2", // b.router h2 GOAWAY tracker (http2.window_update.refused — CVE-2026-21714)
|
|
290
|
+
"tenant", // b.tenantQuota (tenant.quota.exceeded / tenant.budget.exceeded / tenant.crossover)
|
|
255
291
|
];
|
|
256
292
|
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
257
293
|
|
|
@@ -293,6 +329,25 @@ var _chainWriter = chainWriter.create({
|
|
|
293
329
|
|
|
294
330
|
// ---- Public API ----
|
|
295
331
|
|
|
332
|
+
/**
|
|
333
|
+
* @primitive b.audit.registerNamespace
|
|
334
|
+
* @signature b.audit.registerNamespace(name)
|
|
335
|
+
* @since 0.1.0
|
|
336
|
+
* @related b.audit.record, b.audit.safeEmit
|
|
337
|
+
*
|
|
338
|
+
* Register an action namespace at app bootstrap so `record()` / `emit()`
|
|
339
|
+
* accept events under it. Names must match `[a-z][a-z0-9_]*`. Calling
|
|
340
|
+
* twice is a no-op. Framework namespaces (auth / system / audit /
|
|
341
|
+
* consent / subject + every per-primitive namespace) are pre-registered.
|
|
342
|
+
*
|
|
343
|
+
* @example
|
|
344
|
+
* b.audit.registerNamespace("orders");
|
|
345
|
+
* b.audit.safeEmit({
|
|
346
|
+
* action: "orders.shipped",
|
|
347
|
+
* actor: { userId: "u-42" },
|
|
348
|
+
* outcome: "success",
|
|
349
|
+
* });
|
|
350
|
+
*/
|
|
296
351
|
function registerNamespace(name) {
|
|
297
352
|
if (typeof name !== "string" || !/^[a-z][a-z0-9_]*$/.test(name)) {
|
|
298
353
|
throw new Error("audit namespace must match [a-z][a-z0-9_]* — got: " + name);
|
|
@@ -316,6 +371,42 @@ function _validateAction(action) {
|
|
|
316
371
|
}
|
|
317
372
|
}
|
|
318
373
|
|
|
374
|
+
/**
|
|
375
|
+
* @primitive b.audit.record
|
|
376
|
+
* @signature b.audit.record(event)
|
|
377
|
+
* @since 0.1.0
|
|
378
|
+
* @compliance hipaa, pci-dss, gdpr, soc2, sox-404
|
|
379
|
+
* @related b.audit.safeEmit, b.audit.emit, b.audit.flush
|
|
380
|
+
*
|
|
381
|
+
* Append one event to the audit chain and await durability. Throws on a
|
|
382
|
+
* bad action shape, an unregistered namespace, or an outcome outside
|
|
383
|
+
* {success, failure, denied}. The chain-writer serializes the actual
|
|
384
|
+
* INSERT under a mutex so concurrent record() calls produce a strictly
|
|
385
|
+
* monotonic counter and a valid prevHash → rowHash chain.
|
|
386
|
+
*
|
|
387
|
+
* Use record() when the caller must know the row landed before
|
|
388
|
+
* continuing (consent grants, break-glass unseals, change-control
|
|
389
|
+
* approvals). For request hot paths where best-effort is acceptable,
|
|
390
|
+
* prefer safeEmit().
|
|
391
|
+
*
|
|
392
|
+
* @opts
|
|
393
|
+
* actor: { userId, ip, userAgent, sessionId },
|
|
394
|
+
* action: "namespace.verb[.qualifier]",
|
|
395
|
+
* resource: { kind, id },
|
|
396
|
+
* outcome: "success" | "failure" | "denied",
|
|
397
|
+
* reason: string,
|
|
398
|
+
* metadata: object, // serialized to JSON
|
|
399
|
+
* requestId: string,
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* await b.audit.record({
|
|
403
|
+
* actor: { userId: "u-42", ip: "10.0.0.1" },
|
|
404
|
+
* action: "consent.granted",
|
|
405
|
+
* resource: { kind: "purpose", id: "marketing" },
|
|
406
|
+
* outcome: "success",
|
|
407
|
+
* metadata: { traceId: b.audit.beginTrace() },
|
|
408
|
+
* });
|
|
409
|
+
*/
|
|
319
410
|
async function record(event) {
|
|
320
411
|
if (!event || typeof event !== "object") {
|
|
321
412
|
throw new Error("audit.record requires an event object");
|
|
@@ -364,6 +455,39 @@ async function record(event) {
|
|
|
364
455
|
// (otherwise legitimate audit auditing produces a Russell-set spiral).
|
|
365
456
|
var _selfLogging = false;
|
|
366
457
|
|
|
458
|
+
/**
|
|
459
|
+
* @primitive b.audit.query
|
|
460
|
+
* @signature b.audit.query(criteria)
|
|
461
|
+
* @since 0.1.0
|
|
462
|
+
* @compliance pci-dss, soc2
|
|
463
|
+
* @related b.audit.verify, b.audit.verifyCheckpoints
|
|
464
|
+
*
|
|
465
|
+
* Read audit rows matching the criteria, returning unsealed rows for
|
|
466
|
+
* the auditor's view. Every call self-logs an `audit.read` event before
|
|
467
|
+
* returning (PCI DSS 10.2.3) so exfiltration attempts are forensically
|
|
468
|
+
* visible; recursion is guarded so the self-log doesn't trigger its own
|
|
469
|
+
* self-log. Plain-field criteria translate into derived-hash equality
|
|
470
|
+
* where the column is sealed.
|
|
471
|
+
*
|
|
472
|
+
* @opts
|
|
473
|
+
* from: number | Date | string, // recordedAt >=
|
|
474
|
+
* to: number | Date | string, // recordedAt <=
|
|
475
|
+
* actorUserId: string,
|
|
476
|
+
* resourceId: string,
|
|
477
|
+
* action: string,
|
|
478
|
+
* resourceKind: string,
|
|
479
|
+
* outcome: "success" | "failure" | "denied",
|
|
480
|
+
* limit: number,
|
|
481
|
+
* offset: number,
|
|
482
|
+
*
|
|
483
|
+
* @example
|
|
484
|
+
* var rows = await b.audit.query({
|
|
485
|
+
* action: "consent.granted",
|
|
486
|
+
* from: Date.now() - 86400000,
|
|
487
|
+
* limit: 100,
|
|
488
|
+
* });
|
|
489
|
+
* rows.length; // → 42
|
|
490
|
+
*/
|
|
367
491
|
async function query(criteria) {
|
|
368
492
|
criteria = criteria || {};
|
|
369
493
|
if (!_selfLogging && criteria.action !== "audit.read") {
|
|
@@ -462,6 +586,30 @@ function _redactCriteria(c) {
|
|
|
462
586
|
// bytes hex-encoded → 32 chars). Routed through C.BYTES so the byte
|
|
463
587
|
// count has a single source of truth.
|
|
464
588
|
var TRACE_ID_BYTES = C.BYTES.bytes(16);
|
|
589
|
+
/**
|
|
590
|
+
* @primitive b.audit.beginTrace
|
|
591
|
+
* @signature b.audit.beginTrace()
|
|
592
|
+
* @since 0.1.0
|
|
593
|
+
* @related b.audit.record, b.audit.query
|
|
594
|
+
*
|
|
595
|
+
* Mint a fresh 32-hex-char trace id apps thread through linked events
|
|
596
|
+
* via `metadata.traceId`. Width matches the W3C traceparent trace-id
|
|
597
|
+
* format (16 random bytes hex-encoded), so the id is interoperable with
|
|
598
|
+
* OpenTelemetry / W3C Trace Context propagation.
|
|
599
|
+
*
|
|
600
|
+
* @example
|
|
601
|
+
* var traceId = b.audit.beginTrace();
|
|
602
|
+
* await b.audit.record({
|
|
603
|
+
* action: "subject.export.requested",
|
|
604
|
+
* outcome: "success",
|
|
605
|
+
* metadata: { traceId: traceId },
|
|
606
|
+
* });
|
|
607
|
+
* await b.audit.record({
|
|
608
|
+
* action: "subject.export.delivered",
|
|
609
|
+
* outcome: "success",
|
|
610
|
+
* metadata: { traceId: traceId, parentEventId: "..." },
|
|
611
|
+
* });
|
|
612
|
+
*/
|
|
465
613
|
function beginTrace() {
|
|
466
614
|
return generateToken(TRACE_ID_BYTES);
|
|
467
615
|
}
|
|
@@ -491,6 +639,32 @@ function _checkpointPayload(atMonotonicCounter, atRowHash, createdAt) {
|
|
|
491
639
|
// opts:
|
|
492
640
|
// skipIfUnchanged: bool — return null without inserting if the chain tip
|
|
493
641
|
// hasn't advanced since the most recent checkpoint
|
|
642
|
+
/**
|
|
643
|
+
* @primitive b.audit.checkpoint
|
|
644
|
+
* @signature b.audit.checkpoint(opts)
|
|
645
|
+
* @since 0.4.0
|
|
646
|
+
* @compliance soc2, pci-dss, sox-404
|
|
647
|
+
* @related b.audit.verifyCheckpoints, b.audit.verify
|
|
648
|
+
*
|
|
649
|
+
* Anchor the current chain tip with a fresh ML-DSA-87 (post-quantum)
|
|
650
|
+
* signature. Inserts a row into `audit_checkpoints` and updates the
|
|
651
|
+
* boot-time rollback-detection sidecar (single-node) or the cluster
|
|
652
|
+
* audit-tip row (cluster mode, fencing-token guarded). Cluster mode
|
|
653
|
+
* requires the caller hold leader status — `cluster.requireLeader()`
|
|
654
|
+
* throws otherwise.
|
|
655
|
+
*
|
|
656
|
+
* Returns the inserted checkpoint row, or `null` when the chain is
|
|
657
|
+
* empty / `skipIfUnchanged` and the tip hasn't advanced.
|
|
658
|
+
*
|
|
659
|
+
* @opts
|
|
660
|
+
* skipIfUnchanged: boolean, // null-return when tip didn't move
|
|
661
|
+
*
|
|
662
|
+
* @example
|
|
663
|
+
* var ckpt = await b.audit.checkpoint({ skipIfUnchanged: true });
|
|
664
|
+
* if (ckpt) {
|
|
665
|
+
* console.log("anchored at counter", ckpt.atMonotonicCounter);
|
|
666
|
+
* }
|
|
667
|
+
*/
|
|
494
668
|
async function checkpoint(opts) {
|
|
495
669
|
cluster.requireLeader();
|
|
496
670
|
opts = opts || {};
|
|
@@ -571,6 +745,32 @@ async function checkpoint(opts) {
|
|
|
571
745
|
// audit_log row at atMonotonicCounter still has the recorded rowHash.
|
|
572
746
|
//
|
|
573
747
|
// Returns { ok, checkpointsVerified, breakAt? }.
|
|
748
|
+
/**
|
|
749
|
+
* @primitive b.audit.verifyCheckpoints
|
|
750
|
+
* @signature b.audit.verifyCheckpoints()
|
|
751
|
+
* @since 0.4.0
|
|
752
|
+
* @compliance soc2, pci-dss, sox-404
|
|
753
|
+
* @related b.audit.checkpoint, b.audit.verify
|
|
754
|
+
*
|
|
755
|
+
* Walk every checkpoint and verify (a) the public-key fingerprint
|
|
756
|
+
* matches the current signing key, (b) the ML-DSA-87 signature over the
|
|
757
|
+
* payload still verifies, (c) the audit_log row at the anchored counter
|
|
758
|
+
* still has the recorded rowHash. Catches tampering that recomputed
|
|
759
|
+
* chain hashes after holding the vault key, because the off-chain
|
|
760
|
+
* signature anchor is unforgeable without the signing key.
|
|
761
|
+
*
|
|
762
|
+
* Returns `{ ok: true, checkpointsVerified }` on success, or
|
|
763
|
+
* `{ ok: false, checkpointsVerified, breakAt, checkpointId, reason }`
|
|
764
|
+
* at the first break.
|
|
765
|
+
*
|
|
766
|
+
* @example
|
|
767
|
+
* var result = await b.audit.verifyCheckpoints();
|
|
768
|
+
* if (!result.ok) {
|
|
769
|
+
* throw new Error("audit checkpoint break at " + result.breakAt +
|
|
770
|
+
* ": " + result.reason);
|
|
771
|
+
* }
|
|
772
|
+
* result.checkpointsVerified; // → 17
|
|
773
|
+
*/
|
|
574
774
|
async function verifyCheckpoints() {
|
|
575
775
|
var rows = await _readAllCheckpointsAsc();
|
|
576
776
|
|
|
@@ -648,6 +848,33 @@ function _toMs(value) {
|
|
|
648
848
|
|
|
649
849
|
// ---- Verify ----
|
|
650
850
|
|
|
851
|
+
/**
|
|
852
|
+
* @primitive b.audit.verify
|
|
853
|
+
* @signature b.audit.verify(opts)
|
|
854
|
+
* @since 0.1.0
|
|
855
|
+
* @compliance hipaa, pci-dss, gdpr, soc2, sox-404
|
|
856
|
+
* @related b.audit.verifyCheckpoints, b.audit.query
|
|
857
|
+
*
|
|
858
|
+
* Walk every audit_log row in monotonic order and recompute each
|
|
859
|
+
* `rowHash` against the canonicalized columns + nonce, confirming each
|
|
860
|
+
* row's `prevHash` matches the previous row's `rowHash`. Catches any
|
|
861
|
+
* insert / delete / mutation between checkpoints. Runs at boot in
|
|
862
|
+
* `db.init()`; operators also call it from a periodic job.
|
|
863
|
+
*
|
|
864
|
+
* Returns `{ ok: true, rowsVerified }` on a clean chain, or
|
|
865
|
+
* `{ ok: false, rowsVerified, breakAt, reason }` at the first break.
|
|
866
|
+
*
|
|
867
|
+
* @opts
|
|
868
|
+
* from: number, // start counter (incremental verify after a known-good checkpoint)
|
|
869
|
+
* to: number, // end counter
|
|
870
|
+
*
|
|
871
|
+
* @example
|
|
872
|
+
* var result = await b.audit.verify();
|
|
873
|
+
* if (!result.ok) {
|
|
874
|
+
* console.error("audit chain break at row", result.breakAt);
|
|
875
|
+
* process.exit(1);
|
|
876
|
+
* }
|
|
877
|
+
*/
|
|
651
878
|
async function verify(opts) {
|
|
652
879
|
// verifyChain just needs an executeAll; route through the same
|
|
653
880
|
// resilience-wrapped reader the rest of audit uses.
|
|
@@ -752,6 +979,28 @@ function _ensureHandler() {
|
|
|
752
979
|
return _auditHandler;
|
|
753
980
|
}
|
|
754
981
|
|
|
982
|
+
/**
|
|
983
|
+
* @primitive b.audit.emit
|
|
984
|
+
* @signature b.audit.emit(event)
|
|
985
|
+
* @since 0.1.0
|
|
986
|
+
* @related b.audit.safeEmit, b.audit.record, b.audit.flush
|
|
987
|
+
*
|
|
988
|
+
* Synchronous fire-and-forget emit — events buffer in an AsyncHandler
|
|
989
|
+
* and drain serially through `record()`. Returns immediately; never
|
|
990
|
+
* returns a Promise. Unlike `safeEmit()`, emit() does NOT normalize
|
|
991
|
+
* outcome / action and does NOT redact metadata — callers pass already-
|
|
992
|
+
* shaped events. Most call sites should prefer `safeEmit` instead;
|
|
993
|
+
* `emit` is the lower-level surface the framework's own bound-actor
|
|
994
|
+
* wrapper uses.
|
|
995
|
+
*
|
|
996
|
+
* @example
|
|
997
|
+
* b.audit.emit({
|
|
998
|
+
* actor: { userId: "u-42" },
|
|
999
|
+
* action: "system.config.reloaded",
|
|
1000
|
+
* outcome: "success",
|
|
1001
|
+
* metadata: { source: "SIGHUP" },
|
|
1002
|
+
* });
|
|
1003
|
+
*/
|
|
755
1004
|
function emit(event) {
|
|
756
1005
|
_ensureHandler().emit(event);
|
|
757
1006
|
}
|
|
@@ -818,6 +1067,43 @@ function _normalizeAction(action) {
|
|
|
818
1067
|
// record() and await it with their own error handling. The audit chain
|
|
819
1068
|
// itself is verified at boot, so a silently dropped row shows up in the
|
|
820
1069
|
// next chain integrity sweep.
|
|
1070
|
+
/**
|
|
1071
|
+
* @primitive b.audit.safeEmit
|
|
1072
|
+
* @signature b.audit.safeEmit(event)
|
|
1073
|
+
* @since 0.1.0
|
|
1074
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
1075
|
+
* @related b.audit.emit, b.audit.record, b.audit.flush
|
|
1076
|
+
*
|
|
1077
|
+
* Hot-path-safe fire-and-forget audit emit. Drop-silent on malformed
|
|
1078
|
+
* input by design — safeEmit runs from request middleware, log-stream
|
|
1079
|
+
* hooks, and finalizers where throwing on a missing `action` would
|
|
1080
|
+
* crash the request that triggered the audit attempt. Operators who
|
|
1081
|
+
* need durability guarantees call `record()` and await it.
|
|
1082
|
+
*
|
|
1083
|
+
* Built-in normalization: action segments with hyphens become
|
|
1084
|
+
* underscores ("biometric-id" → "biometric_id"); outcome aliases
|
|
1085
|
+
* collapse to {success, failure, denied} ("ok" → "success", "error" →
|
|
1086
|
+
* "failure", "refused" → "denied"). Actor / reason / metadata pass
|
|
1087
|
+
* through `b.redact.redact()` so connection strings, JWTs, PEM blocks,
|
|
1088
|
+
* AWS keys, and SSNs are scrubbed before they reach the chain.
|
|
1089
|
+
*
|
|
1090
|
+
* @opts
|
|
1091
|
+
* actor: { userId, ip, userAgent, sessionId },
|
|
1092
|
+
* action: "namespace.verb[.qualifier]",
|
|
1093
|
+
* resource: { kind, id },
|
|
1094
|
+
* outcome: string, // normalized
|
|
1095
|
+
* reason: string, // redacted
|
|
1096
|
+
* metadata: object, // redacted
|
|
1097
|
+
* requestId: string,
|
|
1098
|
+
*
|
|
1099
|
+
* @example
|
|
1100
|
+
* b.audit.safeEmit({
|
|
1101
|
+
* actor: { userId: req.user && req.user.id },
|
|
1102
|
+
* action: "auth.login",
|
|
1103
|
+
* outcome: "success",
|
|
1104
|
+
* metadata: { traceId: req.traceId, ua: req.headers["user-agent"] },
|
|
1105
|
+
* });
|
|
1106
|
+
*/
|
|
821
1107
|
function safeEmit(event) {
|
|
822
1108
|
if (!event || typeof event !== "object") return;
|
|
823
1109
|
if (typeof event.action !== "string") return; // can't emit without an action
|
|
@@ -851,16 +1137,351 @@ function safeEmit(event) {
|
|
|
851
1137
|
} catch (_e) { /* audit best-effort — never break the caller */ }
|
|
852
1138
|
}
|
|
853
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* @primitive b.audit.flush
|
|
1142
|
+
* @signature b.audit.flush()
|
|
1143
|
+
* @since 0.1.0
|
|
1144
|
+
* @related b.audit.emit, b.audit.safeEmit
|
|
1145
|
+
*
|
|
1146
|
+
* Drain the AsyncHandler buffer — every queued `emit()` / `safeEmit()`
|
|
1147
|
+
* lands in the audit chain before the returned Promise resolves. Tests,
|
|
1148
|
+
* graceful shutdown, and any code that needs to read audit_log
|
|
1149
|
+
* immediately after emitting awaits flush().
|
|
1150
|
+
*
|
|
1151
|
+
* @example
|
|
1152
|
+
* b.audit.safeEmit({ action: "system.shutdown.requested", outcome: "success" });
|
|
1153
|
+
* await b.audit.flush();
|
|
1154
|
+
* var rows = await b.audit.query({ action: "system.shutdown.requested" });
|
|
1155
|
+
* rows.length; // → 1
|
|
1156
|
+
*/
|
|
854
1157
|
async function flush() {
|
|
855
1158
|
if (!_auditHandler) return;
|
|
856
1159
|
await _auditHandler.drain();
|
|
857
1160
|
}
|
|
858
1161
|
|
|
1162
|
+
// ---- SOX §404 / SOC 2 CC1.3 — actor-binding + segregation of duties ----
|
|
1163
|
+
//
|
|
1164
|
+
// Anyone with write access to the audit_log table can INSERT a row
|
|
1165
|
+
// claiming any actor identity. The framework already records actor
|
|
1166
|
+
// from the request context, but a privileged caller (operator script,
|
|
1167
|
+
// migration runner, anyone with the externalDb credentials) can claim
|
|
1168
|
+
// a different actor.
|
|
1169
|
+
//
|
|
1170
|
+
// bindActor(actorId, opts) returns a wrapper around audit.safeEmit /
|
|
1171
|
+
// audit.record that refuses any event whose actor.userId mismatches
|
|
1172
|
+
// the bound identity OR the SQL-bound role (when db-role-context is
|
|
1173
|
+
// active).
|
|
1174
|
+
//
|
|
1175
|
+
// SQL-side enforcement lives in lib/cluster-storage.js's framework-
|
|
1176
|
+
// schema generator — see generateActorBindingTriggerSql() below for
|
|
1177
|
+
// the Postgres trigger DDL. Operators apply that DDL in a migration
|
|
1178
|
+
// when they boot under sox-404 / soc2 / pci-dss posture so a non-
|
|
1179
|
+
// framework writer can't INSERT rows under a different role.
|
|
1180
|
+
function _checkActorBinding(actorId, eventActorId, opts) {
|
|
1181
|
+
if (!actorId) return true; // unbound — no enforcement
|
|
1182
|
+
if (!eventActorId) {
|
|
1183
|
+
return { ok: false, reason: "event missing actor.userId — refused under bound emit" };
|
|
1184
|
+
}
|
|
1185
|
+
if (eventActorId !== actorId) {
|
|
1186
|
+
return { ok: false,
|
|
1187
|
+
reason: "actor mismatch: bound='" + actorId + "', event='" + eventActorId + "'" };
|
|
1188
|
+
}
|
|
1189
|
+
// db-role-context check — when the caller is inside a runWithRole
|
|
1190
|
+
// scope, the SQL-bound role and the bound actor must agree (subject
|
|
1191
|
+
// to the operator-supplied `roleEquivalent` mapping).
|
|
1192
|
+
if (opts && typeof opts.roleEquivalent === "function") {
|
|
1193
|
+
var role = dbRoleContext.getRole();
|
|
1194
|
+
if (role && !opts.roleEquivalent(actorId, role)) {
|
|
1195
|
+
return { ok: false,
|
|
1196
|
+
reason: "db-role mismatch: bound actor '" + actorId +
|
|
1197
|
+
"' is not equivalent to SQL role '" + role + "'" };
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return { ok: true };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* @primitive b.audit.bindActor
|
|
1205
|
+
* @signature b.audit.bindActor(actorId, opts)
|
|
1206
|
+
* @since 0.7.0
|
|
1207
|
+
* @compliance sox-404, soc2
|
|
1208
|
+
* @related b.audit.assertSegregation, b.audit.generateActorBindingTriggerSql
|
|
1209
|
+
*
|
|
1210
|
+
* Wrap `safeEmit` / `record` so any event whose `actor.userId` doesn't
|
|
1211
|
+
* match the bound id is refused (and an `audit.actor_binding.violation`
|
|
1212
|
+
* event is recorded under the bound actor). When `opts.roleEquivalent`
|
|
1213
|
+
* is provided and the caller is inside a `db-role-context.runWithRole`
|
|
1214
|
+
* scope, the SQL-bound role and bound actor must agree per the
|
|
1215
|
+
* operator-supplied mapping.
|
|
1216
|
+
*
|
|
1217
|
+
* Pair with `generateActorBindingTriggerSql()` for SQL-side enforcement
|
|
1218
|
+
* — application-layer binding catches typos; the trigger catches
|
|
1219
|
+
* privileged callers bypassing the framework.
|
|
1220
|
+
*
|
|
1221
|
+
* @opts
|
|
1222
|
+
* roleEquivalent: function (actorId, sqlRole) -> boolean,
|
|
1223
|
+
*
|
|
1224
|
+
* @example
|
|
1225
|
+
* var bound = b.audit.bindActor("u-42");
|
|
1226
|
+
* bound.safeEmit({
|
|
1227
|
+
* actor: { userId: "u-42" },
|
|
1228
|
+
* action: "orders.shipped",
|
|
1229
|
+
* outcome: "success",
|
|
1230
|
+
* });
|
|
1231
|
+
* bound.safeEmit({
|
|
1232
|
+
* actor: { userId: "u-other" },
|
|
1233
|
+
* action: "orders.shipped",
|
|
1234
|
+
* outcome: "success",
|
|
1235
|
+
* });
|
|
1236
|
+
* // → drops + records "audit.actor_binding.violation" under u-42
|
|
1237
|
+
*/
|
|
1238
|
+
function bindActor(actorId, opts) {
|
|
1239
|
+
if (typeof actorId !== "string" || actorId.length === 0) {
|
|
1240
|
+
throw new AuditSegregationError("audit/bind-actor-missing",
|
|
1241
|
+
"audit.bindActor: actorId must be a non-empty string");
|
|
1242
|
+
}
|
|
1243
|
+
opts = opts || {};
|
|
1244
|
+
function _violationEmit(eventAction, reason) {
|
|
1245
|
+
try {
|
|
1246
|
+
// Surface via the un-bound _ensureHandler so the violation row
|
|
1247
|
+
// lands in the chain regardless of bind state.
|
|
1248
|
+
_ensureHandler().emit({
|
|
1249
|
+
action: "audit.actor_binding.violation",
|
|
1250
|
+
outcome: "denied",
|
|
1251
|
+
actor: { userId: actorId },
|
|
1252
|
+
metadata: { attemptedAction: eventAction, reason: reason },
|
|
1253
|
+
});
|
|
1254
|
+
} catch (_e) { /* drop-silent — never break the caller */ }
|
|
1255
|
+
}
|
|
1256
|
+
function boundSafeEmit(event) {
|
|
1257
|
+
var rv = _checkActorBinding(actorId,
|
|
1258
|
+
event && event.actor && event.actor.userId, opts);
|
|
1259
|
+
if (rv !== true && !rv.ok) {
|
|
1260
|
+
_violationEmit(event && event.action, rv.reason);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
safeEmit(event);
|
|
1264
|
+
}
|
|
1265
|
+
async function boundRecord(event) {
|
|
1266
|
+
var rv = _checkActorBinding(actorId,
|
|
1267
|
+
event && event.actor && event.actor.userId, opts);
|
|
1268
|
+
if (rv !== true && !rv.ok) {
|
|
1269
|
+
_violationEmit(event && event.action, rv.reason);
|
|
1270
|
+
throw new AuditSegregationError("audit/actor-binding-violation",
|
|
1271
|
+
"audit.bindActor.record: " + rv.reason);
|
|
1272
|
+
}
|
|
1273
|
+
return await record(event);
|
|
1274
|
+
}
|
|
1275
|
+
return {
|
|
1276
|
+
actorId: actorId,
|
|
1277
|
+
safeEmit: boundSafeEmit,
|
|
1278
|
+
record: boundRecord,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Trigger-SQL generator — operators apply the returned DDL via
|
|
1283
|
+
// b.externalDb.migrate so the database itself refuses INSERTs into
|
|
1284
|
+
// _blamejs_audit_log where the row's stored actor mismatches the
|
|
1285
|
+
// SQL session's current_user.
|
|
1286
|
+
//
|
|
1287
|
+
// opts.column — defaults to "actorUserId"; operators with a separate
|
|
1288
|
+
// role mapping table pass an explicit column name.
|
|
1289
|
+
// opts.roleMappingFn — Postgres function name that maps the row's
|
|
1290
|
+
// actorUserId to the expected SQL role; defaults to identity match
|
|
1291
|
+
// (current_user must equal the actorUserId).
|
|
1292
|
+
// opts.tableName — defaults to "_blamejs_audit_log".
|
|
1293
|
+
// opts.allowRoles — array of roles allowed to insert ANY actor (e.g.
|
|
1294
|
+
// the framework's own service account); skipped checks for those
|
|
1295
|
+
// roles.
|
|
1296
|
+
//
|
|
1297
|
+
// Returns { up: ddl, down: ddl } so the migration runner can install +
|
|
1298
|
+
// uninstall.
|
|
1299
|
+
/**
|
|
1300
|
+
* @primitive b.audit.generateActorBindingTriggerSql
|
|
1301
|
+
* @signature b.audit.generateActorBindingTriggerSql(opts)
|
|
1302
|
+
* @since 0.7.0
|
|
1303
|
+
* @compliance sox-404, soc2
|
|
1304
|
+
* @related b.audit.bindActor, b.audit.assertSegregation
|
|
1305
|
+
*
|
|
1306
|
+
* Emit Postgres trigger DDL that refuses INSERTs into the audit_log
|
|
1307
|
+
* table whose stored `actorUserId` column doesn't match the SQL
|
|
1308
|
+
* session's `current_user`. Operators apply the returned `up` script
|
|
1309
|
+
* via `b.externalDb.migrate` under sox-404 / soc2 posture so a
|
|
1310
|
+
* privileged caller (operator script, migration runner) can't write
|
|
1311
|
+
* audit rows under a different actor identity.
|
|
1312
|
+
*
|
|
1313
|
+
* Returns `{ up, down, functionName, triggerName }` for migration
|
|
1314
|
+
* runner symmetry.
|
|
1315
|
+
*
|
|
1316
|
+
* @opts
|
|
1317
|
+
* column: string, // default "actorUserId"
|
|
1318
|
+
* tableName: string, // default "_blamejs_audit_log"
|
|
1319
|
+
* roleMappingFn: string, // SQL fn name mapping actor → role
|
|
1320
|
+
* allowRoles: string[], // roles that bypass the check
|
|
1321
|
+
*
|
|
1322
|
+
* @example
|
|
1323
|
+
* var ddl = b.audit.generateActorBindingTriggerSql({
|
|
1324
|
+
* allowRoles: ["blamejs_service"],
|
|
1325
|
+
* });
|
|
1326
|
+
* await db.query(ddl.up);
|
|
1327
|
+
*/
|
|
1328
|
+
function generateActorBindingTriggerSql(opts) {
|
|
1329
|
+
opts = opts || {};
|
|
1330
|
+
var column = opts.column || "actorUserId";
|
|
1331
|
+
var tableName = opts.tableName || "_blamejs_audit_log";
|
|
1332
|
+
var allowRoles = Array.isArray(opts.allowRoles) ? opts.allowRoles : [];
|
|
1333
|
+
var fnName = "_blamejs_audit_actor_binding_check";
|
|
1334
|
+
var trigName = "_blamejs_audit_actor_binding_trig";
|
|
1335
|
+
var allowList = allowRoles.length === 0 ? "" :
|
|
1336
|
+
" IF current_user IN (" +
|
|
1337
|
+
allowRoles.map(function (r) { return "'" + r.replace(/'/g, "''") + "'"; }).join(", ") +
|
|
1338
|
+
") THEN RETURN NEW; END IF;\n";
|
|
1339
|
+
var roleMatch = opts.roleMappingFn
|
|
1340
|
+
? " IF " + opts.roleMappingFn + "(NEW.\"" + column + "\") IS DISTINCT FROM current_user THEN\n"
|
|
1341
|
+
: " IF NEW.\"" + column + "\" IS DISTINCT FROM current_user THEN\n";
|
|
1342
|
+
var up =
|
|
1343
|
+
"CREATE OR REPLACE FUNCTION " + fnName + "() RETURNS trigger AS $$\n" +
|
|
1344
|
+
"BEGIN\n" +
|
|
1345
|
+
allowList +
|
|
1346
|
+
roleMatch +
|
|
1347
|
+
" RAISE EXCEPTION 'segregation-of-duties violation: actor=% does not match current_user=%', NEW.\"" + column + "\", current_user\n" +
|
|
1348
|
+
" USING ERRCODE = 'P0001';\n" +
|
|
1349
|
+
" END IF;\n" +
|
|
1350
|
+
" RETURN NEW;\n" +
|
|
1351
|
+
"END;\n" +
|
|
1352
|
+
"$$ LANGUAGE plpgsql;\n" +
|
|
1353
|
+
"DROP TRIGGER IF EXISTS " + trigName + " ON " + tableName + ";\n" +
|
|
1354
|
+
"CREATE TRIGGER " + trigName + "\n" +
|
|
1355
|
+
" BEFORE INSERT ON " + tableName + "\n" +
|
|
1356
|
+
" FOR EACH ROW EXECUTE FUNCTION " + fnName + "();\n";
|
|
1357
|
+
var down =
|
|
1358
|
+
"DROP TRIGGER IF EXISTS " + trigName + " ON " + tableName + ";\n" +
|
|
1359
|
+
"DROP FUNCTION IF EXISTS " + fnName + "();\n";
|
|
1360
|
+
return { up: up, down: down, functionName: fnName, triggerName: trigName };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Boot-time check operators wire under sox-404 / soc2 posture. Verifies
|
|
1364
|
+
// the trigger function + trigger row are present in the externalDb
|
|
1365
|
+
// information_schema. Returns { ok, missing? } so the caller decides
|
|
1366
|
+
// whether to refuse boot.
|
|
1367
|
+
/**
|
|
1368
|
+
* @primitive b.audit.assertSegregation
|
|
1369
|
+
* @signature b.audit.assertSegregation(opts)
|
|
1370
|
+
* @since 0.7.0
|
|
1371
|
+
* @compliance sox-404, soc2
|
|
1372
|
+
* @related b.audit.generateActorBindingTriggerSql, b.audit.bindActor
|
|
1373
|
+
*
|
|
1374
|
+
* Boot-time check that confirms the actor-binding trigger function and
|
|
1375
|
+
* trigger row exist in the externalDb's `pg_proc` / `pg_trigger`
|
|
1376
|
+
* catalogs. Throws `AuditSegregationError` with the missing artifacts
|
|
1377
|
+
* named when either is absent — operators wire this into the
|
|
1378
|
+
* sox-404 / soc2 boot sequence so a forgotten migration refuses-to-boot
|
|
1379
|
+
* instead of silently shipping without enforcement.
|
|
1380
|
+
*
|
|
1381
|
+
* @opts
|
|
1382
|
+
* db: { query(sql, params) -> { rows } }, // required
|
|
1383
|
+
* functionName: string,
|
|
1384
|
+
* triggerName: string,
|
|
1385
|
+
*
|
|
1386
|
+
* @example
|
|
1387
|
+
* await b.audit.assertSegregation({ db: externalDb });
|
|
1388
|
+
* // throws if the trigger DDL hasn't been applied
|
|
1389
|
+
*/
|
|
1390
|
+
async function assertSegregation(opts) {
|
|
1391
|
+
opts = opts || {};
|
|
1392
|
+
var db = opts.db || null;
|
|
1393
|
+
if (!db || typeof db.query !== "function") {
|
|
1394
|
+
throw new AuditSegregationError("audit/segregation-no-db",
|
|
1395
|
+
"audit.assertSegregation: opts.db with a query() method is required");
|
|
1396
|
+
}
|
|
1397
|
+
var fnName = opts.functionName || "_blamejs_audit_actor_binding_check";
|
|
1398
|
+
var trigName = opts.triggerName || "_blamejs_audit_actor_binding_trig";
|
|
1399
|
+
var fnRes = await db.query(
|
|
1400
|
+
"SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1", [fnName]
|
|
1401
|
+
);
|
|
1402
|
+
var fnPresent = !!(fnRes && fnRes.rows && fnRes.rows.length > 0);
|
|
1403
|
+
var trigRes = await db.query(
|
|
1404
|
+
"SELECT 1 FROM pg_trigger WHERE tgname = $1 LIMIT 1", [trigName]
|
|
1405
|
+
);
|
|
1406
|
+
var trigPresent = !!(trigRes && trigRes.rows && trigRes.rows.length > 0);
|
|
1407
|
+
var missing = [];
|
|
1408
|
+
if (!fnPresent) missing.push("function:" + fnName);
|
|
1409
|
+
if (!trigPresent) missing.push("trigger:" + trigName);
|
|
1410
|
+
var ok = missing.length === 0;
|
|
1411
|
+
if (!ok) {
|
|
1412
|
+
safeEmit({
|
|
1413
|
+
action: "audit.actor_binding.violation",
|
|
1414
|
+
outcome: "denied",
|
|
1415
|
+
metadata: {
|
|
1416
|
+
reason: "boot-time segregation check failed",
|
|
1417
|
+
missing: missing,
|
|
1418
|
+
},
|
|
1419
|
+
});
|
|
1420
|
+
throw new AuditSegregationError("audit/segregation-not-installed",
|
|
1421
|
+
"audit.assertSegregation: SQL-side actor-binding trigger missing — " +
|
|
1422
|
+
"apply the DDL from audit.generateActorBindingTriggerSql() under sox-404 / soc2 posture. " +
|
|
1423
|
+
"Missing: " + missing.join(", "));
|
|
1424
|
+
}
|
|
1425
|
+
return { ok: ok, missing: missing };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// applyPosture — F-POSTURE-1 cascade hook. b.compliance.set(posture)
|
|
1429
|
+
// calls this to record the active posture so audit emissions can
|
|
1430
|
+
// surface the regulatory regime in metadata where downstream tooling
|
|
1431
|
+
// (forensic export, SIEM correlation) needs it. The chain itself is
|
|
1432
|
+
// posture-agnostic (every posture audits with the same SLH-DSA-SHAKE-
|
|
1433
|
+
// 256f signing key); this hook captures the posture name so query()
|
|
1434
|
+
// callers that filter by-posture have a stable column to look at.
|
|
1435
|
+
var _activePosture = null;
|
|
1436
|
+
/**
|
|
1437
|
+
* @primitive b.audit.applyPosture
|
|
1438
|
+
* @signature b.audit.applyPosture(posture)
|
|
1439
|
+
* @since 0.7.27
|
|
1440
|
+
* @compliance hipaa, pci-dss, gdpr, soc2, sox-404
|
|
1441
|
+
* @related b.audit.activePosture, b.compliance
|
|
1442
|
+
*
|
|
1443
|
+
* Cascade hook called by `b.compliance.set(posture)` to record the
|
|
1444
|
+
* active regulatory regime. The chain itself is posture-agnostic —
|
|
1445
|
+
* every posture audits with the same SLH-DSA-SHAKE-256f signing key —
|
|
1446
|
+
* but downstream tooling (forensic export, SIEM correlation) reads the
|
|
1447
|
+
* stored posture to filter / route. Returns `{ posture }` on accept,
|
|
1448
|
+
* `null` on a non-string / empty argument.
|
|
1449
|
+
*
|
|
1450
|
+
* @example
|
|
1451
|
+
* b.audit.applyPosture("hipaa");
|
|
1452
|
+
* b.audit.activePosture(); // → "hipaa"
|
|
1453
|
+
*/
|
|
1454
|
+
function applyPosture(posture) {
|
|
1455
|
+
if (typeof posture !== "string" || posture.length === 0) return null;
|
|
1456
|
+
_activePosture = posture;
|
|
1457
|
+
return { posture: posture };
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* @primitive b.audit.activePosture
|
|
1461
|
+
* @signature b.audit.activePosture()
|
|
1462
|
+
* @since 0.7.27
|
|
1463
|
+
* @related b.audit.applyPosture
|
|
1464
|
+
*
|
|
1465
|
+
* Return the posture string most recently passed to `applyPosture()`,
|
|
1466
|
+
* or `null` if none has been set. Read-only accessor for downstream
|
|
1467
|
+
* tooling that wants to tag audit-derived artifacts with the regime.
|
|
1468
|
+
*
|
|
1469
|
+
* @example
|
|
1470
|
+
* b.audit.applyPosture("pci-dss");
|
|
1471
|
+
* b.audit.activePosture(); // → "pci-dss"
|
|
1472
|
+
*/
|
|
1473
|
+
function activePosture() { return _activePosture; }
|
|
1474
|
+
|
|
859
1475
|
module.exports = {
|
|
860
1476
|
registerNamespace: registerNamespace,
|
|
861
1477
|
record: record,
|
|
862
1478
|
emit: emit,
|
|
863
1479
|
safeEmit: safeEmit,
|
|
1480
|
+
applyPosture: applyPosture,
|
|
1481
|
+
activePosture: activePosture,
|
|
1482
|
+
bindActor: bindActor,
|
|
1483
|
+
assertSegregation: assertSegregation,
|
|
1484
|
+
generateActorBindingTriggerSql: generateActorBindingTriggerSql,
|
|
864
1485
|
flush: flush,
|
|
865
1486
|
query: query,
|
|
866
1487
|
verify: verify,
|