From 17c3c6ad9c3eec812dac5ae2d8b83847a558eea7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:45:54 +0000 Subject: [PATCH 1/2] Initial plan From 5571189ac2e399405cf9b586388b30e77ebb7b27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:04:25 +0000 Subject: [PATCH 2/2] fix: authenticate git fetch/push via env vars after clean_git_credentials.sh Add getGitAuthEnv() helper to git_helpers.cjs that builds GIT_CONFIG_* environment variables encoding an Authorization header for git network operations. This avoids relying on .git/config credentials that are cleaned by clean_git_credentials.sh before the agent runs. Update push_to_pull_request_branch.cjs to use getGitAuthEnv() for the git fetch and git push operations, switching to args-array form with env option to pass the auth header. Refactor generate_git_patch.cjs to use the shared getGitAuthEnv helper (removes inline duplicate code). Fixes: clean_git_credentials.sh breaks push_to_pull_request_branch Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/generate_git_patch.cjs | 27 ++---- actions/setup/js/git_helpers.cjs | 33 +++++++ actions/setup/js/git_helpers.test.cjs | 94 +++++++++++++++++++ .../setup/js/push_to_pull_request_branch.cjs | 18 +++- 4 files changed, 150 insertions(+), 22 deletions(-) diff --git a/actions/setup/js/generate_git_patch.cjs b/actions/setup/js/generate_git_patch.cjs index 5e154041804..200298e813e 100644 --- a/actions/setup/js/generate_git_patch.cjs +++ b/actions/setup/js/generate_git_patch.cjs @@ -5,7 +5,7 @@ const fs = require("fs"); const path = require("path"); const { getErrorMessage } = require("./error_helpers.cjs"); -const { execGitSync } = require("./git_helpers.cjs"); +const { execGitSync, getGitAuthEnv } = require("./git_helpers.cjs"); const { ERR_SYSTEM } = require("./error_codes.cjs"); /** @@ -143,25 +143,12 @@ async function generateGitPatch(branchName, baseBranch, options = {}) { // We must explicitly fetch origin/branchName and fail if it doesn't exist. debugLog(`Strategy 1 (incremental): Fetching origin/${branchName}`); - // Configure git authentication using GITHUB_TOKEN and GITHUB_SERVER_URL. - // This ensures the fetch works on GitHub Enterprise Server (GHES) where - // the default credential helper may not be configured for the enterprise endpoint. - // SECURITY: The auth header is passed via GIT_CONFIG_* environment variables so it - // is never written to .git/config on disk. This prevents an attacker monitoring file - // changes from reading the secret. - const githubToken = process.env.GITHUB_TOKEN; - const githubServerUrl = process.env.GITHUB_SERVER_URL || "https://github.com"; - const extraHeaderKey = `http.${githubServerUrl}/.extraheader`; - - // Build environment for the fetch command with git config passed via env vars. - const fetchEnv = { ...process.env }; - if (githubToken) { - const tokenBase64 = Buffer.from(`x-access-token:${githubToken}`).toString("base64"); - fetchEnv.GIT_CONFIG_COUNT = "1"; - fetchEnv.GIT_CONFIG_KEY_0 = extraHeaderKey; - fetchEnv.GIT_CONFIG_VALUE_0 = `Authorization: basic ${tokenBase64}`; - debugLog(`Strategy 1 (incremental): Configured git auth for ${githubServerUrl} via environment variables`); - } + // Configure git authentication via GIT_CONFIG_* environment variables. + // This ensures the fetch works when .git/config credentials are unavailable + // (e.g. after clean_git_credentials.sh) and on GitHub Enterprise Server (GHES). + // SECURITY: The auth header is passed via env vars so it is never written to + // .git/config on disk, preventing file-monitoring attacks. + const fetchEnv = { ...process.env, ...getGitAuthEnv() }; try { // Explicitly fetch origin/branchName to ensure we have the latest diff --git a/actions/setup/js/git_helpers.cjs b/actions/setup/js/git_helpers.cjs index d608809d4cd..84bf2b65630 100644 --- a/actions/setup/js/git_helpers.cjs +++ b/actions/setup/js/git_helpers.cjs @@ -4,6 +4,38 @@ const { spawnSync } = require("child_process"); const { ERR_SYSTEM } = require("./error_codes.cjs"); +/** + * Build GIT_CONFIG_* environment variables that inject an Authorization header + * for git network operations (fetch, push, clone) without writing credentials + * to .git/config on disk. + * + * Use this whenever .git/config credentials may have been cleaned (e.g. after + * clean_git_credentials.sh runs in the agent job) to ensure git can still + * authenticate against the GitHub server. + * + * SECURITY: Credentials are passed via GIT_CONFIG_* environment variables and + * never written to .git/config, so they are not visible to file-monitoring + * attacks and are not inherited by sub-processes that don't receive the env. + * + * @param {string} [token] - GitHub token to use. Falls back to GITHUB_TOKEN env var. + * @returns {Object} Environment variables to spread into child_process/exec options. + * Returns an empty object when no token is available. + */ +function getGitAuthEnv(token) { + const authToken = token || process.env.GITHUB_TOKEN; + if (!authToken) { + core.debug("getGitAuthEnv: no token available, git network operations may fail if credentials were cleaned"); + return {}; + } + const serverUrl = (process.env.GITHUB_SERVER_URL || "https://github.com").replace(/\/$/, ""); + const tokenBase64 = Buffer.from(`x-access-token:${authToken}`).toString("base64"); + return { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `http.${serverUrl}/.extraheader`, + GIT_CONFIG_VALUE_0: `Authorization: basic ${tokenBase64}`, + }; +} + /** * Safely execute git command using spawnSync with args array to prevent shell injection * @param {string[]} args - Git command arguments @@ -73,4 +105,5 @@ function execGitSync(args, options = {}) { module.exports = { execGitSync, + getGitAuthEnv, }; diff --git a/actions/setup/js/git_helpers.test.cjs b/actions/setup/js/git_helpers.test.cjs index 4c28195d5a9..04d35c76e20 100644 --- a/actions/setup/js/git_helpers.test.cjs +++ b/actions/setup/js/git_helpers.test.cjs @@ -173,4 +173,98 @@ describe("git_helpers.cjs", () => { } }); }); + + describe("getGitAuthEnv", () => { + let originalEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + Object.assign(process.env, originalEnv); + }); + + it("should export getGitAuthEnv function", async () => { + const { getGitAuthEnv } = await import("./git_helpers.cjs"); + expect(typeof getGitAuthEnv).toBe("function"); + }); + + it("should return GIT_CONFIG_* env vars when token is provided", async () => { + const { getGitAuthEnv } = await import("./git_helpers.cjs"); + const env = getGitAuthEnv("my-test-token"); + + expect(env).toHaveProperty("GIT_CONFIG_COUNT", "1"); + expect(env).toHaveProperty("GIT_CONFIG_KEY_0"); + expect(env).toHaveProperty("GIT_CONFIG_VALUE_0"); + expect(env.GIT_CONFIG_VALUE_0).toContain("Authorization: basic"); + }); + + it("should use GITHUB_TOKEN env var when no token is passed", async () => { + const { getGitAuthEnv } = await import("./git_helpers.cjs"); + process.env.GITHUB_TOKEN = "env-test-token"; + + const env = getGitAuthEnv(); + + expect(env).toHaveProperty("GIT_CONFIG_COUNT", "1"); + expect(env.GIT_CONFIG_VALUE_0).toBeDefined(); + // Value should be base64 of "x-access-token:env-test-token" + const expected = Buffer.from("x-access-token:env-test-token").toString("base64"); + expect(env.GIT_CONFIG_VALUE_0).toContain(expected); + }); + + it("should prefer the provided token over GITHUB_TOKEN", async () => { + const { getGitAuthEnv } = await import("./git_helpers.cjs"); + process.env.GITHUB_TOKEN = "env-token"; + + const env = getGitAuthEnv("override-token"); + + const expectedBase64 = Buffer.from("x-access-token:override-token").toString("base64"); + expect(env.GIT_CONFIG_VALUE_0).toContain(expectedBase64); + // Should NOT contain the env token + const envBase64 = Buffer.from("x-access-token:env-token").toString("base64"); + expect(env.GIT_CONFIG_VALUE_0).not.toContain(envBase64); + }); + + it("should return empty object when no token is available", async () => { + const { getGitAuthEnv } = await import("./git_helpers.cjs"); + delete process.env.GITHUB_TOKEN; + + const env = getGitAuthEnv(); + + expect(env).toEqual({}); + }); + + it("should scope extraheader to GITHUB_SERVER_URL", async () => { + const { getGitAuthEnv } = await import("./git_helpers.cjs"); + process.env.GITHUB_SERVER_URL = "https://github.example.com"; + + const env = getGitAuthEnv("test-token"); + + expect(env.GIT_CONFIG_KEY_0).toBe("http.https://github.example.com/.extraheader"); + }); + + it("should default server URL to https://github.com", async () => { + const { getGitAuthEnv } = await import("./git_helpers.cjs"); + delete process.env.GITHUB_SERVER_URL; + + const env = getGitAuthEnv("test-token"); + + expect(env.GIT_CONFIG_KEY_0).toBe("http.https://github.com/.extraheader"); + }); + + it("should strip trailing slash from server URL", async () => { + const { getGitAuthEnv } = await import("./git_helpers.cjs"); + process.env.GITHUB_SERVER_URL = "https://github.example.com/"; + + const env = getGitAuthEnv("test-token"); + + expect(env.GIT_CONFIG_KEY_0).toBe("http.https://github.example.com/.extraheader"); + }); + }); }); diff --git a/actions/setup/js/push_to_pull_request_branch.cjs b/actions/setup/js/push_to_pull_request_branch.cjs index 412a7614793..0348c4fed02 100644 --- a/actions/setup/js/push_to_pull_request_branch.cjs +++ b/actions/setup/js/push_to_pull_request_branch.cjs @@ -14,6 +14,7 @@ const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { checkFileProtection } = require("./manifest_file_helpers.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); const { renderTemplate } = require("./messages_core.cjs"); +const { getGitAuthEnv } = require("./git_helpers.cjs"); /** * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction @@ -42,6 +43,13 @@ async function main(config = {}) { const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); const githubClient = await createAuthenticatedGitHubClient(config); + // Build git auth env once for all network operations in this handler. + // clean_git_credentials.sh removes credentials from .git/config before the + // agent runs, so git fetch/push must authenticate via GIT_CONFIG_* env vars. + // Use the per-handler github-token (for cross-repo PAT) when available, + // falling back to GITHUB_TOKEN for the default workflow token. + const gitAuthEnv = getGitAuthEnv(config["github-token"]); + // Base branch from config (if set) - used only for logging at factory level // Dynamic base branch resolution happens per-message after resolving the actual target repo const configBaseBranch = config.base_branch || null; @@ -369,9 +377,13 @@ async function main(config = {}) { core.info(`Switching to branch: ${branchName}`); // Fetch the specific target branch from origin + // Use GIT_CONFIG_* env vars for auth because .git/config credentials are + // cleaned by clean_git_credentials.sh before the agent runs. try { core.info(`Fetching branch: ${branchName}`); - await exec.exec(`git fetch origin ${branchName}:refs/remotes/origin/${branchName}`); + await exec.exec("git", ["fetch", "origin", `${branchName}:refs/remotes/origin/${branchName}`], { + env: { ...process.env, ...gitAuthEnv }, + }); } catch (fetchError) { return { success: false, error: `Failed to fetch branch ${branchName}: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}` }; } @@ -471,7 +483,9 @@ async function main(config = {}) { // Push the applied commits to the branch (outside patch try/catch so push failures are not misattributed) try { - await exec.exec(`git push origin ${branchName}`); + await exec.exec("git", ["push", "origin", branchName], { + env: { ...process.env, ...gitAuthEnv }, + }); core.info(`Changes committed and pushed to branch: ${branchName}`); } catch (pushError) { const pushErrorMessage = getErrorMessage(pushError);