mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 12:20:44 +00:00
fix(config): close dashboard secret redaction gaps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<UnifiedConfig['cliproxy']['auth']>,
|
||||
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>
|
||||
): 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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user