@blamejs/core 0.14.27 → 0.15.1
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 +6 -0
- package/README.md +2 -2
- package/index.js +4 -0
- package/lib/ai-content-detect.js +9 -10
- package/lib/api-key.js +158 -77
- package/lib/atomic-file.js +29 -1
- package/lib/audit-chain.js +47 -11
- package/lib/audit-sign.js +77 -2
- package/lib/audit-tools.js +79 -51
- package/lib/audit.js +228 -100
- package/lib/backup/index.js +13 -10
- package/lib/break-glass.js +202 -144
- package/lib/cache.js +174 -105
- package/lib/chain-writer.js +38 -16
- package/lib/cli.js +19 -14
- package/lib/cluster-provider-db.js +130 -104
- package/lib/cluster-storage.js +119 -22
- package/lib/cluster.js +119 -71
- package/lib/compliance.js +22 -0
- package/lib/consent.js +82 -29
- package/lib/constants.js +16 -11
- package/lib/crypto-field.js +387 -91
- package/lib/db-declare-row-policy.js +35 -22
- package/lib/db-file-lifecycle.js +3 -2
- package/lib/db-query.js +517 -256
- package/lib/db-schema.js +209 -44
- package/lib/db.js +202 -95
- package/lib/external-db-migrate.js +229 -139
- package/lib/external-db.js +25 -15
- package/lib/framework-error.js +11 -0
- package/lib/framework-files.js +73 -0
- package/lib/framework-schema.js +695 -394
- package/lib/gate-contract.js +596 -1
- package/lib/guard-agent-registry.js +26 -44
- package/lib/guard-all.js +1 -0
- package/lib/guard-auth.js +42 -112
- package/lib/guard-cidr.js +33 -154
- package/lib/guard-csv.js +46 -113
- package/lib/guard-domain.js +34 -157
- package/lib/guard-dsn.js +27 -43
- package/lib/guard-email.js +47 -69
- package/lib/guard-envelope.js +19 -32
- package/lib/guard-event-bus-payload.js +24 -42
- package/lib/guard-event-bus-topic.js +25 -43
- package/lib/guard-filename.js +42 -106
- package/lib/guard-graphql.js +42 -123
- package/lib/guard-html.js +53 -108
- package/lib/guard-idempotency-key.js +24 -42
- package/lib/guard-image.js +46 -103
- package/lib/guard-imap-command.js +18 -32
- package/lib/guard-jmap.js +16 -30
- package/lib/guard-json.js +38 -108
- package/lib/guard-jsonpath.js +38 -171
- package/lib/guard-jwt.js +49 -179
- package/lib/guard-list-id.js +25 -41
- package/lib/guard-list-unsubscribe.js +27 -43
- package/lib/guard-mail-compose.js +24 -42
- package/lib/guard-mail-move.js +26 -44
- package/lib/guard-mail-query.js +28 -46
- package/lib/guard-mail-reply.js +24 -42
- package/lib/guard-mail-sieve.js +24 -42
- package/lib/guard-managesieve-command.js +17 -31
- package/lib/guard-markdown.js +37 -104
- package/lib/guard-message-id.js +26 -45
- package/lib/guard-mime.js +39 -151
- package/lib/guard-oauth.js +54 -135
- package/lib/guard-pdf.js +45 -101
- package/lib/guard-pop3-command.js +21 -31
- package/lib/guard-posture-chain.js +24 -42
- package/lib/guard-regex.js +33 -107
- package/lib/guard-saga-config.js +24 -42
- package/lib/guard-shell.js +42 -172
- package/lib/guard-smtp-command.js +48 -54
- package/lib/guard-snapshot-envelope.js +24 -42
- package/lib/guard-sql.js +1491 -0
- package/lib/guard-stream-args.js +24 -43
- package/lib/guard-svg.js +47 -65
- package/lib/guard-template.js +35 -172
- package/lib/guard-tenant-id.js +26 -45
- package/lib/guard-time.js +32 -154
- package/lib/guard-trace-context.js +25 -44
- package/lib/guard-uuid.js +32 -153
- package/lib/guard-xml.js +38 -113
- package/lib/guard-yaml.js +51 -163
- package/lib/http-client.js +14 -0
- package/lib/inbox.js +120 -107
- package/lib/legal-hold.js +107 -50
- package/lib/log-stream-cloudwatch.js +47 -31
- package/lib/log-stream-otlp.js +32 -18
- package/lib/mail-crypto-smime.js +2 -6
- package/lib/mail-greylist.js +2 -6
- package/lib/mail-helo.js +2 -6
- package/lib/mail-journal.js +85 -64
- package/lib/mail-rbl.js +2 -6
- package/lib/mail-scan.js +2 -6
- package/lib/mail-spam-score.js +2 -6
- package/lib/mail-store.js +293 -154
- package/lib/middleware/fetch-metadata.js +17 -7
- package/lib/middleware/idempotency-key.js +54 -38
- package/lib/middleware/rate-limit.js +102 -32
- package/lib/middleware/security-headers.js +21 -5
- package/lib/migrations.js +108 -66
- package/lib/network-heartbeat.js +7 -0
- package/lib/nonce-store.js +31 -9
- package/lib/object-store/azure-blob-bucket-ops.js +9 -4
- package/lib/object-store/azure-blob.js +31 -3
- package/lib/object-store/sigv4.js +10 -0
- package/lib/outbox.js +136 -82
- package/lib/pqc-agent.js +44 -0
- package/lib/pubsub-cluster.js +42 -20
- package/lib/queue-local.js +202 -139
- package/lib/queue-redis.js +9 -1
- package/lib/queue-sqs.js +6 -0
- package/lib/retention.js +82 -39
- package/lib/safe-dns.js +29 -45
- package/lib/safe-ical.js +18 -33
- package/lib/safe-icap.js +27 -43
- package/lib/safe-sieve.js +21 -40
- package/lib/safe-sql.js +124 -3
- package/lib/safe-vcard.js +18 -33
- package/lib/scheduler.js +35 -12
- package/lib/seeders.js +122 -74
- package/lib/session-stores.js +42 -14
- package/lib/session.js +116 -72
- package/lib/sql.js +3885 -0
- package/lib/static.js +45 -7
- package/lib/subject.js +89 -49
- package/lib/vault/index.js +3 -2
- package/lib/vault/passphrase-ops.js +3 -2
- package/lib/vault/rotate.js +104 -64
- package/lib/vendor-data.js +2 -0
- package/lib/websocket.js +16 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/audit.js
CHANGED
|
@@ -55,7 +55,9 @@ var cluster = require("./cluster");
|
|
|
55
55
|
var clusterStorage = require("./cluster-storage");
|
|
56
56
|
var { generateToken } = require("./crypto");
|
|
57
57
|
var cryptoField = require("./crypto-field");
|
|
58
|
+
var frameworkSchema = require("./framework-schema");
|
|
58
59
|
var safeSql = require("./safe-sql");
|
|
60
|
+
var sql = require("./sql");
|
|
59
61
|
var dbRoleContext = require("./db-role-context");
|
|
60
62
|
var handlers = require("./handlers");
|
|
61
63
|
var { boot } = require("./log");
|
|
@@ -101,19 +103,36 @@ var _externalStore = null;
|
|
|
101
103
|
// the worst case.
|
|
102
104
|
var FRAMEWORK_SQL_TIMEOUT_MS = C.TIME.seconds(30);
|
|
103
105
|
|
|
106
|
+
// b.sql opts for every framework-table statement: thread the ACTIVE backend
|
|
107
|
+
// dialect (clusterStorage.dialect() — "sqlite" single-node, "postgres" |
|
|
108
|
+
// "mysql" in cluster mode) so the emitted identifier quoting + dialect
|
|
109
|
+
// idioms (ON CONFLICT vs ON DUPLICATE KEY) match the backend the SQL
|
|
110
|
+
// dispatches to. Defaulting to "sqlite" works on Postgres only by accident
|
|
111
|
+
// (both double-quote identifiers) and emits the wrong quoting on MySQL.
|
|
112
|
+
// clusterStorage.execute still rewrites table names + translates `?`
|
|
113
|
+
// placeholders at dispatch; this controls only the builder-side quoting +
|
|
114
|
+
// idiom selection.
|
|
115
|
+
function _sqlOpts() { return { dialect: clusterStorage.dialect() }; }
|
|
116
|
+
|
|
104
117
|
// ---- Resilience-wrapped SQL operations (audit-specific reads) ----
|
|
105
118
|
// Chain APPEND lives in chain-writer (race-safe via mutex, retry, timeout).
|
|
106
119
|
// The wrappers below cover audit-specific reads/writes that aren't part
|
|
107
120
|
// of the chain append: checkpoint queries, verifyCheckpoints reads,
|
|
108
121
|
// audit-tip cluster-row updates.
|
|
109
122
|
|
|
123
|
+
// Framework-state reads compose b.sql with BARE logical table names —
|
|
124
|
+
// clusterStorage rewrites the framework names to their configured-prefix
|
|
125
|
+
// form and placeholderizes per dialect; b.sql quotes the camelCase columns
|
|
126
|
+
// and runs the output validator.
|
|
110
127
|
async function _readLastCheckpointCounter() {
|
|
128
|
+
var built = sql.select("audit_checkpoints", _sqlOpts())
|
|
129
|
+
.columns(["atMonotonicCounter"])
|
|
130
|
+
.orderBy("atMonotonicCounter", "desc")
|
|
131
|
+
.limit(1)
|
|
132
|
+
.toSql();
|
|
111
133
|
return await safeAsync.withTimeout(
|
|
112
134
|
safeAsync.asyncRetry(function () {
|
|
113
|
-
return clusterStorage.executeOne(
|
|
114
|
-
"SELECT atMonotonicCounter FROM audit_checkpoints " +
|
|
115
|
-
"ORDER BY atMonotonicCounter DESC LIMIT 1"
|
|
116
|
-
);
|
|
135
|
+
return clusterStorage.executeOne(built.sql, built.params);
|
|
117
136
|
}),
|
|
118
137
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
119
138
|
{ name: "audit.readLastCheckpoint" }
|
|
@@ -121,11 +140,12 @@ async function _readLastCheckpointCounter() {
|
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
async function _readAllAuditRowsAsc() {
|
|
143
|
+
var built = sql.select("audit_log", _sqlOpts())
|
|
144
|
+
.orderBy("monotonicCounter", "asc")
|
|
145
|
+
.toSql();
|
|
124
146
|
return await safeAsync.withTimeout(
|
|
125
147
|
safeAsync.asyncRetry(function () {
|
|
126
|
-
return clusterStorage.executeAll(
|
|
127
|
-
'SELECT * FROM "audit_log" ORDER BY monotonicCounter ASC'
|
|
128
|
-
);
|
|
148
|
+
return clusterStorage.executeAll(built.sql, built.params);
|
|
129
149
|
}),
|
|
130
150
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
131
151
|
{ name: "audit.readAllRowsAsc" }
|
|
@@ -133,11 +153,12 @@ async function _readAllAuditRowsAsc() {
|
|
|
133
153
|
}
|
|
134
154
|
|
|
135
155
|
async function _readAllCheckpointsAsc() {
|
|
156
|
+
var built = sql.select("audit_checkpoints", _sqlOpts())
|
|
157
|
+
.orderBy("atMonotonicCounter", "asc")
|
|
158
|
+
.toSql();
|
|
136
159
|
return await safeAsync.withTimeout(
|
|
137
160
|
safeAsync.asyncRetry(function () {
|
|
138
|
-
return clusterStorage.executeAll(
|
|
139
|
-
"SELECT * FROM audit_checkpoints ORDER BY atMonotonicCounter ASC"
|
|
140
|
-
);
|
|
161
|
+
return clusterStorage.executeAll(built.sql, built.params);
|
|
141
162
|
}),
|
|
142
163
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
143
164
|
{ name: "audit.readAllCheckpoints" }
|
|
@@ -145,12 +166,13 @@ async function _readAllCheckpointsAsc() {
|
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
async function _readAuditRowHashAtCounter(counter) {
|
|
169
|
+
var built = sql.select("audit_log", _sqlOpts())
|
|
170
|
+
.columns(["rowHash"])
|
|
171
|
+
.where("monotonicCounter", counter)
|
|
172
|
+
.toSql();
|
|
148
173
|
return await safeAsync.withTimeout(
|
|
149
174
|
safeAsync.asyncRetry(function () {
|
|
150
|
-
return clusterStorage.executeOne(
|
|
151
|
-
"SELECT rowHash FROM audit_log WHERE monotonicCounter = ?",
|
|
152
|
-
[counter]
|
|
153
|
-
);
|
|
175
|
+
return clusterStorage.executeOne(built.sql, built.params);
|
|
154
176
|
}),
|
|
155
177
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
156
178
|
{ name: "audit.readRowHashAtCounter" }
|
|
@@ -158,26 +180,37 @@ async function _readAuditRowHashAtCounter(counter) {
|
|
|
158
180
|
}
|
|
159
181
|
|
|
160
182
|
async function _insertAuditRow(allCols, values) {
|
|
161
|
-
// No retry — non-idempotent. Timeout only.
|
|
162
|
-
|
|
163
|
-
|
|
183
|
+
// No retry — non-idempotent. Timeout only. Map each column to its
|
|
184
|
+
// positional value and bind as a row object (the unambiguous b.sql form;
|
|
185
|
+
// a flat value array whose first element is a Buffer would be misread as
|
|
186
|
+
// an array-of-rows). BARE logical table name → clusterStorage rewrites.
|
|
187
|
+
var rowObj = {};
|
|
188
|
+
for (var i = 0; i < allCols.length; i++) rowObj[allCols[i]] = values[i];
|
|
189
|
+
var built = sql.insert("audit_log", _sqlOpts())
|
|
190
|
+
.columns(allCols)
|
|
191
|
+
.values(rowObj)
|
|
192
|
+
.toSql();
|
|
164
193
|
return await safeAsync.withTimeout(
|
|
165
|
-
clusterStorage.execute(
|
|
166
|
-
"INSERT INTO audit_log (" + quoted + ") VALUES (" + placeholders + ")",
|
|
167
|
-
values
|
|
168
|
-
),
|
|
194
|
+
clusterStorage.execute(built.sql, built.params),
|
|
169
195
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
170
196
|
{ name: "audit.insertRow" }
|
|
171
197
|
);
|
|
172
198
|
}
|
|
173
199
|
|
|
200
|
+
var _CHECKPOINT_COLS = [
|
|
201
|
+
"_id", "createdAt", "atMonotonicCounter", "atRowHash",
|
|
202
|
+
"signature", "publicKeyFingerprint", "fencingToken",
|
|
203
|
+
];
|
|
204
|
+
|
|
174
205
|
async function _insertCheckpoint(values) {
|
|
206
|
+
var rowObj = {};
|
|
207
|
+
for (var i = 0; i < _CHECKPOINT_COLS.length; i++) rowObj[_CHECKPOINT_COLS[i]] = values[i];
|
|
208
|
+
var built = sql.insert("audit_checkpoints", _sqlOpts())
|
|
209
|
+
.columns(_CHECKPOINT_COLS)
|
|
210
|
+
.values(rowObj)
|
|
211
|
+
.toSql();
|
|
175
212
|
return await safeAsync.withTimeout(
|
|
176
|
-
clusterStorage.execute(
|
|
177
|
-
"INSERT INTO audit_checkpoints (_id, createdAt, atMonotonicCounter, atRowHash, signature, publicKeyFingerprint, fencingToken) " +
|
|
178
|
-
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
179
|
-
values
|
|
180
|
-
),
|
|
213
|
+
clusterStorage.execute(built.sql, built.params),
|
|
181
214
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
182
215
|
{ name: "audit.insertCheckpoint" }
|
|
183
216
|
);
|
|
@@ -198,24 +231,79 @@ async function _upsertAuditTip(counter, rowHash, signedAt, fencingToken) {
|
|
|
198
231
|
// as ClusterError(code='FENCED_OUT', permanent=true) so the
|
|
199
232
|
// dispatching node knows it's been superseded and should step down
|
|
200
233
|
// rather than retry.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
)
|
|
218
|
-
|
|
234
|
+
// Single atomic INSERT … ON CONFLICT(scope) DO UPDATE … WHERE … RETURNING
|
|
235
|
+
// via b.sql. BARE logical table name (`_blamejs_audit_tip`) — clusterStorage
|
|
236
|
+
// rewrites it (and the same bare name inside the conflictWhere fence) to
|
|
237
|
+
// the configured prefix and placeholderizes. The fenced WHERE enforces the
|
|
238
|
+
// monotonic-non-decreasing fencingToken at the DB level; on rejection
|
|
239
|
+
// RETURNING produces 0 rows.
|
|
240
|
+
// The audit-tip is external-only; its LOGICAL name IS the
|
|
241
|
+
// `_blamejs_`-prefixed name (self-mapped in LOCAL_TO_EXTERNAL), passed
|
|
242
|
+
// bare to b.sql so clusterStorage rewrites it (and the same bare name
|
|
243
|
+
// inside the guarded fence) to the configured prefix.
|
|
244
|
+
//
|
|
245
|
+
// The fence `<table>.<fencingToken> <= EXCLUDED.<fencingToken>` references
|
|
246
|
+
// both the EXISTING row (table-qualified) and the PROPOSED row (EXCLUDED on
|
|
247
|
+
// Postgres/SQLite, VALUES() on MySQL ON DUPLICATE KEY UPDATE), and the
|
|
248
|
+
// identifier quoting differs per dialect — so the raw fragment is built
|
|
249
|
+
// dialect-aware: safeSql.quoteQualified for the table-qualified existing
|
|
250
|
+
// column (backticks on MySQL, double-quotes on PG/SQLite), and the
|
|
251
|
+
// proposed-row reference spelled per dialect (the EXCLUDED keyword has no
|
|
252
|
+
// MySQL equivalent — ON DUPLICATE KEY UPDATE uses VALUES(col)). guardColumn
|
|
253
|
+
// tells the MySQL upsert renderer that the fence protects `fencingToken`,
|
|
254
|
+
// so the IF(<fence>, …, col) wrap on the other SET targets evaluates
|
|
255
|
+
// against the fencingToken column's PRE-update value (the IF-eval-order
|
|
256
|
+
// hazard) and the guard column is assigned last.
|
|
257
|
+
var dialect = clusterStorage.dialect();
|
|
258
|
+
var fenceExisting = safeSql.quoteQualified(["_blamejs_audit_tip", "fencingToken"], dialect); // allow:hand-rolled-sql
|
|
259
|
+
var fenceProposed = dialect === "mysql"
|
|
260
|
+
? "VALUES(" + safeSql.quoteIdentifier("fencingToken", "mysql") + ")"
|
|
261
|
+
: "EXCLUDED." + safeSql.quoteIdentifier("fencingToken", dialect);
|
|
262
|
+
var tipFence = fenceExisting + " <= " + fenceProposed;
|
|
263
|
+
var tipBuilt = sql.upsert("_blamejs_audit_tip", { dialect: dialect }) // allow:hand-rolled-sql
|
|
264
|
+
.columns(["scope", "atMonotonicCounter", "rowHash", "signedAt", "fencingToken"])
|
|
265
|
+
.values({
|
|
266
|
+
scope: "audit",
|
|
267
|
+
atMonotonicCounter: counter,
|
|
268
|
+
rowHash: rowHash,
|
|
269
|
+
signedAt: signedAt,
|
|
270
|
+
fencingToken: fencingToken,
|
|
271
|
+
})
|
|
272
|
+
.onConflict(["scope"])
|
|
273
|
+
.doUpdateFromExcluded(["atMonotonicCounter", "rowHash", "signedAt", "fencingToken"])
|
|
274
|
+
.conflictWhere(tipFence, [], { guardColumn: "fencingToken" })
|
|
275
|
+
.returning(["fencingToken"])
|
|
276
|
+
.toSql();
|
|
277
|
+
var fenced;
|
|
278
|
+
if (dialect === "mysql") {
|
|
279
|
+
// MySQL ON DUPLICATE KEY UPDATE has no WHERE + no RETURNING — the fence
|
|
280
|
+
// becomes an IF(<guard>, VALUES(col), col) per SET target, so a fenced-out
|
|
281
|
+
// (strictly-lower) token leaves every column at its stored value and the
|
|
282
|
+
// statement still "succeeds". Detection therefore can't read RETURNING
|
|
283
|
+
// rows: run the upsert, then read the stored fencingToken back (the b.sql
|
|
284
|
+
// builder hands us the keyed readback SELECT) and compare. If the stored
|
|
285
|
+
// token is ABOVE our incoming one, a higher-token successor won and we
|
|
286
|
+
// were fenced out.
|
|
287
|
+
await safeAsync.withTimeout(
|
|
288
|
+
clusterStorage.execute(tipBuilt.sql, tipBuilt.params),
|
|
289
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
290
|
+
{ name: "audit.upsertAuditTip" }
|
|
291
|
+
);
|
|
292
|
+
var back = await safeAsync.withTimeout(
|
|
293
|
+
clusterStorage.executeOne(tipBuilt.readbackSql.sql, tipBuilt.readbackSql.params),
|
|
294
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
295
|
+
{ name: "audit.upsertAuditTip.readback" }
|
|
296
|
+
);
|
|
297
|
+
fenced = !back || Number(back.fencingToken) > Number(fencingToken);
|
|
298
|
+
} else {
|
|
299
|
+
var result = await safeAsync.withTimeout(
|
|
300
|
+
clusterStorage.execute(tipBuilt.sql, tipBuilt.params),
|
|
301
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
302
|
+
{ name: "audit.upsertAuditTip" }
|
|
303
|
+
);
|
|
304
|
+
fenced = !result.rows || result.rows.length === 0;
|
|
305
|
+
}
|
|
306
|
+
if (fenced) {
|
|
219
307
|
throw new ClusterError(
|
|
220
308
|
"FENCED_OUT",
|
|
221
309
|
"audit-tip update rejected: incoming fencingToken=" + fencingToken +
|
|
@@ -708,35 +796,41 @@ async function query(criteria) {
|
|
|
708
796
|
}
|
|
709
797
|
|
|
710
798
|
async function _queryCluster(criteria) {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
if (criteria.to)
|
|
718
|
-
conds.push("recordedAt <= ?");
|
|
719
|
-
params.push(_toMs(criteria.to));
|
|
720
|
-
}
|
|
799
|
+
// Compose the criteria onto a b.sql SELECT with a BARE logical table name
|
|
800
|
+
// (clusterStorage rewrites + placeholderizes). Sealed-field criteria
|
|
801
|
+
// translate to the derived-hash column via cryptoField.lookupHash exactly
|
|
802
|
+
// as before; b.sql quotes every identifier and binds every value.
|
|
803
|
+
var qb = sql.select("audit_log", _sqlOpts());
|
|
804
|
+
if (criteria.from) qb.whereOp("recordedAt", ">=", _toMs(criteria.from));
|
|
805
|
+
if (criteria.to) qb.whereOp("recordedAt", "<=", _toMs(criteria.to));
|
|
721
806
|
if (criteria.actorUserId) {
|
|
722
807
|
var auh = cryptoField.lookupHash("audit_log", "actorUserId", criteria.actorUserId);
|
|
723
|
-
if (auh) {
|
|
808
|
+
if (auh) {
|
|
809
|
+
// Dual-read across the keyed-MAC flip so an actor query still returns
|
|
810
|
+
// audit rows written under the legacy salted-sha3 actor digest.
|
|
811
|
+
var auv = [auh.value];
|
|
812
|
+
if (auh.legacyValue != null && auh.legacyValue !== auh.value) auv.push(auh.legacyValue);
|
|
813
|
+
qb.whereIn(auh.field, auv);
|
|
814
|
+
}
|
|
724
815
|
}
|
|
725
816
|
if (criteria.resourceId) {
|
|
726
817
|
var rh = cryptoField.lookupHash("audit_log", "resourceId", criteria.resourceId);
|
|
727
|
-
if (rh) {
|
|
818
|
+
if (rh) {
|
|
819
|
+
var rhv = [rh.value];
|
|
820
|
+
if (rh.legacyValue != null && rh.legacyValue !== rh.value) rhv.push(rh.legacyValue);
|
|
821
|
+
qb.whereIn(rh.field, rhv);
|
|
822
|
+
}
|
|
728
823
|
}
|
|
729
|
-
if (criteria.action)
|
|
730
|
-
if (criteria.resourceKind)
|
|
731
|
-
if (criteria.outcome)
|
|
824
|
+
if (criteria.action) qb.where("action", criteria.action);
|
|
825
|
+
if (criteria.resourceKind) qb.where("resourceKind", criteria.resourceKind);
|
|
826
|
+
if (criteria.outcome) qb.where("outcome", criteria.outcome);
|
|
732
827
|
|
|
733
|
-
|
|
734
|
-
if (
|
|
735
|
-
|
|
736
|
-
if (criteria.limit != null) { sql += " LIMIT ?"; params.push(criteria.limit); }
|
|
737
|
-
if (criteria.offset != null) { sql += " OFFSET ?"; params.push(criteria.offset); }
|
|
828
|
+
qb.orderBy("monotonicCounter", "asc");
|
|
829
|
+
if (criteria.limit != null) qb.limit(criteria.limit);
|
|
830
|
+
if (criteria.offset != null) qb.offset(criteria.offset);
|
|
738
831
|
|
|
739
|
-
var
|
|
832
|
+
var built = qb.toSql();
|
|
833
|
+
var rows = await clusterStorage.executeAll(built.sql, built.params);
|
|
740
834
|
return rows.map(function (row) { return cryptoField.unsealRow("audit_log", row); });
|
|
741
835
|
}
|
|
742
836
|
|
|
@@ -848,12 +942,14 @@ async function checkpoint(opts) {
|
|
|
848
942
|
cluster.requireLeader();
|
|
849
943
|
opts = opts || {};
|
|
850
944
|
|
|
945
|
+
var tipReadBuilt = sql.select("audit_log", _sqlOpts())
|
|
946
|
+
.columns(["_id", "monotonicCounter", "rowHash"])
|
|
947
|
+
.orderBy("monotonicCounter", "desc")
|
|
948
|
+
.limit(1)
|
|
949
|
+
.toSql();
|
|
851
950
|
var tip = await safeAsync.withTimeout(
|
|
852
951
|
safeAsync.asyncRetry(function () {
|
|
853
|
-
return clusterStorage.executeOne(
|
|
854
|
-
"SELECT _id, monotonicCounter, rowHash FROM audit_log " +
|
|
855
|
-
"ORDER BY monotonicCounter DESC LIMIT 1"
|
|
856
|
-
);
|
|
952
|
+
return clusterStorage.executeOne(tipReadBuilt.sql, tipReadBuilt.params);
|
|
857
953
|
}),
|
|
858
954
|
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
859
955
|
{ name: "audit.checkpoint.readTip" }
|
|
@@ -898,16 +994,31 @@ async function checkpoint(opts) {
|
|
|
898
994
|
if (cluster.isClusterMode()) {
|
|
899
995
|
await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
|
|
900
996
|
} else {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
997
|
+
// Flush the anchored rows to durable db.enc BEFORE advancing the durable
|
|
998
|
+
// tip sidecar. Otherwise a crash between this checkpoint and the next
|
|
999
|
+
// encrypt leaves the tip referencing a counter not yet on durable disk;
|
|
1000
|
+
// on reboot the rollback detector reads the tip ahead of the restored
|
|
1001
|
+
// db.enc and FALSELY refuses boot as a rollback/deletion, even though the
|
|
1002
|
+
// chain is intact and only unflushed rows were lost in a normal crash.
|
|
1003
|
+
// Rows-before-tip is the correct durability ordering. flushToDisk is a
|
|
1004
|
+
// no-op outside encrypted-at-rest mode (the live file is already durable).
|
|
1005
|
+
// The tip advances ONLY if the flush succeeded, so the durable tip can
|
|
1006
|
+
// never get ahead of the durable rows.
|
|
1007
|
+
var rowsFlushed = false;
|
|
1008
|
+
try { db().flushToDisk(); rowsFlushed = true; }
|
|
1009
|
+
catch (_e) { /* flush failed - do not advance the tip ahead of the rows */ }
|
|
1010
|
+
if (rowsFlushed) {
|
|
1011
|
+
try {
|
|
1012
|
+
db()._writeAuditTip({
|
|
1013
|
+
atMonotonicCounter: counter,
|
|
1014
|
+
atRowHash: tip.rowHash,
|
|
1015
|
+
anchoredAt: createdAt,
|
|
1016
|
+
checkpointId: ckptId,
|
|
1017
|
+
publicKeyFingerprint: pubFp,
|
|
1018
|
+
version: 1,
|
|
1019
|
+
});
|
|
1020
|
+
} catch (_e) { /* best effort */ }
|
|
1021
|
+
}
|
|
911
1022
|
}
|
|
912
1023
|
|
|
913
1024
|
return {
|
|
@@ -955,28 +1066,28 @@ async function verifyCheckpoints() {
|
|
|
955
1066
|
|
|
956
1067
|
if (rows.length === 0) return { ok: true, checkpointsVerified: 0 };
|
|
957
1068
|
|
|
958
|
-
var currentFp = auditSign.getPublicKeyFingerprint();
|
|
959
|
-
var currentPub = auditSign.getPublicKey();
|
|
960
|
-
|
|
961
1069
|
for (var i = 0; i < rows.length; i++) {
|
|
962
1070
|
var c = rows[i];
|
|
963
|
-
//
|
|
964
|
-
// key-
|
|
965
|
-
//
|
|
966
|
-
|
|
1071
|
+
// Resolve the public key the checkpoint was signed under: the current
|
|
1072
|
+
// key, or a rotated-out key from the unsealed public-key history. A
|
|
1073
|
+
// rotation archives the prior public key, so a checkpoint anchored
|
|
1074
|
+
// before the rotation still verifies (no re-signing required). A
|
|
1075
|
+
// fingerprint with no recorded key is the genuine break (key rotated
|
|
1076
|
+
// away with no history, or a forged fingerprint).
|
|
1077
|
+
var pub = auditSign.getPublicKeyByFingerprint(c.publicKeyFingerprint);
|
|
1078
|
+
if (!pub) {
|
|
967
1079
|
return {
|
|
968
1080
|
ok: false,
|
|
969
1081
|
checkpointsVerified: i,
|
|
970
1082
|
breakAt: i,
|
|
971
1083
|
checkpointId: c._id,
|
|
972
|
-
reason: "
|
|
973
|
-
expected: currentFp,
|
|
1084
|
+
reason: "no audit-signing key on record for this checkpoint's fingerprint (key rotated without history?)",
|
|
974
1085
|
actual: c.publicKeyFingerprint,
|
|
975
1086
|
};
|
|
976
1087
|
}
|
|
977
1088
|
var payload = _checkpointPayload(Number(c.atMonotonicCounter), c.atRowHash, Number(c.createdAt));
|
|
978
1089
|
var sigBuf = Buffer.isBuffer(c.signature) ? c.signature : Buffer.from(c.signature);
|
|
979
|
-
if (!auditSign.verify(payload, sigBuf,
|
|
1090
|
+
if (!auditSign.verify(payload, sigBuf, pub)) {
|
|
980
1091
|
return {
|
|
981
1092
|
ok: false,
|
|
982
1093
|
checkpointsVerified: i,
|
|
@@ -1523,10 +1634,15 @@ function bindActor(actorId, opts) {
|
|
|
1523
1634
|
function generateActorBindingTriggerSql(opts) {
|
|
1524
1635
|
opts = opts || {};
|
|
1525
1636
|
var columnRaw = opts.column || "actorUserId";
|
|
1526
|
-
|
|
1637
|
+
// Default resolves through frameworkSchema.tableName so the configurable
|
|
1638
|
+
// framework-table prefix flows into the operator-applied trigger DDL.
|
|
1639
|
+
var tableNameRaw = opts.tableName || frameworkSchema.tableName("audit_log");
|
|
1527
1640
|
var allowRoles = Array.isArray(opts.allowRoles) ? opts.allowRoles : [];
|
|
1528
|
-
|
|
1529
|
-
|
|
1641
|
+
// Trigger function + trigger object NAMES (not framework tables — they have
|
|
1642
|
+
// no LOCAL_TO_EXTERNAL mapping and carry no prefix). assertSegregation
|
|
1643
|
+
// looks them up under these exact names.
|
|
1644
|
+
var fnNameRaw = "_blamejs_audit_actor_binding_check"; // allow:hand-rolled-sql
|
|
1645
|
+
var trigNameRaw = "_blamejs_audit_actor_binding_trig"; // allow:hand-rolled-sql
|
|
1530
1646
|
// Quote-and-validate every identifier through safeSql.quoteIdentifier
|
|
1531
1647
|
// so operator-supplied opts.column / opts.tableName / opts.roleMappingFn
|
|
1532
1648
|
// can't reach raw concatenation. PostgreSQL + SQLite both use the
|
|
@@ -1545,8 +1661,14 @@ function generateActorBindingTriggerSql(opts) {
|
|
|
1545
1661
|
var roleMatch = qRoleMapFn
|
|
1546
1662
|
? " IF " + qRoleMapFn + "(NEW." + qColumn + ") IS DISTINCT FROM current_user THEN\n"
|
|
1547
1663
|
: " IF NEW." + qColumn + " IS DISTINCT FROM current_user THEN\n";
|
|
1664
|
+
// Operator-applied plpgsql trigger DDL — a CREATE FUNCTION body + RAISE
|
|
1665
|
+
// EXCEPTION + CREATE/DROP TRIGGER, none of which b.sql's verb builders
|
|
1666
|
+
// model. Every identifier is quoted through safeSql.quoteIdentifier above;
|
|
1667
|
+
// the table name resolves via frameworkSchema.tableName, so the prefix is
|
|
1668
|
+
// honored. allow:hand-rolled-sql — this is migration-script generation,
|
|
1669
|
+
// not a framework-state DML path.
|
|
1548
1670
|
var up =
|
|
1549
|
-
"CREATE OR REPLACE FUNCTION " + qFn + "() RETURNS trigger AS $$\n" +
|
|
1671
|
+
"CREATE OR REPLACE FUNCTION " + qFn + "() RETURNS trigger AS $$\n" + // allow:hand-rolled-sql
|
|
1550
1672
|
"BEGIN\n" +
|
|
1551
1673
|
allowList +
|
|
1552
1674
|
roleMatch +
|
|
@@ -1556,12 +1678,12 @@ function generateActorBindingTriggerSql(opts) {
|
|
|
1556
1678
|
" RETURN NEW;\n" +
|
|
1557
1679
|
"END;\n" +
|
|
1558
1680
|
"$$ LANGUAGE plpgsql;\n" +
|
|
1559
|
-
"DROP TRIGGER IF EXISTS " + qTrig + " ON " + qTable + ";\n" +
|
|
1560
|
-
"CREATE TRIGGER " + qTrig + "\n" +
|
|
1681
|
+
"DROP TRIGGER IF EXISTS " + qTrig + " ON " + qTable + ";\n" + // allow:hand-rolled-sql
|
|
1682
|
+
"CREATE TRIGGER " + qTrig + "\n" + // allow:hand-rolled-sql
|
|
1561
1683
|
" BEFORE INSERT ON " + qTable + "\n" +
|
|
1562
1684
|
" FOR EACH ROW EXECUTE FUNCTION " + qFn + "();\n";
|
|
1563
1685
|
var down =
|
|
1564
|
-
"DROP TRIGGER IF EXISTS " + qTrig + " ON " + qTable + ";\n" +
|
|
1686
|
+
"DROP TRIGGER IF EXISTS " + qTrig + " ON " + qTable + ";\n" + // allow:hand-rolled-sql
|
|
1565
1687
|
"DROP FUNCTION IF EXISTS " + qFn + "();\n";
|
|
1566
1688
|
return { up: up, down: down, functionName: fnNameRaw, triggerName: trigNameRaw };
|
|
1567
1689
|
}
|
|
@@ -1600,14 +1722,20 @@ async function assertSegregation(opts) {
|
|
|
1600
1722
|
throw new AuditSegregationError("audit/segregation-no-db",
|
|
1601
1723
|
"audit.assertSegregation: opts.db with a query() method is required");
|
|
1602
1724
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1725
|
+
// Trigger / function object NAMES (not framework tables — they have no
|
|
1726
|
+
// LOCAL_TO_EXTERNAL mapping and carry no prefix). They must match the
|
|
1727
|
+
// names generateActorBindingTriggerSql emits.
|
|
1728
|
+
var fnName = opts.functionName || "_blamejs_audit_actor_binding_check"; // allow:hand-rolled-sql
|
|
1729
|
+
var trigName = opts.triggerName || "_blamejs_audit_actor_binding_trig"; // allow:hand-rolled-sql
|
|
1730
|
+
// Operator-DB system-catalog introspection (Postgres pg_proc / pg_trigger,
|
|
1731
|
+
// $N-native, against the operator-supplied db.query) — not a framework
|
|
1732
|
+
// table, so b.sql's verb builders don't apply.
|
|
1605
1733
|
var fnRes = await db.query(
|
|
1606
|
-
"SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1", [fnName]
|
|
1734
|
+
"SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1", [fnName] // allow:hand-rolled-sql
|
|
1607
1735
|
);
|
|
1608
1736
|
var fnPresent = !!(fnRes && fnRes.rows && fnRes.rows.length > 0);
|
|
1609
1737
|
var trigRes = await db.query(
|
|
1610
|
-
"SELECT 1 FROM pg_trigger WHERE tgname = $1 LIMIT 1", [trigName]
|
|
1738
|
+
"SELECT 1 FROM pg_trigger WHERE tgname = $1 LIMIT 1", [trigName] // allow:hand-rolled-sql
|
|
1611
1739
|
);
|
|
1612
1740
|
var trigPresent = !!(trigRes && trigRes.rows && trigRes.rows.length > 0);
|
|
1613
1741
|
var missing = [];
|
package/lib/backup/index.js
CHANGED
|
@@ -54,6 +54,7 @@ var nodePath = require("node:path");
|
|
|
54
54
|
var bCrypto = require("../crypto");
|
|
55
55
|
var atomicFile = require("../atomic-file");
|
|
56
56
|
var backupBundle = require("./bundle");
|
|
57
|
+
var frameworkFiles = require("../framework-files");
|
|
57
58
|
var backupManifest = require("./manifest");
|
|
58
59
|
var lazyRequire = require("../lazy-require");
|
|
59
60
|
var validateOpts = require("../validate-opts");
|
|
@@ -887,21 +888,23 @@ function recommendedFiles(opts) {
|
|
|
887
888
|
var files = [];
|
|
888
889
|
|
|
889
890
|
if (atRest === "encrypted") {
|
|
890
|
-
files.push({ relativePath:
|
|
891
|
-
files.push({ relativePath:
|
|
891
|
+
files.push({ relativePath: frameworkFiles.fileName("dbEnc"), kind: "raw", required: true });
|
|
892
|
+
files.push({ relativePath: frameworkFiles.fileName("dbKeyEnc"), kind: "raw", required: true });
|
|
892
893
|
} else {
|
|
893
894
|
files.push({ relativePath: dbName, kind: "raw", required: true });
|
|
894
895
|
}
|
|
895
896
|
|
|
896
897
|
if (vaultMode === "wrapped") {
|
|
897
|
-
files.push({ relativePath: "
|
|
898
|
+
files.push({ relativePath: frameworkFiles.fileName("vaultKey") + ".sealed", kind: "raw", required: true });
|
|
898
899
|
} else {
|
|
899
|
-
files.push({ relativePath:
|
|
900
|
+
files.push({ relativePath: frameworkFiles.fileName("vaultKey"), kind: "raw", required: true });
|
|
900
901
|
}
|
|
901
902
|
|
|
902
903
|
// Audit-signing key (always present; sealed in wrapped mode)
|
|
903
904
|
files.push({
|
|
904
|
-
relativePath: vaultMode === "wrapped"
|
|
905
|
+
relativePath: vaultMode === "wrapped"
|
|
906
|
+
? frameworkFiles.fileName("auditSignKey") + ".sealed"
|
|
907
|
+
: frameworkFiles.fileName("auditSignKey"),
|
|
905
908
|
kind: "raw", required: false,
|
|
906
909
|
});
|
|
907
910
|
|
|
@@ -2368,7 +2371,7 @@ bundleAdapterStorage.objectStoreAdapter = function (client, osOpts) {
|
|
|
2368
2371
|
// b.objectStore surfaces NOT_FOUND via the framework's
|
|
2369
2372
|
// err.code === "NOT_FOUND" convention — translate to the
|
|
2370
2373
|
// backup adapter contract's no-key error.
|
|
2371
|
-
if (e && (e.code === "NOT_FOUND" || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2374
|
+
if (e && (e.code === "NOT_FOUND" || e.statusCode === 404 || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2372
2375
|
throw new BackupError("backup/no-key",
|
|
2373
2376
|
"objectStoreAdapter: key not found: " + JSON.stringify(key));
|
|
2374
2377
|
}
|
|
@@ -2425,7 +2428,7 @@ bundleAdapterStorage.objectStoreAdapter = function (client, osOpts) {
|
|
|
2425
2428
|
} catch (e) {
|
|
2426
2429
|
// drop-silent on NOT_FOUND — adapter contract is idempotent
|
|
2427
2430
|
// delete (fsAdapter same shape).
|
|
2428
|
-
if (e && (e.code === "NOT_FOUND" || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2431
|
+
if (e && (e.code === "NOT_FOUND" || e.statusCode === 404 || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2429
2432
|
return;
|
|
2430
2433
|
}
|
|
2431
2434
|
throw e;
|
|
@@ -2436,7 +2439,7 @@ bundleAdapterStorage.objectStoreAdapter = function (client, osOpts) {
|
|
|
2436
2439
|
await client.head(_scopedKey(key));
|
|
2437
2440
|
return true;
|
|
2438
2441
|
} catch (e) {
|
|
2439
|
-
if (e && (e.code === "NOT_FOUND" || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2442
|
+
if (e && (e.code === "NOT_FOUND" || e.statusCode === 404 || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2440
2443
|
return false;
|
|
2441
2444
|
}
|
|
2442
2445
|
throw e;
|
|
@@ -2453,7 +2456,7 @@ bundleAdapterStorage.objectStoreAdapter = function (client, osOpts) {
|
|
|
2453
2456
|
var buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
2454
2457
|
return buf.slice(0, length);
|
|
2455
2458
|
} catch (e) {
|
|
2456
|
-
if (e && (e.code === "NOT_FOUND" || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2459
|
+
if (e && (e.code === "NOT_FOUND" || e.statusCode === 404 || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2457
2460
|
throw new BackupError("backup/no-key",
|
|
2458
2461
|
"objectStoreAdapter.readPartial: key not found: " + JSON.stringify(key));
|
|
2459
2462
|
}
|
|
@@ -2466,7 +2469,7 @@ bundleAdapterStorage.objectStoreAdapter = function (client, osOpts) {
|
|
|
2466
2469
|
if (!meta || typeof meta.size !== "number") return null;
|
|
2467
2470
|
return { size: meta.size, mtimeMs: meta.lastModified || null };
|
|
2468
2471
|
} catch (e) {
|
|
2469
|
-
if (e && (e.code === "NOT_FOUND" || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2472
|
+
if (e && (e.code === "NOT_FOUND" || e.statusCode === 404 || /NOT_FOUND|not found/i.test(e.message || ""))) {
|
|
2470
2473
|
return null;
|
|
2471
2474
|
}
|
|
2472
2475
|
throw e;
|