mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-09-29 20:17:05 -07:00
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:
+23
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user