checkout/src/git-auth-helper.ts

388 lines
13 KiB
TypeScript
Raw Normal View History

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'
2020-03-05 14:21:59 -05:00
import * as regexpHelper from './regexp-helper'
import * as stateHelper from './state-helper'
import * as urlHelper from './url-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'
2020-03-11 15:55:17 -04:00
const SSH_COMMAND_KEY = 'core.sshCommand'
export interface IGitAuthHelper {
configureAuth(): Promise<void>
2020-03-05 14:21:59 -05:00
configureGlobalAuth(): Promise<void>
configureSubmoduleAuth(): Promise<void>
configureTempGlobalConfig(repositoryPath?: string): Promise<string>
removeAuth(): Promise<void>
removeGlobalConfig(): Promise<void>
}
export function createAuthHelper(
git: IGitCommandManager,
settings?: IGitSourceSettings
): IGitAuthHelper {
return new GitAuthHelper(git, settings)
}
class GitAuthHelper {
2020-03-05 14:21:59 -05:00
private readonly git: IGitCommandManager
private readonly settings: IGitSourceSettings
private readonly tokenConfigKey: string
private readonly tokenConfigValue: string
2020-03-05 14:21:59 -05:00
private readonly tokenPlaceholderConfigValue: string
private readonly insteadOfKey: string
2021-11-01 12:43:18 -04:00
private readonly insteadOfValues: string[] = []
private sshCommand = ''
2020-03-11 15:55:17 -04:00
private sshKeyPath = ''
private sshKnownHostsPath = ''
2020-03-05 14:21:59 -05:00
private temporaryHomePath = ''
constructor(
gitCommandManager: IGitCommandManager,
2021-11-01 12:43:18 -04:00
gitSourceSettings: IGitSourceSettings | undefined
) {
this.git = gitCommandManager
this.settings = gitSourceSettings || (({} as unknown) as IGitSourceSettings)
2020-03-05 14:21:59 -05:00
// Token auth header
const serverUrl = urlHelper.getServerUrl()
this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader` // "origin" is SCHEME://HOSTNAME[:PORT]
2020-03-05 14:21:59 -05:00
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]
2021-11-01 12:43:18 -04:00
this.insteadOfValues.push(`git@${serverUrl.hostname}:`)
if (this.settings.workflowOrganizationId) {
this.insteadOfValues.push(
`org-${this.settings.workflowOrganizationId}@github.com:`
)
}
}
async configureAuth(): Promise<void> {
// Remove possible previous values
await this.removeAuth()
// Configure new values
2020-03-11 15:55:17 -04:00
await this.configureSsh()
await this.configureToken()
}
async configureTempGlobalConfig(repositoryPath?: string): Promise<string> {
// Already setup global config
if (this.temporaryHomePath?.length > 0) {
return path.join(this.temporaryHomePath, '.gitconfig')
}
2020-03-05 14:21:59 -05:00
// 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') {
2020-03-05 14:21:59 -05:00
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)
// Setup the workspace as a safe directory, so if we pass this into a container job with a different user it doesn't fail
// Otherwise all git commands we run in a container fail
core.info(
`Adding working directory to the temporary git global config as a safe directory`
)
await this.git
.config(
'safe.directory',
repositoryPath ?? this.settings.repositoryPath,
true,
true
2020-03-05 14:21:59 -05:00
)
.catch(error => {
core.info(`Failed to initialize safe directory with error: ${error}`)
})
return newGitConfigPath
}
2020-03-10 10:45:50 -04:00
async configureGlobalAuth(): Promise<void> {
// 'configureTempGlobalConfig' noops if already set, just returns the path
const newGitConfigPath = await this.configureTempGlobalConfig()
try {
2020-03-10 10:45:50 -04:00
// Configure the token
2020-03-05 14:21:59 -05:00
await this.configureToken(newGitConfigPath, true)
2020-03-10 10:45:50 -04:00
// Configure HTTPS instead of SSH
await this.git.tryConfigUnset(this.insteadOfKey, true)
2020-03-11 15:55:17 -04:00
if (!this.settings.sshKey) {
2021-11-01 12:43:18 -04:00
for (const insteadOfValue of this.insteadOfValues) {
await this.git.config(this.insteadOfKey, insteadOfValue, true, true)
}
2020-03-11 15:55:17 -04:00
}
2020-03-05 14:21:59 -05:00
} 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<void> {
2020-03-11 15:55:17 -04:00
// Remove possible previous HTTPS instead of SSH
await this.removeGitConfig(this.insteadOfKey, true)
2020-03-05 14:21:59 -05:00
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(
2020-03-11 15:55:17 -04:00
`git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`,
2020-03-05 14:21:59 -05:00
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}'`)
Add missing `await`s (#379) * auth-helper: properly await replacement of the token value in the config After writing the `.extraheader` config, we manually replace the token with the actual value. This is done in an `async` function, but we were not `await`ing the result. In our tests, this commit fixes a flakiness we observed where `remote.origin.url` sometimes (very rarely, actually) is not set for submodules. Our interpretation is that the configs are in the process of being rewritten with the correct token value _while_ another `git config` that wants to set the `insteadOf` value is reading the config, which is currently empty. A more idiomatic way to fix this in Typescript would use `Promise.all()`, like this: await Promise.all( configPaths.map(async configPath => { core.debug(`Replacing token placeholder in '${configPath}'`) await this.replaceTokenPlaceholder(configPath) }) ) However, during review of https://github.com/actions/checkout/pull/379 it was decided to keep the `for` loop in the interest of simplicity. Reported by Ian Lynagh. Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> * downloadRepository(): await the result of recursive deletions Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> * Ask ESLint to report floating Promises This rule is quite helpful in avoiding hard-to-debug missing `await`s. Note: there are two locations in `src/main.ts` that trigger warnings: the `run()` and the `cleanup()` function are called without `await` and without any `.catch()` clause. In the initial version of https://github.com/actions/checkout/pull/379, this was addressed by adding `.catch()` clauses. However, it was determined that this is boilerplate code that will need to be fixed in a broader way. Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de> * Rebuild This trick was brought to you by `npm ci && npm run build`. Needed to get the PR build to pass. Signed-off-by: Johannes Schindelin <johannes.schindelin@gmx.de>
2020-11-03 09:44:09 -05:00
await this.replaceTokenPlaceholder(configPath)
2020-03-05 14:21:59 -05:00
}
2020-03-11 15:55:17 -04:00
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
2021-11-01 12:43:18 -04:00
for (const insteadOfValue of this.insteadOfValues) {
await this.git.submoduleForeach(
`git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`,
this.settings.nestedSubmodules
)
}
2020-03-11 15:55:17 -04:00
}
2020-03-05 14:21:59 -05:00
}
}
async removeAuth(): Promise<void> {
2020-03-11 15:55:17 -04:00
await this.removeSsh()
await this.removeToken()
}
async removeGlobalConfig(): Promise<void> {
if (this.temporaryHomePath?.length > 0) {
core.debug(`Unsetting HOME override`)
this.git.removeEnvironmentVariable('HOME')
await io.rmRF(this.temporaryHomePath)
}
2020-03-05 14:21:59 -05:00
}
2020-03-11 15:55:17 -04:00
private async configureSsh(): Promise<void> {
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') {
2020-03-11 15:55:17 -04:00
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 AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\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(
2020-03-11 15:55:17 -04:00
this.sshKeyPath
)}"`
if (this.settings.sshStrict) {
this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
2020-03-11 15:55:17 -04:00
}
this.sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
2020-03-11 15:55:17 -04:00
this.sshKnownHostsPath
)}"`
core.info(`Temporarily overriding GIT_SSH_COMMAND=${this.sshCommand}`)
this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand)
2020-03-11 15:55:17 -04:00
// Configure core.sshCommand
if (this.settings.persistCredentials) {
await this.git.config(SSH_COMMAND_KEY, this.sshCommand)
2020-03-11 15:55:17 -04:00
}
}
2020-03-05 14:21:59 -05:00
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
2020-03-05 14:21:59 -05:00
await this.git.config(
this.tokenConfigKey,
this.tokenPlaceholderConfigValue,
globalConfig
)
2020-03-05 14:21:59 -05:00
// Replace the placeholder
await this.replaceTokenPlaceholder(configPath || '')
}
2020-03-05 14:21:59 -05:00
private async replaceTokenPlaceholder(configPath: string): Promise<void> {
assert.ok(configPath, 'configPath is not defined')
let content = (await fs.promises.readFile(configPath)).toString()
2020-03-05 14:21:59 -05:00
const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue)
if (
placeholderIndex < 0 ||
2020-03-05 14:21:59 -05:00
placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)
) {
2020-03-05 14:21:59 -05:00
throw new Error(`Unable to replace auth placeholder in ${configPath}`)
}
2020-03-05 14:21:59 -05:00
assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
content = content.replace(
2020-03-05 14:21:59 -05:00
this.tokenPlaceholderConfigValue,
this.tokenConfigValue
)
await fs.promises.writeFile(configPath, content)
}
2020-03-11 15:55:17 -04:00
private async removeSsh(): Promise<void> {
// SSH key
const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
if (keyPath) {
try {
await io.rmRF(keyPath)
} catch (err) {
core.debug(`${(err as any)?.message ?? err}`)
2020-03-11 15:55:17 -04:00
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<void> {
// HTTP extra header
2020-03-05 14:21:59 -05:00
await this.removeGitConfig(this.tokenConfigKey)
}
2020-03-11 15:55:17 -04:00
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`)
}
}
2020-03-05 14:21:59 -05:00
const pattern = regexpHelper.escape(configKey)
await this.git.submoduleForeach(
2020-03-11 15:55:17 -04:00
`git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`,
2020-03-05 14:21:59 -05:00
true
)
}
}