diff --git a/src/main/wrapper/CxConstants.ts b/src/main/wrapper/CxConstants.ts index cdc8d35..14cc2a1 100644 --- a/src/main/wrapper/CxConstants.ts +++ b/src/main/wrapper/CxConstants.ts @@ -29,6 +29,12 @@ export enum CxConstants { SCA_REMEDIATION_PACKAGE_VERSION = "--package-version", CMD_AUTH = "auth", SUB_CMD_VALIDATE = "validate", + SUB_CMD_LOGOUT = "logout", + CMD_CONFIGURE = "configure", + SUB_CMD_SET = "set", + PROP_NAME = "--prop-name", + PROP_VALUE = "--prop-value", + PROP_API_KEY = "cx_apikey", CMD_PROJECT = "project", SUB_CMD_BRANCHES = "branches", CMD_SCAN = "scan", diff --git a/src/main/wrapper/CxWrapper.ts b/src/main/wrapper/CxWrapper.ts index 2725051..5768eff 100644 --- a/src/main/wrapper/CxWrapper.ts +++ b/src/main/wrapper/CxWrapper.ts @@ -110,6 +110,35 @@ export class CxWrapper { return await exec.executeCommands(this.config.pathToExecutable, commands); } + /** Signs the cx CLI out (`cx auth logout`). No credentials are sent. */ + async authLogout(): Promise { + const commands: string[] = [CxConstants.CMD_AUTH, CxConstants.SUB_CMD_LOGOUT]; + const exec = new ExecutionService(); + return await exec.executeCommands(this.config.pathToExecutable, commands); + } + + /** + * Saves an API key or OAuth refresh token as the CLI's own login (`cx configure set cx_apikey`), so + * every cx process of this user, such as the cx-devassist plugin, uses the same login. cx decides + * where it is stored. The value is a secret, so cx's output is never logged. + */ + async configureApiKey(apiKey: string): Promise { + return this.setApiKeyProperty(apiKey); + } + + private async setApiKeyProperty(value: string): Promise { + const commands: string[] = [ + CxConstants.CMD_CONFIGURE, + CxConstants.SUB_CMD_SET, + CxConstants.PROP_NAME, + CxConstants.PROP_API_KEY, + CxConstants.PROP_VALUE, + value, + ]; + const exec = new ExecutionService(); + return await exec.executeSensitiveCommands(this.config.pathToExecutable, commands); + } + async scanCreate(params: ParamTypeMap): Promise { const commands: string[] = [CxConstants.CMD_SCAN, CxConstants.SUB_CMD_CREATE]; commands.push(...this.initializeCommands(false)); diff --git a/src/main/wrapper/ExecutionService.ts b/src/main/wrapper/ExecutionService.ts index 0df0136..2d2f46e 100644 --- a/src/main/wrapper/ExecutionService.ts +++ b/src/main/wrapper/ExecutionService.ts @@ -105,6 +105,39 @@ export class ExecutionService { })); } + /** + * Runs a command whose arguments or output hold a secret, such as `configure set` with an API key + * (cx prints the value it sets). Unlike executeCommands, the arguments are passed unchanged, and + * cx's stdout and stderr are drained but never logged or returned: only the exit code is. + */ + executeSensitiveCommands(pathToExecutable: string, commands: string[]): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (code: number, status: string) => { + if (settled) { + return; + } + settled = true; + const cxCommandOutput = new CxCommandOutput(); + cxCommandOutput.exitCode = code; + cxCommandOutput.status = status; + resolve(cxCommandOutput); + }; + + const child = spawner.spawn(pathToExecutable, commands, {windowsHide: true}); + child.stdout?.resume(); + child.stderr?.resume(); + child.on('error', () => { + logger.error("Failed to start AST-CLI"); + finish(-1, "Failed to start AST-CLI"); + }); + child.on('exit', (code: number) => { + logger.info("Exit code received from AST-CLI: " + code); + finish(code, code === 0 ? "" : "AST-CLI exited with code " + code); + }); + }); + } + executeKicsCommands(pathToExecutable: string, commands: string[], output?: string): [Promise, any] { return [new Promise((resolve, reject) => { let stderr = ""; diff --git a/src/tests/ConfigureApiKeyTest.test.ts b/src/tests/ConfigureApiKeyTest.test.ts new file mode 100644 index 0000000..a70488c --- /dev/null +++ b/src/tests/ConfigureApiKeyTest.test.ts @@ -0,0 +1,78 @@ +import {CxWrapper} from '../main/wrapper/CxWrapper'; +import {CxConfig} from "../main/wrapper/CxConfig"; +import {CxCommandOutput} from "../main/wrapper/CxCommandOutput"; +import {ExecutionService} from "../main/wrapper/ExecutionService"; +import {logger} from "../main/wrapper/loggerConfig"; + +// These tests never run the real cx: they check the arguments, and run Node itself for the output checks. +describe("Configure API key", () => { + const secret = "the-secret-api-key"; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function wrapper(): CxWrapper { + const config = new CxConfig(); + config.pathToExecutable = process.execPath; + config.apiKey = "key-from-config"; + config.agentName = "VS Code"; + return new CxWrapper(config); + } + + function stubSensitiveCommands(): jest.SpyInstance { + const done = new CxCommandOutput(); + done.exitCode = 0; + return jest.spyOn(ExecutionService.prototype, "executeSensitiveCommands").mockResolvedValue(done); + } + + it('configureApiKey runs configure set for cx_apikey, with only the given value', async () => { + const spy = stubSensitiveCommands(); + + const output = await wrapper().configureApiKey(secret); + + expect(output.exitCode).toBe(0); + expect(spy).toHaveBeenCalledWith(process.execPath, + ["configure", "set", "--prop-name", "cx_apikey", "--prop-value", secret]); + }); + + it('authLogout runs auth logout without credentials', async () => { + const done = new CxCommandOutput(); + done.exitCode = 0; + const spy = jest.spyOn(ExecutionService.prototype, "executeCommands").mockResolvedValue(done); + + const output = await wrapper().authLogout(); + + expect(output.exitCode).toBe(0); + expect(spy).toHaveBeenCalledWith(process.execPath, ["auth", "logout"]); + }); + + it('executeSensitiveCommands returns the exit code and never logs or returns the output', async () => { + const info = jest.spyOn(logger, "info"); + const error = jest.spyOn(logger, "error"); + const script = `process.stdout.write('${secret}'); process.stderr.write('${secret}'); process.exit(3)`; + + const output = await new ExecutionService().executeSensitiveCommands(process.execPath, ["-e", script]); + + expect(output.exitCode).toBe(3); + expect(output.status).not.toContain(secret); + expect(output.payload).toBeUndefined(); + const logged = [...info.mock.calls, ...error.mock.calls].map((call) => String(call[0])).join("\n"); + expect(logged).not.toContain(secret); + }); + + it('executeSensitiveCommands passes arguments unchanged', async () => { + const value = `a"b'c, d`; + const script = `process.exit(process.argv[1] === ${JSON.stringify(value)} ? 0 : 1)`; + + const output = await new ExecutionService().executeSensitiveCommands(process.execPath, ["-e", script, value]); + + expect(output.exitCode).toBe(0); + }); + + it('executeSensitiveCommands reports a failure to start', async () => { + const output = await new ExecutionService().executeSensitiveCommands("does-not-exist-cx-binary", []); + + expect(output.exitCode).toBe(-1); + }); +});