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 {default as uuid} from 'uuid/v4' import {IGitCommandManager} from './git-command-manager' import {IGitSourceSettings} from './git-source-settings' const IS_WINDOWS = process.platform === 'win32' const HOSTNAME = 'github.com' export interface IGitAuthHelper { configureAuth(): Promise configureGlobalAuth(): Promise configureSubmoduleAuth(): Promise removeAuth(): Promise removeGlobalAuth(): 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 = `http.https://${HOSTNAME}/.extraheader` private readonly tokenPlaceholderConfigValue: string private readonly insteadOfKey: string = `url.https://${HOSTNAME}/.insteadOf` private readonly insteadOfValue: string = `git@${HOSTNAME}:` private temporaryHomePath = '' private tokenConfigValue: string constructor( gitCommandManager: IGitCommandManager, gitSourceSettings?: IGitSourceSettings ) { this.git = gitCommandManager this.settings = gitSourceSettings || (({} as unknown) as IGitSourceSettings) // Token auth header 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}` } async configureAuth(): Promise { // Remove possible previous values await this.removeAuth() // Configure new values await this.configureToken() } async configureGlobalAuth(): Promise { // 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.code !== 'ENOENT') { throw err } } if (configExists) { core.info(`Copying '${gitConfigPath}' to '${newGitConfigPath}'`) await io.cp(gitConfigPath, newGitConfigPath) } else { await fs.promises.writeFile(newGitConfigPath, '') } try { // Override HOME core.info( `Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes` ) this.git.setEnvironmentVariable('HOME', this.temporaryHomePath) // Configure the token await this.configureToken(newGitConfigPath, true) // Configure HTTPS instead of SSH await this.git.tryConfigUnset(this.insteadOfKey, true) await this.git.config(this.insteadOfKey, this.insteadOfValue, 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 { 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 commands = [ `git config --local "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}"`, `git config --local "${this.insteadOfKey}" "${this.insteadOfValue}"`, `git config --local --show-origin --name-only --get-regexp remote.origin.url` ] const output = await this.git.submoduleForeach( commands.join(' && '), 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}'`) this.replaceTokenPlaceholder(configPath) } } } async removeAuth(): Promise { await this.removeToken() } async removeGlobalAuth(): Promise { core.info(`Unsetting HOME override`) this.git.removeEnvironmentVariable('HOME') await io.rmRF(this.temporaryHomePath) } private async configureToken( configPath?: string, globalConfig?: boolean ): Promise { // 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 await this.git.config( this.tokenConfigKey, this.tokenPlaceholderConfigValue, globalConfig ) // Replace the placeholder await this.replaceTokenPlaceholder(configPath || '') } 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 removeToken(): Promise { // HTTP extra header await this.removeGitConfig(this.tokenConfigKey) } 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`) } const pattern = regexpHelper.escape(configKey) await this.git.submoduleForeach( `git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`, true ) } }