mirror of
https://github.com/actions/checkout.git
synced 2025-10-19 14:29:02 +08:00
Persist creds to a separate file
This commit is contained in:
@@ -43,6 +43,7 @@ class GitAuthHelper {
|
||||
private sshKeyPath = ''
|
||||
private sshKnownHostsPath = ''
|
||||
private temporaryHomePath = ''
|
||||
private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP
|
||||
|
||||
constructor(
|
||||
gitCommandManager: IGitCommandManager,
|
||||
@@ -126,16 +127,21 @@ class GitAuthHelper {
|
||||
|
||||
async configureGlobalAuth(): Promise<void> {
|
||||
// 'configureTempGlobalConfig' noops if already set, just returns the path
|
||||
const newGitConfigPath = await this.configureTempGlobalConfig()
|
||||
await this.configureTempGlobalConfig()
|
||||
try {
|
||||
// Configure the token
|
||||
await this.configureToken(newGitConfigPath, true)
|
||||
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)
|
||||
await this.git.config(
|
||||
this.insteadOfKey,
|
||||
insteadOfValue,
|
||||
true, // globalConfig?
|
||||
true // add?
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -150,24 +156,60 @@ class GitAuthHelper {
|
||||
|
||||
async configureSubmoduleAuth(): Promise<void> {
|
||||
// Remove possible previous HTTPS instead of SSH
|
||||
await this.removeGitConfig(this.insteadOfKey, true)
|
||||
await this.removeSubmoduleGitConfig(this.insteadOfKey)
|
||||
|
||||
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"`,
|
||||
// Get the credentials config file path in RUNNER_TEMP
|
||||
const credentialsConfigPath = this.getCredentialsConfigPath()
|
||||
|
||||
// Container credentials config path
|
||||
const containerCredentialsPath = path.posix.join(
|
||||
'/github/runner_temp',
|
||||
path.basename(credentialsConfigPath)
|
||||
)
|
||||
|
||||
// Get submodule config file paths.
|
||||
const configPaths = await this.git.getSubmoduleConfigPaths(
|
||||
this.settings.nestedSubmodules
|
||||
)
|
||||
|
||||
// Replace the placeholder
|
||||
const configPaths: string[] =
|
||||
output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
|
||||
// For each submodule, configure includeIf entries pointing to the shared credentials file.
|
||||
// Configure both host and container paths to support Docker container actions.
|
||||
for (const configPath of configPaths) {
|
||||
core.debug(`Replacing token placeholder in '${configPath}'`)
|
||||
await this.replaceTokenPlaceholder(configPath)
|
||||
// Submodule Git directory
|
||||
let submoduleGitDir = path.dirname(configPath) // The config file is at .git/modules/submodule-name/config
|
||||
submoduleGitDir = submoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
|
||||
|
||||
// Configure host includeIf
|
||||
await this.git.config(
|
||||
`includeIf.gitdir:${submoduleGitDir}.path`,
|
||||
credentialsConfigPath,
|
||||
false, // globalConfig?
|
||||
false, // add?
|
||||
configPath
|
||||
)
|
||||
|
||||
// Container submodule git directory
|
||||
const githubWorkspace = process.env['GITHUB_WORKSPACE']
|
||||
assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
|
||||
let relativeSubmoduleGitDir = path.relative(
|
||||
githubWorkspace,
|
||||
submoduleGitDir
|
||||
)
|
||||
relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
|
||||
const containerSubmoduleGitDir = path.posix.join(
|
||||
'/github/workspace',
|
||||
relativeSubmoduleGitDir
|
||||
)
|
||||
|
||||
// Configure container includeIf
|
||||
await this.git.config(
|
||||
`includeIf.gitdir:${containerSubmoduleGitDir}.path`,
|
||||
containerCredentialsPath,
|
||||
false, // globalConfig?
|
||||
false, // add?
|
||||
configPath
|
||||
)
|
||||
}
|
||||
|
||||
if (this.settings.sshKey) {
|
||||
@@ -201,6 +243,10 @@ class GitAuthHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -272,57 +318,116 @@ class GitAuthHelper {
|
||||
}
|
||||
}
|
||||
|
||||
private async configureToken(
|
||||
configPath?: string,
|
||||
globalConfig?: boolean
|
||||
): Promise<void> {
|
||||
// Validate args
|
||||
assert.ok(
|
||||
(configPath && globalConfig) || (!configPath && !globalConfig),
|
||||
'Unexpected configureToken parameter combinations'
|
||||
)
|
||||
/**
|
||||
* 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> {
|
||||
// Get the credentials config file path in RUNNER_TEMP
|
||||
const credentialsConfigPath = this.getCredentialsConfigPath()
|
||||
|
||||
// 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
|
||||
// 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,
|
||||
globalConfig
|
||||
false, // globalConfig?
|
||||
false, // add?
|
||||
credentialsConfigPath
|
||||
)
|
||||
|
||||
// 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()
|
||||
// Replace the placeholder in the credentials config file
|
||||
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 ${configPath}`)
|
||||
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(configPath, content)
|
||||
await fs.promises.writeFile(credentialsConfigPath, content)
|
||||
|
||||
// Add include or includeIf to reference the credentials config
|
||||
if (globalConfig) {
|
||||
// Global config file is temporary
|
||||
await this.git.config(
|
||||
'include.path',
|
||||
credentialsConfigPath,
|
||||
true // globalConfig?
|
||||
)
|
||||
} else {
|
||||
// Host git directory
|
||||
let gitDir = path.join(this.git.getWorkingDirectory(), '.git')
|
||||
gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
|
||||
|
||||
// Configure host includeIf
|
||||
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
|
||||
await this.git.config(hostIncludeKey, credentialsConfigPath)
|
||||
|
||||
// Container git directory
|
||||
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, '/') // Use forward slashes, even on Windows
|
||||
const containerGitDir = path.posix.join(
|
||||
'/github/workspace',
|
||||
relativePath,
|
||||
'.git'
|
||||
)
|
||||
|
||||
// Container credentials config path
|
||||
const containerCredentialsPath = path.posix.join(
|
||||
'/github/runner_temp',
|
||||
path.basename(credentialsConfigPath)
|
||||
)
|
||||
|
||||
// Configure container includeIf
|
||||
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
|
||||
await this.git.config(containerIncludeKey, 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')
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes SSH authentication configuration by cleaning up SSH keys,
|
||||
* known hosts files, and SSH command configurations.
|
||||
*/
|
||||
private async removeSsh(): Promise<void> {
|
||||
// SSH key
|
||||
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}`)
|
||||
@@ -335,40 +440,149 @@ class GitAuthHelper {
|
||||
this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
|
||||
if (knownHostsPath) {
|
||||
try {
|
||||
core.info(`Removing SSH known hosts '${knownHostsPath}'`)
|
||||
await io.rmRF(knownHostsPath)
|
||||
} catch {
|
||||
// Intentionally empty
|
||||
} catch (err) {
|
||||
core.debug(`${(err as any)?.message ?? err}`)
|
||||
core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`)
|
||||
}
|
||||
}
|
||||
|
||||
// SSH command
|
||||
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> {
|
||||
// HTTP extra header
|
||||
// Remove HTTP extra header
|
||||
core.info('Removing HTTP extra header')
|
||||
await this.removeGitConfig(this.tokenConfigKey)
|
||||
}
|
||||
await this.removeSubmoduleGitConfig(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`)
|
||||
}
|
||||
// Collect credentials config paths that need to be removed
|
||||
const credentialsPaths = new Set<string>()
|
||||
|
||||
// Remove includeIf entries that point to git-credentials-*.config files
|
||||
core.info('Removing includeIf entries pointing to credentials config files')
|
||||
const mainCredentialsPaths = await this.removeIncludeIfCredentials()
|
||||
mainCredentialsPaths.forEach(path => credentialsPaths.add(path))
|
||||
|
||||
// Remove submodule includeIf entries that point to git-credentials-*.config files
|
||||
const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true)
|
||||
for (const configPath of submoduleConfigPaths) {
|
||||
const submoduleCredentialsPaths =
|
||||
await this.removeIncludeIfCredentials(configPath)
|
||||
submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
|
||||
}
|
||||
|
||||
// Remove credentials config files
|
||||
for (const credentialsPath of credentialsPaths) {
|
||||
// Only remove credentials config files if they are under RUNNER_TEMP
|
||||
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))
|
||||
) {
|
||||
// Load the config contents
|
||||
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(
|
||||
// wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
// Get all includeIf.gitdir keys
|
||||
const keys = await this.git.tryGetConfigKeys(
|
||||
'^includeIf\\.gitdir:',
|
||||
false, // globalConfig?
|
||||
configPath
|
||||
)
|
||||
|
||||
for (const key of keys) {
|
||||
// Get all values for this key
|
||||
const values = await this.git.tryGetConfigValues(
|
||||
key,
|
||||
false, // globalConfig?
|
||||
configPath
|
||||
)
|
||||
if (values.length > 0) {
|
||||
// Remove only values that match git-credentials-<uuid>.config pattern
|
||||
for (const value of values) {
|
||||
if (this.testCredentialsConfigPath(value)) {
|
||||
credentialsPaths.add(value)
|
||||
await this.git.tryConfigUnsetValue(key, value, false, configPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors - this is cleanup code
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user