From cd76dd8538cc52a27af76b3f045312b81d514aeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:15:46 +0000 Subject: [PATCH 1/5] Initial plan From df64f2ddae711c1a3467fdc5a7a3ba03c3ff2776 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:28:19 +0000 Subject: [PATCH 2/5] fix: silence credential-bearing git config calls and mask base64 auth header Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/git_auth_helpers.cjs | 22 +++++++++++++----- actions/setup/js/git_auth_helpers.test.cjs | 26 +++++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/actions/setup/js/git_auth_helpers.cjs b/actions/setup/js/git_auth_helpers.cjs index 051aef16b16..34a7bf1f015 100644 --- a/actions/setup/js/git_auth_helpers.cjs +++ b/actions/setup/js/git_auth_helpers.cjs @@ -188,6 +188,11 @@ async function overridePersistedExtraheader(serverUrl, token, cwd) { core.info(`git_auth_helpers: overriding http.${normalizedUrl}/.extraheader with CI trigger token`); const tokenBase64 = Buffer.from(`x-access-token:${token.trim()}`).toString("base64"); const authHeader = `Authorization: basic ${tokenBase64}`; + // Register the derived base64 header value as a secret so runner-side log + // masking redacts it if it ever leaks. This is defense-in-depth; the silent + // exec calls below prevent the credential-bearing command line from being + // written to stdout (and thus to uploaded artifacts) in the first place. + core.setSecret(tokenBase64); // Clear from ALL writable scopes before writing our token to prevent duplicate // Authorization headers. actions/checkout writes to the global scope; without @@ -195,10 +200,13 @@ async function overridePersistedExtraheader(serverUrl, token, cwd) { // global value in place and causing duplicate-header HTTP 400 errors. await unsetExtraheaderAllScopes(`http.${normalizedUrl}/.extraheader`, cwd); + // silent: true prevents @actions/exec from echoing the credential-bearing + // command line (which contains the base64 Authorization header) to stdout, + // where it would otherwise be captured in uploaded safe-output artifacts. if (cwd) { - await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { cwd }); + await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { cwd, silent: true }); } else { - await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader]); + await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { silent: true }); } core.info(`git_auth_helpers: extraheader override applied`); return previousValues; @@ -236,15 +244,17 @@ async function restorePersistedExtraheader(serverUrl, previousValues, cwd) { // best-effort cleanup, then re-throw so the caller is aware that restoration // failed. try { + // silent: true keeps any credential-bearing previous extraheader values out + // of stdout / uploaded artifacts (see overridePersistedExtraheader). if (cwd) { - await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { cwd }); + await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { cwd, silent: true }); for (const value of previousValues.slice(1)) { - await exec.exec("git", ["config", "--local", "--add", key, value], { cwd }); + await exec.exec("git", ["config", "--local", "--add", key, value], { cwd, silent: true }); } } else { - await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]]); + await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { silent: true }); for (const value of previousValues.slice(1)) { - await exec.exec("git", ["config", "--local", "--add", key, value]); + await exec.exec("git", ["config", "--local", "--add", key, value], { silent: true }); } } } catch (err) { diff --git a/actions/setup/js/git_auth_helpers.test.cjs b/actions/setup/js/git_auth_helpers.test.cjs index 1d2969bb1c1..6c1a79d30ae 100644 --- a/actions/setup/js/git_auth_helpers.test.cjs +++ b/actions/setup/js/git_auth_helpers.test.cjs @@ -17,6 +17,7 @@ describe("git_auth_helpers.cjs", () => { mockCore = { info: vi.fn(), warning: vi.fn(), + setSecret: vi.fn(), }; mockExec = { @@ -216,7 +217,22 @@ describe("git_auth_helpers.cjs", () => { await overridePersistedExtraheader(SERVER_URL, token); - expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, expectedHeader]); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, expectedHeader], expect.objectContaining({ silent: true })); + }); + + it("should register the derived base64 header as a secret and never echo it via exec", async () => { + const token = "ghp_test_token"; + const tokenBase64 = Buffer.from(`x-access-token:${token}`).toString("base64"); + + await overridePersistedExtraheader(SERVER_URL, token); + + // The base64 header is registered for runner-side masking. + expect(mockCore.setSecret).toHaveBeenCalledWith(tokenBase64); + // Every credential-bearing exec.exec call is silenced so the command line + // is not captured in uploaded safe-output artifacts. + for (const call of mockExec.exec.mock.calls) { + expect(call[2]).toEqual(expect.objectContaining({ silent: true })); + } }); it("should return empty array when no previous extraheader exists", async () => { @@ -267,7 +283,7 @@ describe("git_auth_helpers.cjs", () => { expect(previous).toEqual([]); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("could not read existing extraheader")); // Override should still proceed despite read failure - expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, expect.any(String)]); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, expect.any(String)], expect.objectContaining({ silent: true })); }); it("should trim the token before base64-encoding", async () => { @@ -276,7 +292,7 @@ describe("git_auth_helpers.cjs", () => { await overridePersistedExtraheader(SERVER_URL, token); const expected = `Authorization: basic ${Buffer.from("x-access-token:ghp_padded_token").toString("base64")}`; - expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, expected]); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, expected], expect.objectContaining({ silent: true })); }); it("should log the number of existing values before overriding", async () => { @@ -303,7 +319,7 @@ describe("git_auth_helpers.cjs", () => { // Using the literal normalized key makes the assertion explicit. const normalizedKey = "http.https://github.com/.extraheader"; expect(mockExec.getExecOutput).toHaveBeenCalledWith("git", ["config", "--get-all", normalizedKey], expect.anything()); - expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", normalizedKey, expect.any(String)]); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", normalizedKey, expect.any(String)], expect.objectContaining({ silent: true })); }); }); @@ -332,7 +348,7 @@ describe("git_auth_helpers.cjs", () => { await restorePersistedExtraheader(SERVER_URL, [prevHeader]); - expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, prevHeader]); + expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, prevHeader], expect.objectContaining({ silent: true })); expect(mockExec.exec).not.toHaveBeenCalledWith("git", expect.arrayContaining(["--add"])); }); From 9127d2318823e08d38d8d7b2c0292f3f7b45d10b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:17:07 +0000 Subject: [PATCH 3/5] Remove core.setSecret call that leaked tokenBase64 to stdout via add-mask command Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/git_auth_helpers.cjs | 5 ----- actions/setup/js/git_auth_helpers.test.cjs | 9 ++------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/actions/setup/js/git_auth_helpers.cjs b/actions/setup/js/git_auth_helpers.cjs index 34a7bf1f015..cbde8bc01ac 100644 --- a/actions/setup/js/git_auth_helpers.cjs +++ b/actions/setup/js/git_auth_helpers.cjs @@ -188,11 +188,6 @@ async function overridePersistedExtraheader(serverUrl, token, cwd) { core.info(`git_auth_helpers: overriding http.${normalizedUrl}/.extraheader with CI trigger token`); const tokenBase64 = Buffer.from(`x-access-token:${token.trim()}`).toString("base64"); const authHeader = `Authorization: basic ${tokenBase64}`; - // Register the derived base64 header value as a secret so runner-side log - // masking redacts it if it ever leaks. This is defense-in-depth; the silent - // exec calls below prevent the credential-bearing command line from being - // written to stdout (and thus to uploaded artifacts) in the first place. - core.setSecret(tokenBase64); // Clear from ALL writable scopes before writing our token to prevent duplicate // Authorization headers. actions/checkout writes to the global scope; without diff --git a/actions/setup/js/git_auth_helpers.test.cjs b/actions/setup/js/git_auth_helpers.test.cjs index 6c1a79d30ae..ed07b1113c3 100644 --- a/actions/setup/js/git_auth_helpers.test.cjs +++ b/actions/setup/js/git_auth_helpers.test.cjs @@ -220,14 +220,9 @@ describe("git_auth_helpers.cjs", () => { expect(mockExec.exec).toHaveBeenCalledWith("git", ["config", "--local", "--replace-all", EXTRAHEADER_KEY, expectedHeader], expect.objectContaining({ silent: true })); }); - it("should register the derived base64 header as a secret and never echo it via exec", async () => { - const token = "ghp_test_token"; - const tokenBase64 = Buffer.from(`x-access-token:${token}`).toString("base64"); - - await overridePersistedExtraheader(SERVER_URL, token); + it("should silence all exec calls to prevent credential capture in safe-output artifacts", async () => { + await overridePersistedExtraheader(SERVER_URL, "ghp_test_token"); - // The base64 header is registered for runner-side masking. - expect(mockCore.setSecret).toHaveBeenCalledWith(tokenBase64); // Every credential-bearing exec.exec call is silenced so the command line // is not captured in uploaded safe-output artifacts. for (const call of mockExec.exec.mock.calls) { From cf065256567255887841822ed40efe3273733f6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:37:04 +0000 Subject: [PATCH 4/5] Add listeners.stderr to silent git config calls to preserve diagnostic output on failure Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/git_auth_helpers.cjs | 53 ++++++++++++++++++---- actions/setup/js/git_auth_helpers.test.cjs | 2 +- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/git_auth_helpers.cjs b/actions/setup/js/git_auth_helpers.cjs index cbde8bc01ac..8dd8028a787 100644 --- a/actions/setup/js/git_auth_helpers.cjs +++ b/actions/setup/js/git_auth_helpers.cjs @@ -198,10 +198,23 @@ async function overridePersistedExtraheader(serverUrl, token, cwd) { // silent: true prevents @actions/exec from echoing the credential-bearing // command line (which contains the base64 Authorization header) to stdout, // where it would otherwise be captured in uploaded safe-output artifacts. - if (cwd) { - await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { cwd, silent: true }); - } else { - await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { silent: true }); + // listeners.stderr captures git diagnostic output (which never contains the + // secret itself) so failures surface a real error message rather than only + // a generic exit-code string. + let stderrBuf = ""; + const stderrListener = { + stderr: (/** @type {Buffer} */ data) => { + stderrBuf += data.toString(); + }, + }; + try { + if (cwd) { + await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { cwd, silent: true, listeners: stderrListener }); + } else { + await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { silent: true, listeners: stderrListener }); + } + } catch (err) { + throw new Error(`git config extraheader override failed: ${stderrBuf.trim() || getErrorMessage(err)}`); } core.info(`git_auth_helpers: extraheader override applied`); return previousValues; @@ -241,15 +254,39 @@ async function restorePersistedExtraheader(serverUrl, previousValues, cwd) { try { // silent: true keeps any credential-bearing previous extraheader values out // of stdout / uploaded artifacts (see overridePersistedExtraheader). + // listeners.stderr captures git diagnostic output for real error messages. + let restoreStderrBuf = ""; + const restoreStderrListener = { + stderr: (/** @type {Buffer} */ data) => { + restoreStderrBuf += data.toString(); + }, + }; + const wrapExecError = (/** @type {unknown} */ err) => new Error(`git config extraheader restore failed: ${restoreStderrBuf.trim() || getErrorMessage(err)}`); if (cwd) { - await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { cwd, silent: true }); + try { + await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { cwd, silent: true, listeners: restoreStderrListener }); + } catch (err) { + throw wrapExecError(err); + } for (const value of previousValues.slice(1)) { - await exec.exec("git", ["config", "--local", "--add", key, value], { cwd, silent: true }); + try { + await exec.exec("git", ["config", "--local", "--add", key, value], { cwd, silent: true, listeners: restoreStderrListener }); + } catch (err) { + throw wrapExecError(err); + } } } else { - await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { silent: true }); + try { + await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { silent: true, listeners: restoreStderrListener }); + } catch (err) { + throw wrapExecError(err); + } for (const value of previousValues.slice(1)) { - await exec.exec("git", ["config", "--local", "--add", key, value], { silent: true }); + try { + await exec.exec("git", ["config", "--local", "--add", key, value], { silent: true, listeners: restoreStderrListener }); + } catch (err) { + throw wrapExecError(err); + } } } } catch (err) { diff --git a/actions/setup/js/git_auth_helpers.test.cjs b/actions/setup/js/git_auth_helpers.test.cjs index ed07b1113c3..8bb368592bd 100644 --- a/actions/setup/js/git_auth_helpers.test.cjs +++ b/actions/setup/js/git_auth_helpers.test.cjs @@ -375,7 +375,7 @@ describe("git_auth_helpers.cjs", () => { return 0; }); - await expect(restorePersistedExtraheader(SERVER_URL, [header1, header2])).rejects.toThrow(addError); + await expect(restorePersistedExtraheader(SERVER_URL, [header1, header2])).rejects.toThrow("git config extraheader restore failed"); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("partial extraheader restore")); // Cleanup should use unsetExtraheaderAllScopes (getExecOutput calls for --unset-all) From bd712e9b103c8dd1a07b7e34e6e9564ae9fbfe54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:28:52 +0000 Subject: [PATCH 5/5] Extract gitExecSilent helper and reuse at all call sites Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/git_auth_helpers.cjs | 84 +++++++--------------- actions/setup/js/git_auth_helpers.test.cjs | 2 +- 2 files changed, 28 insertions(+), 58 deletions(-) diff --git a/actions/setup/js/git_auth_helpers.cjs b/actions/setup/js/git_auth_helpers.cjs index 8dd8028a787..da3c2717c31 100644 --- a/actions/setup/js/git_auth_helpers.cjs +++ b/actions/setup/js/git_auth_helpers.cjs @@ -166,6 +166,29 @@ async function checkoutHasPersistedExtraheader(serverUrl) { } } +/** + * Run `git config` with `silent: true` (to prevent credential-bearing command + * lines from reaching stdout / uploaded safe-output artifacts) while still + * capturing stderr so that real git diagnostics surface on failure. + * + * @param {string[]} gitArgs - Arguments passed to `git` (e.g. `["config", "--local", ...]`) + * @param {string} [cwd] - Optional working directory + * @returns {Promise} + */ +async function gitExecSilent(gitArgs, cwd) { + let stderrBuf = ""; + const listeners = { + stderr: (/** @type {Buffer} */ data) => { + stderrBuf += data.toString(); + }, + }; + try { + await exec.exec("git", gitArgs, { silent: true, listeners, ...(cwd ? { cwd } : {}) }); + } catch (err) { + throw new Error(`git ${gitArgs.join(" ")} failed: ${stderrBuf.trim() || getErrorMessage(err)}`); + } +} + /** * Replace any existing extraheader values with a single token-based Authorization * header and return the previous values for restoration. @@ -195,27 +218,7 @@ async function overridePersistedExtraheader(serverUrl, token, cwd) { // global value in place and causing duplicate-header HTTP 400 errors. await unsetExtraheaderAllScopes(`http.${normalizedUrl}/.extraheader`, cwd); - // silent: true prevents @actions/exec from echoing the credential-bearing - // command line (which contains the base64 Authorization header) to stdout, - // where it would otherwise be captured in uploaded safe-output artifacts. - // listeners.stderr captures git diagnostic output (which never contains the - // secret itself) so failures surface a real error message rather than only - // a generic exit-code string. - let stderrBuf = ""; - const stderrListener = { - stderr: (/** @type {Buffer} */ data) => { - stderrBuf += data.toString(); - }, - }; - try { - if (cwd) { - await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { cwd, silent: true, listeners: stderrListener }); - } else { - await exec.exec("git", ["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], { silent: true, listeners: stderrListener }); - } - } catch (err) { - throw new Error(`git config extraheader override failed: ${stderrBuf.trim() || getErrorMessage(err)}`); - } + await gitExecSilent(["config", "--local", "--replace-all", `http.${normalizedUrl}/.extraheader`, authHeader], cwd); core.info(`git_auth_helpers: extraheader override applied`); return previousValues; } @@ -252,42 +255,9 @@ async function restorePersistedExtraheader(serverUrl, previousValues, cwd) { // best-effort cleanup, then re-throw so the caller is aware that restoration // failed. try { - // silent: true keeps any credential-bearing previous extraheader values out - // of stdout / uploaded artifacts (see overridePersistedExtraheader). - // listeners.stderr captures git diagnostic output for real error messages. - let restoreStderrBuf = ""; - const restoreStderrListener = { - stderr: (/** @type {Buffer} */ data) => { - restoreStderrBuf += data.toString(); - }, - }; - const wrapExecError = (/** @type {unknown} */ err) => new Error(`git config extraheader restore failed: ${restoreStderrBuf.trim() || getErrorMessage(err)}`); - if (cwd) { - try { - await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { cwd, silent: true, listeners: restoreStderrListener }); - } catch (err) { - throw wrapExecError(err); - } - for (const value of previousValues.slice(1)) { - try { - await exec.exec("git", ["config", "--local", "--add", key, value], { cwd, silent: true, listeners: restoreStderrListener }); - } catch (err) { - throw wrapExecError(err); - } - } - } else { - try { - await exec.exec("git", ["config", "--local", "--replace-all", key, previousValues[0]], { silent: true, listeners: restoreStderrListener }); - } catch (err) { - throw wrapExecError(err); - } - for (const value of previousValues.slice(1)) { - try { - await exec.exec("git", ["config", "--local", "--add", key, value], { silent: true, listeners: restoreStderrListener }); - } catch (err) { - throw wrapExecError(err); - } - } + await gitExecSilent(["config", "--local", "--replace-all", key, previousValues[0]], cwd); + for (const value of previousValues.slice(1)) { + await gitExecSilent(["config", "--local", "--add", key, value], cwd); } } catch (err) { core.warning(`git_auth_helpers: partial extraheader restore for ${key} — attempting cleanup: ${getErrorMessage(err)}`); diff --git a/actions/setup/js/git_auth_helpers.test.cjs b/actions/setup/js/git_auth_helpers.test.cjs index 8bb368592bd..9f6608d7b17 100644 --- a/actions/setup/js/git_auth_helpers.test.cjs +++ b/actions/setup/js/git_auth_helpers.test.cjs @@ -375,7 +375,7 @@ describe("git_auth_helpers.cjs", () => { return 0; }); - await expect(restorePersistedExtraheader(SERVER_URL, [header1, header2])).rejects.toThrow("git config extraheader restore failed"); + await expect(restorePersistedExtraheader(SERVER_URL, [header1, header2])).rejects.toThrow(/^git config --local --add http\.https:\/\/github\.com\/\.extraheader .+ failed/); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("partial extraheader restore")); // Cleanup should use unsetExtraheaderAllScopes (getExecOutput calls for --unset-all)