Skip to content

Commit 2365a46

Browse files
authored
Merge pull request #3908 from github/henrymercer/token-stdin
Update scripts to read tokens more securely
2 parents cf51dca + 93c8a9e commit 2365a46

5 files changed

Lines changed: 137 additions & 21 deletions

File tree

.github/update-release-branch.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,23 @@
1616
"""
1717

1818
# NB: This exact commit message is used to find commits for reverting during backports.
19-
# Changing it requires a transition period where both old and new versions are supported.
19+
# Changing it requires a transition period where both old and new versions are supported.
2020
BACKPORT_COMMIT_MESSAGE = 'Update version and changelog for v'
2121

2222
# Name of the remote
2323
ORIGIN = 'origin'
2424

25+
# Environment variables to check for a GitHub API token.
26+
TOKEN_ENVIRONMENT_VARIABLES = ('GH_TOKEN', 'GITHUB_TOKEN')
27+
28+
# Gets a GitHub API token from one of the supported environment variables.
29+
def get_github_token():
30+
for variable_name in TOKEN_ENVIRONMENT_VARIABLES:
31+
token = os.environ.get(variable_name, '').strip()
32+
if token:
33+
return token
34+
raise Exception('Missing GitHub token. Set GITHUB_TOKEN or GH_TOKEN.')
35+
2536
# Runs git with the given args and returns the stdout.
2637
# Raises an error if git does not exit successfully (unless passed
2738
# allow_non_zero_exit_code=True).
@@ -270,12 +281,6 @@ def update_changelog(version):
270281
def main():
271282
parser = argparse.ArgumentParser('update-release-branch.py')
272283

273-
parser.add_argument(
274-
'--github-token',
275-
type=str,
276-
required=True,
277-
help='GitHub token, typically from GitHub Actions.'
278-
)
279284
parser.add_argument(
280285
'--repository-nwo',
281286
type=str,
@@ -313,7 +318,7 @@ def main():
313318
target_branch = args.target_branch
314319
is_primary_release = args.is_primary_release
315320

316-
repo = Github(args.github_token).get_repo(args.repository_nwo)
321+
repo = Github(get_github_token()).get_repo(args.repository_nwo)
317322

318323
# the target branch will be of the form releases/vN, where N is the major version number
319324
target_branch_major_version = target_branch.strip('releases/v')

.github/workflows/update-release-branch.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,12 @@ jobs:
6464
6565
- name: Update current release branch
6666
if: github.event_name == 'workflow_dispatch'
67+
env:
68+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
6769
run: |
6870
echo SOURCE_BRANCH=${REF_NAME}
6971
echo TARGET_BRANCH=releases/${MAJOR_VERSION}
7072
python .github/update-release-branch.py \
71-
--github-token ${{ secrets.GITHUB_TOKEN }} \
7273
--repository-nwo ${{ github.repository }} \
7374
--source-branch '${{ env.REF_NAME }}' \
7475
--target-branch 'releases/${{ env.MAJOR_VERSION }}' \
@@ -107,11 +108,12 @@ jobs:
107108
- uses: ./.github/actions/release-initialise
108109

109110
- name: Update older release branch
111+
env:
112+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
110113
run: |
111114
echo SOURCE_BRANCH=${SOURCE_BRANCH}
112115
echo TARGET_BRANCH=${TARGET_BRANCH}
113116
python .github/update-release-branch.py \
114-
--github-token ${{ secrets.GITHUB_TOKEN }} \
115117
--repository-nwo ${{ github.repository }} \
116118
--source-branch ${SOURCE_BRANCH} \
117119
--target-branch ${TARGET_BRANCH} \

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Once the mergeback and backport pull request have been merged, the release is co
7171

7272
Since the `codeql-action` runs most of its testing through individual Actions workflows, there are over two hundred required jobs that need to pass in order for a PR to turn green. It would be too tedious to maintain that list manually. You can regenerate the set of required checks automatically by running the [sync-checks.ts](pr-checks/sync-checks.ts) script:
7373

74-
- At a minimum, you must provide an argument for the `--token` input. For example, `--token "$(gh auth token)"` to use the same token that `gh` uses. If no token is provided or the token has insufficient permissions, the script will fail.
74+
- At a minimum, you must provide a token with permissions to update branch protection rules. For example, `gh auth token | pr-checks/sync-checks.ts --token-stdin` uses the same token that `gh` uses. You can also set the `GH_TOKEN` or `GITHUB_TOKEN` environment variable. If no token is provided or the token has insufficient permissions, the script will fail.
7575
- By default, the script performs a dry run and outputs information about the changes it would make to the branch protection rules. To actually apply the changes, specify the `--apply` flag.
7676
- If you run the script without any other arguments, it will retrieve the set of workflows that ran for the latest commit on `main`.
7777
- You can specify a different git ref with the `--ref` input. You will likely want to use this if you have a PR that removes or adds PR checks. For example, `--ref "some/branch/name"` to use the HEAD of the `some/branch/name` branch.

pr-checks/sync-checks.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ Tests for the sync-checks.ts script
77
import * as assert from "node:assert/strict";
88
import { describe, it } from "node:test";
99

10-
import { CheckInfo, Exclusions, Options, removeExcluded } from "./sync-checks";
10+
import {
11+
CheckInfo,
12+
Exclusions,
13+
Options,
14+
removeExcluded,
15+
resolveToken,
16+
} from "./sync-checks";
1117

1218
const defaultOptions: Options = {
1319
apply: false,
@@ -58,3 +64,46 @@ describe("removeExcluded", async () => {
5864
assert.deepEqual(retained, expectedExactMatches);
5965
});
6066
});
67+
68+
describe("resolveToken", async () => {
69+
await it("reads the token from standard input", async () => {
70+
const token = await resolveToken(
71+
{ tokenStdin: true },
72+
{ env: {}, readStdin: async () => " stdin-token\n" },
73+
);
74+
assert.equal(token, "stdin-token");
75+
});
76+
77+
await it("reads the token from the GH_TOKEN environment variable", async () => {
78+
const token = await resolveToken(
79+
{},
80+
{ env: { GH_TOKEN: "env-token" }, readStdin: async () => "" },
81+
);
82+
assert.equal(token, "env-token");
83+
});
84+
85+
await it("reads the token from the GITHUB_TOKEN environment variable", async () => {
86+
const token = await resolveToken(
87+
{},
88+
{ env: { GITHUB_TOKEN: "env-token" }, readStdin: async () => "" },
89+
);
90+
assert.equal(token, "env-token");
91+
});
92+
93+
await it("rejects an empty standard input token", async () => {
94+
await assert.rejects(
95+
resolveToken(
96+
{ tokenStdin: true },
97+
{ env: {}, readStdin: async () => "\n" },
98+
),
99+
/No token received on standard input/,
100+
);
101+
});
102+
103+
await it("rejects missing token sources", async () => {
104+
await assert.rejects(
105+
resolveToken({}, { env: {}, readStdin: async () => "" }),
106+
/Missing authentication token/,
107+
);
108+
});
109+
});

pr-checks/sync-checks.ts

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import {
1515

1616
/** Represents the command-line options. */
1717
export interface Options {
18-
/** The token to use to authenticate to the GitHub API. */
19-
token?: string;
18+
/** Whether to read the GitHub API token from standard input. */
19+
tokenStdin?: boolean;
2020
/** The git ref to use the checks for. */
2121
ref?: string;
2222
/** Whether to actually apply the changes or not. */
@@ -31,6 +31,65 @@ const codeqlActionRepo = {
3131
repo: "codeql-action",
3232
};
3333

34+
/** Environment variables to check for a GitHub API token. */
35+
const TOKEN_ENVIRONMENT_VARIABLES = ["GH_TOKEN", "GITHUB_TOKEN"];
36+
37+
/** Represents the sources from which we can retrieve the GitHub API token. */
38+
interface TokenSource {
39+
/** Environment variables to inspect. */
40+
env: NodeJS.ProcessEnv;
41+
/** Reads a token from standard input. */
42+
readStdin: () => Promise<string>;
43+
}
44+
45+
/** Reads the GitHub API token from standard input. */
46+
async function readTokenFromStdin(): Promise<string> {
47+
let token = "";
48+
process.stdin.setEncoding("utf8");
49+
for await (const chunk of process.stdin) {
50+
token += chunk;
51+
}
52+
return token.trim();
53+
}
54+
55+
/** Gets a GitHub API token from one of the supported environment variables. */
56+
function getTokenFromEnvironment(env: NodeJS.ProcessEnv): string | undefined {
57+
for (const variableName of TOKEN_ENVIRONMENT_VARIABLES) {
58+
const token = env[variableName]?.trim();
59+
if (token) {
60+
return token;
61+
}
62+
}
63+
return undefined;
64+
}
65+
66+
/** Gets the token to use to authenticate to the GitHub API. */
67+
export async function resolveToken(
68+
options: Pick<Options, "tokenStdin">,
69+
tokenSource: TokenSource = {
70+
env: process.env,
71+
readStdin: readTokenFromStdin,
72+
},
73+
): Promise<string> {
74+
if (options.tokenStdin) {
75+
const token = (await tokenSource.readStdin()).trim();
76+
if (token.length === 0) {
77+
throw new Error("No token received on standard input.");
78+
}
79+
return token;
80+
}
81+
82+
const environmentToken = getTokenFromEnvironment(tokenSource.env);
83+
if (environmentToken !== undefined) {
84+
return environmentToken;
85+
}
86+
87+
throw new Error(
88+
"Missing authentication token. Set GH_TOKEN/GITHUB_TOKEN or pipe a token " +
89+
"to --token-stdin.",
90+
);
91+
}
92+
3493
/** Represents a configuration of which checks should not be set up as required checks. */
3594
export interface Exclusions {
3695
/** A list of strings that, if contained in a check name, are excluded. */
@@ -205,9 +264,10 @@ async function updateBranch(
205264
async function main(): Promise<void> {
206265
const { values: options } = parseArgs({
207266
options: {
208-
// The token to use to authenticate to the API.
209-
token: {
210-
type: "string",
267+
// Read the token to use to authenticate to the API from standard input.
268+
"token-stdin": {
269+
type: "boolean",
270+
default: false,
211271
},
212272
// The git ref for which to retrieve the check runs.
213273
ref: {
@@ -228,16 +288,16 @@ async function main(): Promise<void> {
228288
strict: true,
229289
});
230290

231-
if (options.token === undefined) {
232-
throw new Error("Missing --token");
233-
}
291+
const token = await resolveToken({
292+
tokenStdin: options["token-stdin"],
293+
});
234294

235295
console.info(
236296
`Oldest supported major version is: ${OLDEST_SUPPORTED_MAJOR_VERSION}`,
237297
);
238298

239299
// Initialise the API client.
240-
const client = getApiClient(options.token);
300+
const client = getApiClient(token);
241301

242302
// Find the check runs for the specified `ref` that we will later set as the required checks
243303
// for the main and release branches.

0 commit comments

Comments
 (0)