import * as assert from 'assert' import * as core from '@actions/core' import * as exec from '@actions/exec' import * as fs from 'fs' import * as io from '@actions/io' import * as os from 'os' import * as path from 'path' import * as regexpHelper from './regexp-helper' import * as stateHelper from './state-helper' import * as urlHelper from './url-helper' import {v4 as uuid} from 'uuid' import {IGitCommandManager} from './git-command-manager' import {IGitSourceSettings} from './git-source-settings' const IS_WINDOWS = process.platform === 'win32' const SSH_COMMAND_KEY = 'core.sshCommand' export interface IGitAuthHelper { configureAuth(): Promise configureGlobalAuth(): Promise configureSubmoduleAuth(): Promise configureTempGlobalConfig(): Promise removeAuth(): Promise removeGlobalConfig(): Promise } export function createAuthHelper( git: IGitCommandManager, settings?: IGitSourceSettings ): IGitAuthHelper { return new GitAuthHelper(git, settings) } class GitAuthHelper { private readonly git: IGitCommandManager private readonly settings: IGitSourceSettings private readonly tokenConfigKey: string private readonly tokenConfigValue: string private readonly tokenPlaceholderConfigValue: string private readonly insteadOfKey: string private readonly insteadOfValues: string[] = [] private sshCommand = '' private sshKeyPath = '' private sshKnownHostsPath = '' private temporaryHomePath = '' private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP private credentialsIncludeKeys: string[] = [] // Track includeIf/include config keys for cleanup constructor( gitCommandManager: IGitCommandManager, gitSourceSettings: IGitSourceSettings | undefined ) { this.git = gitCommandManager this.settings = gitSourceSettings || ({} as unknown as IGitSourceSettings) // Token auth header const serverUrl = urlHelper.getServerUrl(this.settings.githubServerUrl) this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader` // "origin" is SCHEME://HOSTNAME[:PORT] const basicCredential = Buffer.from( `x-access-token:${this.settings.authToken}`, 'utf8' ).toString('base64') core.setSecret(basicCredential) this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***` this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}` // Instead of SSH URL this.insteadOfKey = `url.${serverUrl.origin}/.insteadOf` // "origin" is SCHEME://HOSTNAME[:PORT] this.insteadOfValues.push(`git@${serverUrl.hostname}:`) if (this.settings.workflowOrganizationId) { this.insteadOfValues.push( `org-${this.settings.workflowOrganizationId}@github.com:` ) } } async configureAuth(): Promise { // Remove possible previous values await this.removeAuth() // Configure new values await this.configureSsh() 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) { return path.join(this.temporaryHomePath, '.gitconfig') } // Create a temp home directory const runnerTemp = process.env['RUNNER_TEMP'] || '' assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') const uniqueId = uuid() this.temporaryHomePath = path.join(runnerTemp, uniqueId) await fs.promises.mkdir(this.temporaryHomePath, {recursive: true}) // Copy the global git config const gitConfigPath = path.join( process.env['HOME'] || os.homedir(), '.gitconfig' ) const newGitConfigPath = path.join(this.temporaryHomePath, '.gitconfig') let configExists = false try { await fs.promises.stat(gitConfigPath) configExists = true } catch (err) { if ((err as any)?.code !== 'ENOENT') { throw err } } if (configExists) { core.info(`Copying '${gitConfigPath}' to '${newGitConfigPath}'`) await io.cp(gitConfigPath, newGitConfigPath) } else { await fs.promises.writeFile(newGitConfigPath, '') } // Override HOME core.info( `Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes` ) this.git.setEnvironmentVariable('HOME', this.temporaryHomePath) return newGitConfigPath } async configureGlobalAuth(): Promise { // 'configureTempGlobalConfig' noops if already set, just returns the path await this.configureTempGlobalConfig() try { // Configure the token 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) } } } catch (err) { // Unset in case somehow written to the real global config core.info( 'Encountered an error when attempting to configure token. Attempting unconfigure.' ) await this.git.tryConfigUnset(this.tokenConfigKey, true) throw err } } async configureSubmoduleAuth(): Promise { // Remove possible previous HTTPS instead of SSH await this.removeGitConfig(this.insteadOfKey, true) if (this.settings.persistCredentials) { // TODO: UPDATE THIS // 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"`, this.settings.nestedSubmodules ) // Replace the placeholder const configPaths: string[] = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [] for (const configPath of configPaths) { core.debug(`Replacing token placeholder in '${configPath}'`) await this.replaceTokenPlaceholder(configPath) } if (this.settings.sshKey) { // Configure core.sshCommand await this.git.submoduleForeach( `git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`, this.settings.nestedSubmodules ) } else { // Configure HTTPS instead of SSH for (const insteadOfValue of this.insteadOfValues) { await this.git.submoduleForeach( `git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`, this.settings.nestedSubmodules ) } } } } async removeAuth(): Promise { await this.removeSsh() await this.removeToken() } async removeGlobalConfig(): Promise { if (this.temporaryHomePath?.length > 0) { core.debug(`Unsetting HOME override`) this.git.removeEnvironmentVariable('HOME') await io.rmRF(this.temporaryHomePath) } } private async configureSsh(): Promise { if (!this.settings.sshKey) { return } // Write key const runnerTemp = process.env['RUNNER_TEMP'] || '' assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') const uniqueId = uuid() this.sshKeyPath = path.join(runnerTemp, uniqueId) stateHelper.setSshKeyPath(this.sshKeyPath) await fs.promises.mkdir(runnerTemp, {recursive: true}) await fs.promises.writeFile( this.sshKeyPath, this.settings.sshKey.trim() + '\n', {mode: 0o600} ) // Remove inherited permissions on Windows if (IS_WINDOWS) { const icacls = await io.which('icacls.exe') await exec.exec( `"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"` ) await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`) } // Write known hosts const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts') let userKnownHosts = '' try { userKnownHosts = ( await fs.promises.readFile(userKnownHostsPath) ).toString() } catch (err) { if ((err as any)?.code !== 'ENOENT') { throw err } } let knownHosts = '' if (userKnownHosts) { knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n` } if (this.settings.sshKnownHosts) { knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n` } knownHosts += `# Begin implicitly added github.com\ngithub.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=\n# End implicitly added github.com\n` this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`) stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath) await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts) // Configure GIT_SSH_COMMAND const sshPath = await io.which('ssh', true) this.sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename( this.sshKeyPath )}"` if (this.settings.sshStrict) { this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no' } this.sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename( this.sshKnownHostsPath )}"` core.info(`Temporarily overriding GIT_SSH_COMMAND=${this.sshCommand}`) this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand) // Configure core.sshCommand if (this.settings.persistCredentials) { await this.git.config(SSH_COMMAND_KEY, this.sshCommand) } } private async configureToken(globalConfig?: boolean): Promise { // Get the credentials config file path in RUNNER_TEMP const credentialsConfigPath = await 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, false, false, credentialsConfigPath ) // 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 { // For local config, use includeIf.gitdir to match the .git directory. // Configure for both host and container paths to support Docker container actions. let gitDir = path.join(this.git.getWorkingDirectory(), '.git') // Use forward slashes for git config, even on Windows gitDir = gitDir.replace(/\\/g, '/') const hostIncludeKey = `includeIf.gitdir:${gitDir}.path` await this.git.config(hostIncludeKey, credentialsConfigPath) this.credentialsIncludeKeys.push(hostIncludeKey) // Configure for container scenario where paths are mapped to fixed locations const githubWorkspace = process.env['GITHUB_WORKSPACE'] assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined') // Calculate the relative path of the working directory from GITHUB_WORKSPACE const workingDirectory = this.git.getWorkingDirectory() let relativePath = path.relative(githubWorkspace, workingDirectory) // Container paths: GITHUB_WORKSPACE -> /github/workspace, RUNNER_TEMP -> /github/runner_temp // Use forward slashes for git config relativePath = relativePath.replace(/\\/g, '/') const containerGitDir = path.posix.join( '/github/workspace', relativePath, '.git' ) const containerCredentialsPath = path.posix.join( '/github/runner_temp', path.basename(credentialsConfigPath) ) const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path` await this.git.config(containerIncludeKey, containerCredentialsPath) this.credentialsIncludeKeys.push(containerIncludeKey) } } private async replaceTokenPlaceholder(configPath: string): Promise { assert.ok(configPath, 'configPath is not defined') let content = (await fs.promises.readFile(configPath)).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}`) } assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined') content = content.replace( this.tokenPlaceholderConfigValue, this.tokenConfigValue ) await fs.promises.writeFile(configPath, content) } private async removeSsh(): Promise { // SSH key const keyPath = this.sshKeyPath || stateHelper.SshKeyPath if (keyPath) { try { await io.rmRF(keyPath) } catch (err) { core.debug(`${(err as any)?.message ?? err}`) core.warning(`Failed to remove SSH key '${keyPath}'`) } } // SSH known hosts const knownHostsPath = this.sshKnownHostsPath || stateHelper.SshKnownHostsPath if (knownHostsPath) { try { await io.rmRF(knownHostsPath) } catch { // Intentionally empty } } // SSH command await this.removeGitConfig(SSH_COMMAND_KEY) } private async removeToken(): Promise { // HTTP extra header await this.removeGitConfig(this.tokenConfigKey) // Remove include/includeIf config entries for (const includeKey of this.credentialsIncludeKeys) { await this.removeGitConfig(includeKey) } this.credentialsIncludeKeys = [] // 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, 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`) } } 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 `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true ) } }