From e3a7e635dda93ba3c3110b6b80c5d8e98824da14 Mon Sep 17 00:00:00 2001 From: Ewen Date: Wed, 5 Aug 2026 13:11:06 +0800 Subject: [PATCH 1/6] fix(logger): redact sensitive data at log boundary --- src/main/logger.test.ts | 213 +++++++++++++++++++++++++++++++++++++++- src/main/logger.ts | 161 +++++++++++++++++++++++++++++- 2 files changed, 369 insertions(+), 5 deletions(-) diff --git a/src/main/logger.test.ts b/src/main/logger.test.ts index 9a31630a6..25898f856 100644 --- a/src/main/logger.test.ts +++ b/src/main/logger.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createLogger, @@ -20,6 +20,7 @@ import { let logDir: string | undefined afterEach(async () => { + vi.restoreAllMocks() if (logDir) { await rm(logDir, { recursive: true, force: true }) logDir = undefined @@ -65,6 +66,216 @@ describe('logger: formatLine', () => { expect(() => JSON.parse(line)).not.toThrow() expect((JSON.parse(line) as { data: unknown }).data).toBe('[unserializable]') }) + + it('recursively redacts sensitive field variants while retaining token metrics', () => { + const sentinel = 'opaque-field-value-7319' + const sensitiveKeys = [ + 'authorization', + 'proxy-authorization', + 'providerApiKey', + 'providerApiKeys', + 'access_token', + 'refreshToken', + 'x-amz-security-token', + 'clientSecret', + 'secrets', + 'privateKey', + 'cookieValue', + 'cookies', + 'password', + 'credentials' + ] + const nestings = [ + (key: string): unknown => ({ [key]: sentinel }), + (key: string): unknown => ({ nested: { [key]: sentinel } }), + (key: string): unknown => ({ items: [{ [key]: sentinel }] }), + (key: string): unknown => ({ data: { cause: { [key]: sentinel } } }) + ] + + for (const key of sensitiveKeys) { + for (const nest of nestings) { + const line = formatLine('error', 'redaction', 'failed', nest(key)) + expect(line).not.toContain(sentinel) + expect(line).toContain('[redacted]') + } + } + + const preserved = JSON.parse( + formatLine('info', 'usage', 'counted', { + tokenUsage: { inputTokens: 21, outputTokens: 8, cachedInputTokens: 5 }, + tokenCount: 29, + maxOutputTokens: 100 + }) + ) as { + data: { + tokenUsage: { inputTokens: number; outputTokens: number; cachedInputTokens: number } + tokenCount: number + maxOutputTokens: number + } + } + + expect(preserved.data).toEqual({ + tokenUsage: { inputTokens: 21, outputTokens: 8, cachedInputTokens: 5 }, + tokenCount: 29, + maxOutputTokens: 100 + }) + }) + + it('redacts credential-shaped strings in messages and nested error context', () => { + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJsb2ctcmVkYWN0aW9uIn0.signaturevalue123' + const examples = [ + { text: 'Bearer bearer-opaque-7319', secrets: ['bearer-opaque-7319'] }, + { text: `jwt=${jwt}`, secrets: [jwt] }, + { text: 'Authorization: Basic dXNlcjpwYXNz', secrets: ['dXNlcjpwYXNz'] }, + { text: 'Cookie: session=cookie-opaque-7319; Path=/', secrets: ['cookie-opaque-7319'] }, + { text: 'apiKey="json-opaque-7319"', secrets: ['json-opaque-7319'] }, + { text: 'OPENAI_API_KEY=env-opaque-7319', secrets: ['env-opaque-7319'] }, + { text: '--api-key cli-opaque-7319', secrets: ['cli-opaque-7319'] }, + { + text: 'https://alice:password-opaque-7319@example.test/v1?token=query-opaque-7319&ok=1', + secrets: ['alice', 'password-opaque-7319', 'query-opaque-7319'] + }, + { text: 'sk-1234567890abcdef', secrets: ['sk-1234567890abcdef'] }, + { text: 'github_pat_1234567890abcdef', secrets: ['github_pat_1234567890abcdef'] }, + { text: 'AKIA1234567890ABCDEF', secrets: ['AKIA1234567890ABCDEF'] } + ] + + for (const { text, secrets } of examples) { + const line = formatLine('error', 'redaction', text, { + data: { cause: { message: text, stack: `Error: ${text}` } } + }) + for (const secret of secrets) expect(line).not.toContain(secret) + expect(line).toContain('[redacted]') + } + }) + + it('drops content-bearing fields and unlabeled oversized text', () => { + const sentinel = 'opaque-body-value-7319' + for (const key of [ + 'body', + 'rawBody', + 'requestBody', + 'response_body', + 'payload', + 'requestPayload', + 'responsePayload' + ]) { + const line = formatLine('error', 'redaction', 'request failed', { + nested: { [key]: { research: sentinel } } + }) + expect(line).not.toContain(sentinel) + expect(line).toContain('[redacted]') + } + + const oversized = `${sentinel}${'x'.repeat(8192)}` + const line = formatLine('error', 'redaction', oversized, { detail: oversized }) + expect(line).not.toContain(sentinel) + expect(line).toContain('[redacted: oversized text]') + }) + + it('preserves error classification, status, and correlation identifiers', () => { + const parsed = JSON.parse( + formatLine( + 'error', + 'provider', + 'request failed', + { + errorCategory: 'request', + name: 'RequestError', + code: -32603, + status: 502, + statusCode: 503, + errno: -2, + syscall: 'connect', + requestId: 'req-1', + sessionId: 'session-1', + operationId: 'operation-1', + correlationId: 'correlation-1', + traceId: 'trace-1' + }, + 'run-1' + ) + ) as Record + + expect(parsed).toMatchObject({ + runId: 'run-1', + data: { + errorCategory: 'request', + name: 'RequestError', + code: -32603, + status: 502, + statusCode: 503, + errno: -2, + syscall: 'connect', + requestId: 'req-1', + sessionId: 'session-1', + operationId: 'operation-1', + correlationId: 'correlation-1', + traceId: 'trace-1' + } + }) + }) + + it('redacts a rich provider RequestError at the persisted boundary', () => { + const sentinel = 'provider-opaque-value-7319' + const requestError = Object.assign(new Error(`request rejected: Bearer ${sentinel}`), { + name: 'RequestError', + code: -32603, + data: { + authorization: sentinel, + responseBody: { research: sentinel }, + statusCode: 429, + requestId: 'request-7319' + }, + cause: new Error(`upstream https://user:${sentinel}@example.test/v1`) + }) + + const line = formatLine('error', 'acp', 'prompt failed', { + sessionId: 'session-7319', + ...errorLogFields(requestError) + }) + const parsed = JSON.parse(line) as { + data: { code: number; data: { statusCode: number; requestId: string }; sessionId: string } + } + + expect(line).not.toContain(sentinel) + expect(parsed.data).toMatchObject({ + code: -32603, + data: { statusCode: 429, requestId: 'request-7319' }, + sessionId: 'session-7319' + }) + }) +}) + +describe('logger: redacted sinks', () => { + it('keeps secrets out of the console mirror and every rotated JSONL file', async () => { + logDir = await mkdtemp(join(tmpdir(), 'os-logger-redaction-')) + const sentinel = 'sink-opaque-value-7319' + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + initLogger({ + logDir, + fileName: 'main.log', + maxBytes: 240, + maxFiles: 3, + mirrorToConsole: true, + runId: 'redaction-run' + }) + const log = createLogger('redaction') + + for (let index = 0; index < 12; index += 1) { + log.warn(`Bearer ${sentinel}`, { authorization: sentinel, index }) + } + await flushLogs() + + expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain(sentinel) + const files = (await readdir(logDir)).filter((name) => name.startsWith('main')) + expect(files.length).toBeGreaterThan(1) + const jsonl = ( + await Promise.all(files.map((file) => readFile(join(logDir!, file), 'utf8'))) + ).join('\n') + expect(jsonl).not.toContain(sentinel) + expect(jsonl).toContain('[redacted]') + }) }) describe('logger: process run context', () => { diff --git a/src/main/logger.ts b/src/main/logger.ts index bf9bc9807..5b8f73cdd 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -78,6 +78,55 @@ const MAX_TOTAL_NODES = 10000 // bounds total SIZE, since node-count × per-string-cap alone would still allow a very large line. const MAX_TOTAL_CHARS = 256 * 1024 +// One mandatory policy for every logger sink. Callers may still pre-sanitize, but cannot opt out here. +const REDACTED_MARKER = '[redacted]' +const OVERSIZED_TEXT_MARKER = '[redacted: oversized text]' +const CONTENT_BEARING_KEYS = new Set([ + 'body', + 'payload', + 'rawbody', + 'requestbody', + 'requestpayload', + 'responsebody', + 'responsepayload' +]) +const SENSITIVE_KEY_WORDS = new Set([ + 'auth', + 'authentication', + 'authorization', + 'authorizations', + 'bearer', + 'cookie', + 'cookies', + 'credential', + 'credentials', + 'password', + 'passwords', + 'passphrase', + 'passphrases', + 'passwd', + 'pat', + 'pats', + 'secret', + 'secrets', + 'token', + 'tokens' +]) +const TOKEN_METRIC_WORDS = new Set([ + 'budget', + 'cached', + 'count', + 'counts', + 'input', + 'limit', + 'max', + 'output', + 'reasoning', + 'remaining', + 'total', + 'usage' +]) + // Mutable budget shared across one errorLogFields call: `nodes` bounds how many values are emitted, // `chars` bounds their combined length — together they bound both the count and the size of the output // regardless of reference sharing. @@ -89,6 +138,105 @@ const truncate = (value: string): string => ? value : `${value.slice(0, MAX_STRING_LENGTH)}…[+${value.length - MAX_STRING_LENGTH} chars]` +const logKeyWords = (key: string): string[] => + key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + +const isTokenMetricKey = (words: string[]): boolean => + words.some((word) => word === 'token' || word === 'tokens') && + words.some((word) => TOKEN_METRIC_WORDS.has(word)) && + words.every((word) => word === 'token' || word === 'tokens' || TOKEN_METRIC_WORDS.has(word)) + +const isSensitiveLogKey = (key: string): boolean => { + const words = logKeyWords(key) + const normalized = words.join('') + + if (isTokenMetricKey(words)) return false + if (words.some((word) => SENSITIVE_KEY_WORDS.has(word))) return true + + return [ + 'accesstoken', + 'apikey', + 'apikeys', + 'authtoken', + 'bearertoken', + 'clientsecret', + 'clientsecrets', + 'privatekey', + 'privatekeys', + 'refreshtoken', + 'secretaccesskey', + 'securitytoken', + 'sessiontoken', + 'xapikey' + ].some((suffix) => normalized.endsWith(suffix)) +} + +const isContentBearingLogKey = (key: string): boolean => + CONTENT_BEARING_KEYS.has(logKeyWords(key).join('')) + +const redactUrlCredentials = (rawUrl: string): string => { + try { + const url = new URL(rawUrl) + let changed = false + + if (url.username || url.password) { + url.username = REDACTED_MARKER + url.password = '' + changed = true + } + for (const key of [...url.searchParams.keys()]) { + if (!isSensitiveLogKey(key)) continue + url.searchParams.set(key, REDACTED_MARKER) + changed = true + } + + return changed ? url.toString().replaceAll('%5Bredacted%5D', REDACTED_MARKER) : rawUrl + } catch { + return rawUrl + } +} + +const redactLogText = (value: string): string => { + if (value.length > MAX_STRING_LENGTH) return OVERSIZED_TEXT_MARKER + + return value + .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi, redactUrlCredentials) + .replace( + /\b(authorization|proxy-authorization|x-api-key|api-key|x-auth-token|x-amz-security-token|cookie|set-cookie)\b(\s*["']?\s*:\s*["']?)[^"'\r\n,}]*/gi, + `$1$2${REDACTED_MARKER}` + ) + .replace( + /\b(api[_-]?key|access[_-]?token|auth[_-]?token|authorization|bearer[_-]?token|client[_-]?secret|cookie|credential|password|passphrase|passwd|private[_-]?key|refresh[_-]?token|secret|secret[_-]?access[_-]?key|security[_-]?token|session[_-]?token|token)\b(\s*["']?\s*[:=]\s*["']?)[^\s,"'&;}]+/gi, + `$1$2${REDACTED_MARKER}` + ) + .replace( + /\b([A-Z0-9_]*(?:ACCESS_KEY(?:_ID)?|API_KEY|CLIENT_SECRET|CREDENTIALS?|PASSWORD|PASSPHRASE|PASSWD|PAT|PRIVATE_KEY|SECRET|SECRET_ACCESS_KEY|TOKEN))(\s*=\s*)[^\s"'&;]+/g, + `$1$2${REDACTED_MARKER}` + ) + .replace( + /(--?(?:access[-_]?token|api[-_]?key|auth[-_]?token|authorization|bearer[-_]?token|client[-_]?secret|cookie|credentials?|passphrase|passwd|password|pat|private[-_]?key|secret|token))(\s+|=)[^\s"'&;]+/gi, + `$1$2${REDACTED_MARKER}` + ) + .replace(/\bBearer\s+[^\s"']+/gi, `Bearer ${REDACTED_MARKER}`) + .replace(/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g, REDACTED_MARKER) + .replace( + /\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]{8,}|sk-[A-Za-z0-9_-]{8,})\b/g, + REDACTED_MARKER + ) +} + +const stringifyLogRecord = (record: Record): string => + // JSON's replacer recursively covers every serializable descendant and prunes sensitive subtrees + // before they can reach the file or the console mirror. + JSON.stringify(record, (key, value: unknown) => { + if (key && (isSensitiveLogKey(key) || isContentBearingLogKey(key))) return REDACTED_MARKER + return typeof value === 'string' ? redactLogText(value) : value + }) + // Applies the per-field cap AND the shared character budget to a string about to be emitted, charging // the budget for what it keeps. Once the global budget is spent, further strings collapse to a short // marker so the total line size stays bounded. @@ -550,10 +698,10 @@ const formatLine = ( if (data !== undefined) record.data = toSerializable(data) try { - return JSON.stringify(record) + return stringifyLogRecord(record) } catch { // Fall back to a best-effort line if the payload has circular refs. - return JSON.stringify({ + return stringifyLogRecord({ t: record.t, level, ...(runId === undefined ? {} : { runId }), @@ -661,15 +809,20 @@ const flushLogs = (): Promise => writeChain const emit = (level: LogLevel, scope: string, message: string, data?: unknown): void => { const mirror = config?.mirrorToConsole ?? true + const line = formatLine(level, scope, message, data, config?.runId) if (mirror) { const consoleMethod = level === 'debug' ? 'log' : level - console[consoleMethod](`[${scope}] ${message}`, data === undefined ? '' : toSerializable(data)) + const record = JSON.parse(line) as { scope: string; msg: string; data?: unknown } + console[consoleMethod]( + `[${record.scope}] ${record.msg}`, + Object.hasOwn(record, 'data') ? record.data : '' + ) } if (config && LEVEL_ORDER[level] < LEVEL_ORDER[config.minLevel]) return - appendLine(formatLine(level, scope, message, data, config?.runId)) + appendLine(line) } export type Logger = { From 8f85994a31cd8740797b722289c5ee60b4666f9f Mon Sep 17 00:00:00 2001 From: Ewen Date: Wed, 5 Aug 2026 13:28:24 +0800 Subject: [PATCH 2/6] fix(logger): preserve complete redaction diagnostics --- src/main/logger.test.ts | 24 ++++++++++++++++++------ src/main/logger.ts | 7 ++++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/main/logger.test.ts b/src/main/logger.test.ts index 25898f856..68e2fa46b 100644 --- a/src/main/logger.test.ts +++ b/src/main/logger.test.ts @@ -127,8 +127,16 @@ describe('logger: formatLine', () => { { text: 'Bearer bearer-opaque-7319', secrets: ['bearer-opaque-7319'] }, { text: `jwt=${jwt}`, secrets: [jwt] }, { text: 'Authorization: Basic dXNlcjpwYXNz', secrets: ['dXNlcjpwYXNz'] }, + { + text: 'Authorization: Digest comma-opaque-7319,remaining-opaque-7319', + secrets: ['comma-opaque-7319', 'remaining-opaque-7319'] + }, { text: 'Cookie: session=cookie-opaque-7319; Path=/', secrets: ['cookie-opaque-7319'] }, { text: 'apiKey="json-opaque-7319"', secrets: ['json-opaque-7319'] }, + { + text: 'token=comma-token-opaque-7319,remaining-token-opaque-7319', + secrets: ['comma-token-opaque-7319', 'remaining-token-opaque-7319'] + }, { text: 'OPENAI_API_KEY=env-opaque-7319', secrets: ['env-opaque-7319'] }, { text: '--api-key cli-opaque-7319', secrets: ['cli-opaque-7319'] }, { @@ -262,12 +270,16 @@ describe('logger: redacted sinks', () => { }) const log = createLogger('redaction') + log.warn('nested error', { error: new Error(`Bearer ${sentinel}`) }) for (let index = 0; index < 12; index += 1) { log.warn(`Bearer ${sentinel}`, { authorization: sentinel, index }) } await flushLogs() - expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain(sentinel) + const consoleOutput = JSON.stringify(consoleWarn.mock.calls) + expect(consoleOutput).not.toContain(sentinel) + expect(consoleOutput).toContain('Bearer [redacted]') + expect(consoleOutput).toContain('Error') const files = (await readdir(logDir)).filter((name) => name.startsWith('main')) expect(files.length).toBeGreaterThan(1) const jsonl = ( @@ -1155,14 +1167,14 @@ describe('logger: errorLogFields', () => { expect(errorLogFields(42).error).toBe('42') }) - it('survives the file logger nested in a context object (the {} regression it guards)', () => { - // A raw Error nested in a context object serializes to {} — its fields are non-enumerable. + it('preserves nested and explicitly expanded error diagnostics', () => { const raw = JSON.parse( formatLine('error', 'acp', 'failed', { error: new Error('x'), framework: 'claude-code' }) - ) as { data: { error: unknown } } - expect(raw.data.error).toEqual({}) + ) as { data: { error: { message: string; name: string; stack?: string } } } + expect(raw.data.error).toMatchObject({ message: 'x', name: 'Error' }) + expect(typeof raw.data.error.stack).toBe('string') - // Spreading errorLogFields keeps message + stack + context visible. + // Spreading errorLogFields additionally keeps richer error details at the context root. const fixed = JSON.parse( formatLine('error', 'acp', 'failed', { ...errorLogFields(new Error('x')), diff --git a/src/main/logger.ts b/src/main/logger.ts index 5b8f73cdd..e60c3651f 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -206,11 +206,11 @@ const redactLogText = (value: string): string => { return value .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi, redactUrlCredentials) .replace( - /\b(authorization|proxy-authorization|x-api-key|api-key|x-auth-token|x-amz-security-token|cookie|set-cookie)\b(\s*["']?\s*:\s*["']?)[^"'\r\n,}]*/gi, + /\b(authorization|proxy-authorization|x-api-key|api-key|x-auth-token|x-amz-security-token|cookie|set-cookie)\b(\s*["']?\s*:\s*["']?)[^"'\r\n}]*/gi, `$1$2${REDACTED_MARKER}` ) .replace( - /\b(api[_-]?key|access[_-]?token|auth[_-]?token|authorization|bearer[_-]?token|client[_-]?secret|cookie|credential|password|passphrase|passwd|private[_-]?key|refresh[_-]?token|secret|secret[_-]?access[_-]?key|security[_-]?token|session[_-]?token|token)\b(\s*["']?\s*[:=]\s*["']?)[^\s,"'&;}]+/gi, + /\b(api[_-]?key|access[_-]?token|auth[_-]?token|authorization|bearer[_-]?token|client[_-]?secret|cookie|credential|password|passphrase|passwd|private[_-]?key|refresh[_-]?token|secret|secret[_-]?access[_-]?key|security[_-]?token|session[_-]?token|token)\b(\s*["']?\s*[:=]\s*["']?)[^\s"'&;}]+/gi, `$1$2${REDACTED_MARKER}` ) .replace( @@ -234,7 +234,8 @@ const stringifyLogRecord = (record: Record): string => // before they can reach the file or the console mirror. JSON.stringify(record, (key, value: unknown) => { if (key && (isSensitiveLogKey(key) || isContentBearingLogKey(key))) return REDACTED_MARKER - return typeof value === 'string' ? redactLogText(value) : value + const serializable = toSerializable(value) + return typeof serializable === 'string' ? redactLogText(serializable) : serializable }) // Applies the per-field cap AND the shared character budget to a string about to be emitted, charging From b1fd8d6e23c88eb9b4741fff2d3219b19e885c77 Mon Sep 17 00:00:00 2001 From: Ewen Date: Wed, 5 Aug 2026 13:37:32 +0800 Subject: [PATCH 3/6] fix(logger): redact URL credential fragments --- src/main/logger.test.ts | 10 ++++++++-- src/main/logger.ts | 6 +++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/logger.test.ts b/src/main/logger.test.ts index 68e2fa46b..286bb9ad4 100644 --- a/src/main/logger.test.ts +++ b/src/main/logger.test.ts @@ -140,8 +140,14 @@ describe('logger: formatLine', () => { { text: 'OPENAI_API_KEY=env-opaque-7319', secrets: ['env-opaque-7319'] }, { text: '--api-key cli-opaque-7319', secrets: ['cli-opaque-7319'] }, { - text: 'https://alice:password-opaque-7319@example.test/v1?token=query-opaque-7319&ok=1', - secrets: ['alice', 'password-opaque-7319', 'query-opaque-7319'] + text: 'https://alice:password-opaque-7319@example.test/v1?token=query-opaque-7319&key=generic-key-opaque-7319&ok=1#access_token=fragment-opaque-7319', + secrets: [ + 'alice', + 'password-opaque-7319', + 'query-opaque-7319', + 'generic-key-opaque-7319', + 'fragment-opaque-7319' + ] }, { text: 'sk-1234567890abcdef', secrets: ['sk-1234567890abcdef'] }, { text: 'github_pat_1234567890abcdef', secrets: ['github_pat_1234567890abcdef'] }, diff --git a/src/main/logger.ts b/src/main/logger.ts index e60c3651f..40d8af345 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -189,10 +189,14 @@ const redactUrlCredentials = (rawUrl: string): string => { changed = true } for (const key of [...url.searchParams.keys()]) { - if (!isSensitiveLogKey(key)) continue + if (!isSensitiveLogKey(key) && key.toLowerCase() !== 'key') continue url.searchParams.set(key, REDACTED_MARKER) changed = true } + if (url.hash) { + url.hash = '' + changed = true + } return changed ? url.toString().replaceAll('%5Bredacted%5D', REDACTED_MARKER) : rawUrl } catch { From ff96ff97979ac424119db78d37c061d060bb577d Mon Sep 17 00:00:00 2001 From: Ewen Date: Wed, 5 Aug 2026 13:45:39 +0800 Subject: [PATCH 4/6] fix(logger): redact scheme-prefixed credentials --- src/main/logger.test.ts | 8 ++++++++ src/main/logger.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/logger.test.ts b/src/main/logger.test.ts index 286bb9ad4..de57672e8 100644 --- a/src/main/logger.test.ts +++ b/src/main/logger.test.ts @@ -137,8 +137,16 @@ describe('logger: formatLine', () => { text: 'token=comma-token-opaque-7319,remaining-token-opaque-7319', secrets: ['comma-token-opaque-7319', 'remaining-token-opaque-7319'] }, + { + text: 'Authorization=Bearer assignment-scheme-opaque-7319', + secrets: ['assignment-scheme-opaque-7319'] + }, { text: 'OPENAI_API_KEY=env-opaque-7319', secrets: ['env-opaque-7319'] }, { text: '--api-key cli-opaque-7319', secrets: ['cli-opaque-7319'] }, + { + text: '--authorization Bearer cli-scheme-opaque-7319', + secrets: ['cli-scheme-opaque-7319'] + }, { text: 'https://alice:password-opaque-7319@example.test/v1?token=query-opaque-7319&key=generic-key-opaque-7319&ok=1#access_token=fragment-opaque-7319', secrets: [ diff --git a/src/main/logger.ts b/src/main/logger.ts index 40d8af345..d26e3aa8e 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -214,7 +214,7 @@ const redactLogText = (value: string): string => { `$1$2${REDACTED_MARKER}` ) .replace( - /\b(api[_-]?key|access[_-]?token|auth[_-]?token|authorization|bearer[_-]?token|client[_-]?secret|cookie|credential|password|passphrase|passwd|private[_-]?key|refresh[_-]?token|secret|secret[_-]?access[_-]?key|security[_-]?token|session[_-]?token|token)\b(\s*["']?\s*[:=]\s*["']?)[^\s"'&;}]+/gi, + /\b(api[_-]?key|access[_-]?token|auth[_-]?token|authorization|bearer[_-]?token|client[_-]?secret|cookie|credential|password|passphrase|passwd|private[_-]?key|refresh[_-]?token|secret|secret[_-]?access[_-]?key|security[_-]?token|session[_-]?token|token)\b(\s*["']?\s*[:=]\s*["']?)(?:(?:Bearer|Basic|Digest|Negotiate)\s+)?[^\s"'&;}]+/gi, `$1$2${REDACTED_MARKER}` ) .replace( @@ -222,7 +222,7 @@ const redactLogText = (value: string): string => { `$1$2${REDACTED_MARKER}` ) .replace( - /(--?(?:access[-_]?token|api[-_]?key|auth[-_]?token|authorization|bearer[-_]?token|client[-_]?secret|cookie|credentials?|passphrase|passwd|password|pat|private[-_]?key|secret|token))(\s+|=)[^\s"'&;]+/gi, + /(--?(?:access[-_]?token|api[-_]?key|auth[-_]?token|authorization|bearer[-_]?token|client[-_]?secret|cookie|credentials?|passphrase|passwd|password|pat|private[-_]?key|secret|token))(\s+|=)(?:(?:Bearer|Basic|Digest|Negotiate)\s+)?[^\s"'&;]+/gi, `$1$2${REDACTED_MARKER}` ) .replace(/\bBearer\s+[^\s"']+/gi, `Bearer ${REDACTED_MARKER}`) From ed4c9e684ce90d72a6af585072cb10baf71809a2 Mon Sep 17 00:00:00 2001 From: Ewen Date: Wed, 5 Aug 2026 13:54:50 +0800 Subject: [PATCH 5/6] fix(logger): fail closed on malformed URLs --- src/main/logger.test.ts | 4 ++++ src/main/logger.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/logger.test.ts b/src/main/logger.test.ts index de57672e8..d944d993c 100644 --- a/src/main/logger.test.ts +++ b/src/main/logger.test.ts @@ -157,6 +157,10 @@ describe('logger: formatLine', () => { 'fragment-opaque-7319' ] }, + { + text: 'https://alice:malformed-url-opaque-7319@example.test:99999/path', + secrets: ['alice', 'malformed-url-opaque-7319'] + }, { text: 'sk-1234567890abcdef', secrets: ['sk-1234567890abcdef'] }, { text: 'github_pat_1234567890abcdef', secrets: ['github_pat_1234567890abcdef'] }, { text: 'AKIA1234567890ABCDEF', secrets: ['AKIA1234567890ABCDEF'] } diff --git a/src/main/logger.ts b/src/main/logger.ts index d26e3aa8e..c2b59d0df 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -200,7 +200,7 @@ const redactUrlCredentials = (rawUrl: string): string => { return changed ? url.toString().replaceAll('%5Bredacted%5D', REDACTED_MARKER) : rawUrl } catch { - return rawUrl + return REDACTED_MARKER } } From 4b97317e7d714c4b09eecc3a5fdb2d3bd4e38fd3 Mon Sep 17 00:00:00 2001 From: Ewen Date: Wed, 5 Aug 2026 14:04:57 +0800 Subject: [PATCH 6/6] fix(logger): redact compound credential assignments --- src/main/logger.test.ts | 8 ++++++++ src/main/logger.ts | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/logger.test.ts b/src/main/logger.test.ts index d944d993c..ce138a7be 100644 --- a/src/main/logger.test.ts +++ b/src/main/logger.test.ts @@ -141,6 +141,14 @@ describe('logger: formatLine', () => { text: 'Authorization=Bearer assignment-scheme-opaque-7319', secrets: ['assignment-scheme-opaque-7319'] }, + { + text: 'providerApiKey=compound-camel-opaque-7319', + secrets: ['compound-camel-opaque-7319'] + }, + { + text: 'openai_api_key=compound-lower-opaque-7319', + secrets: ['compound-lower-opaque-7319'] + }, { text: 'OPENAI_API_KEY=env-opaque-7319', secrets: ['env-opaque-7319'] }, { text: '--api-key cli-opaque-7319', secrets: ['cli-opaque-7319'] }, { diff --git a/src/main/logger.ts b/src/main/logger.ts index c2b59d0df..fd169fb20 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -218,8 +218,9 @@ const redactLogText = (value: string): string => { `$1$2${REDACTED_MARKER}` ) .replace( - /\b([A-Z0-9_]*(?:ACCESS_KEY(?:_ID)?|API_KEY|CLIENT_SECRET|CREDENTIALS?|PASSWORD|PASSPHRASE|PASSWD|PAT|PRIVATE_KEY|SECRET|SECRET_ACCESS_KEY|TOKEN))(\s*=\s*)[^\s"'&;]+/g, - `$1$2${REDACTED_MARKER}` + /\b([a-z][a-z0-9_-]*)(\s*=\s*)(?:(?:Bearer|Basic|Digest|Negotiate)\s+)?[^\s"'&;}]+/gi, + (match, key: string, separator: string) => + isSensitiveLogKey(key) ? `${key}${separator}${REDACTED_MARKER}` : match ) .replace( /(--?(?:access[-_]?token|api[-_]?key|auth[-_]?token|authorization|bearer[-_]?token|client[-_]?secret|cookie|credentials?|passphrase|passwd|password|pat|private[-_]?key|secret|token))(\s+|=)(?:(?:Bearer|Basic|Digest|Negotiate)\s+)?[^\s"'&;]+/gi,