Read-only mirror — the public repositories of the Forgejo instance, refreshed every 10 minutes. The live instance (fg.dieschoenewolke.de) is reachable only over the VPN; clone here with https://git.dieschoenewolke.de/git/<owner>/<repo>.git.
summaryrefslogtreecommitdiff
path: root/src/git-auth-helper.ts
diff options
context:
space:
mode:
authoreric sciple <ericsciple@users.noreply.github.com>2025-11-03 13:08:38 -0600
committerGitHub <noreply@github.com>2025-11-03 13:08:38 -0600
commitff20ae9cd9d5fc6ce99d108d89ea013d4c12f489 (patch)
treed75c06690a2192c6fdeacc5da6222a0683ab81d6 /src/git-auth-helper.ts
parent33f3caae0058b205c722b2415877b66f0b33d1d9 (diff)
Persist creds to a separate file (#2286)
Diffstat (limited to 'src/git-auth-helper.ts')
-rw-r--r--src/git-auth-helper.ts332
1 files changed, 273 insertions, 59 deletions
diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts
index 126e8e5..a1950a6 100644
--- a/src/git-auth-helper.ts
+++ b/src/git-auth-helper.ts
@@ -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'
- )
-
- // 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
+ /**
+ * 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()
+
+ // 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)
+
+ // 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))
+ }
- 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`)
+ // 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)
+ }
}