Skip to content

Commit b0168aa

Browse files
authored
Merge pull request #1964 from github/koesie10/packages-auth
Use stdin for supplying auth to CodeQL
2 parents c5722d7 + 2957979 commit b0168aa

3 files changed

Lines changed: 180 additions & 16 deletions

File tree

extensions/ql-vscode/src/authentication.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { retry } from "@octokit/plugin-retry";
44

55
const GITHUB_AUTH_PROVIDER_ID = "github";
66

7-
// We need 'repo' scope for triggering workflows and 'gist' scope for exporting results to Gist.
7+
// We need 'repo' scope for triggering workflows, 'gist' scope for exporting results to Gist,
8+
// and 'read:packages' for reading private CodeQL packages.
89
// For a comprehensive list of scopes, see:
910
// https://docs.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps
10-
const SCOPES = ["repo", "gist"];
11+
const SCOPES = ["repo", "gist", "read:packages"];
1112

1213
/**
1314
* Handles authentication to GitHub, using the VS Code [authentication API](https://code.visualstudio.com/api/references/vscode-api#authentication).
@@ -57,15 +58,31 @@ export class Credentials {
5758
return this.octokit;
5859
}
5960

61+
const accessToken = await this.getAccessToken();
62+
63+
return new Octokit.Octokit({
64+
auth: accessToken,
65+
retry,
66+
});
67+
}
68+
69+
async getAccessToken(): Promise<string> {
6070
const session = await vscode.authentication.getSession(
6171
GITHUB_AUTH_PROVIDER_ID,
6272
SCOPES,
6373
{ createIfNone: true },
6474
);
6575

66-
return new Octokit.Octokit({
67-
auth: session.accessToken,
68-
retry,
69-
});
76+
return session.accessToken;
77+
}
78+
79+
async getExistingAccessToken(): Promise<string | undefined> {
80+
const session = await vscode.authentication.getSession(
81+
GITHUB_AUTH_PROVIDER_ID,
82+
SCOPES,
83+
{ createIfNone: false },
84+
);
85+
86+
return session?.accessToken;
7087
}
7188
}

extensions/ql-vscode/src/cli.ts

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EOL } from "os";
12
import { spawn } from "child-process-promise";
23
import * as child_process from "child_process";
34
import { readFile } from "fs-extra";
@@ -26,6 +27,7 @@ import { Logger, ProgressReporter } from "./common";
2627
import { CompilationMessage } from "./pure/legacy-messages";
2728
import { sarifParser } from "./sarif-parser";
2829
import { dbSchemeToLanguage, walkDirectory } from "./helpers";
30+
import { Credentials } from "./authentication";
2931

3032
/**
3133
* The version of the SARIF format that we are using.
@@ -156,6 +158,10 @@ interface BqrsDecodeOptions {
156158
entities?: string[];
157159
}
158160

161+
export type OnLineCallback = (
162+
line: string,
163+
) => Promise<string | undefined> | string | undefined;
164+
159165
/**
160166
* This class manages a cli server started by `codeql execute cli-server` to
161167
* run commands without the overhead of starting a new java
@@ -304,6 +310,7 @@ export class CodeQLCliServer implements Disposable {
304310
command: string[],
305311
commandArgs: string[],
306312
description: string,
313+
onLine?: OnLineCallback,
307314
): Promise<string> {
308315
const stderrBuffers: Buffer[] = [];
309316
if (this.commandInProcess) {
@@ -328,6 +335,22 @@ export class CodeQLCliServer implements Disposable {
328335
await new Promise<void>((resolve, reject) => {
329336
// Start listening to stdout
330337
process.stdout.addListener("data", (newData: Buffer) => {
338+
if (onLine) {
339+
void (async () => {
340+
const response = await onLine(newData.toString("utf-8"));
341+
342+
if (!response) {
343+
return;
344+
}
345+
346+
process.stdin.write(`${response}${EOL}`);
347+
348+
// Remove newData from stdoutBuffers because the data has been consumed
349+
// by the onLine callback.
350+
stdoutBuffers.splice(stdoutBuffers.indexOf(newData), 1);
351+
})();
352+
}
353+
331354
stdoutBuffers.push(newData);
332355
// If the buffer ends in '0' then exit.
333356
// We don't have to check the middle as no output will be written after the null until
@@ -487,13 +510,15 @@ export class CodeQLCliServer implements Disposable {
487510
* @param commandArgs The arguments to pass to the `codeql` command.
488511
* @param description Description of the action being run, to be shown in log and error messages.
489512
* @param progressReporter Used to output progress messages, e.g. to the status bar.
513+
* @param onLine Used for responding to interactive output on stdout/stdin.
490514
* @returns The contents of the command's stdout, if the command succeeded.
491515
*/
492516
runCodeQlCliCommand(
493517
command: string[],
494518
commandArgs: string[],
495519
description: string,
496520
progressReporter?: ProgressReporter,
521+
onLine?: OnLineCallback,
497522
): Promise<string> {
498523
if (progressReporter) {
499524
progressReporter.report({ message: description });
@@ -503,10 +528,12 @@ export class CodeQLCliServer implements Disposable {
503528
// Construct the command that actually does the work
504529
const callback = (): void => {
505530
try {
506-
this.runCodeQlCliInternal(command, commandArgs, description).then(
507-
resolve,
508-
reject,
509-
);
531+
this.runCodeQlCliInternal(
532+
command,
533+
commandArgs,
534+
description,
535+
onLine,
536+
).then(resolve, reject);
510537
} catch (err) {
511538
reject(err);
512539
}
@@ -522,12 +549,13 @@ export class CodeQLCliServer implements Disposable {
522549
}
523550

524551
/**
525-
* Runs a CodeQL CLI command, returning the output as JSON.
552+
* Runs a CodeQL CLI command, parsing the output as JSON.
526553
* @param command The `codeql` command to be run, provided as an array of command/subcommand names.
527554
* @param commandArgs The arguments to pass to the `codeql` command.
528555
* @param description Description of the action being run, to be shown in log and error messages.
529556
* @param addFormat Whether or not to add commandline arguments to specify the format as JSON.
530557
* @param progressReporter Used to output progress messages, e.g. to the status bar.
558+
* @param onLine Used for responding to interactive output on stdout/stdin.
531559
* @returns The contents of the command's stdout, if the command succeeded.
532560
*/
533561
async runJsonCodeQlCliCommand<OutputType>(
@@ -536,6 +564,7 @@ export class CodeQLCliServer implements Disposable {
536564
description: string,
537565
addFormat = true,
538566
progressReporter?: ProgressReporter,
567+
onLine?: OnLineCallback,
539568
): Promise<OutputType> {
540569
let args: string[] = [];
541570
if (addFormat)
@@ -547,6 +576,7 @@ export class CodeQLCliServer implements Disposable {
547576
args,
548577
description,
549578
progressReporter,
579+
onLine,
550580
);
551581
try {
552582
return JSON.parse(result) as OutputType;
@@ -559,6 +589,67 @@ export class CodeQLCliServer implements Disposable {
559589
}
560590
}
561591

592+
/**
593+
* Runs a CodeQL CLI command with authentication, parsing the output as JSON.
594+
*
595+
* This method is intended for use with commands that accept a `--github-auth-stdin` argument. This
596+
* will be added to the command line arguments automatically if an access token is available.
597+
*
598+
* When the argument is given to the command, the CLI server will prompt for the access token on
599+
* stdin. This method will automatically respond to the prompt with the access token.
600+
*
601+
* There are a few race conditions that can potentially happen:
602+
* 1. The user logs in after the command has started. In this case, no access token will be given.
603+
* 2. The user logs out after the command has started. In this case, the user will be prompted
604+
* to login again. If they cancel the login, the old access token that was present before the
605+
* command was started will be used.
606+
*
607+
* @param command The `codeql` command to be run, provided as an array of command/subcommand names.
608+
* @param commandArgs The arguments to pass to the `codeql` command.
609+
* @param description Description of the action being run, to be shown in log and error messages.
610+
* @param addFormat Whether or not to add commandline arguments to specify the format as JSON.
611+
* @param progressReporter Used to output progress messages, e.g. to the status bar.
612+
* @returns The contents of the command's stdout, if the command succeeded.
613+
*/
614+
async runJsonCodeQlCliCommandWithAuthentication<OutputType>(
615+
command: string[],
616+
commandArgs: string[],
617+
description: string,
618+
addFormat = true,
619+
progressReporter?: ProgressReporter,
620+
): Promise<OutputType> {
621+
const credentials = await Credentials.initialize();
622+
623+
const accessToken = await credentials.getExistingAccessToken();
624+
625+
const extraArgs = accessToken ? ["--github-auth-stdin"] : [];
626+
627+
return this.runJsonCodeQlCliCommand(
628+
command,
629+
[...extraArgs, ...commandArgs],
630+
description,
631+
addFormat,
632+
progressReporter,
633+
async (line) => {
634+
if (line.startsWith("Enter value for --github-auth-stdin")) {
635+
try {
636+
return await credentials.getAccessToken();
637+
} catch (e) {
638+
// If the user cancels the authentication prompt, we still need to give a value to the CLI.
639+
// By giving a potentially invalid value, the user will just get a 401/403 when they try to access a
640+
// private package and the access token is invalid.
641+
// This code path is very rare to hit. It would only be hit if the user is logged in when
642+
// starting the command, then logging out before the getAccessToken() is called again and
643+
// then cancelling the authentication prompt.
644+
return accessToken;
645+
}
646+
}
647+
648+
return undefined;
649+
},
650+
);
651+
}
652+
562653
/**
563654
* Resolve the library path and dbscheme for a query.
564655
* @param workspaces The current open workspaces
@@ -1136,7 +1227,7 @@ export class CodeQLCliServer implements Disposable {
11361227
* @param packs The `<package-scope/name[@version]>` of the packs to download.
11371228
*/
11381229
async packDownload(packs: string[]) {
1139-
return this.runJsonCodeQlCliCommand(
1230+
return this.runJsonCodeQlCliCommandWithAuthentication(
11401231
["pack", "download"],
11411232
packs,
11421233
"Downloading packs",
@@ -1148,7 +1239,7 @@ export class CodeQLCliServer implements Disposable {
11481239
if (forceUpdate) {
11491240
args.push("--mode", "update");
11501241
}
1151-
return this.runJsonCodeQlCliCommand(
1242+
return this.runJsonCodeQlCliCommandWithAuthentication(
11521243
["pack", "install"],
11531244
args,
11541245
"Installing pack dependencies",
@@ -1169,7 +1260,7 @@ export class CodeQLCliServer implements Disposable {
11691260
...this.getAdditionalPacksArg(workspaceFolders),
11701261
];
11711262

1172-
return this.runJsonCodeQlCliCommand(
1263+
return this.runJsonCodeQlCliCommandWithAuthentication(
11731264
["pack", "bundle"],
11741265
args,
11751266
"Bundling pack",
@@ -1200,7 +1291,7 @@ export class CodeQLCliServer implements Disposable {
12001291
): Promise<{ [pack: string]: string }> {
12011292
// Uses the default `--mode use-lock`, which creates the lock file if it doesn't exist.
12021293
const results: { [pack: string]: string } =
1203-
await this.runJsonCodeQlCliCommand(
1294+
await this.runJsonCodeQlCliCommandWithAuthentication(
12041295
["pack", "resolve-dependencies"],
12051296
[dir],
12061297
"Resolving pack dependencies",

extensions/ql-vscode/test/vscode-tests/cli-integration/run-cli.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { extensions, Uri } from "vscode";
1+
import { authentication, extensions, Uri } from "vscode";
22
import { join } from "path";
33
import { SemVer } from "semver";
44

@@ -12,6 +12,7 @@ import {
1212
} from "../../../src/helpers";
1313
import { resolveQueries } from "../../../src/contextual/queryResolver";
1414
import { KeyType } from "../../../src/contextual/keyType";
15+
import { faker } from "@faker-js/faker";
1516

1617
jest.setTimeout(60_000);
1718

@@ -104,4 +105,59 @@ describe("Use cli", () => {
104105
}
105106
},
106107
);
108+
109+
describe("github authentication", () => {
110+
itWithCodeQL()(
111+
"should not use authentication if there are no credentials",
112+
async () => {
113+
const getSession = jest
114+
.spyOn(authentication, "getSession")
115+
.mockResolvedValue(undefined);
116+
117+
await cli.packDownload(["codeql/tutorial"]);
118+
expect(getSession).toHaveBeenCalledTimes(1);
119+
expect(getSession).toHaveBeenCalledWith(
120+
"github",
121+
expect.arrayContaining(["read:packages"]),
122+
{
123+
createIfNone: false,
124+
},
125+
);
126+
},
127+
);
128+
129+
itWithCodeQL()(
130+
"should use authentication if there are credentials",
131+
async () => {
132+
const getSession = jest
133+
.spyOn(authentication, "getSession")
134+
.mockResolvedValue({
135+
id: faker.datatype.uuid(),
136+
accessToken: "gho_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
137+
account: {
138+
id: faker.datatype.uuid(),
139+
label: "Account",
140+
},
141+
scopes: ["read:packages"],
142+
});
143+
144+
await cli.packDownload(["codeql/tutorial"]);
145+
expect(getSession).toHaveBeenCalledTimes(2);
146+
expect(getSession).toHaveBeenCalledWith(
147+
"github",
148+
expect.arrayContaining(["read:packages"]),
149+
{
150+
createIfNone: false,
151+
},
152+
);
153+
expect(getSession).toHaveBeenCalledWith(
154+
"github",
155+
expect.arrayContaining(["read:packages"]),
156+
{
157+
createIfNone: true,
158+
},
159+
);
160+
},
161+
);
162+
});
107163
});

0 commit comments

Comments
 (0)