diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e62ac3b..daeacc9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -299,15 +299,18 @@ jobs: test-output: runs-on: ubuntu-latest steps: - # Clone this repo + # Download the action at the current ref - name: Checkout uses: actions/checkout@v4.1.6 + with: + path: actions-checkout # Basic checkout using git - name: Checkout basic id: checkout - uses: ./ + uses: ./actions-checkout with: + path: cloned-using-local-action ref: test-data/v2/basic # Verify output @@ -325,7 +328,3 @@ jobs: echo "Expected commit to be 82f71901cf8c021332310dcc8cdba84c4193ff5d" exit 1 fi - - # needed to make checkout post cleanup succeed - - name: Fix Checkout - uses: actions/checkout@v4.1.6 diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 7633704..f8700ee 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -86,16 +86,29 @@ describe('git-auth-helper tests', () => { // Act await authHelper.configureAuth() - // Assert config - const configContent = ( + // Assert config - check that .git/config contains includeIf entries + const localConfigContent = ( await fs.promises.readFile(localGitConfigPath) ).toString() + expect( + localConfigContent.indexOf('includeIf.gitdir:') + ).toBeGreaterThanOrEqual(0) + + // Assert credentials config file contains the actual credentials + const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBe(1) + const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0]) + const credentialsContent = ( + await fs.promises.readFile(credentialsConfigPath) + ).toString() const basicCredential = Buffer.from( `x-access-token:${settings.authToken}`, 'utf8' ).toString('base64') expect( - configContent.indexOf( + credentialsContent.indexOf( `http.${expectedServerUrl}/.extraheader AUTHORIZATION: basic ${basicCredential}` ) ).toBeGreaterThanOrEqual(0) @@ -120,7 +133,7 @@ describe('git-auth-helper tests', () => { 'inject https://github.com as github server url' it(configureAuth_AcceptsGitHubServerUrlSetToGHEC, async () => { await testAuthHeader( - configureAuth_AcceptsGitHubServerUrl, + configureAuth_AcceptsGitHubServerUrlSetToGHEC, 'https://github.com' ) }) @@ -141,12 +154,17 @@ describe('git-auth-helper tests', () => { // Act await authHelper.configureAuth() - // Assert config - const configContent = ( - await fs.promises.readFile(localGitConfigPath) + // Assert config - check credentials config file (not local .git/config) + const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBe(1) + const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0]) + const credentialsContent = ( + await fs.promises.readFile(credentialsConfigPath) ).toString() expect( - configContent.indexOf( + credentialsContent.indexOf( `http.https://github.com/.extraheader AUTHORIZATION` ) ).toBeGreaterThanOrEqual(0) @@ -251,13 +269,16 @@ describe('git-auth-helper tests', () => { expectedSshCommand ) - // Asserty git config + // Assert git config const gitConfigLines = (await fs.promises.readFile(localGitConfigPath)) .toString() .split('\n') .filter(x => x) - expect(gitConfigLines).toHaveLength(1) - expect(gitConfigLines[0]).toMatch(/^http\./) + // Should have includeIf entries pointing to credentials file + expect(gitConfigLines.length).toBeGreaterThan(0) + expect( + gitConfigLines.some(line => line.indexOf('includeIf.gitdir:') >= 0) + ).toBeTruthy() }) const configureAuth_setsSshCommandWhenPersistCredentialsTrue = @@ -419,8 +440,20 @@ describe('git-auth-helper tests', () => { expect( configContent.indexOf('value-from-global-config') ).toBeGreaterThanOrEqual(0) + // Global config should have include.path pointing to credentials file + expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0) + + // Check credentials in the separate config file + const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBeGreaterThan(0) + const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0]) + const credentialsContent = ( + await fs.promises.readFile(credentialsConfigPath) + ).toString() expect( - configContent.indexOf( + credentialsContent.indexOf( `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}` ) ).toBeGreaterThanOrEqual(0) @@ -463,8 +496,20 @@ describe('git-auth-helper tests', () => { const configContent = ( await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig')) ).toString() + // Global config should have include.path pointing to credentials file + expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0) + + // Check credentials in the separate config file + const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBeGreaterThan(0) + const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0]) + const credentialsContent = ( + await fs.promises.readFile(credentialsConfigPath) + ).toString() expect( - configContent.indexOf( + credentialsContent.indexOf( `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}` ) ).toBeGreaterThanOrEqual(0) @@ -550,11 +595,14 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert + // Should get submodule config paths (1 call) and configure insteadOf (2 calls for two values) expect(mockSubmoduleForeach).toHaveBeenCalledTimes(4) expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( /unset-all.*insteadOf/ ) - expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/) + expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch( + /show-origin.*remote\.origin\.url/ + ) expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch( /url.*insteadOf.*git@github.com:/ ) @@ -589,11 +637,14 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert + // Should get submodule config paths (1 call) and configure sshCommand (1 call) expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3) expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( /unset-all.*insteadOf/ ) - expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/) + expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch( + /show-origin.*remote\.origin\.url/ + ) expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/core\.sshCommand/) } ) @@ -660,19 +711,35 @@ describe('git-auth-helper tests', () => { await setup(removeAuth_removesToken) const authHelper = gitAuthHelper.createAuthHelper(git, settings) await authHelper.configureAuth() - let gitConfigContent = ( + + // Sanity check - verify includeIf entries exist in local config + let localConfigContent = ( await fs.promises.readFile(localGitConfigPath) ).toString() - expect(gitConfigContent.indexOf('http.')).toBeGreaterThanOrEqual(0) // sanity check + expect( + localConfigContent.indexOf('includeIf.gitdir:') + ).toBeGreaterThanOrEqual(0) + + // Sanity check - verify credentials file exists + let credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBe(1) // Act await authHelper.removeAuth() - // Assert git config - gitConfigContent = ( + // Assert includeIf entries removed from local git config + localConfigContent = ( await fs.promises.readFile(localGitConfigPath) ).toString() - expect(gitConfigContent.indexOf('http.')).toBeLessThan(0) + expect(localConfigContent.indexOf('includeIf.gitdir:')).toBeLessThan(0) + + // Assert credentials config file deleted + credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBe(0) }) const removeGlobalConfig_removesOverride = @@ -715,6 +782,7 @@ async function setup(testName: string): Promise { await fs.promises.mkdir(tempHomedir, {recursive: true}) process.env['RUNNER_TEMP'] = runnerTemp process.env['HOME'] = tempHomedir + process.env['GITHUB_WORKSPACE'] = workspace // Create git config globalGitConfigPath = path.join(tempHomedir, '.gitconfig') @@ -733,10 +801,20 @@ async function setup(testName: string): Promise { checkout: jest.fn(), checkoutDetach: jest.fn(), config: jest.fn( - async (key: string, value: string, globalConfig?: boolean) => { - const configPath = globalConfig - ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') - : localGitConfigPath + async ( + key: string, + value: string, + globalConfig?: boolean, + add?: boolean, + configFile?: string + ) => { + const configPath = + configFile || + (globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath) + // Ensure directory exists + await fs.promises.mkdir(path.dirname(configPath), {recursive: true}) await fs.promises.appendFile(configPath, `\n${key} ${value}`) } ), @@ -794,8 +872,53 @@ async function setup(testName: string): Promise { return true } ), + tryConfigUnsetValue: jest.fn( + async (key: string, value: string, globalConfig?: boolean): Promise => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + let content = await fs.promises.readFile(configPath) + let lines = content + .toString() + .split('\n') + .filter(x => x) + .filter(x => !(x.startsWith(key) && x.includes(value))) + await fs.promises.writeFile(configPath, lines.join('\n')) + return true + } + ), tryDisableAutomaticGarbageCollection: jest.fn(), tryGetFetchUrl: jest.fn(), + tryGetConfigValues: jest.fn( + async (key: string, globalConfig?: boolean): Promise => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + const content = await fs.promises.readFile(configPath) + const lines = content + .toString() + .split('\n') + .filter(x => x && x.startsWith(key)) + .map(x => x.substring(key.length).trim()) + return lines + } + ), + tryGetConfigKeys: jest.fn( + async (pattern: string, globalConfig?: boolean): Promise => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + const content = await fs.promises.readFile(configPath) + const lines = content + .toString() + .split('\n') + .filter(x => x) + const keys = lines + .filter(x => new RegExp(pattern).test(x.split(' ')[0])) + .map(x => x.split(' ')[0]) + return [...new Set(keys)] // Remove duplicates + } + ), tryReset: jest.fn(), version: jest.fn() } @@ -830,6 +953,7 @@ async function setup(testName: string): Promise { async function getActualSshKeyPath(): Promise { let actualTempFiles = (await fs.promises.readdir(runnerTemp)) + .filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file .sort() .map(x => path.join(runnerTemp, x)) if (actualTempFiles.length === 0) { @@ -843,6 +967,7 @@ async function getActualSshKeyPath(): Promise { async function getActualSshKnownHostsPath(): Promise { let actualTempFiles = (await fs.promises.readdir(runnerTemp)) + .filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file .sort() .map(x => path.join(runnerTemp, x)) if (actualTempFiles.length === 0) { diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index 22e9ae6..d728e28 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -493,12 +493,15 @@ async function setup(testName: string): Promise { return true }), tryConfigUnset: jest.fn(), + tryConfigUnsetValue: jest.fn(), tryDisableAutomaticGarbageCollection: jest.fn(), tryGetFetchUrl: jest.fn(async () => { // Sanity check - this function shouldn't be called when the .git directory doesn't exist await fs.promises.stat(path.join(repositoryPath, '.git')) return repositoryUrl }), + tryGetConfigValues: jest.fn(), + tryGetConfigKeys: jest.fn(), tryReset: jest.fn(async () => { return true }), diff --git a/dist/index.js b/dist/index.js index f3ae6f3..df77d86 100644 --- a/dist/index.js +++ b/dist/index.js @@ -162,6 +162,8 @@ class GitAuthHelper { this.sshKeyPath = ''; this.sshKnownHostsPath = ''; this.temporaryHomePath = ''; + this.credentialsConfigPath = ''; // Path to separate credentials config file in RUNNER_TEMP + this.credentialsIncludeKeys = []; // Track includeIf config keys for cleanup this.git = gitCommandManager; this.settings = gitSourceSettings || {}; // Token auth header @@ -187,6 +189,20 @@ class GitAuthHelper { yield this.configureToken(); }); } + getCredentialsConfigPath() { + return __awaiter(this, void 0, void 0, function* () { + if (this.credentialsConfigPath) { + return this.credentialsConfigPath; + } + const runnerTemp = process.env['RUNNER_TEMP'] || ''; + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); + // Create a unique filename for this checkout instance + const configFileName = `git-credentials-${(0, uuid_1.v4)()}.config`; + this.credentialsConfigPath = path.join(runnerTemp, configFileName); + core.debug(`Credentials config path: ${this.credentialsConfigPath}`); + return this.credentialsConfigPath; + }); + } configureTempGlobalConfig() { return __awaiter(this, void 0, void 0, function* () { var _a; @@ -229,10 +245,10 @@ class GitAuthHelper { configureGlobalAuth() { return __awaiter(this, void 0, void 0, function* () { // 'configureTempGlobalConfig' noops if already set, just returns the path - const newGitConfigPath = yield this.configureTempGlobalConfig(); + yield this.configureTempGlobalConfig(); try { // Configure the token - yield this.configureToken(newGitConfigPath, true); + yield this.configureToken(true); // Configure HTTPS instead of SSH yield this.git.tryConfigUnset(this.insteadOfKey, true); if (!this.settings.sshKey) { @@ -252,19 +268,37 @@ class GitAuthHelper { configureSubmoduleAuth() { return __awaiter(this, void 0, void 0, function* () { // Remove possible previous HTTPS instead of SSH - yield this.removeGitConfig(this.insteadOfKey, true); + yield this.removeSubmoduleGitConfig(this.insteadOfKey); if (this.settings.persistCredentials) { - // Configure a placeholder value. This approach avoids the credential being captured - // by process creation audit events, which are commonly logged. For more information, - // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing - const output = yield this.git.submoduleForeach( - // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline - `sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, this.settings.nestedSubmodules); - // Replace the placeholder + // Credentials config path + const credentialsConfigPath = yield this.getCredentialsConfigPath(); + // Container credentials config path + const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath)); + // Container repo path + const workingDirectory = this.git.getWorkingDirectory(); + const githubWorkspace = process.env['GITHUB_WORKSPACE']; + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined'); + let relativePath = path.relative(githubWorkspace, workingDirectory); + relativePath = relativePath.replace(/\\/g, '/'); + const containerRepoPath = path.posix.join('/github/workspace', relativePath); + // Get submodule config file paths. + // Use `--show-origin` to get the config file path for each submodule. + const output = yield this.git.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, this.settings.nestedSubmodules); + // Extract config file paths from the output (lines starting with "file:"). const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; + // For each submodule, configure includeIf entries pointing to the shared credentials file. + // Configure both host and container paths to support Docker container actions. for (const configPath of configPaths) { - core.debug(`Replacing token placeholder in '${configPath}'`); - yield this.replaceTokenPlaceholder(configPath); + // Submodule Git directory + let submoduleGitDir = path.dirname(configPath); // The config file is at .git/modules/submodule-name/config + submoduleGitDir = submoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows + // Configure host includeIf + yield this.git.config(`includeIf.gitdir:${submoduleGitDir}.path`, credentialsConfigPath, false, false, configPath); + // Configure container includeIf + let relativeSubmoduleGitDir = path.relative(githubWorkspace, submoduleGitDir); + relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows + const containerSubmoduleGitDir = path.posix.join('/github/workspace', relativeSubmoduleGitDir); + yield this.git.config(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, false, configPath); } if (this.settings.sshKey) { // Configure core.sshCommand @@ -351,20 +385,44 @@ class GitAuthHelper { } }); } - configureToken(configPath, globalConfig) { + configureToken(globalConfig) { return __awaiter(this, void 0, void 0, function* () { - // Validate args - assert.ok((configPath && globalConfig) || (!configPath && !globalConfig), 'Unexpected configureToken parameter combinations'); - // Default config path - if (!configPath && !globalConfig) { - configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config'); + // Get the credentials config file path in RUNNER_TEMP + const credentialsConfigPath = yield this.getCredentialsConfigPath(); + // Write placeholder to the separate credentials config file using git config. + // This approach avoids the credential being captured by process creation audit events, + // which are commonly logged. For more information, refer to + // https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing + yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, false, false, credentialsConfigPath); + // Replace the placeholder in the credentials config file + yield this.replaceTokenPlaceholder(credentialsConfigPath); + // Add include or includeIf to reference the credentials config + if (globalConfig) { + // Global config file is temporary + yield this.git.config('include.path', credentialsConfigPath, true); + } + else { + // Host git directory + let gitDir = path.join(this.git.getWorkingDirectory(), '.git'); + gitDir = gitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows + // Configure host includeIf + const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`; + yield this.git.config(hostIncludeKey, credentialsConfigPath); + this.credentialsIncludeKeys.push(hostIncludeKey); + // Container git directory + const githubWorkspace = process.env['GITHUB_WORKSPACE']; + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined'); + const workingDirectory = this.git.getWorkingDirectory(); + let relativePath = path.relative(githubWorkspace, workingDirectory); + relativePath = relativePath.replace(/\\/g, '/'); // Use forward slashes, even on Windows + const containerGitDir = path.posix.join('/github/workspace', relativePath, '.git'); + // Container credentials config path + const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath)); + // Configure container includeIf + const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`; + yield this.git.config(containerIncludeKey, containerCredentialsPath); + this.credentialsIncludeKeys.push(containerIncludeKey); } - // Configure a placeholder value. This approach avoids the credential being captured - // by process creation audit events, which are commonly logged. For more information, - // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing - yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, globalConfig); - // Replace the placeholder - yield this.replaceTokenPlaceholder(configPath || ''); }); } replaceTokenPlaceholder(configPath) { @@ -407,26 +465,66 @@ class GitAuthHelper { } // SSH command yield this.removeGitConfig(SSH_COMMAND_KEY); + yield this.removeSubmoduleGitConfig(SSH_COMMAND_KEY); }); } removeToken() { return __awaiter(this, void 0, void 0, function* () { - // HTTP extra header + var _a; + // Remove HTTP extra header yield this.removeGitConfig(this.tokenConfigKey); - }); - } - removeGitConfig(configKey_1) { - return __awaiter(this, arguments, void 0, function* (configKey, submoduleOnly = false) { - if (!submoduleOnly) { - if ((yield this.git.configExists(configKey)) && - !(yield this.git.tryConfigUnset(configKey))) { - // Load the config contents - core.warning(`Failed to remove '${configKey}' from the git config`); + yield this.removeSubmoduleGitConfig(this.tokenConfigKey); + // Remove includeIf entries that point to git-credentials-*.config files + // This is more aggressive than tracking keys, but necessary since cleanup + // runs in a post-step where this.credentialsIncludeKeys is empty + try { + // Get all includeIf.gitdir keys + const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:'); + for (const key of keys) { + // Get all values for this key + const values = yield this.git.tryGetConfigValues(key); + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) { + yield this.git.tryConfigUnsetValue(key, value); + } + } + } } } + catch (err) { + // Ignore errors - this is cleanup code + core.debug(`Error during includeIf cleanup: ${err}`); + } + // Remove submodule includeIf + yield this.git.submoduleForeach(`sh -c "git config --local --get-regexp '^includeIf\\.' && git config --local --remove-section includeIf || :"`, true); + // Remove credentials config file + if (this.credentialsConfigPath) { + try { + yield io.rmRF(this.credentialsConfigPath); + } + catch (err) { + core.debug(`${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); + core.warning(`Failed to remove credentials config '${this.credentialsConfigPath}'`); + } + } + }); + } + removeGitConfig(configKey) { + return __awaiter(this, void 0, void 0, function* () { + if ((yield this.git.configExists(configKey)) && + !(yield this.git.tryConfigUnset(configKey))) { + // Load the config contents + core.warning(`Failed to remove '${configKey}' from the git config`); + } + }); + } + removeSubmoduleGitConfig(configKey) { + return __awaiter(this, void 0, void 0, function* () { const pattern = regexpHelper.escape(configKey); yield this.git.submoduleForeach( - // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline + // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline. `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true); }); } @@ -627,9 +725,15 @@ class GitCommandManager { yield this.execGit(args); }); } - config(configKey, configValue, globalConfig, add) { + config(configKey, configValue, globalConfig, add, configFile) { return __awaiter(this, void 0, void 0, function* () { - const args = ['config', globalConfig ? '--global' : '--local']; + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } if (add) { args.push('--add'); } @@ -836,6 +940,18 @@ class GitCommandManager { return output.exitCode === 0; }); } + tryConfigUnsetValue(configKey, configValue, globalConfig) { + return __awaiter(this, void 0, void 0, function* () { + const output = yield this.execGit([ + 'config', + globalConfig ? '--global' : '--local', + '--unset', + configKey, + configValue + ], true); + return output.exitCode === 0; + }); + } tryDisableAutomaticGarbageCollection() { return __awaiter(this, void 0, void 0, function* () { const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true); @@ -855,6 +971,35 @@ class GitCommandManager { return stdout; }); } + tryGetConfigValues(configKey, globalConfig) { + return __awaiter(this, void 0, void 0, function* () { + const output = yield this.execGit([ + 'config', + globalConfig ? '--global' : '--local', + '--get-all', + configKey + ], true); + if (output.exitCode !== 0) { + return []; + } + return output.stdout.trim().split('\n').filter(value => value.trim()); + }); + } + tryGetConfigKeys(pattern, globalConfig) { + return __awaiter(this, void 0, void 0, function* () { + const output = yield this.execGit([ + 'config', + globalConfig ? '--global' : '--local', + '--name-only', + '--get-regexp', + pattern + ], true); + if (output.exitCode !== 0) { + return []; + } + return output.stdout.trim().split('\n').filter(key => key.trim()); + }); + } tryReset() { return __awaiter(this, void 0, void 0, function* () { const output = yield this.execGit(['reset', '--hard', 'HEAD'], true); diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index 126e8e5..a529041 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -43,6 +43,8 @@ class GitAuthHelper { private sshKeyPath = '' private sshKnownHostsPath = '' private temporaryHomePath = '' + private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP + private credentialsIncludeKeys: string[] = [] // Track includeIf config keys for cleanup constructor( gitCommandManager: IGitCommandManager, @@ -81,6 +83,22 @@ class GitAuthHelper { await this.configureToken() } + private async getCredentialsConfigPath(): Promise { + if (this.credentialsConfigPath) { + return this.credentialsConfigPath + } + + const runnerTemp = process.env['RUNNER_TEMP'] || '' + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') + + // Create a unique filename for this checkout instance + const configFileName = `git-credentials-${uuid()}.config` + this.credentialsConfigPath = path.join(runnerTemp, configFileName) + + core.debug(`Credentials config path: ${this.credentialsConfigPath}`) + return this.credentialsConfigPath + } + async configureTempGlobalConfig(): Promise { // Already setup global config if (this.temporaryHomePath?.length > 0) { @@ -126,10 +144,10 @@ class GitAuthHelper { async configureGlobalAuth(): Promise { // 'configureTempGlobalConfig' noops if already set, just returns the path - const newGitConfigPath = await this.configureTempGlobalConfig() + await this.configureTempGlobalConfig() try { // Configure the token - await this.configureToken(newGitConfigPath, true) + await this.configureToken(true) // Configure HTTPS instead of SSH await this.git.tryConfigUnset(this.insteadOfKey, true) @@ -150,24 +168,73 @@ class GitAuthHelper { async configureSubmoduleAuth(): Promise { // Remove possible previous HTTPS instead of SSH - await this.removeGitConfig(this.insteadOfKey, true) + await this.removeSubmoduleGitConfig(this.insteadOfKey) if (this.settings.persistCredentials) { - // Configure a placeholder value. This approach avoids the credential being captured - // by process creation audit events, which are commonly logged. For more information, - // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing + // Credentials config path + const credentialsConfigPath = await this.getCredentialsConfigPath() + + // Container credentials config path + const containerCredentialsPath = path.posix.join( + '/github/runner_temp', + path.basename(credentialsConfigPath) + ) + + // Container repo path + const workingDirectory = this.git.getWorkingDirectory() + const githubWorkspace = process.env['GITHUB_WORKSPACE'] + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined') + let relativePath = path.relative(githubWorkspace, workingDirectory) + relativePath = relativePath.replace(/\\/g, '/') + const containerRepoPath = path.posix.join( + '/github/workspace', + relativePath + ) + + // Get submodule config file paths. + // Use `--show-origin` to get the config file path for each submodule. const output = await this.git.submoduleForeach( - // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline - `sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, + `git config --local --show-origin --name-only --get-regexp remote.origin.url`, this.settings.nestedSubmodules ) - // Replace the placeholder - const configPaths: string[] = + // Extract config file paths from the output (lines starting with "file:"). + const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [] + + // For each submodule, configure includeIf entries pointing to the shared credentials file. + // Configure both host and container paths to support Docker container actions. for (const configPath of configPaths) { - core.debug(`Replacing token placeholder in '${configPath}'`) - await this.replaceTokenPlaceholder(configPath) + // Submodule Git directory + let submoduleGitDir = path.dirname(configPath) // The config file is at .git/modules/submodule-name/config + submoduleGitDir = submoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows + + // Configure host includeIf + await this.git.config( + `includeIf.gitdir:${submoduleGitDir}.path`, + credentialsConfigPath, + false, + false, + configPath + ) + + // Configure container includeIf + let relativeSubmoduleGitDir = path.relative( + githubWorkspace, + submoduleGitDir + ) + relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows + const containerSubmoduleGitDir = path.posix.join( + '/github/workspace', + relativeSubmoduleGitDir + ) + await this.git.config( + `includeIf.gitdir:${containerSubmoduleGitDir}.path`, + containerCredentialsPath, + false, + false, + configPath + ) } if (this.settings.sshKey) { @@ -272,32 +339,62 @@ class GitAuthHelper { } } - private async configureToken( - configPath?: string, - globalConfig?: boolean - ): Promise { - // Validate args - assert.ok( - (configPath && globalConfig) || (!configPath && !globalConfig), - 'Unexpected configureToken parameter combinations' - ) + private async configureToken(globalConfig?: boolean): Promise { + // Get the credentials config file path in RUNNER_TEMP + const credentialsConfigPath = await this.getCredentialsConfigPath() - // Default config path - if (!configPath && !globalConfig) { - configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config') - } - - // Configure a placeholder value. This approach avoids the credential being captured - // by process creation audit events, which are commonly logged. For more information, - // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing + // Write placeholder to the separate credentials config file using git config. + // This approach avoids the credential being captured by process creation audit events, + // which are commonly logged. For more information, refer to + // https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing await this.git.config( this.tokenConfigKey, this.tokenPlaceholderConfigValue, - globalConfig + false, + false, + credentialsConfigPath ) - // Replace the placeholder - await this.replaceTokenPlaceholder(configPath || '') + // Replace the placeholder in the credentials config file + await this.replaceTokenPlaceholder(credentialsConfigPath) + + // Add include or includeIf to reference the credentials config + if (globalConfig) { + // Global config file is temporary + await this.git.config('include.path', credentialsConfigPath, true) + } else { + // Host git directory + let gitDir = path.join(this.git.getWorkingDirectory(), '.git') + gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows + + // Configure host includeIf + const hostIncludeKey = `includeIf.gitdir:${gitDir}.path` + await this.git.config(hostIncludeKey, credentialsConfigPath) + this.credentialsIncludeKeys.push(hostIncludeKey) + + // Container git directory + const githubWorkspace = process.env['GITHUB_WORKSPACE'] + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined') + const workingDirectory = this.git.getWorkingDirectory() + let relativePath = path.relative(githubWorkspace, workingDirectory) + relativePath = relativePath.replace(/\\/g, '/') // Use forward slashes, even on Windows + const containerGitDir = path.posix.join( + '/github/workspace', + relativePath, + '.git' + ) + + // Container credentials config path + const containerCredentialsPath = path.posix.join( + '/github/runner_temp', + path.basename(credentialsConfigPath) + ) + + // Configure container includeIf + const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path` + await this.git.config(containerIncludeKey, containerCredentialsPath) + this.credentialsIncludeKeys.push(containerIncludeKey) + } } private async replaceTokenPlaceholder(configPath: string): Promise { @@ -343,30 +440,71 @@ class GitAuthHelper { // SSH command await this.removeGitConfig(SSH_COMMAND_KEY) + await this.removeSubmoduleGitConfig(SSH_COMMAND_KEY) } private async removeToken(): Promise { - // HTTP extra header + // Remove HTTP extra header await this.removeGitConfig(this.tokenConfigKey) - } + await this.removeSubmoduleGitConfig(this.tokenConfigKey) - private async removeGitConfig( - configKey: string, - submoduleOnly: boolean = false - ): Promise { - if (!submoduleOnly) { - if ( - (await this.git.configExists(configKey)) && - !(await this.git.tryConfigUnset(configKey)) - ) { - // Load the config contents - core.warning(`Failed to remove '${configKey}' from the git config`) + // Remove includeIf entries that point to git-credentials-*.config files + // This is more aggressive than tracking keys, but necessary since cleanup + // runs in a post-step where this.credentialsIncludeKeys is empty + try { + // Get all includeIf.gitdir keys + const keys = await this.git.tryGetConfigKeys('^includeIf\\.gitdir:') + + for (const key of keys) { + // Get all values for this key + const values = await this.git.tryGetConfigValues(key) + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) { + await this.git.tryConfigUnsetValue(key, value) + } + } + } } + } catch (err) { + // Ignore errors - this is cleanup code + core.debug(`Error during includeIf cleanup: ${err}`) } + // Remove submodule includeIf + await this.git.submoduleForeach( + `sh -c "git config --local --get-regexp '^includeIf\\.' && git config --local --remove-section includeIf || :"`, + true + ) + + // Remove credentials config file + if (this.credentialsConfigPath) { + try { + await io.rmRF(this.credentialsConfigPath) + } catch (err) { + core.debug(`${(err as any)?.message ?? err}`) + core.warning( + `Failed to remove credentials config '${this.credentialsConfigPath}'` + ) + } + } + } + + private async removeGitConfig(configKey: string): Promise { + if ( + (await this.git.configExists(configKey)) && + !(await this.git.tryConfigUnset(configKey)) + ) { + // Load the config contents + core.warning(`Failed to remove '${configKey}' from the git config`) + } + } + + private async removeSubmoduleGitConfig(configKey: string): Promise { const pattern = regexpHelper.escape(configKey) await this.git.submoduleForeach( - // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline + // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline. `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true ) diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 8e42a38..ed3220b 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -28,7 +28,8 @@ export interface IGitCommandManager { configKey: string, configValue: string, globalConfig?: boolean, - add?: boolean + add?: boolean, + configFile?: string ): Promise configExists(configKey: string, globalConfig?: boolean): Promise fetch( @@ -59,8 +60,11 @@ export interface IGitCommandManager { tagExists(pattern: string): Promise tryClean(): Promise tryConfigUnset(configKey: string, globalConfig?: boolean): Promise + tryConfigUnsetValue(configKey: string, configValue: string, globalConfig?: boolean): Promise tryDisableAutomaticGarbageCollection(): Promise tryGetFetchUrl(): Promise + tryGetConfigValues(configKey: string, globalConfig?: boolean): Promise + tryGetConfigKeys(pattern: string, globalConfig?: boolean): Promise tryReset(): Promise version(): Promise } @@ -223,9 +227,15 @@ class GitCommandManager { configKey: string, configValue: string, globalConfig?: boolean, - add?: boolean + add?: boolean, + configFile?: string ): Promise { - const args: string[] = ['config', globalConfig ? '--global' : '--local'] + const args: string[] = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } if (add) { args.push('--add') } @@ -455,6 +465,24 @@ class GitCommandManager { return output.exitCode === 0 } + async tryConfigUnsetValue( + configKey: string, + configValue: string, + globalConfig?: boolean + ): Promise { + const output = await this.execGit( + [ + 'config', + globalConfig ? '--global' : '--local', + '--unset', + configKey, + configValue + ], + true + ) + return output.exitCode === 0 + } + async tryDisableAutomaticGarbageCollection(): Promise { const output = await this.execGit( ['config', '--local', 'gc.auto', '0'], @@ -481,6 +509,49 @@ class GitCommandManager { return stdout } + async tryGetConfigValues( + configKey: string, + globalConfig?: boolean + ): Promise { + const output = await this.execGit( + [ + 'config', + globalConfig ? '--global' : '--local', + '--get-all', + configKey + ], + true + ) + + if (output.exitCode !== 0) { + return [] + } + + return output.stdout.trim().split('\n').filter(value => value.trim()) + } + + async tryGetConfigKeys( + pattern: string, + globalConfig?: boolean + ): Promise { + const output = await this.execGit( + [ + 'config', + globalConfig ? '--global' : '--local', + '--name-only', + '--get-regexp', + pattern + ], + true + ) + + if (output.exitCode !== 0) { + return [] + } + + return output.stdout.trim().split('\n').filter(key => key.trim()) + } + async tryReset(): Promise { const output = await this.execGit(['reset', '--hard', 'HEAD'], true) return output.exitCode === 0