Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 7 additions & 20 deletions actions/setup/js/generate_git_patch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

/**
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions actions/setup/js/git_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,4 +105,5 @@ function execGitSync(args, options = {}) {

module.exports = {
execGitSync,
getGitAuthEnv,
};
94 changes: 94 additions & 0 deletions actions/setup/js/git_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
18 changes: 16 additions & 2 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
});
Comment on lines 383 to +386
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new authentication behavior (passing env: { ...process.env, ...gitAuthEnv } into exec.exec for git fetch/git push) isn’t covered by the existing push_to_pull_request_branch tests. Please add an assertion that the fetch/push calls include GIT_CONFIG_COUNT/GIT_CONFIG_KEY_0/GIT_CONFIG_VALUE_0 when a token is available (and that the per-handler github-token takes precedence over process.env.GITHUB_TOKEN).

Copilot uses AI. Check for mistakes.
} catch (fetchError) {
return { success: false, error: `Failed to fetch branch ${branchName}: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}` };
}
Expand Down Expand Up @@ -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);
Expand Down
Loading