diff --git a/src/utils/status.test.ts b/src/utils/status.test.ts index f8786b504..ed8b4d286 100644 --- a/src/utils/status.test.ts +++ b/src/utils/status.test.ts @@ -27,6 +27,10 @@ function restoreEnv(): void { async function readPropertyValue( label: string, provider: + | 'firstParty' + | 'bedrock' + | 'vertex' + | 'foundry' | 'openai' | 'codex' | 'nvidia-nim' @@ -45,6 +49,31 @@ async function readPropertyValue( ?.value } +async function readAPIProviderProperties( + provider: + | 'firstParty' + | 'bedrock' + | 'vertex' + | 'foundry' + | 'openai' + | 'gemini' + | 'mistral', +) { + mock.restore() + mock.module('./model/providers.js', () => ({ + getAPIProvider: () => provider, + getAPIProviderForStatsig: () => provider, + isFirstPartyAnthropicBaseUrl: () => true, + isGithubNativeAnthropicMode: () => false, + })) + mock.module('./mtls.js', () => ({ + getMTLSConfig: () => undefined, + })) + const nonce = `${Date.now()}-${Math.random()}` + const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) + return buildAPIProviderProperties() +} + beforeEach(async () => { await acquireSharedMutationLock('utils/status.test.ts') }) @@ -108,3 +137,176 @@ test('buildAPIProviderProperties redacts credentials in OpenAI-compatible base U 'https://redacted:redacted@example.com/v1?api_key=redacted&model=qwen', ) }) + +test('buildAPIProviderProperties redacts token-bearing OpenAI-compatible base URLs', async () => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_BASE_URL = + 'https://api.example.test/v1?token=OPENAI_LEAK&mode=test#access_token=fragment-leak' + + const properties = await readAPIProviderProperties('openai') + const value = properties.find(property => property.label === 'OpenAI base URL') + ?.value + + expect(value).toBe('https://api.example.test/v1?token=redacted&mode=test') + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('OPENAI_LEAK') + expect(serialized).not.toContain('fragment-leak') +}) + +test('buildAPIProviderProperties redacts token-bearing Gemini base URLs', async () => { + process.env.CLAUDE_CODE_USE_GEMINI = '1' + process.env.GEMINI_BASE_URL = + 'https://gemini.example.test/v1?api_key=GEMINI_LEAK&mode=test#secret=fragment-leak' + + const properties = await readAPIProviderProperties('gemini') + const value = properties.find(property => property.label === 'Gemini base URL') + ?.value + + expect(value).toBe('https://gemini.example.test/v1?api_key=redacted&mode=test') + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('GEMINI_LEAK') + expect(serialized).not.toContain('fragment-leak') +}) + +test('buildAPIProviderProperties redacts token-bearing direct provider base URLs', async () => { + const cases = [ + { + provider: 'firstParty' as const, + env: 'ANTHROPIC_BASE_URL', + label: 'Anthropic base URL', + }, + { + provider: 'bedrock' as const, + env: 'BEDROCK_BASE_URL', + label: 'Bedrock base URL', + }, + { + provider: 'vertex' as const, + env: 'VERTEX_BASE_URL', + label: 'Vertex base URL', + }, + { + provider: 'foundry' as const, + env: 'ANTHROPIC_FOUNDRY_BASE_URL', + label: 'Microsoft Foundry base URL', + }, + { + provider: 'mistral' as const, + env: 'MISTRAL_BASE_URL', + label: 'Mistral base URL', + }, + ] + + for (const { provider, env, label } of cases) { + restoreEnv() + const host = provider.toLowerCase() + process.env[env] = + `https://${host}.example.test/v1?authorization=${provider}-leak&mode=test#token=fragment-leak` + + const properties = await readAPIProviderProperties(provider) + const value = properties.find(property => property.label === label)?.value + + expect(value).toBe( + `https://${host}.example.test/v1?authorization=redacted&mode=test`, + ) + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain(`${provider}-leak`) + expect(serialized).not.toContain('fragment-leak') + } +}) + +test('buildAPIProviderProperties redacts proxy credentials and mTLS paths', async () => { + const home = '/home/openclaude-status-test' + process.env.HOME = home + process.env.CLAUDE_CODE_USE_OPENAI = '1' + delete process.env.http_proxy + delete process.env.https_proxy + delete process.env.HTTP_PROXY + process.env.HTTPS_PROXY = 'https://alice:secret@proxy.example.com:8080' + process.env.NODE_EXTRA_CA_CERTS = `${home}/.config/ca-bundle.crt` + process.env.CLAUDE_CODE_CLIENT_CERT = `${home}/.config/client.crt` + process.env.CLAUDE_CODE_CLIENT_KEY = `${home}/.config/client.key` + + mock.restore() + mock.module('./model/providers.js', () => ({ + getAPIProvider: () => 'openai', + getAPIProviderForStatsig: () => 'openai', + isFirstPartyAnthropicBaseUrl: () => true, + isGithubNativeAnthropicMode: () => false, + })) + // getProxyUrl accepts env directly (not memoized), so no stub needed. + mock.module('./mtls.js', () => ({ + getMTLSConfig: () => ({ + cert: 'loaded-cert', + key: 'loaded-key', + }), + })) + + const nonce = `${Date.now()}-${Math.random()}` + const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) + const properties = buildAPIProviderProperties() + const byLabel = (label: string): unknown => + properties.find(property => property.label === label)?.value + + // Proxy URL is fully redacted: no username, no password. + expect(byLabel('Proxy')).toBe( + 'https://redacted:redacted@proxy.example.com:8080/', + ) + + // CA cert path: home directory shortened to ~. + expect(byLabel('Additional CA cert(s)')).toBe('~/.config/ca-bundle.crt') + + // mTLS cert path: home directory shortened to ~. + expect(byLabel('mTLS client cert')).toBe('~/.config/client.crt') + + // mTLS client key: never reveal the private key path. + expect(byLabel('mTLS client key')).toBe('configured') + + // No raw secrets leak into any property value. Cover every home-directory + // source redactPathForStatus consults (HOME, USERPROFILE, os.homedir()). + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('alice') + expect(serialized).not.toContain('secret') + const homeCandidates = [ + process.env.HOME, + process.env.USERPROFILE, + ].filter((v): v is string => Boolean(v)) + for (const candidate of homeCandidates) { + expect(serialized).not.toContain(candidate) + } +}) + +test('buildAPIProviderProperties redacts proxy credentials from lowercase https_proxy', async () => { + // getProxyUrl() prefers the lowercase variant. Confirm the redaction path + // covers it — both env spellings flow through the same redactUrlForStatus. + process.env.CLAUDE_CODE_USE_OPENAI = '1' + delete process.env.http_proxy + delete process.env.HTTPS_PROXY + delete process.env.HTTP_PROXY + process.env.https_proxy = 'https://bob:hunter2@proxy.example.com:9090' + + mock.restore() + mock.module('./model/providers.js', () => ({ + getAPIProvider: () => 'openai', + getAPIProviderForStatsig: () => 'openai', + isFirstPartyAnthropicBaseUrl: () => true, + isGithubNativeAnthropicMode: () => false, + })) + mock.module('./mtls.js', () => ({ + getMTLSConfig: () => undefined, + })) + + const nonce = `${Date.now()}-${Math.random()}` + const { buildAPIProviderProperties } = await import(`./status.js?ts=${nonce}`) + const properties = buildAPIProviderProperties() + const byLabel = (label: string): unknown => + properties.find(property => property.label === label)?.value + + expect(byLabel('Proxy')).toBe( + 'https://redacted:redacted@proxy.example.com:9090/', + ) + + const serialized = JSON.stringify(properties) + expect(serialized).not.toContain('bob') + expect(serialized).not.toContain('hunter2') +}) diff --git a/src/utils/status.tsx b/src/utils/status.tsx index ed1cde429..11d720879 100644 --- a/src/utils/status.tsx +++ b/src/utils/status.tsx @@ -22,7 +22,7 @@ import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js'; import type { ThemeName } from './theme.js'; import { getKnownProviderSecretEnvKeys, redactSecretValueForDisplay, type SecretValueSource } from './providerSecrets.js'; -import { redactUrlForDisplay } from './urlRedaction.js'; +import { redactPathForStatus, redactUrlForStatus } from './statusRedaction.js'; export type Property = { label?: string; value: React.ReactNode | Array; @@ -115,8 +115,7 @@ function pushRedactedProperty( value: redactSecretValueForDisplay(value, secretSource) ?? value }); } - -function pushRedactedBaseUrlProperty( +function pushRedactedUrlProperty( properties: Property[], label: string, value: string | undefined, @@ -126,12 +125,11 @@ function pushRedactedBaseUrlProperty( return; } - pushRedactedProperty( - properties, + const redactedUrl = redactUrlForStatus(value); + properties.push({ label, - redactUrlForDisplay(value), - secretSource, - ); + value: redactSecretValueForDisplay(redactedUrl, secretSource) ?? redactedUrl + }); } export function buildSandboxProperties(): Property[] { if (process.env.USER_TYPE !== 'ant') { @@ -363,15 +361,19 @@ export function buildAPIProviderProperties(): Property[] { }); } if (apiProvider === 'firstParty') { - const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; - if (anthropicBaseUrl) { - pushRedactedBaseUrlProperty(properties, 'Anthropic base URL', anthropicBaseUrl, secretSource); - } + pushRedactedUrlProperty( + properties, + 'Anthropic base URL', + process.env.ANTHROPIC_BASE_URL, + secretSource, + ); } else if (apiProvider === 'bedrock') { - const bedrockBaseUrl = process.env.BEDROCK_BASE_URL; - if (bedrockBaseUrl) { - pushRedactedBaseUrlProperty(properties, 'Bedrock base URL', bedrockBaseUrl, secretSource); - } + pushRedactedUrlProperty( + properties, + 'Bedrock base URL', + process.env.BEDROCK_BASE_URL, + secretSource, + ); properties.push({ label: 'AWS region', value: getAWSRegion() @@ -382,10 +384,12 @@ export function buildAPIProviderProperties(): Property[] { }); } } else if (apiProvider === 'vertex') { - const vertexBaseUrl = process.env.VERTEX_BASE_URL; - if (vertexBaseUrl) { - pushRedactedBaseUrlProperty(properties, 'Vertex base URL', vertexBaseUrl, secretSource); - } + pushRedactedUrlProperty( + properties, + 'Vertex base URL', + process.env.VERTEX_BASE_URL, + secretSource, + ); const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID; if (gcpProject) { properties.push({ @@ -403,10 +407,12 @@ export function buildAPIProviderProperties(): Property[] { }); } } else if (apiProvider === 'foundry') { - const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL; - if (foundryBaseUrl) { - pushRedactedBaseUrlProperty(properties, 'Microsoft Foundry base URL', foundryBaseUrl, secretSource); - } + pushRedactedUrlProperty( + properties, + 'Microsoft Foundry base URL', + process.env.ANTHROPIC_FOUNDRY_BASE_URL, + secretSource, + ); const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE; if (foundryResource) { properties.push({ @@ -422,7 +428,7 @@ export function buildAPIProviderProperties(): Property[] { } else if (apiProvider in OPENAI_COMPATIBLE_STATUS_METADATA) { const metadata = OPENAI_COMPATIBLE_STATUS_METADATA[apiProvider]!; - pushRedactedBaseUrlProperty( + pushRedactedUrlProperty( properties, metadata.baseUrlLabel, process.env.OPENAI_BASE_URL, @@ -443,40 +449,35 @@ export function buildAPIProviderProperties(): Property[] { } } else if (apiProvider === 'gemini') { const geminiBaseUrl = process.env.GEMINI_BASE_URL; - pushRedactedBaseUrlProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource); + pushRedactedUrlProperty(properties, 'Gemini base URL', geminiBaseUrl, secretSource); const geminiModel = process.env.GEMINI_MODEL; pushRedactedProperty(properties, 'Model', geminiModel, secretSource); } else if (apiProvider === 'mistral') { const mistralBaseUrl = process.env.MISTRAL_BASE_URL; - pushRedactedBaseUrlProperty(properties, 'Mistral base URL', mistralBaseUrl, secretSource); + pushRedactedUrlProperty(properties, 'Mistral base URL', mistralBaseUrl, secretSource); const mistralModel = process.env.MISTRAL_MODEL; pushRedactedProperty(properties, 'Model', mistralModel, secretSource); } const proxyUrl = getProxyUrl(); - if (proxyUrl) { - properties.push({ - label: 'Proxy', - value: proxyUrl - }); - } + pushRedactedUrlProperty(properties, 'Proxy', proxyUrl, secretSource); const mtlsConfig = getMTLSConfig(); if (process.env.NODE_EXTRA_CA_CERTS) { properties.push({ label: 'Additional CA cert(s)', - value: process.env.NODE_EXTRA_CA_CERTS + value: redactPathForStatus(process.env.NODE_EXTRA_CA_CERTS) }); } if (mtlsConfig) { if (mtlsConfig.cert && process.env.CLAUDE_CODE_CLIENT_CERT) { properties.push({ label: 'mTLS client cert', - value: process.env.CLAUDE_CODE_CLIENT_CERT + value: redactPathForStatus(process.env.CLAUDE_CODE_CLIENT_CERT) }); } if (mtlsConfig.key && process.env.CLAUDE_CODE_CLIENT_KEY) { properties.push({ label: 'mTLS client key', - value: process.env.CLAUDE_CODE_CLIENT_KEY + value: 'configured' }); } } diff --git a/src/utils/statusRedaction.test.ts b/src/utils/statusRedaction.test.ts new file mode 100644 index 000000000..603ee556d --- /dev/null +++ b/src/utils/statusRedaction.test.ts @@ -0,0 +1,172 @@ +import { homedir } from 'os' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import { redactPathForStatus, redactUrlForStatus } from './statusRedaction.ts' + +const REAL_HOMEDIR = homedir() +const ORIGINAL_HOME = process.env.HOME +const ORIGINAL_USERPROFILE = process.env.USERPROFILE + +function restoreEnvValue(key: 'HOME' | 'USERPROFILE', value: string | undefined): void { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } +} + +describe('redactUrlForStatus', () => { + test('redacts username and password in proxy URLs', () => { + const redacted = redactUrlForStatus( + 'https://alice:secret@proxy.example.com:8080', + ) + + expect(redacted).not.toContain('alice') + expect(redacted).not.toContain('secret') + expect(redacted).toBe( + 'https://redacted:redacted@proxy.example.com:8080/', + ) + }) + + test('redacts token-like query parameters', () => { + const redacted = redactUrlForStatus( + 'https://proxy.example.com:8080?token=abc123', + ) + + expect(redacted).not.toContain('abc123') + expect(redacted).toBe('https://proxy.example.com:8080/?token=redacted') + }) + + test('redacts password-only credentials', () => { + const redacted = redactUrlForStatus( + 'https://:s3cret@proxy.example.com:8080', + ) + + expect(redacted).not.toContain('s3cret') + expect(redacted).toBe('https://:redacted@proxy.example.com:8080/') + }) + + test('removes fragments (may carry tokens)', () => { + const redacted = redactUrlForStatus( + 'https://proxy.example.com:8080/path#top', + ) + + expect(redacted).toBe('https://proxy.example.com:8080/path') + expect(redacted).not.toContain('#') + }) + + test('removes fragment that carries a token-like value', () => { + const redacted = redactUrlForStatus( + 'https://proxy.example.com:8080#access_token=leaked', + ) + + expect(redacted).not.toContain('leaked') + expect(redacted).not.toContain('#') + }) + + test('keeps local proxy URLs useful', () => { + const redacted = redactUrlForStatus('http://localhost:8888') + expect(redacted).toMatch(/^http:\/\/localhost:8888\/?$/) + }) + + test('keeps non-sensitive query params', () => { + const redacted = redactUrlForStatus('http://localhost:8888?model=llama3') + expect(redacted).toMatch(/^http:\/\/localhost:8888\/?\?model=llama3$/) + }) + + test('still redacts creds when the URL is malformed (regex fallback)', () => { + // No scheme -> `new URL()` throws; redactUrlForDisplay falls back to a + // regex that must still scrub the userinfo. + const redacted = redactUrlForStatus('//alice:secret@proxy.example.com:8080') + + expect(redacted).not.toContain('alice') + expect(redacted).not.toContain('secret') + expect(redacted).toContain('redacted') + }) + + test('returns empty string as-is', () => { + expect(redactUrlForStatus('')).toBe('') + }) +}) + +describe('redactPathForStatus', () => { + beforeEach(async () => { + await acquireSharedMutationLock('utils/statusRedaction.test.ts') + }) + + afterEach(() => { + try { + // Defensive: tests below mutate env. Restore so subsequent suites see + // the real environment. + restoreEnvValue('HOME', ORIGINAL_HOME) + restoreEnvValue('USERPROFILE', ORIGINAL_USERPROFILE) + } finally { + releaseSharedMutationLock() + } + }) + + test('shortens POSIX home directory paths to ~', () => { + const result = redactPathForStatus(`${REAL_HOMEDIR}/secrets/client.key`) + expect(result).toBe('~/secrets/client.key') + expect(result).not.toContain(REAL_HOMEDIR) + }) + + test('handles the home directory exactly', () => { + expect(redactPathForStatus(REAL_HOMEDIR)).toBe('~') + }) + + test('redacts via USERPROFILE when HOME does not match (Windows-style)', () => { + // Simulate a Windows path where HOME is unset/irrelevant but + // USERPROFILE points at the profile dir. On a POSIX test host + // os.homedir() returns the POSIX home and would mask this case, + // so unset HOME to prove USERPROFILE is consulted independently. + const fakeProfile = 'C:\\Users\\bob' + delete process.env.HOME + process.env.USERPROFILE = fakeProfile + const result = redactPathForStatus(`${fakeProfile}\\secrets\\client.key`) + expect(result).toBe('~\\secrets\\client.key') + expect(result).not.toContain('bob') + }) + + test('redacts Windows home paths when path casing differs', () => { + const fakeProfile = 'C:\\Users\\Bob' + delete process.env.HOME + process.env.USERPROFILE = fakeProfile + const result = redactPathForStatus('c:\\users\\bob\\secrets\\client.key') + expect(result).toBe('~\\secrets\\client.key') + expect(result.toLowerCase()).not.toContain('bob') + }) + + test('falls back to os.homedir() when HOME and USERPROFILE are unset', () => { + // Container/sandbox scenario: no env hints, rely on the OS passwd db. + delete process.env.HOME + delete process.env.USERPROFILE + const osHome = homedir() + // Skip on hosts where os.homedir() is '/' (filtered out by the helper). + if (!osHome || osHome === '/') return + const result = redactPathForStatus(`${osHome}/.config/client.key`) + expect(result).toBe('~/.config/client.key') + expect(result).not.toContain(osHome) + }) + + test('does not redact a path that merely contains "home" as a segment', () => { + // E.g. `/opt/home/backup/ca.crt` — substring match must not trigger. + expect(redactPathForStatus('/opt/home/backup/ca.crt')).toBe( + '/opt/home/backup/ca.crt', + ) + }) + + test('leaves non-home absolute paths unchanged', () => { + expect(redactPathForStatus('/etc/ssl/certs/ca-certificates.crt')).toBe( + '/etc/ssl/certs/ca-certificates.crt', + ) + }) + + test('returns empty string as-is', () => { + expect(redactPathForStatus('')).toBe('') + }) +}) diff --git a/src/utils/statusRedaction.ts b/src/utils/statusRedaction.ts new file mode 100644 index 000000000..be3cce4be --- /dev/null +++ b/src/utils/statusRedaction.ts @@ -0,0 +1,71 @@ +import { homedir } from 'os' + +import { redactUrlForDisplay } from './urlRedaction.js' + +/** + * Redact a URL for /status and other public-safe diagnostic surfaces. + * + * Wraps {@link redactUrlForDisplay} (which masks user/password and sensitive + * query params) and additionally drops the fragment, which can carry tokens + * or session IDs and is not useful when debugging proxy/TLS issues. + * + * Returned URLs are safe to paste in public issues or screenshots. + */ +export function redactUrlForStatus(rawUrl: string): string { + if (!rawUrl) return rawUrl + + const redacted = redactUrlForDisplay(rawUrl) + + // Drop the fragment. On the well-formed path (new URL succeeded) the + // produced string contains at most one '#', which is the fragment + // delimiter. On the malformed/regex-fallback path there is normally no + // '#' (userinfo containing '#' broke URL parsing and the regex consumed + // it); slicing at a stray '#' there would only shorten already-safe + // output, never expose a secret. + const hashIndex = redacted.indexOf('#') + return hashIndex === -1 ? redacted : redacted.slice(0, hashIndex) +} + +/** + * Redact a filesystem path for /status and other public-safe diagnostic + * surfaces. Replaces a leading $HOME segment with `~` so absolute paths + * (e.g. mTLS cert/key, CA bundle) stay useful without leaking usernames + * or home directory layout. + */ +export function redactPathForStatus(rawPath: string): string { + if (!rawPath) return rawPath + + const stripTrailingSep = (path: string) => path.replace(/[\\/]+$/, '') + const isWindowsLike = (path: string) => + /^[a-zA-Z]:[\\/]/.test(path) || path.includes('\\') + const normalizeForCompare = (path: string) => + isWindowsLike(path) ? path.toLowerCase() : path + const normalizedRawPath = stripTrailingSep(rawPath) + const rawPathForCompare = normalizeForCompare(normalizedRawPath) + + // Cover POSIX (`HOME`), Windows (`USERPROFILE`), and containers where + // neither is set (`os.homedir()` reads the OS passwd db). Check each + // candidate; redact on the first prefix match. Filter out root-like + // candidates so a misconfigured homedir never causes mass over-redaction. + const candidates = [ + process.env.HOME, + process.env.USERPROFILE, + homedir(), + ] + .filter((h): h is string => Boolean(h)) + .map(stripTrailingSep) + .filter(home => home !== '' && home !== '/' && !/^[a-zA-Z]:$/.test(home)) + + for (const home of candidates) { + const homeForCompare = normalizeForCompare(home) + if (rawPathForCompare === homeForCompare) return '~' + // Match either `/home/user/...` or `C:\Users\user\...` style prefixes. + if ( + rawPathForCompare.startsWith(homeForCompare + '/') || + rawPathForCompare.startsWith(homeForCompare + '\\') + ) { + return '~' + rawPath.slice(home.length) + } + } + return rawPath +}