diff options
Diffstat (limited to 'src/git-auth-helper.ts')
| -rw-r--r-- | src/git-auth-helper.ts | 332 |
1 files changed, 273 insertions, 59 deletions
diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index 126e8e5..a1950a6 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -43,6 +43,7 @@ class GitAuthHelper { private sshKeyPath = '' private sshKnownHostsPath = '' private temporaryHomePath = '' + private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP constructor( gitCommandManager: IGitCommandManager, @@ -126,16 +127,21 @@ class GitAuthHelper { async configureGlobalAuth(): Promise<void> { // '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) if (!this.settings.sshKey) { for (const insteadOfValue of this.insteadOfValues) { - await this.git.config(this.insteadOfKey, insteadOfValue, true, true) + await this.git.config( + this.insteadOfKey, + insteadOfValue, + true, // globalConfig? + true // add? + ) } } } catch (err) { @@ -150,24 +156,60 @@ class GitAuthHelper { async configureSubmoduleAuth(): Promise<void> { // 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 - 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"`, + // 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 = await this.git.getSubmoduleConfigPaths( this.settings.nestedSubmodules ) - // Replace the placeholder - const configPaths: string[] = - 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, // 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 + await this.git.config( + `includeIf.gitdir:${containerSubmoduleGitDir}.path`, + containerCredentialsPath, + false, // globalConfig? + false, // add? + configPath + ) } if (this.settings.sshKey) { @@ -201,6 +243,10 @@ class GitAuthHelper { } } + /** + * Configures SSH authentication by writing the SSH key and known hosts, + * and setting up the GIT_SSH_COMMAND environment variable. + */ private async configureSsh(): Promise<void> { if (!this.settings.sshKey) { return @@ -272,57 +318,116 @@ class GitAuthHelper { } } - private async configureToken( - configPath?: string, - globalConfig?: boolean - ): Promise<void> { - // 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 + /** + * 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 + */ + private async configureToken(globalConfig?: boolean): Promise<void> { + // 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 await this.git.config( this.tokenConfigKey, this.tokenPlaceholderConfigValue, - globalConfig + false, // globalConfig? + false, // add? + credentialsConfigPath ) - // Replace the placeholder - await this.replaceTokenPlaceholder(configPath || '') - } - - private async replaceTokenPlaceholder(configPath: string): Promise<void> { - assert.ok(configPath, 'configPath is not defined') - let content = (await fs.promises.readFile(configPath)).toString() + // Replace the placeholder in the credentials config file + let content = (await 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 ) - await fs.promises.writeFile(configPath, content) + await fs.promises.writeFile(credentialsConfigPath, content) + + // Add include or includeIf to reference the credentials config + if (globalConfig) { + // Global config file is temporary + await 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` + await 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` + await 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 + */ + private getCredentialsConfigPath(): string { + 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 } + /** + * Removes SSH authentication configuration by cleaning up SSH keys, + * known hosts files, and SSH command configurations. + */ private async removeSsh(): Promise<void> { // SSH key const keyPath = this.sshKeyPath || stateHelper.SshKeyPath if (keyPath) { try { + core.info(`Removing SSH key '${keyPath}'`) await io.rmRF(keyPath) } catch (err) { core.debug(`${(err as any)?.message ?? err}`) @@ -335,40 +440,149 @@ class GitAuthHelper { this.sshKnownHostsPath || stateHelper.SshKnownHostsPath if (knownHostsPath) { try { + core.info(`Removing SSH known hosts '${knownHostsPath}'`) await io.rmRF(knownHostsPath) - } catch { - // Intentionally empty + } catch (err) { + core.debug(`${(err as any)?.message ?? err}`) + core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`) } } // SSH command + core.info('Removing SSH command configuration') await this.removeGitConfig(SSH_COMMAND_KEY) + await this.removeSubmoduleGitConfig(SSH_COMMAND_KEY) } + /** + * Removes token-based authentication by cleaning up HTTP headers, + * includeIf entries, and credentials config files. + */ private async removeToken(): Promise<void> { - // HTTP extra header + // Remove HTTP extra header + core.info('Removing HTTP extra header') await this.removeGitConfig(this.tokenConfigKey) - } + await this.removeSubmoduleGitConfig(this.tokenConfigKey) + + // Collect credentials config paths that need to be removed + const credentialsPaths = new Set<string>() + + // Remove includeIf entries that point to git-credentials-*.config files + core.info('Removing includeIf entries pointing to credentials config files') + const mainCredentialsPaths = await this.removeIncludeIfCredentials() + mainCredentialsPaths.forEach(path => credentialsPaths.add(path)) + + // Remove submodule includeIf entries that point to git-credentials-*.config files + const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true) + for (const configPath of submoduleConfigPaths) { + const submoduleCredentialsPaths = + await this.removeIncludeIfCredentials(configPath) + submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path)) + } - private async removeGitConfig( - configKey: string, - submoduleOnly: boolean = false - ): Promise<void> { - 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 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}'`) + await io.rmRF(credentialsPath) + } catch (err) { + core.debug(`${(err as any)?.message ?? err}`) + core.warning( + `Failed to remove credentials config '${credentialsPath}'` + ) + } + } else { + core.debug( + `Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP` + ) } } + } + + /** + * Removes a git config key from the local repository config. + * @param configKey The git config key to remove + */ + private async removeGitConfig(configKey: string): Promise<void> { + 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`) + } + } + /** + * Removes a git config key from all submodule configs. + * @param configKey The git config key to remove + */ + private async removeSubmoduleGitConfig(configKey: string): Promise<void> { 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 ) } + + /** + * 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 + */ + private async removeIncludeIfCredentials( + configPath?: string + ): Promise<string[]> { + const credentialsPaths = new Set<string>() + + try { + // Get all includeIf.gitdir keys + const keys = await this.git.tryGetConfigKeys( + '^includeIf\\.gitdir:', + false, // globalConfig? + configPath + ) + + for (const key of keys) { + // Get all values for this key + const values = await this.git.tryGetConfigValues( + key, + false, // globalConfig? + configPath + ) + if (values.length > 0) { + // Remove only values that match git-credentials-<uuid>.config pattern + for (const value of values) { + if (this.testCredentialsConfigPath(value)) { + credentialsPaths.add(value) + await 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 + */ + private testCredentialsConfigPath(path: string): boolean { + return /git-credentials-[0-9a-f-]+\.config$/i.test(path) + } } |
