security: avoid interpolating values into a bash -c string in downloadCli

CodeQL flagged the install.sh invocation (js/actions/uncontrolled-
command-line) - correctly this time, unlike the pre-existing false
positive on src/index.ts:45. resolvedVersion/destDir were passed safely
as quoted positional params ($0/$1/$2) rather than concatenated into
the command text, so it wasn't exploitable, but building a `bash -c
'... "$0" ...'` string at all is exactly the shape that query looks
for, and there was a strictly better option available: fetch
install.sh's content directly, write it to a file, and run that file
with a plain args array - the same shape this file's own callers
already use for the CLI binary itself, with no shell-text construction
step for the query to flag in the first place.

Also fixes a real, environment-dependent test bug found while touching
this: the "restores from cache" test's real fs.chmod call throws ENOENT
on Linux for a path that doesn't exist on disk, gets swallowed by
restoreFromCache's own try/catch, and silently falls through to the
real install path - passing locally only because the chmod call is
skipped entirely on `win32` (a Windows dev machine), never because the
cache-restore logic under test actually worked. fs/promises is now
mocked like @actions/cache and @actions/exec already were.
This commit is contained in:
frostebite
2026-08-26 23:28:32 +01:00
parent b695be34bb
commit 1ab7c56964
2 changed files with 83 additions and 45 deletions
+23 -19
View File
@@ -137,28 +137,32 @@ export async function downloadCli(version: string): Promise<string> {
const installScriptUrl = `https://raw.githubusercontent.com/${CLI_REPO}/${resolvedVersion}/scripts/install.sh`;
core.info(`Installing game-ci CLI ${resolvedVersion} via ${installScriptUrl}`);
// Fetched and written to a file, then run as `bash <path> <args...>`,
// rather than piped straight into a `bash -c '... "$0" ...'` wrapper: a
// curl-into-bash one-liner needs resolvedVersion/destDir interpolated
// into the -c script text (even safely, as quoted positional params),
// which is exactly the shape CodeQL's uncontrolled-command-line query
// flags. Passing a real file path plus a plain string[] of args - the
// same pattern this file's own callers already use for the CLI binary
// itself - has no such shell-text construction step to flag at all.
const scriptResponse = await fetch(installScriptUrl);
if (!scriptResponse.ok) {
throw new Error(
`Failed to fetch install.sh for game-ci CLI ${resolvedVersion}: ` +
`GitHub returned ${scriptResponse.status} for ${installScriptUrl}.`,
);
}
const scriptPath = path.join(os.tmpdir(), `game-ci-install-${resolvedVersion.replace(/[^\w.-]/g, '_')}.sh`);
await fs.writeFile(scriptPath, await scriptResponse.text(), { mode: 0o755 });
let stdout = '';
await exec.exec(
'bash',
[
'-c',
// `set -o pipefail` matters here: without it, a failed curl (e.g. a
// typo'd/deleted tag giving a 404) still exits 0 because it's not the
// pipeline's last command, and the inner `bash -s` would silently run
// on an empty script instead of failing loudly.
'set -o pipefail; curl -fsSL "$0" | bash -s -- "$1" "$2"',
installScriptUrl,
resolvedVersion,
destDir,
],
{
listeners: {
stdout: (data: Buffer) => {
stdout += data.toString();
},
await exec.exec('bash', [scriptPath, resolvedVersion, destDir], {
listeners: {
stdout: (data: Buffer) => {
stdout += data.toString();
},
},
);
});
// install.sh writes progress to stderr and only the final binary path to
// stdout, but take the last non-empty line regardless - defensive against