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.js'
import * as stateHelper from './state-helper.js'
import * as urlHelper from './url-helper.js'
import {randomUUID} from 'crypto'
import {IGitCommandManager} from './git-command-manager.js'
import {IGitSourceSettings} from './git-source-settings.js'
const IS_WINDOWS = process.platform === 'win32'
const SSH_COMMAND_KEY = 'core.sshCommand'
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 = ''
private credentialsConfigPath = ''
constructor(
gitCommandManager: IGitCommandManager,
gitSourceSettings: IGitSourceSettings | undefined
) {
this.git = gitCommandManager
this.settings = gitSourceSettings || ({} as unknown as IGitSourceSettings)
const serverUrl = urlHelper.getServerUrl(this.settings.githubServerUrl)
this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader`
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}`
this.insteadOfKey = `url.${serverUrl.origin}/.insteadOf`
this.insteadOfValues.push(`git@${serverUrl.hostname}:`)
if (this.settings.workflowOrganizationId) {
this.insteadOfValues.push(
`org-${this.settings.workflowOrganizationId}@github.com:`
)
}
}
async configureAuth(): Promise<void> {
await this.removeAuth()
await this.configureSsh()
await this.configureToken()
}
async configureTempGlobalConfig(): Promise<string> {
if (this.temporaryHomePath?.length > 0) {
return path.join(this.temporaryHomePath, '.gitconfig')
}
const runnerTemp = process.env['RUNNER_TEMP'] || ''
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
const uniqueId = randomUUID()
this.temporaryHomePath = path.join(runnerTemp, uniqueId)
await fs.promises.mkdir(this.temporaryHomePath, {recursive: true})
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, '')
}
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> {
await this.configureTempGlobalConfig()
try {
await this.configureToken(true)
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) {
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> {
await this.removeSubmoduleGitConfig(this.insteadOfKey)
if (this.settings.persistCredentials) {
const credentialsConfigPath = this.getCredentialsConfigPath()
const containerCredentialsPath = path.posix.join(
'/github/runner_temp',
path.basename(credentialsConfigPath)
)
const configPaths = await this.git.getSubmoduleConfigPaths(
this.settings.nestedSubmodules
)
for (const configPath of configPaths) {
let submoduleGitDir = path.dirname(configPath)
submoduleGitDir = submoduleGitDir.replace(/\\/g, '/')
await this.git.config(
`includeIf.gitdir:${submoduleGitDir}.path`,
credentialsConfigPath,
false,
false,
configPath
)
const githubWorkspace = process.env['GITHUB_WORKSPACE']
assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
let relativeSubmoduleGitDir = path.relative(
githubWorkspace,
submoduleGitDir
)
relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/')
const containerSubmoduleGitDir = path.posix.join(
'/github/workspace',
relativeSubmoduleGitDir
)
await this.git.config(
`includeIf.gitdir:${containerSubmoduleGitDir}.path`,
containerCredentialsPath,
false,
false,
configPath
)
}
if (this.settings.sshKey) {
await this.git.submoduleForeach(
`git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`,
this.settings.nestedSubmodules
)
} else {
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)
}
}
* 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
}
const runnerTemp = process.env['RUNNER_TEMP'] || ''
assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
const uniqueId = randomUUID()
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}
)
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`)
}
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)
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)
if (this.settings.persistCredentials) {
await this.git.config(SSH_COMMAND_KEY, this.sshCommand)
}
}
* 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> {
const credentialsConfigPath = this.getCredentialsConfigPath()
await this.git.config(
this.tokenConfigKey,
this.tokenPlaceholderConfigValue,
false,
false,
credentialsConfigPath
)
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 ${credentialsConfigPath}`
)
}
assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
content = content.replace(
this.tokenPlaceholderConfigValue,
this.tokenConfigValue
)
await fs.promises.writeFile(credentialsConfigPath, content)
if (globalConfig) {
await this.git.config(
'include.path',
credentialsConfigPath,
true
)
} else {
let gitDir = path.join(this.git.getWorkingDirectory(), '.git')
gitDir = gitDir.replace(/\\/g, '/')
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
await this.git.config(hostIncludeKey, credentialsConfigPath)
const hostWorktreeIncludeKey = `includeIf.gitdir:${gitDir}/worktrees/*.path`
await this.git.config(hostWorktreeIncludeKey, credentialsConfigPath)
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, '/')
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)
const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path`
await this.git.config(
containerWorktreeIncludeKey,
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')
const configFileName = `git-credentials-${randomUUID()}.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> {
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}`)
core.warning(`Failed to remove SSH key '${keyPath}'`)
}
}
const knownHostsPath =
this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
if (knownHostsPath) {
try {
core.info(`Removing SSH known hosts '${knownHostsPath}'`)
await io.rmRF(knownHostsPath)
} catch (err) {
core.debug(`${(err as any)?.message ?? err}`)
core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`)
}
}
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> {
core.info('Removing HTTP extra header')
await this.removeGitConfig(this.tokenConfigKey)
await this.removeSubmoduleGitConfig(this.tokenConfigKey)
const credentialsPaths = new Set<string>()
core.info('Removing includeIf entries pointing to credentials config files')
const mainCredentialsPaths = await this.removeIncludeIfCredentials()
mainCredentialsPaths.forEach(path => credentialsPaths.add(path))
const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true)
for (const configPath of submoduleConfigPaths) {
const submoduleCredentialsPaths =
await this.removeIncludeIfCredentials(configPath)
submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
}
for (const credentialsPath of credentialsPaths) {
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))
) {
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(
`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 {
const keys = await this.git.tryGetConfigKeys(
'^includeIf\\.gitdir:',
false,
configPath
)
for (const key of keys) {
const values = await this.git.tryGetConfigValues(
key,
false,
configPath
)
if (values.length > 0) {
for (const value of values) {
if (this.testCredentialsConfigPath(value)) {
credentialsPaths.add(value)
await this.git.tryConfigUnsetValue(key, value, false, configPath)
}
}
}
}
} catch (err) {
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)
}
}