mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
security(status): redact proxy and TLS-sensitive values in /status (#1672)
* security(status): redact proxy and TLS-sensitive values in /status Make /status safe to share in public issues and screenshots by ensuring proxy credentials, mTLS private key/cert paths, CA bundle paths, and token-bearing URLs are never printed verbatim. - Proxy URL: wrap with redactUrlForStatus (reuses redactUrlForDisplay for credential + sensitive query-param masking; additionally strips the URL fragment, which can carry tokens). - NODE_EXTRA_CA_CERTS / CLAUDE_CODE_CLIENT_CERT: wrap with redactPathForStatus, which shortens a leading $HOME to ~ so paths stay useful without leaking usernames or home directory layout. - CLAUDE_CODE_CLIENT_KEY: show the literal 'configured' rather than the path or value of a private key. Adds two small reusable helpers in src/utils/statusRedaction.ts plus unit tests, and extends status.test.ts with an integration test that asserts the full buildAPIProviderProperties output is leak-free when proxy credentials and mTLS env vars are set. * fix(status): address status redaction review feedback * fix(status): redact provider base URL secrets * fix(status): unify URL status redaction
This commit is contained in:
@@ -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')
|
||||
})
|
||||
|
||||
+37
-36
@@ -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<string>;
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user