Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/main/wrapper/CxConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/main/wrapper/CxWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CxCommandOutput> {
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<CxCommandOutput> {
return this.setApiKeyProperty(apiKey);
}

private async setApiKeyProperty(value: string): Promise<CxCommandOutput> {
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<CxCommandOutput> {
const commands: string[] = [CxConstants.CMD_SCAN, CxConstants.SUB_CMD_CREATE];
commands.push(...this.initializeCommands(false));
Expand Down
33 changes: 33 additions & 0 deletions src/main/wrapper/ExecutionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CxCommandOutput> {
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<CxCommandOutput>, any] {
return [new Promise((resolve, reject) => {
let stderr = "";
Expand Down
78 changes: 78 additions & 0 deletions src/tests/ConfigureApiKeyTest.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading