checkout/src/git-auth-helper.ts
2023-04-09 21:14:37 +02:00

376 lines
13 KiB
TypeScript

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 {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 SSH_COMMAND_KEY = 'core.sshCommand'
const SIGNING_KEY = 'user.signingKey'
export interface IGitAuthHelper {
configureAuth(): Promise<void>
configureGlobalAuth(): Promise<void>
configureSubmoduleAuth(): Promise<void>
configureTempGlobalConfig(): Promise<string>
removeAuth(): Promise<void>
removeGlobalConfig(): Promise<void>
}
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 = ''
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<void> {
// Remove possible previous values
await this.removeAuth()
// Configure new values
await this.configureSsh()
await this.configureToken()
}
async configureTempGlobalConfig(): Promise<string> {
// 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<void> {
// 'configureTempGlobalConfig' noops if already set, just returns the path
const newGitConfigPath = await this.configureTempGlobalConfig()
try {
// Configure the token
await this.configureToken(newGitConfigPath, 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<void> {
// Remove possible previous HTTPS instead of SSH
await this.removeGitConfig(this.insteadOfKey, true)
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"`,
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<void> {
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)
}
}
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') {
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)
await this.git.config(SIGNING_KEY, this.sshKeyPath)
}
}
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
await this.git.config(
this.tokenConfigKey,
this.tokenPlaceholderConfigValue,
globalConfig
)
// 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()
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<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}`)
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
await this.removeGitConfig(this.tokenConfigKey)
}
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`)
}
}
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
)
}
}