fix(security): use temp file for GitHub token to avoid process listing exposure (#3301)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run
Lint / ShellCheck (push) Waiting to run

* fix(security): use temp file for GitHub token to avoid process listing exposure

Fixes #3300

Agent: security-auditor
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(security): pass GitHub token via heredoc instead of local temp file

The previous fix wrote the token to a temp file on the LOCAL host, but
the command string was executed on the REMOTE server via runner.runServer(),
so `cat` would fail with 'No such file or directory'. Switch to a heredoc
which is parsed by the remote shell and never appears in /proc/*/cmdline.

Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(security): upload token to remote via SCP instead of heredoc

The previous heredoc approach (`cat <<'EOF'`) doesn't work because all
cloud runners wrap commands in `bash -c ${shellQuote(cmd)}`, and heredocs
are not valid inside single-quoted bash -c strings.

Use runner.uploadFile() (SCP) to place the token on the remote server as
a temp file (mode 0600), then cat+rm it in the remote command. This is
the same proven pattern used by uploadConfigFile(). The local temp file
is always cleaned up after upload, and the remote temp file is cleaned up
both on success (inline rm) and on failure (best-effort rm).

Agent: security-auditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-04-14 07:56:13 -07:00 committed by GitHub
parent 352c55c068
commit fbf7aaa067
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -266,14 +266,33 @@ export async function offerGithubAuth(runner: CloudRunner, explicitlyRequested?:
} }
let ghCmd = "curl --proto '=https' -fsSL https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash"; let ghCmd = "curl --proto '=https' -fsSL https://openrouter.ai/labs/spawn/shared/github-auth.sh | bash";
// Upload the token to a remote temp file so it never appears in `ps auxe`
// process listings. We use runner.uploadFile() (SCP) — the same proven
// pattern as uploadConfigFile(). A heredoc won't work here because all
// cloud runners wrap commands in `bash -c ${shellQuote(cmd)}`, and
// heredocs are not valid inside single-quoted `bash -c '...'` strings.
let remoteTokenPath = "";
if (githubToken) { if (githubToken) {
const tokenB64 = Buffer.from(githubToken).toString("base64"); const localTmpFile = join(getTmpDir(), `spawn_gh_token_${Date.now()}_${Math.random().toString(36).slice(2)}`);
ghCmd = `export GITHUB_TOKEN=$(printf '%s' ${shellQuote(tokenB64)} | base64 -d) && ${ghCmd}`; remoteTokenPath = `/tmp/spawn_gh_token_${Date.now()}`;
writeFileSync(localTmpFile, githubToken, {
mode: 0o600,
});
const uploadResult = await asyncTryCatch(() => runner.uploadFile(localTmpFile, remoteTokenPath));
tryCatchIf(isOperationalError, () => unlinkSync(localTmpFile));
if (!uploadResult.ok) {
throw uploadResult.error;
}
ghCmd = `export GITHUB_TOKEN=$(cat ${shellQuote(remoteTokenPath)}) && rm -f ${shellQuote(remoteTokenPath)} && ${ghCmd}`;
} }
logStep("Installing and authenticating GitHub CLI on the remote server..."); logStep("Installing and authenticating GitHub CLI on the remote server...");
const ghSetup = await asyncTryCatchIf(isOperationalError, () => runner.runServer(ghCmd)); const ghSetup = await asyncTryCatchIf(isOperationalError, () => runner.runServer(ghCmd));
if (!ghSetup.ok) { if (!ghSetup.ok) {
// Best-effort cleanup of remote token file if the command failed before rm ran
if (remoteTokenPath) {
await asyncTryCatchIf(isOperationalError, () => runner.runServer(`rm -f ${shellQuote(remoteTokenPath)}`));
}
logWarn("GitHub CLI setup failed (non-fatal, continuing)"); logWarn("GitHub CLI setup failed (non-fatal, continuing)");
} }