Repository navigation
Expand file tree
/
Copy pathaudit.ts
More file actions
738 lines (705 loc) · 37.6 KB
/
Copy pathaudit.ts
File metadata and controls
738 lines (705 loc) · 37.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
import { storage } from "@/lib/storage";
export type AuditEventType =
| "maintenance"
| "kill_session"
| "masking_config"
| "threshold_config"
| "connection_test"
| "query_execution"
| "managed_connection"
/**
* An agent-path operation: one event for the policy decision and, when that
* decision allowed execution, one for its outcome. Distinct from
* `query_execution` on purpose — an operator filtering the log needs to
* separate what a human ran in the editor from what an agent was permitted
* to run (#328).
*/
| "agent_operation"
/**
* An MCP endpoint operation (#246): for a tool call, one event for the decision and, when the
* decision allowed the call, one for its outcome, joined by one correlation id; for minting a
* token, one event before the token is signed. Distinct from agent_operation on purpose: an MCP
* call comes from a client of the user's own with a token of its own, and an operator has to be
* able to tell it from an agent run and from an editor statement. The user field carries the
* token's verified username, never client text.
*/
| "mcp_operation"
/**
* A tree-driven DDL: one event for the decision to apply an edited definition and one for
* what the engine did with it, joined by one correlation id (#789 Phase 3).
*
* Distinct from `query_execution` on purpose, and the distinction is measured rather than
* argued: three DDL statements through `POST /api/db/query` added ZERO events to this ring
* while one VACUUM on the same connection in the same minute added exactly one, so this arm is
* the first record of a user's WRITE anywhere in this product and an operator filtering the log
* has to be able to tell it from an editor statement.
*
* The claim it makes is narrow and deliberately so: it records that an edit was applied AT THIS
* ADDRESS, with this strategy, and with this outcome. What it does NOT claim is WHICH statements
* the round trip carried, and the reason is measured rather than argued (D76).
*
* The day-one PostgreSQL unit is a multi-statement simple query with the reader's text
* concatenated into it, and no reader in `src/lib/sql/` can count the statements in a routine
* body: a dollar-quoted body may carry any number of semicolons and a `BEGIN ATOMIC` body
* carries them by construction. So the count is not taken here, it is ASKED OF THE ENGINE, on
* both sides of the plan. `buildObjectEdit` parses the reader's submitted text alone as a named
* prepared statement inside a transaction block it has already poisoned, and refuses a text
* PostgreSQL answers `42601 cannot insert multiple commands` for; `applyObjectEdit` counts the
* results the round trip answered, one per statement, and reports `interrupted` with
* `committed: "unknown"` rather than a plain `applied` when that count is not the number of
* statements the plan is made of.
*
* So an `applied` event is now as wide as the write in the one way a count can make it. It still
* says how many statements ran and never which, and it still describes one statement whose
* effects reach past the addressed routine by the plan's own consequence list, which for this
* strategy is empty. `docs/SECURITY.md` control 3.6 carries both limits, and this docblock is
* the shipped source's copy of them: if one moves, move the other in the same commit.
*/
| "object_edit"
// Phase 1 auth events
| "login_success"
| "login_failure"
| "logout"
| "permission_denied"
| "rate_limit_exceeded"
/** A change to a stored local account: create, role, disable, delete, password, TOTP. */
| "account";
/**
* Why a reason is a closed union and never free text: it is the mechanism that makes redaction
* unnecessary rather than best-effort. No code path can put an Error.message, a driver string or
* a request header into an audit record, because there is no field that would accept one.
*/
export type AuditReason =
| "bad_credentials"
/**
* A correct password on a TOTP-protected local account, with no code presented yet. Recorded
* even though it is a normal step of the two-request flow, because in the abnormal case it is
* the highest-value line in this log: it says someone holds a working password for that
* account and was stopped only by the second factor.
*/
| "mfa_required"
/** A correct password, but the second factor did not verify — a wrong, expired or replayed code. */
| "bad_totp"
/** A stored local account was created, changed, or removed. The verb is `action`; the subject is `target`. */
| "account_changed"
/** An admin's create, change or delete of a stored account was refused: bad input, no such account, or the last enabled admin. */
| "account_refused"
| "malformed_body"
| "no_session"
| "insufficient_role"
| "origin_mismatch"
| "rate_limited"
| "oidc_state_missing"
| "oidc_state_invalid"
| "oidc_no_claims"
| "oidc_failed"
| "oidc_config"
/**
* The login route's configuration was complete, but the provider did not answer discovery: an
* issuer that does not resolve, a TLS failure, a response that is not JSON or names a different
* issuer. (openid-client checks nothing else in the document, so one that parses but lacks an
* endpoint fails later, as `oidc_failed`.) Kept apart from
* `oidc_config` because Studio cannot tell up front whether .env is to blame (a mistyped issuer
* host lands here too; only the scheme is checked before discovery), and from `oidc_failed`
* because the login page tells the user something different for each.
*/
| "oidc_discovery"
/**
* The provider authenticated the account, but with OIDC_USER_ROLES set its role claim holds none
* of OIDC_ADMIN_ROLES or OIDC_USER_ROLES, so no session was created. The record names the account,
* which the provider vouched for, so an operator can tell whose role assignment is missing.
*/
| "oidc_no_role"
/**
* The provider authenticated the account, but its role claim held more than 20 claim roles matching
* OIDC_ROLE_PREFIX or a claim role longer than 64 characters, so no session was created.
*/
| "oidc_role_overflow"
/**
* Under governance (docs/GOVERNANCE.md#sign-in), the provider authenticated the account, but the governance source
* revoked its subject's sessions at or after this second, so every request would refuse the session the callback
* was about to mint and none was minted. The record names the account, which the provider vouched for.
*/
| "oidc_session_revoked"
/**
* A `POST /api/auth/login` under governance (docs/GOVERNANCE.md), where the identity provider is the only way in.
* Refused before the body is read, so the record names no submitted account and the actor is anonymous.
*/
| "password_login_disabled"
/**
* Under governance (docs/GOVERNANCE.md), a request named a connection the session's identity provider subject holds
* no grant on: one that does not exist, one granted to someone else, an expired grant or a row Studio did not admit,
* which the 404 CONNECTION_NOT_FOUND it answers leaves indistinguishable. The record names the id that was asked for.
*/
| "connection_not_granted"
// Passkey sign-in and management (#785). Each code has one meaning, so an operator can tell a
// cloned authenticator from a replay or a stale cookie without the export.
/**
* The ceremony cookie was missing, forged, expired, issued for the other ceremony, for another
* account or session version (including one that moved while the ceremony ran), or for another RP ID.
* A first registration that lost the race to another first registration of the same account lands
* here too: its ceremony carries a user handle the account no longer has, and no cookie is at fault.
*/
| "passkey_ceremony_invalid"
/**
* A response made on another origin than PASSKEY_ORIGIN: a non-browser client, a modified client,
* or a page on a subdomain of Studio's host. Studio's own UI never produces it in a real browser,
* so it is not a configuration symptom; a wrong PASSKEY_ORIGIN shows as a missing passkey button.
*/
| "passkey_origin_mismatch"
/** No credential with that ID for the configured RP ID, or the assertion carried no user handle. */
| "passkey_unknown"
/**
* WebAuthn verification refused the response: challenge, RP ID, flags, signature, attestation
* format, cross-origin use, a credential ID that is not the attested one, or a user handle or
* backup eligibility that does not match the stored credential.
*/
| "passkey_rejected"
/**
* The signature counter did not increase, a possible cloned authenticator; the sign-in was refused
* and the account is not locked.
*/
| "passkey_counter"
/** The challenge of an already successful ceremony was presented again. */
| "passkey_replayed"
/** A valid passkey of an account that is disabled or gone. */
| "passkey_account_unavailable"
/** A registration presented a credential ID already registered to an account. */
| "passkey_duplicate"
// Launch-token sign-in (docs/LAUNCH.md), each a `login_failure` reason on POST /api/auth/launch.
// One code per refusal, so an operator can tell a launch secret the platform and Studio no longer
// share from a slow click or a replay without the export. The token itself is never recorded.
/** Not a compact JWS, unparseable, or a claim missing or of the wrong shape. */
| "launch_token_malformed"
/** A header typ other than exactly libredb-launch+jwt, or none: another kind of JWT, such as a Studio session. */
| "launch_token_type"
/** Signed with an algorithm other than HS256, with none, or with a key other than LAUNCH_TOKEN_SECRET. */
| "launch_token_signature"
/** Issued by an issuer other than LAUNCH_TOKEN_ISSUER. */
| "launch_token_issuer"
/** Issued for an audience other than LAUNCH_TOKEN_AUDIENCE. */
| "launch_token_audience"
/** Past its expiry by more than the clock tolerance. */
| "launch_token_expired"
/** Issued, or valid from, further in the future than the clock tolerance allows: the two clocks disagree. */
| "launch_token_premature"
/** Issued for longer than the 60 seconds a launch token may live. */
| "launch_token_lifetime"
/** A token whose jti this process has already accepted. */
| "launch_token_replayed"
/** A valid token refused while this process remembers as many unexpired launch tokens as it holds. */
| "launch_capacity_exceeded"
/** A valid launch token for an account that is disabled in the server store. */
| "launch_account_disabled"
/**
* A valid launch token for an account a launch cannot sign in to: one with a password, an authenticator or
* a passkey, or one a launch created for another platform identity (issuer and subject).
*/
| "launch_identity_mismatch"
/** A valid launch token for another account than the one this browser is signed in as; the session stays. */
| "launch_session_conflict"
// Agent execution path (#328). The thirteen `agent_*` codes below mirror
// `PolicyDenyCode` one-for-one, plus the two outcomes that are not policy
// denials: an operation that may only ever require approval, and a provider
// that failed after the decision allowed it. The mirror is not maintained by
// hand — `DENY_REASONS` in src/lib/db/operations/execution.ts is typed
// `Record<PolicyDenyCode, AuditReason>`, so a new deny code with no reason
// here, or a reason renamed here, fails to compile.
| "agent_unknown_operation"
| "agent_ambiguous_operation"
| "agent_malformed_policy_context"
| "agent_invalid_actor"
| "agent_target_out_of_scope"
| "agent_input_validation_failed"
| "agent_capability_unsupported"
| "agent_role_forbidden"
| "agent_mode_forbidden"
| "agent_risk_exceeds_policy"
| "agent_concurrency_budget_exceeded"
| "agent_statement_budget_exceeded"
| "agent_total_run_budget_exceeded"
| "agent_approval_required"
| "agent_execution_failed"
// The run loop's own wall-clock refusals (#329). They are NOT policy denials and
// deliberately do not share that vocabulary: they fire before
// `executeAuditedOperation` is reached, so without these two a run that stopped on
// its own deadline would leave no trace at all. The mirror is kept honest the same
// way — `DEADLINE_REASONS` in src/lib/agent/tools.ts is typed
// `Record<AgentDeadlineDenyCode, AuditReason>`.
| "agent_run_deadline_exceeded"
| "agent_insufficient_time_remaining"
// The agent drive callback (#329 T9) refusing a caller that presented no valid
// single-purpose credential. Distinct from `no_session` on purpose: this path
// never wanted a session, so recording one vocabulary for both would make a
// forged drive token indistinguishable in the trail from an expired login.
| "no_agent_drive_token"
// The MCP endpoint (#246). The first three are its identity refusals, written by src/proxy.ts
// or by the route when the proxy was bypassed: a bearer that is missing, malformed, forged,
// expired, revoked or foreign (one code covers missing and invalid, as no_agent_drive_token
// does); any bearer while LIBREDB_MCP_TOKEN_LABEL is unset, or a token that verifies while
// LIBREDB_MCP_URL is unset or invalid; and a Host the loopback-bind check refused.
| "mcp_token_invalid"
| "mcp_channel_unconfigured"
| "mcp_host_not_allowed"
// The OIDC bearer channel (src/lib/oidc-bearer.ts), written by src/proxy.ts: an access token that is malformed,
// forged, expired, an ID token, for another audience, issuer or client; one whose account holds no role while
// OIDC_USER_ROLES is set; and one that carries no sub, or neither email nor preferred_username.
| "bearer_token_invalid"
| "bearer_no_role"
| "bearer_no_identity"
// The rest are mcp_operation reasons, each with one meaning. Arguments that failed the tool's
// input schema, which the SDK refuses before any handler runs and the route records.
| "mcp_invalid_arguments"
// A connection id outside the token's opted-in connections, the same for one that exists and
// one that does not.
| "mcp_connection_not_visible"
// A statement the execution fence refused before any provider was reached.
| "mcp_statement_refused"
// A named schema the connection does not have; an outcome, since the lookup needs the provider.
| "mcp_schema_not_found"
// offset above zero on a query the provider did not rewrite, so it could not be paged.
| "mcp_offset_unsupported"
// The request's signal aborted before or during the call.
| "mcp_cancelled"
// The call's deadline passed, including a provider's time-budget refusal and any failure that
// settled after the deadline.
| "mcp_timeout"
// A provider budget refusal (rows, bytes, a value SQL Server cut, a serialised result), or one
// row larger than the result cap.
| "mcp_result_too_large"
// Any other provider or acquisition failure.
| "mcp_execution_failed"
// The seed file could not be loaded, so no connection could be resolved.
| "mcp_connections_unreadable"
// The object edit path (#789 Phase 3). Eight codes for one apply's decidable outcomes, mapped
// from `ObjectEditOutcome` by a total record in src/lib/db/object-edit.ts, so a new outcome
// with no reading here fails to compile. `object_edit_plan_invalid` is the analogue of
// `no_agent_drive_token` and exists for the same recorded reason: without it a forged or
// tampered plan is indistinguishable in the trail from an ordinary failure.
| "object_edit_collateral_loss"
| "object_edit_applied_elsewhere"
| "object_edit_conflict"
| "object_edit_concurrent_update"
| "object_edit_refused"
| "object_edit_guard_refused"
| "object_edit_interrupted"
| "object_edit_plan_invalid"
// `POST /api/db/maintenance` when the provider's `runMaintenance` THREW (#1091 review, R04 G8).
// Kept apart from the reasonless failure row an engine's own `success: false` writes: a thrown
// run may have reached the engine before it failed, so its disposition is not the engine's
// answer, and an operator reading the log needs to tell the two apart.
| "maintenance_execution_failed";
export interface AuditEvent {
id: string;
timestamp: string;
type: AuditEventType;
action: string;
target: string;
/**
* The CONTAINER the target was addressed in, when the request named one: the schema of a
* PostgreSQL table, the database on ClickHouse, the bucket on a document store. It is what
* tells `app.orders` apart from `public.orders` in this log (#1091 review), so a maintenance
* row is two facts rather than one.
*
* Optional, and set only by the maintenance route: every other event's `target` already names
* a route rather than an object, and a field that claimed a container there would be a value
* its writer never meant. `toAuditLine` omits it entirely when it is unset, the way `reason`
* and `bucket` are omitted, so the line's shape does not grow a null.
*/
container?: string;
/**
* The engine principal a maintenance run acted as, when its provider names one through the optional
* `DatabaseProvider.engineUser()` (spec 3.11): a user name, never any part of a secret. Set by the maintenance route
* only, and omitted by `toAuditLine` when unset, as `container` is.
*/
engineUser?: string;
connectionName?: string;
user: string;
result: "success" | "failure";
duration?: number;
details?: string;
/**
* Derived from forwarded headers. It is a HINT, not an identity: X-Forwarded-For is
* attacker-controlled, and nothing in this product makes an authorization decision from it.
*/
ip?: string;
reason?: AuditReason;
/**
* Which rate-limit bucket tripped (e.g. "login_client", "login_account"). Only
* rate_limit_exceeded events set this. Without it, the audit trail cannot tell a broad address
* flood (login_client) apart from a targeted attack on one account (login_account) - the two
* call for a different operator response, but would otherwise read identically.
*/
bucket?: string;
/**
* Joins the events of ONE agent execution: the policy decision and, when the
* decision allowed it, the execution outcome. Server-generated per execution
* (src/lib/db/operations/execution.ts) and opaque — it identifies an
* execution, never a session, a user or a token, so it stays safe to log
* while remaining the key an operator groups by.
*
* Set by `agent_operation` events, by `object_edit` events, and by `mcp_operation` tool events,
* the three paths that emit a decision and an outcome as two records of one action (#789 Phase 3,
* #246).
*/
correlationId?: string;
/** The internal id of the passkey the event concerns, never the WebAuthn credential ID. */
passkey?: string;
}
const MAX_EVENTS = 1000;
/**
* Declared at module scope, not inline in `filter`'s signature: a type literal inside a function
* body is inside that function's coverage span, and bun reports never-executed functions as a
* coarse zero-hit block that includes type-only lines as if they were statements. A module-scope
* declaration is erased before any function span exists, so it can never appear as a phantom
* uncovered line the way an inline literal did.
*/
interface AuditFilterOptions {
type?: AuditEventType;
result?: "success" | "failure";
connectionName?: string;
since?: string;
/** Keep at most this many of the newest matches. Absent, NaN or negative means all of them. */
limit?: number;
}
export class AuditRingBuffer {
private events: AuditEvent[] = [];
private maxSize: number;
constructor(maxSize = MAX_EVENTS) {
this.maxSize = maxSize;
}
push(event: Omit<AuditEvent, "id" | "timestamp">) {
const fullEvent: AuditEvent = {
...event,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
timestamp: new Date().toISOString(),
};
this.events.push(fullEvent);
if (this.events.length > this.maxSize) {
this.events = this.events.slice(-this.maxSize);
}
return fullEvent;
}
getAll(): AuditEvent[] {
return [...this.events];
}
/**
* The newest `count` events, newest first. The buffer appends, so it is stored oldest
* first; the people reading it (Admin > Audit and its exports) want the event that just
* happened at the top, not below a page of sign-ins from when the server started.
*/
getRecent(count: number): AuditEvent[] {
return this.newestFirst(this.events, count);
}
/** Every match, newest first, capped to `opts.limit` of the newest when one is given. */
filter(opts: AuditFilterOptions): AuditEvent[] {
const matches = this.events.filter((e) => {
if (opts.type && e.type !== opts.type) return false;
if (opts.result && e.result !== opts.result) return false;
if (opts.connectionName && e.connectionName !== opts.connectionName) return false;
if (opts.since && e.timestamp < opts.since) return false;
return true;
});
return this.newestFirst(matches, opts.limit);
}
private newestFirst(chronological: AuditEvent[], limit?: number): AuditEvent[] {
const reversed = [...chronological].reverse();
return limit !== undefined && limit >= 0 ? reversed.slice(0, limit) : reversed;
}
clear() {
this.events = [];
}
get size() {
return this.events.length;
}
toJSON(): AuditEvent[] {
return this.events;
}
loadFrom(events: AuditEvent[]) {
this.events = events.slice(-this.maxSize);
}
}
// Global server-side instance
let _serverBuffer: AuditRingBuffer | null = null;
export function getServerAuditBuffer(): AuditRingBuffer {
if (!_serverBuffer) {
_serverBuffer = new AuditRingBuffer();
}
return _serverBuffer;
}
const AUDIT_SCHEMA = "libredb.audit.v1";
/**
* RFC 5321's maximum address length: enough for any real account, bounded against a 10 KB one.
* One rule for every free-text field, wherever it is stored — the ring buffer or the stdout line —
* not a fresh number per field or per destination.
*
* Exported so any call site that pre-truncates a value before it becomes an AuditEvent field (the
* login route's actor, for one) imports this constant instead of redeclaring its own copy of 254 -
* two independent constants with the same value today are one unnoticed edit away from drifting.
*/
export const MAX_AUDIT_FIELD_LENGTH = 254;
/** The address derivation's "no usable signal" placeholder; never recorded as if it were one. */
const UNKNOWN_ADDRESS = "unknown";
/**
* Redaction marker for a URI's userinfo segment. Never a value real credentials could equal.
* Exported for the query event redactor, which writes the same marker for every credential it masks.
*/
export const CREDENTIAL_REDACTION = "[REDACTED]";
/**
* Where a connection string's userinfo can be collapsed before the value reaches either
* destination. Once the `scheme://` boundary is found, everything to the end of the string is in
* play (not bounded to "before the next `/`"): a password containing `/`, `?` or `#` is an
* ordinary shape, and a boundary based on those characters cannot tell `postgres://user:pa/ss@host
* /db` (a slash INSIDE the password) apart from `https://example.com/user@example/profile` (an
* `@` INSIDE the path) — they are structurally identical. There is no syntax-only fix for that.
*
* The fix is to stop trying to parse a URI out of this value at all. An audit field is not a URI
* field: nobody downstream needs the full string back, only the scheme and which host it named.
* redactUriCredentials below finds the LAST `@` after the delimiter and keeps only what follows
* it, discarding everything between the scheme and that point regardless of what characters it
* contained. This is deliberately over-eager — a value shaped like case 6 above gets its path
* mangled even though it carried no credential — and that trade is correct here: mangling a
* harmless URL costs an operator nothing, while leaking a password costs them everything. Do not
* "fix" this by trying to distinguish password characters from path characters; that parser cannot
* be written correctly.
*
* Previously implemented as a single backtracking regex,
* `/([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([\s\S]*)$/` applied via `String.replace`. CodeQL flagged that as
* js/polynomial-redos (alert #112): the pattern is unanchored, so `.replace` retries the whole
* scheme-quantifier backtrack at every one of a long value's ~n starting positions before giving
* up, which is O(n^2) on an attacker-controlled field with no length bound applied before this
* runs (the bound below is applied AFTER redaction, deliberately - see sanitizeAuditField). Every
* field this module processes is free text from a request body or a header value, so "attacker
* controls the length and content" is the normal case, not an edge case.
*
* Rewritten below as `indexOf`/`lastIndexOf` plus one bounded scan per candidate delimiter. Why
* this stays O(value.length) even adversarially: `:` and `/` are not URI-scheme characters, so a
* scheme-character run can never span a "://" delimiter it failed to match against. Each loop
* iteration's backward-then-forward scan is therefore confined to the segment strictly between the
* previous rejected delimiter and the current one - those segments never overlap - so their
* lengths sum to at most value.length across every iteration, however many "://" occurrences the
* value contains. (An anchored regex, `/^([a-zA-Z]...)/`, would also kill the O(n^2) blowup, but
* only by matching solely at index 0 - silently DROPPING redaction for a credential that arrives
* with any prefix, e.g. an error message wrapping a connection string. That is a coverage
* regression in a control whose entire job is to never miss a credential, so it was rejected.)
*/
function isSchemeChar(ch: string): boolean {
return (
(ch >= "a" && ch <= "z") ||
(ch >= "A" && ch <= "Z") ||
(ch >= "0" && ch <= "9") ||
ch === "+" ||
ch === "." ||
ch === "-"
);
}
function isLetter(ch: string): boolean {
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z");
}
/** Exported for the query event redactor (`src/lib/query-events/redact.ts`), which applies the same rule to a statement. */
export function redactUriCredentials(value: string): string {
let searchFrom = 0;
for (;;) {
const delimiter = value.indexOf("://", searchFrom);
if (delimiter === -1) return value;
// Walk backward over the maximal run of scheme characters immediately before the delimiter,
// then forward to the first letter within that run: RFC 3986 requires a scheme to START with
// a letter, but the greedy backward walk may have overrun into a non-letter prefix (e.g. the
// "1" in "1://postgres://user:pass@host/db" - see the two tests pinning this loop).
let runStart = delimiter;
while (runStart > 0 && isSchemeChar(value[runStart - 1])) runStart--;
let schemeStart = runStart;
while (schemeStart < delimiter && !isLetter(value[schemeStart])) schemeStart++;
if (schemeStart < delimiter) {
const scheme = value.slice(schemeStart, delimiter);
const rest = value.slice(delimiter + 3);
const lastAt = rest.lastIndexOf("@");
// No `@` at all: no userinfo was ever present, so there is nothing to hide.
if (lastAt === -1) return value;
const host = rest.slice(lastAt + 1);
// No recoverable host (e.g. a dangling "user:pass@" with nothing after it): degrade to the
// marker alone rather than emitting a "scheme://[REDACTED]@" that promises a host it can't
// name.
const redacted = host.length === 0 ? CREDENTIAL_REDACTION : `${scheme}://${CREDENTIAL_REDACTION}@${host}`;
return value.slice(0, schemeStart) + redacted;
}
// No letter anywhere in the run immediately before this delimiter: not a valid scheme. Keep
// looking - a value can legitimately contain more than one "://" (see the same two tests).
searchFrom = delimiter + 3;
}
}
/**
* The one gate every free-text field passes through before it can reach either destination: strip
* any URI-shaped credential, then bound the length. Order matters — redacting first means a value
* long enough to be truncated never has its credential cut in half and left partially exposed.
*/
function sanitizeAuditField(value: string): string {
return redactUriCredentials(value).slice(0, MAX_AUDIT_FIELD_LENGTH);
}
/**
* `sanitizeAuditInput`'s fallback for a value that is present, not the one legitimate non-string
* field (`duration`, a number), and not already a string: turns it into a string so the sweep
* below has something to bound and redact, instead of leaving the original value - object, array,
* boolean, bigint - to reach a destination whose contract promises a string. JSON.stringify covers
* every shape that can actually arrive from a JSON request body; the catch exists only for the
* inputs JSON.stringify itself refuses (a circular reference, a BigInt), which a hand-built
* AuditEvent could construct even though `JSON.parse` output never does.
*
* What this bounds and what it does NOT redact. Coercion is whole-value, so the result goes
* through `sanitizeAuditField` exactly as a real string would - length bounded, URI-shaped
* credentials stripped. It is not recursive per-key redaction: a nested secret under an
* arbitrary key name (`{"apiKey": "sk-live-..."}`) is not URI-shaped, so it survives, truncated,
* inside the stringified value. It no longer breaks the fixed-shape contract, which was the
* reachable half. Closing the rest means walking nested plain objects key-by-key at bounded
* depth - and worth saying plainly, because it reads like a gap being left open: no by-key-name
* scrutiny exists for ANY field today, top-level or nested. That would be a new capability.
*/
function coerceToString(value: unknown): string {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return "[unserializable]";
}
}
/**
* The single sanitization boundary, applied once to a caller-supplied event before it reaches
* either the ring buffer (push) or the stdout line (toAuditLine) — both destinations consume this
* result, so there is exactly one rule to keep correct instead of one per destination. Sweeps
* every own key of the event and sanitizes any string value found there, unconditionally: this is
* deliberately not a per-field allowlist of calls to sanitizeAuditField, so a field added to
* AuditEvent later is covered by construction. Opting a field OUT would require deleting code from
* this sweep; there is no opt-in step to forget.
*
* The allowlist selects KEYS, not TYPES: every AuditEvent field but `duration` is typed as a
* string, but TypeScript cannot enforce that at runtime against a route that destructures a field
* straight out of an untyped `await request.json()` body (`target` in
* `POST /api/db/maintenance`, for one) and hands it to `emitAuditEvent` unchecked. The exemption
* below is therefore keyed on the field NAME (`key === "duration"`), not merely on the runtime
* value happening to be a number - a number arriving in any other field (say, `target`) is not
* `duration`'s legitimate number and is coerced like any other non-string. A value that is
* neither a string nor `duration`'s own number is coerced to a bounded string through the same
* sanitizer a real string would have gone through, rather than passed on verbatim: an object
* reaching either destination as-is would be unbounded and would break the fixed-shape
* `libredb.audit.v1` contract `toAuditLine` promises downstream parsers.
*
* Exported on its own, separately from emitAuditEvent: sanitization and stdout emission are two
* different privileges. `POST /api/admin/audit` accepts a fully client-supplied body with none of
* its fields validated at runtime, so it must never gain the authority to write to the stdout
* channel the design treats as authoritative — it calls this function directly and pushes to the
* buffer itself. `emitAuditEvent` below is a policy built on top of this boundary, for callers
* whose event content is decided by trusted route logic rather than by the request body.
*
* DANGEROUS_KEYS guards the dynamic `mutable[key] = ...` write below against CodeQL's
* js/remote-property-injection (alerts #113/#114): `key` is drawn from `Object.keys()` of an
* object built by spreading that same admin-audit request body. Empirically, this is not currently
* exploitable - object-spread (`{ ...event }` in that route, `{ ...event, ... }` in
* AuditRingBuffer.push below) uses CreateDataPropertyOrThrow, which defines "__proto__" as an
* ordinary own data property rather than invoking Object.prototype's accessor, so a later
* `mutable["__proto__"] = ...` here only overwrites that shadow property, never the real
* prototype. But that safety depends on both call sites staying spread-based forever; it is not a
* property of this function. A three-name skip list costs nothing and removes the dependency.
*/
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
export function sanitizeAuditInput(event: Omit<AuditEvent, "id" | "timestamp">): Omit<AuditEvent, "id" | "timestamp"> {
const sanitized: Omit<AuditEvent, "id" | "timestamp"> = { ...event };
// A second, dynamically-keyed view of the SAME object (not a copy, no cast): every property of
// an AuditEvent is a valid Record<string, unknown> value, so this assignment needs no assertion,
// and mutating through it mutates `sanitized` because both names refer to one object.
const mutable: Record<string, unknown> = sanitized;
for (const key of Object.keys(mutable)) {
if (DANGEROUS_KEYS.has(key)) continue;
const value = mutable[key];
if (typeof value === "string") {
mutable[key] = sanitizeAuditField(value);
} else if (value !== undefined && !(key === "duration" && typeof value === "number")) {
mutable[key] = sanitizeAuditField(coerceToString(value));
}
}
return sanitized;
}
/**
* The stdout record. Built as an explicit allowlist, never as a spread of a wider object, so that
* a field added to AuditEvent later cannot silently start being logged.
*/
interface AuditLogLine {
schema: string;
ts: string;
id: string;
event: AuditEventType;
action: string;
outcome: "success" | "failure";
actor: string;
route: string;
reason?: AuditReason;
ip?: string;
connection?: string;
container?: string;
engine_user?: string;
duration_ms?: number;
bucket?: string;
correlation_id?: string;
passkey?: string;
}
function toAuditLine(event: AuditEvent): AuditLogLine {
return {
schema: AUDIT_SCHEMA,
ts: event.timestamp,
id: event.id,
event: event.type,
action: event.action,
outcome: event.result,
actor: event.user,
route: event.target,
...(event.reason ? { reason: event.reason } : {}),
...(event.ip && event.ip !== UNKNOWN_ADDRESS ? { ip: event.ip } : {}),
...(event.connectionName ? { connection: event.connectionName } : {}),
// The container beside the route, and omitted on the same terms: an event that named none
// must not publish a `container: null` a parser would read as a value (#1091 review).
...(event.container ? { container: event.container } : {}),
// The engine principal beside the container, omitted on the same terms (spec 3.11).
...(event.engineUser ? { engine_user: event.engineUser } : {}),
// Omitted when unset, like container: most events concern no passkey.
...(event.passkey ? { passkey: event.passkey } : {}),
...(event.bucket ? { bucket: event.bucket } : {}),
...(event.correlationId ? { correlation_id: event.correlationId } : {}),
// Number.isFinite excludes NaN and +/-Infinity: JSON.stringify(NaN) silently produces `null`,
// which would flip duration_ms from a number to null for that one line in a contract parsers
// depend on. Omitting it entirely keeps the field's type stable instead.
...(event.duration !== undefined && Number.isFinite(event.duration) ? { duration_ms: event.duration } : {}),
};
}
/**
* The single entry point for an audit event. It does exactly two things:
*
* 1. Pushes to the ring buffer the admin UI reads. That buffer is per process and holds 1000
* events, oldest dropped. It is a CONVENIENCE VIEW, not the durable record - an event emitted
* from proxy() ALWAYS lands in a different instance than the admin API reads, measured in #851:
* Next compiles proxy.ts as its own entry which can run outside the main runtime, so the two
* have separate module graphs and therefore separate buffers. Every denial proxy() records is
* absent from the Admin Audit tab, which discloses that; the stdout channel below carries them.
* 2. Writes one JSON line to stdout. This is the authoritative channel: it works identically in
* all 27 distribution channels with no dependency, and it is what a log pipeline consumes.
*
* The line is NOT gated by LOG_LEVEL. Audit emission is unconditional; logger.ts remains the
* human-readable channel and is not repurposed.
*
* What must never be recorded here: passwords or any credential material, JWTs, cookies or
* Authorization values, OIDC tokens, code or code_verifier or raw claims, connection strings,
* hosts or SSH keys, SQL text, LLM prompts or responses, request bodies, raw Error.message or
* stack traces, and arbitrary request headers. src/lib/data-masking.ts is not reusable here: it
* masks result-grid cell values by column-name pattern and has no bearing on log strings.
*/
export function emitAuditEvent(event: Omit<AuditEvent, "id" | "timestamp">): AuditEvent {
const stored = getServerAuditBuffer().push(sanitizeAuditInput(event));
// JSON.stringify escapes newlines and control characters, so an attacker-controlled actor
// cannot forge a second log line. This is why the audit channel does not reuse logger.ts.
console.log(JSON.stringify(toAuditLine(stored)));
return stored;
}
// Client-side localStorage persistence — delegates to storage module
export function loadAuditFromStorage(): AuditEvent[] {
return storage.getAuditLog();
}
export function saveAuditToStorage(events: AuditEvent[]) {
storage.saveAuditLog(events);
}