From bbcf9e8b156301ce3ec74decc7e650c6d0ea2df7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 22 May 2026 17:04:19 -0400 Subject: [PATCH] fix: mask Docker key rotation banner --- docker/README.md | 2 +- src/docker/docker-key-rotation.ts | 6 +- .../commands/docker-up-subcommand.test.ts | 6 +- .../docker/docker-key-rotation-banner.test.ts | 92 +++++++++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 tests/unit/docker/docker-key-rotation-banner.test.ts diff --git a/docker/README.md b/docker/README.md index 2759d35f..000d3583 100644 --- a/docker/README.md +++ b/docker/README.md @@ -131,7 +131,7 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dash On first startup, the integrated container generates per-install CLIProxy API and management secrets when the config is missing custom values. If you have already configured `cliproxy.auth.api_key` or `cliproxy.auth.management_secret`, Docker preserves those custom values. -If you upgraded from an older Docker deployment that used the historical `ccs-internal-managed` API key, CCS keeps that legacy key valid beside the new per-install key for 14 days by default. During the grace period, every `ccs docker up` prints the new key and expiry date to stderr. Override the window with `CCS_DOCKER_LEGACY_KEY_GRACE_DAYS`. +If you upgraded from an older Docker deployment that used the historical `ccs-internal-managed` API key, CCS keeps that legacy key valid beside the new per-install key for 14 days by default. During the grace period, every `ccs docker up` prints the masked new key and expiry date to stderr. Reveal the full key only with `ccs docker show-key --full`. Override the window with `CCS_DOCKER_LEGACY_KEY_GRACE_DAYS`. ```bash ccs docker show-key # masked diff --git a/src/docker/docker-key-rotation.ts b/src/docker/docker-key-rotation.ts index 30a7600a..50f0f1b7 100644 --- a/src/docker/docker-key-rotation.ts +++ b/src/docker/docker-key-rotation.ts @@ -173,10 +173,14 @@ export function renderDockerKeyRotationBanner(status = getDockerKeyRotationStatu return ''; } + const maskedReplacementKey = + maskDockerApiKey(status.legacyGrace.replacementKey) ?? '(not configured)'; + return [ '[!] Docker CLIProxy API key rotation grace period is active.', - `[i] New CLIProxy API key: ${status.legacyGrace.replacementKey}`, + `[i] New CLIProxy API key: ${maskedReplacementKey}`, `[i] Legacy key ${status.legacyGrace.legacyKey} remains valid until ${status.legacyGrace.expiresAt}.`, + '[i] Reveal the full key with `ccs docker show-key --full`.', '[i] Update existing clients, then run `ccs docker finalize-key-rotation`.', ].join('\n'); } diff --git a/tests/unit/commands/docker-up-subcommand.test.ts b/tests/unit/commands/docker-up-subcommand.test.ts index 39d491e8..49d21eb9 100644 --- a/tests/unit/commands/docker-up-subcommand.test.ts +++ b/tests/unit/commands/docker-up-subcommand.test.ts @@ -31,8 +31,9 @@ describe('docker up subcommand', () => { dockerModule.DockerExecutor.prototype.getKeyRotationBanner = function () { return [ '[!] Docker CLIProxy API key rotation grace period is active.', - '[i] New CLIProxy API key: new-secret', + '[i] New CLIProxy API key: new-...cret', '[i] Legacy key ccs-internal-managed remains valid until 2026-06-05T00:00:00.000Z.', + '[i] Reveal the full key with `ccs docker show-key --full`.', ].join('\n'); }; @@ -51,7 +52,8 @@ describe('docker up subcommand', () => { expect(renderCapturedLines(capture.errorLines)).toContain( 'Docker CLIProxy API key rotation grace period is active' ); - expect(renderCapturedLines(capture.errorLines)).toContain('New CLIProxy API key: new-secret'); + expect(renderCapturedLines(capture.errorLines)).toContain('New CLIProxy API key: new-...cret'); + expect(renderCapturedLines(capture.errorLines)).toContain('ccs docker show-key --full'); expect(process.exitCode).toBe(0); } finally { dockerModule.DockerExecutor.prototype.up = originalUp; diff --git a/tests/unit/docker/docker-key-rotation-banner.test.ts b/tests/unit/docker/docker-key-rotation-banner.test.ts new file mode 100644 index 00000000..442e348b --- /dev/null +++ b/tests/unit/docker/docker-key-rotation-banner.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, describe, expect, it } from 'bun:test'; +import { ensureDockerCliproxyAuth } from '../../../src/docker/docker-bootstrap'; +import { mutateConfig } from '../../../src/config/config-loader-facade'; +import { CCS_INTERNAL_API_KEY } from '../../../src/cliproxy/config/config-generator'; +import { + maskDockerApiKey, + readDockerBootstrapState, + renderDockerKeyRotationBanner, +} from '../../../src/docker/docker-key-rotation'; + +const originalCcsHome = process.env.CCS_HOME; +const tempDirs: string[] = []; + +function useTempCcsHome(): void { + const dir = mkdtempSync(join(tmpdir(), 'ccs-rotation-banner-')); + tempDirs.push(dir); + process.env.CCS_HOME = dir; +} + +afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('renderDockerKeyRotationBanner - key masking', () => { + it('does not leak the full replacement API key into the banner', () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY }; + }); + ensureDockerCliproxyAuth(); + + const replacementKey = readDockerBootstrapState().state?.legacyKeyGrace?.replacementKey; + expect(replacementKey).toBeTruthy(); + + const banner = renderDockerKeyRotationBanner(); + expect(banner).not.toBe(''); + expect(banner).not.toContain(replacementKey ?? ''); + }); + + it('shows the masked replacement key in the banner', () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY }; + }); + ensureDockerCliproxyAuth(); + + const replacementKey = readDockerBootstrapState().state?.legacyKeyGrace?.replacementKey; + const masked = maskDockerApiKey(replacementKey); + expect(masked).toBeTruthy(); + + const banner = renderDockerKeyRotationBanner(); + expect(banner).toContain(masked!); + }); + + it('points at `ccs docker show-key --full` to reveal the key', () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY }; + }); + ensureDockerCliproxyAuth(); + + const banner = renderDockerKeyRotationBanner(); + expect(banner).toContain('ccs docker show-key --full'); + }); + + it('still shows the legacy key value (it is the public-known default, not a secret)', () => { + useTempCcsHome(); + mutateConfig((config) => { + config.cliproxy.auth = { api_key: CCS_INTERNAL_API_KEY }; + }); + ensureDockerCliproxyAuth(); + + const banner = renderDockerKeyRotationBanner(); + expect(banner).toContain(CCS_INTERNAL_API_KEY); + }); + + it('returns empty when grace is not active', () => { + useTempCcsHome(); + const banner = renderDockerKeyRotationBanner(); + expect(banner).toBe(''); + }); +});