From ff20ae9cd9d5fc6ce99d108d89ea013d4c12f489 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 3 Nov 2025 13:08:38 -0600 Subject: Persist creds to a separate file (#2286) --- dist/index.js | 336 +++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 286 insertions(+), 50 deletions(-) (limited to 'dist/index.js') diff --git a/dist/index.js b/dist/index.js index f3ae6f3..a251a19 100644 --- a/dist/index.js +++ b/dist/index.js @@ -162,6 +162,7 @@ class GitAuthHelper { this.sshKeyPath = ''; this.sshKnownHostsPath = ''; this.temporaryHomePath = ''; + this.credentialsConfigPath = ''; // Path to separate credentials config file in RUNNER_TEMP this.git = gitCommandManager; this.settings = gitSourceSettings || {}; // Token auth header @@ -229,15 +230,17 @@ 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) { for (const insteadOfValue of this.insteadOfValues) { - yield this.git.config(this.insteadOfKey, insteadOfValue, true, true); + yield this.git.config(this.insteadOfKey, insteadOfValue, true, // globalConfig? + true // add? + ); } } } @@ -252,19 +255,34 @@ 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 - const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; + // Get the credentials config file path in RUNNER_TEMP + const credentialsConfigPath = this.getCredentialsConfigPath(); + // Container credentials config path + const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath)); + // Get submodule config file paths. + const configPaths = yield this.git.getSubmoduleConfigPaths(this.settings.nestedSubmodules); + // 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, // globalConfig? + false, // add? + configPath); + // Container submodule git directory + const githubWorkspace = process.env['GITHUB_WORKSPACE']; + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined'); + let relativeSubmoduleGitDir = path.relative(githubWorkspace, submoduleGitDir); + relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows + const containerSubmoduleGitDir = path.posix.join('/github/workspace', relativeSubmoduleGitDir); + // Configure container includeIf + yield this.git.config(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig? + false, // add? + configPath); } if (this.settings.sshKey) { // Configure core.sshCommand @@ -295,6 +313,10 @@ class GitAuthHelper { } }); } + /** + * Configures SSH authentication by writing the SSH key and known hosts, + * and setting up the GIT_SSH_COMMAND environment variable. + */ configureSsh() { return __awaiter(this, void 0, void 0, function* () { if (!this.settings.sshKey) { @@ -351,43 +373,88 @@ class GitAuthHelper { } }); } - configureToken(configPath, 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'); - } - // 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) { + /** + * Configures token-based authentication by creating a credentials config file + * and setting up includeIf entries to reference it. + * @param globalConfig Whether to configure global config instead of local + */ + configureToken(globalConfig) { return __awaiter(this, void 0, void 0, function* () { - assert.ok(configPath, 'configPath is not defined'); - let content = (yield fs.promises.readFile(configPath)).toString(); + // Get the credentials config file path in RUNNER_TEMP + const credentialsConfigPath = 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, // globalConfig? + false, // add? + credentialsConfigPath); + // Replace the placeholder in the credentials config file + let content = (yield fs.promises.readFile(credentialsConfigPath)).toString(); const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue); if (placeholderIndex < 0 || placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)) { - throw new Error(`Unable to replace auth placeholder in ${configPath}`); + throw new Error(`Unable to replace auth placeholder in ${credentialsConfigPath}`); } assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined'); content = content.replace(this.tokenPlaceholderConfigValue, this.tokenConfigValue); - yield fs.promises.writeFile(configPath, content); + yield fs.promises.writeFile(credentialsConfigPath, content); + // Add include or includeIf to reference the credentials config + if (globalConfig) { + // Global config file is temporary + yield this.git.config('include.path', credentialsConfigPath, true // globalConfig? + ); + } + 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); + // Container git directory + 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, '/'); // 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); + } }); } + /** + * Gets or creates the path to the credentials config file in RUNNER_TEMP. + * @returns The absolute path to the credentials config file + */ + getCredentialsConfigPath() { + 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; + } + /** + * Removes SSH authentication configuration by cleaning up SSH keys, + * known hosts files, and SSH command configurations. + */ removeSsh() { return __awaiter(this, void 0, void 0, function* () { - var _a; + var _a, _b; // SSH key const keyPath = this.sshKeyPath || stateHelper.SshKeyPath; if (keyPath) { try { + core.info(`Removing SSH key '${keyPath}'`); yield io.rmRF(keyPath); } catch (err) { @@ -399,37 +466,136 @@ class GitAuthHelper { const knownHostsPath = this.sshKnownHostsPath || stateHelper.SshKnownHostsPath; if (knownHostsPath) { try { + core.info(`Removing SSH known hosts '${knownHostsPath}'`); yield io.rmRF(knownHostsPath); } - catch (_b) { - // Intentionally empty + catch (err) { + core.debug(`${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`); + core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`); } } // SSH command + core.info('Removing SSH command configuration'); yield this.removeGitConfig(SSH_COMMAND_KEY); + yield this.removeSubmoduleGitConfig(SSH_COMMAND_KEY); }); } + /** + * Removes token-based authentication by cleaning up HTTP headers, + * includeIf entries, and credentials config files. + */ removeToken() { return __awaiter(this, void 0, void 0, function* () { - // HTTP extra header + var _a; + // Remove HTTP extra header + core.info('Removing HTTP extra header'); yield this.removeGitConfig(this.tokenConfigKey); + yield this.removeSubmoduleGitConfig(this.tokenConfigKey); + // Collect credentials config paths that need to be removed + const credentialsPaths = new Set(); + // Remove includeIf entries that point to git-credentials-*.config files + core.info('Removing includeIf entries pointing to credentials config files'); + const mainCredentialsPaths = yield this.removeIncludeIfCredentials(); + mainCredentialsPaths.forEach(path => credentialsPaths.add(path)); + // Remove submodule includeIf entries that point to git-credentials-*.config files + const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true); + for (const configPath of submoduleConfigPaths) { + const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath); + submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path)); + } + // Remove credentials config files + for (const credentialsPath of credentialsPaths) { + // Only remove credentials config files if they are under RUNNER_TEMP + const runnerTemp = process.env['RUNNER_TEMP']; + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); + if (credentialsPath.startsWith(runnerTemp)) { + try { + core.info(`Removing credentials config '${credentialsPath}'`); + yield io.rmRF(credentialsPath); + } + 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 '${credentialsPath}'`); + } + } + else { + core.debug(`Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP`); + } + } }); } - 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`); - } + /** + * Removes a git config key from the local repository config. + * @param configKey The git config key to remove + */ + 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`); } + }); + } + /** + * Removes a git config key from all submodule configs. + * @param configKey The git config key to remove + */ + 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); }); } + /** + * Removes includeIf entries that point to git-credentials-*.config files. + * @param configPath Optional path to a specific git config file to operate on + * @returns Array of unique credentials config file paths that were found and removed + */ + removeIncludeIfCredentials(configPath) { + return __awaiter(this, void 0, void 0, function* () { + const credentialsPaths = new Set(); + try { + // Get all includeIf.gitdir keys + const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig? + configPath); + for (const key of keys) { + // Get all values for this key + const values = yield this.git.tryGetConfigValues(key, false, // globalConfig? + configPath); + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (this.testCredentialsConfigPath(value)) { + credentialsPaths.add(value); + yield this.git.tryConfigUnsetValue(key, value, false, configPath); + } + } + } + } + } + catch (err) { + // Ignore errors - this is cleanup code + if (configPath) { + core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`); + } + else { + core.debug(`Error during includeIf cleanup: ${err}`); + } + } + return Array.from(credentialsPaths); + }); + } + /** + * Tests if a path matches the git-credentials-*.config pattern. + * @param path The path to test + * @returns True if the path matches the credentials config pattern + */ + testCredentialsConfigPath(path) { + return /git-credentials-[0-9a-f-]+\.config$/i.test(path); + } } @@ -627,9 +793,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'); } @@ -706,6 +878,16 @@ class GitCommandManager { throw new Error('Unexpected output when retrieving default branch'); }); } + getSubmoduleConfigPaths(recursive) { + return __awaiter(this, void 0, void 0, function* () { + // Get submodule config file paths. + // Use `--show-origin` to get the config file path for each submodule. + const output = yield this.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, recursive); + // Extract config file paths from the output (lines starting with "file:"). + const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; + return configPaths; + }); + } getWorkingDirectory() { return this.workingDirectory; } @@ -836,6 +1018,20 @@ class GitCommandManager { return output.exitCode === 0; }); } + tryConfigUnsetValue(configKey, configValue, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--unset', configKey, configValue); + const output = yield this.execGit(args, 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 +1051,46 @@ class GitCommandManager { return stdout; }); } + tryGetConfigValues(configKey, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--get-all', configKey); + const output = yield this.execGit(args, true); + if (output.exitCode !== 0) { + return []; + } + return output.stdout + .trim() + .split('\n') + .filter(value => value.trim()); + }); + } + tryGetConfigKeys(pattern, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--name-only', '--get-regexp', pattern); + const output = yield this.execGit(args, 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); -- cgit v1.2.3