From 100308a3cfb05673c65c4703f8d183cccfdf5d97 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 19 May 2026 07:57:43 -0400 Subject: [PATCH] fix(logging): redact CLI argv in lifecycle start logs Squash merge PR #1297 into dev. --- src/ccs.ts | 3 +- .../logging/cli-entry-log-redaction.test.ts | 151 ++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tests/unit/services/logging/cli-entry-log-redaction.test.ts diff --git a/src/ccs.ts b/src/ccs.ts index f62909a2..28a89ec3 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -6,6 +6,7 @@ import { fail } from './utils/ui'; import { handleError, runCleanup } from './errors'; import { createLogger, runWithRequestId } from './services/logging'; +import { redactArgv } from './services/logging/log-redaction'; // Import target adapter system import { registerTarget, ClaudeAdapter, DroidAdapter, CodexAdapter } from './targets'; @@ -143,7 +144,7 @@ const cliEntryStartedAt = Date.now(); const cliEntryLogger = createLogger('cli:entry'); runWithRequestId(() => { cliEntryLogger.stage('intake', 'cli.command.start', 'CLI invocation started', { - argv: process.argv.slice(2), + argv: redactArgv(process.argv.slice(2)), }); return main() .then(() => { diff --git a/tests/unit/services/logging/cli-entry-log-redaction.test.ts b/tests/unit/services/logging/cli-entry-log-redaction.test.ts new file mode 100644 index 00000000..cf3e34c9 --- /dev/null +++ b/tests/unit/services/logging/cli-entry-log-redaction.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types'; +import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader'; +import { + clearRecentLogEntries, + getRecentLogEntries, +} from '../../../../src/services/logging/log-buffer'; +import { invalidateLoggingConfigCache } from '../../../../src/services/logging/log-config'; + +let originalArgv: string[] = []; +let originalCcsHome: string | undefined; +let tempHome = ''; +let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; +let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; +let baselineUncaughtExceptionListeners: Array<(...args: unknown[]) => void> = []; +let baselineUnhandledRejectionListeners: Array<(...args: unknown[]) => void> = []; + +function removeNewListeners( + event: 'SIGINT' | 'SIGTERM' | 'uncaughtException' | 'unhandledRejection', + baseline: Array<(...args: unknown[]) => void> +): void { + for (const listener of process.listeners(event)) { + if (!baseline.includes(listener as (...args: unknown[]) => void)) { + process.removeListener(event, listener as (...args: unknown[]) => void); + } + } +} + +beforeEach(() => { + originalArgv = process.argv.slice(); + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cli-entry-log-')); + process.env.CCS_HOME = tempHome; + process.argv = [ + 'bun', + 'src/ccs.ts', + 'launch', + '--api-key', + 'secret-key', + '--mode', + 'prod', + '--secret', + 'top-secret', + ]; + + clearRecentLogEntries(); + invalidateLoggingConfigCache(); + const config = createEmptyUnifiedConfig(); + config.logging = { + ...config.logging, + enabled: true, + level: 'debug', + redact: false, + }; + saveUnifiedConfig(config); + invalidateLoggingConfigCache(); + + baselineSigintListeners = process.listeners('SIGINT'); + baselineSigtermListeners = process.listeners('SIGTERM'); + baselineUncaughtExceptionListeners = process.listeners('uncaughtException'); + baselineUnhandledRejectionListeners = process.listeners('unhandledRejection'); + + mock.module('../../../../src/utils/fetch-proxy-setup', () => ({})); + mock.module('../../../../src/utils/error-manager', () => ({ + ErrorManager: class ErrorManager { + static async showProfileNotFound(): Promise {} + }, + })); + mock.module('../../../../src/utils/ui', () => ({ + fail: (message: string) => message, + })); + mock.module('../../../../src/errors', () => ({ + handleError: () => {}, + runCleanup: () => {}, + })); + mock.module('../../../../src/targets', () => ({ + registerTarget: () => {}, + ClaudeAdapter: class ClaudeAdapter {}, + DroidAdapter: class DroidAdapter {}, + CodexAdapter: class CodexAdapter {}, + })); + mock.module('../../../../src/dispatcher/cli-argument-parser', () => ({ + bootstrapAndParseEarlyCli: async () => ({ + exitNow: true, + args: [], + browserLaunchOverride: undefined, + }), + })); + mock.module('../../../../src/dispatcher/pre-dispatch', () => ({ + runPreDispatchHandlers: async () => false, + })); + mock.module('../../../../src/dispatcher/profile-resolver', () => ({ + resolveProfileAndTarget: async () => { + throw new Error('resolveProfileAndTarget should not be called in this test'); + }, + })); + mock.module('../../../../src/dispatcher/target-executor', () => ({ + dispatchProfile: async () => { + throw new Error('dispatchProfile should not be called in this test'); + }, + })); +}); + +afterEach(() => { + mock.restore(); + process.argv = originalArgv; + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + + removeNewListeners('SIGINT', baselineSigintListeners); + removeNewListeners('SIGTERM', baselineSigtermListeners); + removeNewListeners('uncaughtException', baselineUncaughtExceptionListeners); + removeNewListeners('unhandledRejection', baselineUnhandledRejectionListeners); + + fs.rmSync(tempHome, { recursive: true, force: true }); + clearRecentLogEntries(); + invalidateLoggingConfigCache(); +}); + +async function loadCliEntryModule(): Promise { + await import(`../../../../src/ccs?test=${Date.now()}-${Math.random()}`); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('CLI entry log redaction', () => { + it('redacts sensitive argv values before emitting the lifecycle start log', async () => { + await loadCliEntryModule(); + + const startEntry = getRecentLogEntries().find( + (entry) => entry.source === 'cli:entry' && entry.event === 'cli.command.start' + ); + const completeEntry = getRecentLogEntries().find( + (entry) => entry.source === 'cli:entry' && entry.event === 'cli.command.complete' + ); + + expect(startEntry).toBeDefined(); + expect(startEntry?.context).toEqual({ + argv: ['launch', '--api-key', '[redacted]', '--mode', 'prod', '--secret', '[redacted]'], + }); + expect(startEntry?.requestId).toBeTruthy(); + expect(completeEntry?.requestId).toBe(startEntry?.requestId); + + const serializedStartEntry = JSON.stringify(startEntry); + expect(serializedStartEntry).not.toContain('secret-key'); + expect(serializedStartEntry).not.toContain('top-secret'); + }); +});