diff --git a/src/errors/logging.test.ts b/src/errors/logging.test.ts index 36d497bdff..3559b0c6d2 100644 --- a/src/errors/logging.test.ts +++ b/src/errors/logging.test.ts @@ -75,6 +75,17 @@ describe("logging", () => { assertStringIncludes(output, "Component render failed"); }); + it("redacts credential-like context keys in the dev dump (#1989)", () => { + const error = RENDER_ERROR.create(); + + logError(error, { userId: "u-1", apiKey: "sk-secret" }); + + const output = consoleErrorOutput.join("\n"); + assertStringIncludes(output, "[REDACTED]"); + assertStringIncludes(output, "u-1"); + assertEquals(output.includes("sk-secret"), false); + }); + it("should use error.context when no context provided", () => { const error = CONFIG_NOT_FOUND.create({ context: { originalContext: true }, @@ -122,6 +133,17 @@ describe("logging", () => { assertEquals(parsed.context.componentPath, "/app/page.tsx"); }); + it("redacts credential-like context keys in JSON output (#1989)", () => { + const error = RENDER_ERROR.create(); + + logError(error, { userId: "u-1", token: "sk-secret" }); + + const parsed = JSON.parse(consoleErrorOutput[0]); + assertEquals(parsed.context.token, "[REDACTED]"); + assertEquals(parsed.context.userId, "u-1"); + assertEquals(consoleErrorOutput[0].includes("sk-secret"), false); + }); + it("should merge error context with extra context and prefer extra values", () => { const error = CONFIG_NOT_FOUND.create({ context: { diff --git a/src/errors/logging.ts b/src/errors/logging.ts index 45106f35d8..bfcdb96722 100644 --- a/src/errors/logging.ts +++ b/src/errors/logging.ts @@ -7,6 +7,7 @@ import { isProduction } from "#veryfront/platform/environment.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; +import { redactSensitive } from "#veryfront/utils/logger/redact.ts"; import { VeryfrontError } from "./types.ts"; export interface ErrorLogEntry { @@ -54,6 +55,9 @@ export function logError( context?: Record, ): void { const mergedContext = mergeContext(error.context, context); + // Redact once and reuse for both the production JSON entry and the dev-mode + // human-readable dump, so neither path can emit unredacted credentials. + const safeContext = redactSensitive(mergedContext); const entry: ErrorLogEntry = { level: "error", slug: error.slug, @@ -64,7 +68,7 @@ export function logError( status: error.status, docs: error.getDocsUrl(), timestamp: new Date().toISOString(), - context: mergedContext, + context: safeContext, }; if (isProduction()) { @@ -81,8 +85,8 @@ export function logError( serverLogger.error(` 💡 Suggestion: ${error.suggestion}`); } serverLogger.error(` 📚 Docs: ${entry.docs}`); - if (mergedContext) { - serverLogger.error(` Context: ${JSON.stringify(mergedContext, null, 2)}`); + if (safeContext) { + serverLogger.error(` Context: ${JSON.stringify(safeContext, null, 2)}`); } } } diff --git a/src/observability/log-buffer.test.ts b/src/observability/log-buffer.test.ts index 490f711a78..dda28a4439 100644 --- a/src/observability/log-buffer.test.ts +++ b/src/observability/log-buffer.test.ts @@ -1,7 +1,7 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; -import { LogBuffer } from "./log-buffer.ts"; +import { interceptConsole, LogBuffer } from "./log-buffer.ts"; describe("observability/log-buffer", () => { describe("LogBuffer", () => { @@ -18,6 +18,37 @@ describe("observability/log-buffer", () => { assertEquals(a.id !== b.id, true); }); + it("redacts credential-like keys from entry data (#1989)", () => { + const buf = new LogBuffer(); + const seen: Record[] = []; + buf.subscribe((entry) => { + if (entry.data) seen.push(entry.data); + }); + + const entry = buf.info("request", "server", { userId: "u-1", apiKey: "sk-secret" }); + + assertEquals(entry.data?.apiKey, "[REDACTED]"); + assertEquals(entry.data?.userId, "u-1"); + // Subscribers (incl. the file writer) only ever see the redacted copy. + assertEquals(seen[0].apiKey, "[REDACTED]"); + assertEquals(JSON.stringify(entry).includes("sk-secret"), false); + }); + + it("redacts object args captured via interceptConsole (#1989)", () => { + const buf = new LogBuffer(); + const restore = interceptConsole(buf); + try { + console.error("auth attempt", { apiKey: "sk-secret", userId: "u-1" }); + } finally { + restore(); + } + + const message = buf.tail(1)[0].message; + assertEquals(message.includes("sk-secret"), false); + assertEquals(message.includes("[REDACTED]"), true); + assertEquals(message.includes("u-1"), true); + }); + it("should support all log levels", () => { const buf = new LogBuffer(); buf.debug("d"); diff --git a/src/observability/log-buffer.ts b/src/observability/log-buffer.ts index 2d14ecd3dc..4d8a844b85 100644 --- a/src/observability/log-buffer.ts +++ b/src/observability/log-buffer.ts @@ -1,3 +1,5 @@ +import { redactSensitive } from "#veryfront/utils/logger/redact.ts"; + /** Public API contract for log level. */ export type LogLevel = "debug" | "info" | "warn" | "error"; @@ -40,6 +42,9 @@ export class LogBuffer { append(entry: Omit): LogEntry { const fullEntry: LogEntry = { ...entry, + // Redact credential-like keys before the entry is buffered, surfaced to + // subscribers, or written to disk by the file subscriber (#1989). + data: entry.data ? redactSensitive(entry.data) : entry.data, id: this.generateId(), timestamp: Date.now(), }; @@ -193,7 +198,9 @@ export function interceptConsole(buffer: LogBuffer, source = "console"): () => v if (typeof a === "string") return a; try { - return JSON.stringify(a); + // Redact object args before they are folded into the message string, + // where the per-entry data redaction can no longer reach them (#1989). + return JSON.stringify(redactSensitive(a)); } catch (_) { /* expected: circular references or non-serializable values */ return String(a); diff --git a/src/utils/logger/logger.test.ts b/src/utils/logger/logger.test.ts index 865771f4ec..8eed061bef 100644 --- a/src/utils/logger/logger.test.ts +++ b/src/utils/logger/logger.test.ts @@ -258,6 +258,37 @@ describe("logger", () => { } }); + it("redacts credential-like context keys before serialization (#1989)", () => { + const { getOutput, restore } = captureConsoleLog(); + + try { + withJsonLogFormat(() => { + serverLogger.info("Authenticating", { + userId: "u-1", + password: "hunter2", + authorization: "Bearer abc", + headers: { cookie: "session=xyz", accept: "json" }, + }); + + const line = getOutput(); + const entry = JSON.parse(line) as LogEntry; + const context = entry.context as Record; + assertEquals(context.password, "[REDACTED]"); + assertEquals(context.authorization, "[REDACTED]"); + assertEquals((context.headers as Record).cookie, "[REDACTED]"); + // Non-sensitive fields survive. + assertEquals((context.headers as Record).accept, "json"); + // userId is a deliberate extracted field, not a secret. + assertEquals(entry.userId, "u-1"); + // The raw secret must not appear anywhere in the serialized line. + assertEquals(line.includes("hunter2"), false); + assertEquals(line.includes("session=xyz"), false); + }); + } finally { + restore(); + } + }); + it("should surface run user log routing fields as top-level JSON fields", () => { const { getOutput, restore } = captureConsoleLog(); @@ -315,6 +346,45 @@ describe("logger", () => { restore(); } }); + + it("scrubs credentials embedded in error message/stack (#1989)", () => { + const { getOutput, restore } = captureConsoleLog(); + + try { + withJsonLogFormat(() => { + const err = new Error("db connect failed: postgres://admin:s3cret@db.host/app"); + serverLogger.info("DB error", err); + + const line = getOutput(); + const entry = JSON.parse(line) as LogEntry; + assertEquals(line.includes("s3cret"), false); + assertEquals(entry.error?.message?.includes("[REDACTED]"), true); + }); + } finally { + restore(); + } + }); + + it("scrubs credentials from lifted request_url (#1989)", () => { + const { getOutput, restore } = captureConsoleLog(); + + try { + withJsonLogFormat(() => { + serverLogger.info("Incoming request", { + request_url: "https://api.example.com/cb?code=abc123&access_token=xyz&page=2", + }); + + const line = getOutput(); + const entry = JSON.parse(line) as LogEntry; + assertEquals(line.includes("abc123"), false); + assertEquals(line.includes("xyz"), false); + assertEquals(entry.request_url?.includes("page=2"), true); + assertEquals(entry.request_url?.includes("[REDACTED]"), true); + }); + } finally { + restore(); + } + }); }); describe("text output format", () => { @@ -343,6 +413,29 @@ describe("logger", () => { __resetLoggerConfigForTests(); } }); + + it("scrubs credentials from rendered error message (#1989)", () => { + Deno.env.set("LOG_FORMAT", "text"); + Deno.env.set("NO_COLOR", "1"); + __resetLoggerConfigForTests(); + + const { getOutput, restore } = captureConsoleLog(); + + try { + serverLogger.info("DB error", { + error: new Error("connect failed: mongodb://root:p4ss@cluster/db"), + }); + + const output = getOutput(); + assertEquals(output.includes("p4ss"), false); + assertEquals(output.includes("[REDACTED]"), true); + } finally { + restore(); + Deno.env.delete("LOG_FORMAT"); + Deno.env.delete("NO_COLOR"); + __resetLoggerConfigForTests(); + } + }); }); describe("component() logger", () => { diff --git a/src/utils/logger/logger.ts b/src/utils/logger/logger.ts index 009086f8a0..4bc369f183 100644 --- a/src/utils/logger/logger.ts +++ b/src/utils/logger/logger.ts @@ -12,6 +12,7 @@ import { type SerializedError, serializeError, } from "./core.ts"; +import { redactSensitive, sanitizeSerializedError, sanitizeUrlCredentials } from "./redact.ts"; export enum LogLevel { DEBUG = 0, @@ -313,8 +314,16 @@ class ConsoleLogger implements Logger { extractToEntryField(entry, mergedContext, "trace_id", (v) => String(v)); extractToEntryField(entry, mergedContext, "span_id", (v) => String(v)); extractToEntryField(entry, mergedContext, "project_slug", (v) => String(v)); - extractToEntryField(entry, mergedContext, "request_url", (v) => String(v)); - extractToEntryField(entry, mergedContext, "domain", (v) => String(v)); + // request_url / domain are URL-shaped and lifted out of mergedContext + // *before* redactSensitive runs, so they bypass the key-based redactor. + // Scrub embedded credentials (userinfo, ?access_token=, …) here (#1989). + extractToEntryField( + entry, + mergedContext, + "request_url", + (v) => sanitizeUrlCredentials(String(v)), + ); + extractToEntryField(entry, mergedContext, "domain", (v) => sanitizeUrlCredentials(String(v))); extractToEntryField(entry, mergedContext, "project_id", (v) => String(v)); extractToEntryField(entry, mergedContext, "release_id", (v) => String(v)); extractToEntryField(entry, mergedContext, "branch_id", (v) => String(v)); @@ -344,8 +353,14 @@ class ConsoleLogger implements Logger { entry.conversation_id = entry.conversationId; } - if (Object.keys(mergedContext).length > 0) entry.context = mergedContext; - if (error) entry.error = error; + // Redact credential-like keys from the free-form context bag before + // serialization (the deliberate top-level fields above are already + // extracted out of mergedContext, so they are unaffected). + if (Object.keys(mergedContext).length > 0) entry.context = redactSensitive(mergedContext); + // The serialized error (name/message/stack) bypasses the key-based + // redactor; scrub credentials embedded in its message/stack (DSNs, Mongo + // URIs, ?access_token= URLs, userinfo) before emission (#1989). + if (error) entry.error = sanitizeSerializedError(error); return JSON.stringify(entry); } @@ -361,7 +376,11 @@ class ConsoleLogger implements Logger { const componentTag = this.componentName ? ` ${colorize(`[${this.componentName}]`, ANSI.dim, enableColor)}` : ""; - const contextText = formatContextText(mergedContext, error, enableColor); + const contextText = formatContextText( + redactSensitive(mergedContext), + sanitizeSerializedError(error), + enableColor, + ); return `${timestamp} ${tag} ${glyph}${componentTag} ${message}${contextText}`; } diff --git a/src/utils/logger/redact.test.ts b/src/utils/logger/redact.test.ts new file mode 100644 index 0000000000..b831d90be0 --- /dev/null +++ b/src/utils/logger/redact.test.ts @@ -0,0 +1,238 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + isSensitiveKey, + REDACTED, + redactSensitive, + sanitizeSerializedError, + sanitizeUrlCredentials, +} from "./redact.ts"; + +describe("logger/redact", () => { + describe("isSensitiveKey", () => { + it("matches credential-like keys across naming conventions", () => { + for ( + const key of [ + "password", + "passwd", + "pwd", + "passphrase", + "secret", + "clientSecret", + "token", + "access_token", + "refreshToken", + "jwt", + "jwtToken", + "apiKey", + "API-Key", + "x-api-key", + "accessKey", + "privateKey", + "credential", + "authorization", + "Authorization", + "Cookie", + "bearer", + "connectionString", + // Extended deny-list (#1989). + "signature", + "x-csrf-token", + "xsrfToken", + "sessionId", + "otp", + "mfaCode", + "pin", + "salt", + ] + ) { + assertEquals(isSensitiveKey(key), true, `expected ${key} to be sensitive`); + } + }); + + it("does not flag benign keys that merely look similar", () => { + // `author` must NOT match (the deny-list deliberately omits bare `auth`), + // and short tokens like `dsn`/`sas` are omitted to avoid masking e.g. + // `feedsNamespace`. + for ( + const key of ["author", "count", "userId", "requestId", "url", "domain", "feedsNamespace"] + ) { + assertEquals(isSensitiveKey(key), false, `expected ${key} to be non-sensitive`); + } + }); + + it("documents the accepted over-redaction of keys containing a pattern", () => { + // Over-redaction is the safe failure mode: a key like "tokenCount" is + // masked even though it is not itself a secret. + assertEquals(isSensitiveKey("tokenCount"), true); + }); + }); + + describe("redactSensitive", () => { + it("masks top-level sensitive values and preserves the rest", () => { + const result = redactSensitive({ + requestId: "req-1", + password: "hunter2", + authorization: "Bearer abc", + message: "ok", + }); + assertEquals(result, { + requestId: "req-1", + password: REDACTED, + authorization: REDACTED, + message: "ok", + }); + }); + + it("redacts nested objects and arrays of objects", () => { + const result = redactSensitive({ + outer: { + apiKey: "k", + nested: { token: "t", keep: 1 }, + }, + list: [{ secret: "s", id: 2 }], + }); + assertEquals(result, { + outer: { + apiKey: REDACTED, + nested: { token: REDACTED, keep: 1 }, + }, + list: [{ secret: REDACTED, id: 2 }], + }); + }); + + it("traverses class instances so their secret fields cannot leak", () => { + class ApiConfig { + apiKey = "sk-secret"; + name = "app"; + } + const result = redactSensitive({ config: new ApiConfig() }) as Record< + string, + Record + >; + assertEquals(result.config.apiKey, REDACTED); + assertEquals(result.config.name, "app"); + }); + + it("does not mutate the input object", () => { + const input = { password: "hunter2", keep: "v" }; + const result = redactSensitive(input); + assertEquals(input.password, "hunter2"); + assertEquals((result as Record).password, REDACTED); + }); + + it("leaves primitives and scalar-serializing objects untouched", () => { + const date = new Date(0); + const result = redactSensitive({ when: date, n: 5, flag: true, nil: null }) as Record< + string, + unknown + >; + // Date defines toJSON → serializes to a scalar → returned as-is. + assertEquals(result.when, date); + assertEquals(result.n, 5); + assertEquals(result.flag, true); + assertEquals(result.nil, null); + }); + + it("fails closed on cyclic references (no unredacted back-reference)", () => { + const cyclic: Record = { token: "t", keep: 1 }; + cyclic.self = cyclic; + const result = redactSensitive(cyclic) as Record; + assertEquals(result.token, REDACTED); + assertEquals(result.keep, 1); + // The back-reference is masked rather than re-emitting the raw object. + assertEquals(result.self, REDACTED); + }); + + it("fails closed on a throwing getter", () => { + const obj: Record = { password: "x" }; + Object.defineProperty(obj, "boom", { + enumerable: true, + get() { + throw new Error("nope"); + }, + }); + // The whole object is masked rather than crashing the log call. + assertEquals(redactSensitive({ wrap: obj }) as Record, { + wrap: REDACTED, + }); + }); + + it("fails closed past the max traversal depth", () => { + // Build a structure deeper than MAX_DEPTH (16) with a secret at the bottom. + let node: Record = { token: "deep-secret" }; + for (let i = 0; i < 20; i++) node = { child: node }; + const serialized = JSON.stringify(redactSensitive(node)); + assertEquals(serialized.includes("deep-secret"), false); + }); + + it("redacts secrets smuggled through a toJSON method (CODEX P2)", () => { + // `JSON.stringify` invokes toJSON, so a key-based pass over the object's + // own properties would miss the credential the serializer actually emits. + const config = { toJSON: () => ({ apiKey: "sk-secret", name: "app" }) }; + const result = redactSensitive({ config }); + const serialized = JSON.stringify(result); + assertEquals(serialized.includes("sk-secret"), false); + // Non-sensitive sibling from the toJSON output survives. + assertEquals(serialized.includes("app"), true); + }); + + it("redacts a nested toJSON returning an array of credential bags", () => { + const obj = { toJSON: () => [{ token: "t-1" }, { keep: 2 }] }; + const serialized = JSON.stringify(redactSensitive({ obj })); + assertEquals(serialized.includes("t-1"), false); + assertEquals(serialized.includes("2"), true); + }); + }); + + describe("sanitizeUrlCredentials", () => { + it("masks URL userinfo passwords", () => { + assertEquals( + sanitizeUrlCredentials("postgres://user:s3cret@db.host:5432/app"), + `postgres://user:${REDACTED}@db.host:5432/app`, + ); + }); + + it("masks bare-token userinfo (no colon)", () => { + assertEquals( + sanitizeUrlCredentials("https://t0ken@api.example.com/path"), + `https://${REDACTED}@api.example.com/path`, + ); + }); + + it("masks sensitive query params and keeps benign ones", () => { + const out = sanitizeUrlCredentials( + "https://api.example.com/cb?code=abc123&access_token=xyz&page=2", + ); + assertEquals(out.includes("abc123"), false); + assertEquals(out.includes("xyz"), false); + assertEquals(out.includes("page=2"), true); + assertEquals( + out, + `https://api.example.com/cb?code=${REDACTED}&access_token=${REDACTED}&page=2`, + ); + }); + + it("leaves non-URL strings untouched", () => { + assertEquals(sanitizeUrlCredentials("just a plain message"), "just a plain message"); + }); + }); + + describe("sanitizeSerializedError", () => { + it("scrubs credentials from message and stack", () => { + const sanitized = sanitizeSerializedError({ + name: "Error", + message: "connect failed: postgres://u:p4ss@db/app", + stack: "Error: token leak https://x.io?api_key=SECRET\n at f", + }); + assertEquals(sanitized.message.includes("p4ss"), false); + assertEquals(sanitized.stack?.includes("SECRET"), false); + assertEquals(sanitized.name, "Error"); + }); + + it("returns undefined unchanged", () => { + assertEquals(sanitizeSerializedError(undefined), undefined); + }); + }); +}); diff --git a/src/utils/logger/redact.ts b/src/utils/logger/redact.ts new file mode 100644 index 0000000000..f47764b208 --- /dev/null +++ b/src/utils/logger/redact.ts @@ -0,0 +1,249 @@ +/** + * Secret / credential redaction for structured log context. + * + * Defense-in-depth (#1989): the logger, the error-logging path, and the + * observability log buffer all accept arbitrary `context`/`data` objects from + * callers and serialize them to log sinks. There is no guarantee a caller + * never hands us a tokens object, an `Authorization` header bag, or a request + * body with a password field. This pass masks values whose *key* looks like a + * credential before serialization, so an accidental + * `logger.info("...", { authorization: token })` cannot leak the secret. + * + * Scope is intentionally key-based: we do not attempt to find secrets embedded + * in free-form message strings (too lossy). The deny-list errs toward + * over-redaction — masking a benign `tokenCount` is acceptable; leaking a real + * token is not. The traversal fails *closed*: on a cycle, depth overflow, or a + * throwing getter it returns {@link REDACTED} rather than risk emitting an + * unredacted object. + */ + +import { isRecord } from "./core.ts"; + +/** Replacement value substituted for any sensitive field. */ +export const REDACTED = "[REDACTED]"; + +/** + * Normalized substrings that mark a key as sensitive. Matching is done against + * a lowercased, non-alphanumeric-stripped form of the key, so `API-Key`, + * `api_key`, and `apiKey` all collapse to `apikey` and match. + * + * Deliberately omitted to avoid false positives that swamp real logs: + * - bare `"auth"` (would mask `author`); `authorization`/`authToken` are still + * covered via `authorization`/`token`. + * - short tokens like `"dsn"`/`"sas"` (would mask `feedsNamespace`, etc.). + */ +const SENSITIVE_KEY_PATTERNS = [ + "password", + "passwd", + "pwd", + "passphrase", + "secret", + "clientsecret", + "token", + "apikey", + "accesskey", + "privatekey", + "credential", + "authorization", + "cookie", + "bearer", + "jwt", + "connectionstring", + "signature", + "sessionid", + "sid", + "otp", + "mfa", + "pin", + "salt", + "xsrf", + "csrf", +] as const; + +/** Stop traversing past this depth to keep the pass cheap and stack-safe. */ +const MAX_DEPTH = 16; + +/** + * Whether a context key names a credential and should have its value masked. + * + * Uses substring matching on a normalized key, so `clientSecret`, + * `x-api-key`, and `refresh_token` all match while benign words that merely + * *contain* a pattern as a separate token (e.g. `author`) do not — `author` + * normalizes to `author`, which contains none of the patterns. + */ +export function isSensitiveKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + return SENSITIVE_KEY_PATTERNS.some((pattern) => normalized.includes(pattern)); +} + +/** + * A non-null, non-array object. {@link isRecord} covers class instances too, + * whose enumerable fields `JSON.stringify` *would* serialize, so we must + * traverse them to catch secrets. + */ +function isTraversableRecord(value: unknown): value is Record { + return isRecord(value); +} + +function hasToJson(value: object): value is { toJSON: () => unknown } { + return typeof (value as { toJSON?: unknown }).toJSON === "function"; +} + +function redactValue(value: unknown, depth: number, seen: Set): unknown { + if (Array.isArray(value)) { + if (depth >= MAX_DEPTH || seen.has(value)) return REDACTED; + seen.add(value); + try { + return value.map((item) => redactValue(item, depth + 1, seen)); + } finally { + seen.delete(value); + } + } + + // Objects defining `toJSON` (Date, URL, custom serializers) are serialized + // by `JSON.stringify` via the *return value* of `toJSON`, not their own + // enumerable keys. A key-based pass over the object's own properties would + // therefore miss credentials smuggled through `toJSON`, e.g. + // `{ toJSON: () => ({ apiKey: "sk-..." }) }` (CODEX P2). When `toJSON` + // returns a non-scalar (an object/array that could carry credential keys), + // redact *that* — the thing actually emitted. When it returns a scalar + // (Date/URL → ISO string), the original object is left intact, preserving + // prior behavior and identity. + if (isRecord(value) && hasToJson(value)) { + if (depth >= MAX_DEPTH || seen.has(value)) return REDACTED; + seen.add(value); + try { + const serialized = value.toJSON(); + if (isRecord(serialized) || Array.isArray(serialized)) { + return redactValue(serialized, depth + 1, seen); + } + // Scalar result (string/number/…): the object serializes safely as-is. + return value; + } catch { + // A throwing toJSON must never let the raw object (whose own keys we + // skipped) through: fail closed. + return REDACTED; + } finally { + seen.delete(value); + } + } + + if (isTraversableRecord(value)) { + if (depth >= MAX_DEPTH || seen.has(value)) return REDACTED; + seen.add(value); + try { + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + out[key] = isSensitiveKey(key) ? REDACTED : redactValue(child, depth + 1, seen); + } + return out; + } catch { + // A throwing getter (or other access error) must never let an + // unredacted object through: fail closed. + return REDACTED; + } finally { + seen.delete(value); + } + } + + // Primitives and scalar-serializing objects (Date, URL, …) are returned + // untouched: they are not key/value bags we can safely rewrite. + return value; +} + +/** + * Returns a redacted deep copy of `context`. Any property whose key is + * {@link isSensitiveKey} has its value replaced with {@link REDACTED}; nested + * plain objects, class instances, and arrays are traversed. The input is never + * mutated, and the pass fails closed (returns {@link REDACTED}) on cycles, + * depth overflow, or a throwing getter. + */ +export function redactSensitive(context: T): T { + return redactValue(context, 0, new Set()) as T; +} + +/** + * Query-string parameter names that commonly carry credentials in URLs. + * Matched case-insensitively against the parameter name. + */ +const SENSITIVE_URL_PARAMS = [ + "access_token", + "accesstoken", + "refresh_token", + "api_key", + "apikey", + "code", + "token", + "secret", + "client_secret", + "password", + "passwd", + "pwd", + "state", + "sig", + "signature", + "auth", +] as const; + +const URL_USERINFO_RE = /(\b[a-z][a-z0-9+.-]*:\/\/)([^/?#@\s]+)@/gi; + +/** + * Strip credentials from URL-shaped strings so they can be safely emitted in + * free-form text (error messages, stacks, lifted `request_url` fields). Unlike + * {@link redactSensitive}, which is key-based, this scrubs secrets embedded in + * the *value* itself: + * + * - URL userinfo: `http://user:pass@host` → `http://user:[REDACTED]@host` + * - sensitive query params: `?access_token=abc` → `?access_token=[REDACTED]` + * + * It is intentionally tolerant: it operates on any string (a DSN, a Mongo URI, + * an axios error message containing a URL) via regex rather than requiring a + * parseable URL, so malformed or partial URLs in error text are still scrubbed. + * Non-URL strings pass through unchanged. + */ +export function sanitizeUrlCredentials(input: string): string { + if (typeof input !== "string" || input.length === 0) return input; + + // 1) userinfo: scheme://user:pass@ → mask the password (and any bare creds). + let out = input.replace(URL_USERINFO_RE, (_match, scheme: string, userinfo: string) => { + const colon = userinfo.indexOf(":"); + if (colon === -1) { + // `scheme://token@host` — the whole userinfo is credential-like. + return `${scheme}${REDACTED}@`; + } + const user = userinfo.slice(0, colon); + return `${scheme}${user}:${REDACTED}@`; + }); + + // 2) sensitive query/fragment params: `key=value` → `key=[REDACTED]`. + // Match `?key=`, `&key=`, `;key=` separators and stop at the next delimiter. + out = out.replace( + /([?&;])([a-z0-9_.\-]+)=([^&#;\s]*)/gi, + (match, sep: string, key: string, _val: string) => { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ""); + const sensitive = SENSITIVE_URL_PARAMS.some((p) => + normalized === p.replace(/[^a-z0-9]/g, "") + ); + return sensitive ? `${sep}${key}=${REDACTED}` : match; + }, + ); + + return out; +} + +/** + * Apply {@link sanitizeUrlCredentials} to the `message` and `stack` of a + * serialized-error-shaped object, returning a new object. Used by the logger's + * JSON and text paths so errors carrying DSNs, Mongo URIs, or + * `?access_token=`-bearing URLs do not leak credentials (the serialized error + * bypasses the key-based redactor). Returns the input unchanged when falsy. + */ +export function sanitizeSerializedError< + T extends { message?: unknown; stack?: unknown } | undefined, +>(error: T): T { + if (!error) return error; + const out: { message?: unknown; stack?: unknown } = { ...error }; + if (typeof out.message === "string") out.message = sanitizeUrlCredentials(out.message); + if (typeof out.stack === "string") out.stack = sanitizeUrlCredentials(out.stack); + return out as T; +}