diff --git a/src/utils/sensitive-keys.ts b/src/utils/sensitive-keys.ts index d163f90d..b09ddbf7 100644 --- a/src/utils/sensitive-keys.ts +++ b/src/utils/sensitive-keys.ts @@ -11,10 +11,12 @@ */ export const SENSITIVE_KEY_PATTERNS = [ /^ANTHROPIC_AUTH_TOKEN$/, // Exact match for Anthropic auth token + /_TOKEN$/, // Keys ending with _TOKEN /_API_KEY$/, // Keys ending with _API_KEY /_AUTH_TOKEN$/, // Keys ending with _AUTH_TOKEN /_SECRET$/, // Keys ending with _SECRET /_SECRET_KEY$/, // Keys ending with _SECRET_KEY + /^TOKEN$/, // Exact match for TOKEN /^API_KEY$/, // Exact match for API_KEY /^AUTH_TOKEN$/, // Exact match for AUTH_TOKEN /^SECRET$/, // Exact match for SECRET diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 95dead42..45fba833 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -34,6 +34,8 @@ const LOCAL_CONFIG_ERROR = 'Local configuration endpoints require localhost access when dashboard auth is disabled.'; const REDACTED_SECRET_VALUE = '[redacted]'; const RAW_YAML_REDACTED_PATHS = new Set([ + 'cliproxy.auth.api_key', + 'cliproxy.auth.management_secret', 'cliproxy_server.remote.auth_token', 'cliproxy_server.remote.management_key', 'dashboard_auth.password_hash', @@ -67,6 +69,16 @@ function sanitizeUnifiedConfigForDashboard(config: UnifiedConfig): UnifiedConfig ), } : config.global_env, + cliproxy: { + ...config.cliproxy, + auth: sanitizeCliproxyAuthConfig(config.cliproxy.auth), + variants: Object.fromEntries( + Object.entries(config.cliproxy.variants).map(([variantName, variantConfig]) => [ + variantName, + sanitizeCliproxyVariantAuth(variantConfig), + ]) + ), + }, cliproxy_server: config.cliproxy_server ? { ...config.cliproxy_server, @@ -86,6 +98,29 @@ function sanitizeUnifiedConfigForDashboard(config: UnifiedConfig): UnifiedConfig }; } +function sanitizeCliproxyAuthConfig( + auth: UnifiedConfig['cliproxy']['auth'] +): UnifiedConfig['cliproxy']['auth'] { + if (!auth) { + return auth; + } + + return { + ...auth, + api_key: redactSecretValue(auth.api_key), + management_secret: redactSecretValue(auth.management_secret), + }; +} + +function sanitizeCliproxyVariantAuth( + variantConfig: UnifiedConfig['cliproxy']['variants'][string] +): UnifiedConfig['cliproxy']['variants'][string] { + return { + ...variantConfig, + auth: sanitizeCliproxyAuthConfig(variantConfig.auth), + }; +} + function normalizeYamlKey(rawKey: string): string { const key = rawKey.trim(); if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) { @@ -150,13 +185,7 @@ function redactYamlScalarLine(line: string): string { const { value, comment } = splitYamlScalarAndComment(rawTail.slice(leadingSpacing.length)); const trimmedValue = value.trim(); - if ( - trimmedValue.length === 0 || - trimmedValue === '""' || - trimmedValue === "''" || - trimmedValue === '|' || - trimmedValue === '>' - ) { + if (trimmedValue.length === 0 || trimmedValue === '""' || trimmedValue === "''") { return line; } @@ -171,6 +200,10 @@ function redactYamlScalarLine(line: string): string { return `${prefix}${leadingSpacing}${redactedValue}${comment}`; } +function isYamlBlockScalarIndicator(value: string): boolean { + return /^[>|](?:[1-9][+-]?|[+-]?[1-9])?$/.test(value); +} + function shouldRedactRawYamlPath(pathKeys: string[]): boolean { if ( pathKeys.length === 3 && @@ -181,36 +214,88 @@ function shouldRedactRawYamlPath(pathKeys: string[]): boolean { return true; } + if ( + pathKeys.length === 3 && + pathKeys[0] === 'cliproxy' && + pathKeys[1] === 'auth' && + (pathKeys[2] === 'api_key' || pathKeys[2] === 'management_secret') + ) { + return true; + } + + if ( + pathKeys.length === 5 && + pathKeys[0] === 'cliproxy' && + pathKeys[1] === 'variants' && + pathKeys[3] === 'auth' && + (pathKeys[4] === 'api_key' || pathKeys[4] === 'management_secret') + ) { + return true; + } + return RAW_YAML_REDACTED_PATHS.has(pathKeys.join('.')); } function redactRawConfigYamlForDashboard(content: string): string { const stack: Array<{ indent: number; key: string }> = []; + const redactedLines: string[] = []; + const lines = content.split('\n'); - return content - .split('\n') - .map((line) => { - const mappingMatch = line.match(/^(\s*)([^:#][^:]*):(.*)$/); - if (!mappingMatch) { - return line; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const mappingMatch = line.match(/^(\s*)([^:#][^:]*):(.*)$/); + if (!mappingMatch) { + redactedLines.push(line); + continue; + } + + const [, indentText, rawKey, rawTail] = mappingMatch; + if (rawKey.trimStart().startsWith('-')) { + redactedLines.push(line); + continue; + } + + const indent = indentText.length; + while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + + stack.push({ indent, key: normalizeYamlKey(rawKey) }); + const currentPath = stack.map((entry) => entry.key); + + if (!shouldRedactRawYamlPath(currentPath)) { + redactedLines.push(line); + continue; + } + + const redactedLine = redactYamlScalarLine(line); + redactedLines.push(redactedLine); + + const leadingSpacing = rawTail.match(/^\s*/)?.[0] ?? ''; + const { value } = splitYamlScalarAndComment(rawTail.slice(leadingSpacing.length)); + const trimmedValue = value.trim(); + + if (!isYamlBlockScalarIndicator(trimmedValue)) { + continue; + } + + while (index + 1 < lines.length) { + const nextLine = lines[index + 1]; + if (nextLine.trim().length === 0) { + index += 1; + continue; } - const [, indentText, rawKey] = mappingMatch; - if (rawKey.trimStart().startsWith('-')) { - return line; + const nextIndent = nextLine.match(/^\s*/)?.[0].length ?? 0; + if (nextIndent <= indent) { + break; } - const indent = indentText.length; - while (stack.length > 0 && stack[stack.length - 1].indent >= indent) { - stack.pop(); - } + index += 1; + } + } - stack.push({ indent, key: normalizeYamlKey(rawKey) }); - const currentPath = stack.map((entry) => entry.key); - - return shouldRedactRawYamlPath(currentPath) ? redactYamlScalarLine(line) : line; - }) - .join('\n'); + return redactedLines.join('\n'); } function restoreRedactedSecretValue( @@ -302,6 +387,97 @@ function mergeDashboardAuthConfig( }; } +function mergeCliproxyAuthField( + currentValue: string | undefined, + nextAuth: NonNullable, + key: 'api_key' | 'management_secret' +): string | undefined { + if (!Object.prototype.hasOwnProperty.call(nextAuth, key)) { + return undefined; + } + + return restoreRedactedSecretValue(currentValue, nextAuth[key]); +} + +function mergeCliproxyAuthConfig( + currentAuth: UnifiedConfig['cliproxy']['auth'], + nextAuth: UnifiedConfig['cliproxy']['auth'] +): UnifiedConfig['cliproxy']['auth'] { + if (!nextAuth) { + return undefined; + } + + const mergedAuth = { ...nextAuth }; + const apiKey = mergeCliproxyAuthField(currentAuth?.api_key, nextAuth, 'api_key'); + const managementSecret = mergeCliproxyAuthField( + currentAuth?.management_secret, + nextAuth, + 'management_secret' + ); + + if (apiKey === undefined) { + delete mergedAuth.api_key; + } else { + mergedAuth.api_key = apiKey; + } + + if (managementSecret === undefined) { + delete mergedAuth.management_secret; + } else { + mergedAuth.management_secret = managementSecret; + } + + return Object.keys(mergedAuth).length > 0 ? mergedAuth : undefined; +} + +function mergeCliproxyVariantConfig( + currentVariant: UnifiedConfig['cliproxy']['variants'][string] | undefined, + nextVariant: UnifiedConfig['cliproxy']['variants'][string] +): UnifiedConfig['cliproxy']['variants'][string] { + const mergedVariant = { ...nextVariant }; + const mergedAuth = mergeCliproxyAuthConfig(currentVariant?.auth, nextVariant.auth); + + if (mergedAuth === undefined) { + delete mergedVariant.auth; + } else { + mergedVariant.auth = mergedAuth; + } + + return mergedVariant; +} + +function mergeCliproxyConfig( + currentConfig: UnifiedConfig, + nextConfig: Partial +): UnifiedConfig['cliproxy'] { + const nextCliproxy = nextConfig.cliproxy; + if (!nextCliproxy) { + return currentConfig.cliproxy; + } + + const mergedCliproxy = { + ...nextCliproxy, + variants: + nextCliproxy.variants === undefined + ? currentConfig.cliproxy.variants + : Object.fromEntries( + Object.entries(nextCliproxy.variants).map(([variantName, nextVariant]) => [ + variantName, + mergeCliproxyVariantConfig(currentConfig.cliproxy.variants[variantName], nextVariant), + ]) + ), + }; + const mergedAuth = mergeCliproxyAuthConfig(currentConfig.cliproxy.auth, nextCliproxy.auth); + + if (mergedAuth === undefined) { + delete mergedCliproxy.auth; + } else { + mergedCliproxy.auth = mergedAuth; + } + + return mergedCliproxy; +} + function validateAndNormalizeAccountContextMetadata(config: unknown): string | null { if (typeof config !== 'object' || config === null) { return 'Invalid config payload'; @@ -463,7 +639,7 @@ router.put('/', (req: Request, res: Response): void => { } if (config.cliproxy !== undefined) { - currentConfig.cliproxy = config.cliproxy; + currentConfig.cliproxy = mergeCliproxyConfig(currentConfig, config); } if (config.preferences !== undefined) { diff --git a/tests/unit/web-server/config-routes-account-context.test.ts b/tests/unit/web-server/config-routes-account-context.test.ts index 8ccd20fc..9abb5370 100644 --- a/tests/unit/web-server/config-routes-account-context.test.ts +++ b/tests/unit/web-server/config-routes-account-context.test.ts @@ -260,9 +260,22 @@ describe('web-server config-routes account context validation', () => { enabled: true, env: { DEBUG: '1', + GH_TOKEN: 'gh-token-secret', + GITHUB_TOKEN: 'github-token-secret', OPENAI_API_KEY: 'sk-test-123456', }, }; + config.cliproxy.auth = { + api_key: 'cliproxy-global-api-key', + management_secret: 'cliproxy-global-management-secret', + }; + config.cliproxy.variants.gemini = { + provider: 'gemini', + auth: { + api_key: 'variant-api-key', + management_secret: 'variant-management-secret', + }, + }; config.cliproxy_server = { remote: { enabled: true, @@ -296,7 +309,13 @@ describe('web-server config-routes account context validation', () => { const payload = (await response.json()) as typeof config; expect(payload.global_env?.env.DEBUG).toBe('1'); + expect(payload.global_env?.env.GH_TOKEN).toBe('[redacted]'); + expect(payload.global_env?.env.GITHUB_TOKEN).toBe('[redacted]'); expect(payload.global_env?.env.OPENAI_API_KEY).toBe('[redacted]'); + expect(payload.cliproxy.auth?.api_key).toBe('[redacted]'); + expect(payload.cliproxy.auth?.management_secret).toBe('[redacted]'); + expect(payload.cliproxy.variants.gemini?.auth?.api_key).toBe('[redacted]'); + expect(payload.cliproxy.variants.gemini?.auth?.management_secret).toBe('[redacted]'); expect(payload.cliproxy_server?.remote.auth_token).toBe('[redacted]'); expect(payload.cliproxy_server?.remote.management_key).toBe('[redacted]'); expect(payload.dashboard_auth?.password_hash).toBe('[redacted]'); @@ -307,8 +326,18 @@ describe('web-server config-routes account context validation', () => { expect(rawConfig).toContain('# custom comment'); expect(rawConfig).toContain('DEBUG: "1"'); + expect(rawConfig).toContain('GH_TOKEN: [redacted]'); + expect(rawConfig).toContain('GITHUB_TOKEN: [redacted]'); expect(rawConfig).toContain('OPENAI_API_KEY: [redacted]'); + expect(rawConfig).toContain('api_key: [redacted]'); + expect(rawConfig).toContain('management_secret: [redacted]'); expect(rawConfig).not.toContain('sk-test-123456'); + expect(rawConfig).not.toContain('gh-token-secret'); + expect(rawConfig).not.toContain('github-token-secret'); + expect(rawConfig).not.toContain('cliproxy-global-api-key'); + expect(rawConfig).not.toContain('cliproxy-global-management-secret'); + expect(rawConfig).not.toContain('variant-api-key'); + expect(rawConfig).not.toContain('variant-management-secret'); expect(rawConfig).not.toContain('remote-auth-token'); expect(rawConfig).not.toContain('management-secret'); }); @@ -322,6 +351,17 @@ describe('web-server config-routes account context validation', () => { OPENAI_API_KEY: 'sk-test-123456', }, }; + config.cliproxy.auth = { + api_key: 'cliproxy-global-api-key', + management_secret: 'cliproxy-global-management-secret', + }; + config.cliproxy.variants.gemini = { + provider: 'gemini', + auth: { + api_key: 'variant-api-key', + management_secret: 'variant-management-secret', + }, + }; config.cliproxy_server = { remote: { enabled: true, @@ -357,6 +397,14 @@ describe('web-server config-routes account context validation', () => { const savedConfig = loadUnifiedConfig(); expect(savedConfig?.cliproxy.kiro_no_incognito).toBe(true); expect(savedConfig?.global_env?.env.OPENAI_API_KEY).toBe('sk-test-123456'); + expect(savedConfig?.cliproxy.auth?.api_key).toBe('cliproxy-global-api-key'); + expect(savedConfig?.cliproxy.auth?.management_secret).toBe( + 'cliproxy-global-management-secret' + ); + expect(savedConfig?.cliproxy.variants.gemini?.auth?.api_key).toBe('variant-api-key'); + expect(savedConfig?.cliproxy.variants.gemini?.auth?.management_secret).toBe( + 'variant-management-secret' + ); expect(savedConfig?.cliproxy_server?.remote.auth_token).toBe('remote-auth-token'); expect(savedConfig?.cliproxy_server?.remote.management_key).toBe('management-secret'); expect(savedConfig?.dashboard_auth?.password_hash).toBe( @@ -364,6 +412,70 @@ describe('web-server config-routes account context validation', () => { ); }); + it('redacts block-scalar secrets from raw YAML responses', async () => { + const yamlPath = path.join(tempHome, '.ccs', 'config.yaml'); + fs.writeFileSync( + yamlPath, + [ + '# block scalar test', + 'version: 10', + 'accounts: {}', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: []', + ' variants:', + ' gemini:', + ' provider: gemini', + ' auth:', + ' api_key: |', + ' variant-api-secret-line-1', + ' variant-api-secret-line-2', + ' auth:', + ' api_key: >', + ' cliproxy-global-api-secret', + ' management_secret: |', + ' cliproxy-global-management-secret', + 'global_env:', + ' enabled: true', + ' env:', + ' GITHUB_TOKEN: |', + ' github-token-secret-line-1', + ' github-token-secret-line-2', + 'cliproxy_server:', + ' remote:', + ' enabled: true', + ' host: proxy.example.com', + ' protocol: https', + ' auth_token: >', + ' remote-auth-secret', + 'dashboard_auth:', + ' enabled: true', + ' username: admin', + ' password_hash: |', + ' dashboard-password-secret', + '', + ].join('\n') + ); + + const rawResponse = await fetch(`${baseUrl}/api/config/raw`); + expect(rawResponse.status).toBe(200); + const rawConfig = await rawResponse.text(); + + expect(rawConfig).toContain('# block scalar test'); + expect(rawConfig).toContain('GITHUB_TOKEN: [redacted]'); + expect(rawConfig).toContain('api_key: [redacted]'); + expect(rawConfig).toContain('management_secret: [redacted]'); + expect(rawConfig).toContain('auth_token: [redacted]'); + expect(rawConfig).toContain('password_hash: [redacted]'); + expect(rawConfig).not.toContain('github-token-secret-line-1'); + expect(rawConfig).not.toContain('cliproxy-global-api-secret'); + expect(rawConfig).not.toContain('cliproxy-global-management-secret'); + expect(rawConfig).not.toContain('variant-api-secret-line-1'); + expect(rawConfig).not.toContain('remote-auth-secret'); + expect(rawConfig).not.toContain('dashboard-password-secret'); + }); + it('allows deleting visible global env keys while preserving redacted secrets', async () => { const config = createEmptyUnifiedConfig(); config.global_env = {