git-auth-helper.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. import * as assert from 'assert'
  2. import * as core from '@actions/core'
  3. import * as exec from '@actions/exec'
  4. import * as fs from 'fs'
  5. import * as io from '@actions/io'
  6. import * as os from 'os'
  7. import * as path from 'path'
  8. import * as regexpHelper from './regexp-helper'
  9. import * as stateHelper from './state-helper'
  10. import * as urlHelper from './url-helper'
  11. import {randomUUID} from 'crypto'
  12. import {IGitCommandManager} from './git-command-manager'
  13. import {IGitSourceSettings} from './git-source-settings'
  14. const IS_WINDOWS = process.platform === 'win32'
  15. const SSH_COMMAND_KEY = 'core.sshCommand'
  16. export interface IGitAuthHelper {
  17. configureAuth(): Promise<void>
  18. configureGlobalAuth(): Promise<void>
  19. configureSubmoduleAuth(): Promise<void>
  20. configureTempGlobalConfig(): Promise<string>
  21. removeAuth(): Promise<void>
  22. removeGlobalConfig(): Promise<void>
  23. }
  24. export function createAuthHelper(
  25. git: IGitCommandManager,
  26. settings?: IGitSourceSettings
  27. ): IGitAuthHelper {
  28. return new GitAuthHelper(git, settings)
  29. }
  30. class GitAuthHelper {
  31. private readonly git: IGitCommandManager
  32. private readonly settings: IGitSourceSettings
  33. private readonly tokenConfigKey: string
  34. private readonly tokenConfigValue: string
  35. private readonly tokenPlaceholderConfigValue: string
  36. private readonly insteadOfKey: string
  37. private readonly insteadOfValues: string[] = []
  38. private sshCommand = ''
  39. private sshKeyPath = ''
  40. private sshKnownHostsPath = ''
  41. private temporaryHomePath = ''
  42. private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP
  43. constructor(
  44. gitCommandManager: IGitCommandManager,
  45. gitSourceSettings: IGitSourceSettings | undefined
  46. ) {
  47. this.git = gitCommandManager
  48. this.settings = gitSourceSettings || ({} as unknown as IGitSourceSettings)
  49. // Token auth header
  50. const serverUrl = urlHelper.getServerUrl(this.settings.githubServerUrl)
  51. this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader` // "origin" is SCHEME://HOSTNAME[:PORT]
  52. const basicCredential = Buffer.from(
  53. `x-access-token:${this.settings.authToken}`,
  54. 'utf8'
  55. ).toString('base64')
  56. core.setSecret(basicCredential)
  57. this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***`
  58. this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}`
  59. // Instead of SSH URL
  60. this.insteadOfKey = `url.${serverUrl.origin}/.insteadOf` // "origin" is SCHEME://HOSTNAME[:PORT]
  61. this.insteadOfValues.push(`git@${serverUrl.hostname}:`)
  62. if (this.settings.workflowOrganizationId) {
  63. this.insteadOfValues.push(
  64. `org-${this.settings.workflowOrganizationId}@github.com:`
  65. )
  66. }
  67. }
  68. async configureAuth(): Promise<void> {
  69. // Remove possible previous values
  70. await this.removeAuth()
  71. // Configure new values
  72. await this.configureSsh()
  73. await this.configureToken()
  74. }
  75. async configureTempGlobalConfig(): Promise<string> {
  76. // Already setup global config
  77. if (this.temporaryHomePath?.length > 0) {
  78. return path.join(this.temporaryHomePath, '.gitconfig')
  79. }
  80. // Create a temp home directory
  81. const runnerTemp = process.env['RUNNER_TEMP'] || ''
  82. assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
  83. const uniqueId = randomUUID()
  84. this.temporaryHomePath = path.join(runnerTemp, uniqueId)
  85. await fs.promises.mkdir(this.temporaryHomePath, {recursive: true})
  86. // Copy the global git config
  87. const gitConfigPath = path.join(
  88. process.env['HOME'] || os.homedir(),
  89. '.gitconfig'
  90. )
  91. const newGitConfigPath = path.join(this.temporaryHomePath, '.gitconfig')
  92. let configExists = false
  93. try {
  94. await fs.promises.stat(gitConfigPath)
  95. configExists = true
  96. } catch (err) {
  97. if ((err as any)?.code !== 'ENOENT') {
  98. throw err
  99. }
  100. }
  101. if (configExists) {
  102. core.info(`Copying '${gitConfigPath}' to '${newGitConfigPath}'`)
  103. await io.cp(gitConfigPath, newGitConfigPath)
  104. } else {
  105. await fs.promises.writeFile(newGitConfigPath, '')
  106. }
  107. // Override HOME
  108. core.info(
  109. `Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes`
  110. )
  111. this.git.setEnvironmentVariable('HOME', this.temporaryHomePath)
  112. return newGitConfigPath
  113. }
  114. async configureGlobalAuth(): Promise<void> {
  115. // 'configureTempGlobalConfig' noops if already set, just returns the path
  116. await this.configureTempGlobalConfig()
  117. try {
  118. // Configure the token
  119. await this.configureToken(true)
  120. // Configure HTTPS instead of SSH
  121. await this.git.tryConfigUnset(this.insteadOfKey, true)
  122. if (!this.settings.sshKey) {
  123. for (const insteadOfValue of this.insteadOfValues) {
  124. await this.git.config(
  125. this.insteadOfKey,
  126. insteadOfValue,
  127. true, // globalConfig?
  128. true // add?
  129. )
  130. }
  131. }
  132. } catch (err) {
  133. // Unset in case somehow written to the real global config
  134. core.info(
  135. 'Encountered an error when attempting to configure token. Attempting unconfigure.'
  136. )
  137. await this.git.tryConfigUnset(this.tokenConfigKey, true)
  138. throw err
  139. }
  140. }
  141. async configureSubmoduleAuth(): Promise<void> {
  142. // Remove possible previous HTTPS instead of SSH
  143. await this.removeSubmoduleGitConfig(this.insteadOfKey)
  144. if (this.settings.persistCredentials) {
  145. // Get the credentials config file path in RUNNER_TEMP
  146. const credentialsConfigPath = this.getCredentialsConfigPath()
  147. // Container credentials config path
  148. const containerCredentialsPath = path.posix.join(
  149. '/github/runner_temp',
  150. path.basename(credentialsConfigPath)
  151. )
  152. // Get submodule config file paths.
  153. const configPaths = await this.git.getSubmoduleConfigPaths(
  154. this.settings.nestedSubmodules
  155. )
  156. // For each submodule, configure includeIf entries pointing to the shared credentials file.
  157. // Configure both host and container paths to support Docker container actions.
  158. for (const configPath of configPaths) {
  159. // Submodule Git directory
  160. let submoduleGitDir = path.dirname(configPath) // The config file is at .git/modules/submodule-name/config
  161. submoduleGitDir = submoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
  162. // Configure host includeIf
  163. await this.git.config(
  164. `includeIf.gitdir:${submoduleGitDir}.path`,
  165. credentialsConfigPath,
  166. false, // globalConfig?
  167. false, // add?
  168. configPath
  169. )
  170. // Container submodule git directory
  171. const githubWorkspace = process.env['GITHUB_WORKSPACE']
  172. assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
  173. let relativeSubmoduleGitDir = path.relative(
  174. githubWorkspace,
  175. submoduleGitDir
  176. )
  177. relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
  178. const containerSubmoduleGitDir = path.posix.join(
  179. '/github/workspace',
  180. relativeSubmoduleGitDir
  181. )
  182. // Configure container includeIf
  183. await this.git.config(
  184. `includeIf.gitdir:${containerSubmoduleGitDir}.path`,
  185. containerCredentialsPath,
  186. false, // globalConfig?
  187. false, // add?
  188. configPath
  189. )
  190. }
  191. if (this.settings.sshKey) {
  192. // Configure core.sshCommand
  193. await this.git.submoduleForeach(
  194. `git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`,
  195. this.settings.nestedSubmodules
  196. )
  197. } else {
  198. // Configure HTTPS instead of SSH
  199. for (const insteadOfValue of this.insteadOfValues) {
  200. await this.git.submoduleForeach(
  201. `git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`,
  202. this.settings.nestedSubmodules
  203. )
  204. }
  205. }
  206. }
  207. }
  208. async removeAuth(): Promise<void> {
  209. await this.removeSsh()
  210. await this.removeToken()
  211. }
  212. async removeGlobalConfig(): Promise<void> {
  213. if (this.temporaryHomePath?.length > 0) {
  214. core.debug(`Unsetting HOME override`)
  215. this.git.removeEnvironmentVariable('HOME')
  216. await io.rmRF(this.temporaryHomePath)
  217. }
  218. }
  219. /**
  220. * Configures SSH authentication by writing the SSH key and known hosts,
  221. * and setting up the GIT_SSH_COMMAND environment variable.
  222. */
  223. private async configureSsh(): Promise<void> {
  224. if (!this.settings.sshKey) {
  225. return
  226. }
  227. // Write key
  228. const runnerTemp = process.env['RUNNER_TEMP'] || ''
  229. assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
  230. const uniqueId = randomUUID()
  231. this.sshKeyPath = path.join(runnerTemp, uniqueId)
  232. stateHelper.setSshKeyPath(this.sshKeyPath)
  233. await fs.promises.mkdir(runnerTemp, {recursive: true})
  234. await fs.promises.writeFile(
  235. this.sshKeyPath,
  236. this.settings.sshKey.trim() + '\n',
  237. {mode: 0o600}
  238. )
  239. // Remove inherited permissions on Windows
  240. if (IS_WINDOWS) {
  241. const icacls = await io.which('icacls.exe')
  242. await exec.exec(
  243. `"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`
  244. )
  245. await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`)
  246. }
  247. // Write known hosts
  248. const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts')
  249. let userKnownHosts = ''
  250. try {
  251. userKnownHosts = (
  252. await fs.promises.readFile(userKnownHostsPath)
  253. ).toString()
  254. } catch (err) {
  255. if ((err as any)?.code !== 'ENOENT') {
  256. throw err
  257. }
  258. }
  259. let knownHosts = ''
  260. if (userKnownHosts) {
  261. knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`
  262. }
  263. if (this.settings.sshKnownHosts) {
  264. knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`
  265. }
  266. 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`
  267. this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`)
  268. stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath)
  269. await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts)
  270. // Configure GIT_SSH_COMMAND
  271. const sshPath = await io.which('ssh', true)
  272. this.sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
  273. this.sshKeyPath
  274. )}"`
  275. if (this.settings.sshStrict) {
  276. this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
  277. }
  278. this.sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
  279. this.sshKnownHostsPath
  280. )}"`
  281. core.info(`Temporarily overriding GIT_SSH_COMMAND=${this.sshCommand}`)
  282. this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand)
  283. // Configure core.sshCommand
  284. if (this.settings.persistCredentials) {
  285. await this.git.config(SSH_COMMAND_KEY, this.sshCommand)
  286. }
  287. }
  288. /**
  289. * Configures token-based authentication by creating a credentials config file
  290. * and setting up includeIf entries to reference it.
  291. * @param globalConfig Whether to configure global config instead of local
  292. */
  293. private async configureToken(globalConfig?: boolean): Promise<void> {
  294. // Get the credentials config file path in RUNNER_TEMP
  295. const credentialsConfigPath = this.getCredentialsConfigPath()
  296. // Write placeholder to the separate credentials config file using git config.
  297. // This approach avoids the credential being captured by process creation audit events,
  298. // which are commonly logged. For more information, refer to
  299. // https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
  300. await this.git.config(
  301. this.tokenConfigKey,
  302. this.tokenPlaceholderConfigValue,
  303. false, // globalConfig?
  304. false, // add?
  305. credentialsConfigPath
  306. )
  307. // Replace the placeholder in the credentials config file
  308. let content = (await fs.promises.readFile(credentialsConfigPath)).toString()
  309. const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue)
  310. if (
  311. placeholderIndex < 0 ||
  312. placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)
  313. ) {
  314. throw new Error(
  315. `Unable to replace auth placeholder in ${credentialsConfigPath}`
  316. )
  317. }
  318. assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
  319. content = content.replace(
  320. this.tokenPlaceholderConfigValue,
  321. this.tokenConfigValue
  322. )
  323. await fs.promises.writeFile(credentialsConfigPath, content)
  324. // Add include or includeIf to reference the credentials config
  325. if (globalConfig) {
  326. // Global config file is temporary
  327. await this.git.config(
  328. 'include.path',
  329. credentialsConfigPath,
  330. true // globalConfig?
  331. )
  332. } else {
  333. // Host git directory
  334. let gitDir = path.join(this.git.getWorkingDirectory(), '.git')
  335. gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows
  336. // Configure host includeIf
  337. const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
  338. await this.git.config(hostIncludeKey, credentialsConfigPath)
  339. // Configure host includeIf for worktrees
  340. const hostWorktreeIncludeKey = `includeIf.gitdir:${gitDir}/worktrees/*.path`
  341. await this.git.config(hostWorktreeIncludeKey, credentialsConfigPath)
  342. // Container git directory
  343. const workingDirectory = this.git.getWorkingDirectory()
  344. const githubWorkspace = process.env['GITHUB_WORKSPACE']
  345. assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined')
  346. let relativePath = path.relative(githubWorkspace, workingDirectory)
  347. relativePath = relativePath.replace(/\\/g, '/') // Use forward slashes, even on Windows
  348. const containerGitDir = path.posix.join(
  349. '/github/workspace',
  350. relativePath,
  351. '.git'
  352. )
  353. // Container credentials config path
  354. const containerCredentialsPath = path.posix.join(
  355. '/github/runner_temp',
  356. path.basename(credentialsConfigPath)
  357. )
  358. // Configure container includeIf
  359. const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
  360. await this.git.config(containerIncludeKey, containerCredentialsPath)
  361. // Configure container includeIf for worktrees
  362. const containerWorktreeIncludeKey = `includeIf.gitdir:${containerGitDir}/worktrees/*.path`
  363. await this.git.config(
  364. containerWorktreeIncludeKey,
  365. containerCredentialsPath
  366. )
  367. }
  368. }
  369. /**
  370. * Gets or creates the path to the credentials config file in RUNNER_TEMP.
  371. * @returns The absolute path to the credentials config file
  372. */
  373. private getCredentialsConfigPath(): string {
  374. if (this.credentialsConfigPath) {
  375. return this.credentialsConfigPath
  376. }
  377. const runnerTemp = process.env['RUNNER_TEMP'] || ''
  378. assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
  379. // Create a unique filename for this checkout instance
  380. const configFileName = `git-credentials-${randomUUID()}.config`
  381. this.credentialsConfigPath = path.join(runnerTemp, configFileName)
  382. core.debug(`Credentials config path: ${this.credentialsConfigPath}`)
  383. return this.credentialsConfigPath
  384. }
  385. /**
  386. * Removes SSH authentication configuration by cleaning up SSH keys,
  387. * known hosts files, and SSH command configurations.
  388. */
  389. private async removeSsh(): Promise<void> {
  390. // SSH key
  391. const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
  392. if (keyPath) {
  393. try {
  394. core.info(`Removing SSH key '${keyPath}'`)
  395. await io.rmRF(keyPath)
  396. } catch (err) {
  397. core.debug(`${(err as any)?.message ?? err}`)
  398. core.warning(`Failed to remove SSH key '${keyPath}'`)
  399. }
  400. }
  401. // SSH known hosts
  402. const knownHostsPath =
  403. this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
  404. if (knownHostsPath) {
  405. try {
  406. core.info(`Removing SSH known hosts '${knownHostsPath}'`)
  407. await io.rmRF(knownHostsPath)
  408. } catch (err) {
  409. core.debug(`${(err as any)?.message ?? err}`)
  410. core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`)
  411. }
  412. }
  413. // SSH command
  414. core.info('Removing SSH command configuration')
  415. await this.removeGitConfig(SSH_COMMAND_KEY)
  416. await this.removeSubmoduleGitConfig(SSH_COMMAND_KEY)
  417. }
  418. /**
  419. * Removes token-based authentication by cleaning up HTTP headers,
  420. * includeIf entries, and credentials config files.
  421. */
  422. private async removeToken(): Promise<void> {
  423. // Remove HTTP extra header
  424. core.info('Removing HTTP extra header')
  425. await this.removeGitConfig(this.tokenConfigKey)
  426. await this.removeSubmoduleGitConfig(this.tokenConfigKey)
  427. // Collect credentials config paths that need to be removed
  428. const credentialsPaths = new Set<string>()
  429. // Remove includeIf entries that point to git-credentials-*.config files
  430. core.info('Removing includeIf entries pointing to credentials config files')
  431. const mainCredentialsPaths = await this.removeIncludeIfCredentials()
  432. mainCredentialsPaths.forEach(path => credentialsPaths.add(path))
  433. // Remove submodule includeIf entries that point to git-credentials-*.config files
  434. const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true)
  435. for (const configPath of submoduleConfigPaths) {
  436. const submoduleCredentialsPaths =
  437. await this.removeIncludeIfCredentials(configPath)
  438. submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path))
  439. }
  440. // Remove credentials config files
  441. for (const credentialsPath of credentialsPaths) {
  442. // Only remove credentials config files if they are under RUNNER_TEMP
  443. const runnerTemp = process.env['RUNNER_TEMP']
  444. assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
  445. if (credentialsPath.startsWith(runnerTemp)) {
  446. try {
  447. core.info(`Removing credentials config '${credentialsPath}'`)
  448. await io.rmRF(credentialsPath)
  449. } catch (err) {
  450. core.debug(`${(err as any)?.message ?? err}`)
  451. core.warning(
  452. `Failed to remove credentials config '${credentialsPath}'`
  453. )
  454. }
  455. } else {
  456. core.debug(
  457. `Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP`
  458. )
  459. }
  460. }
  461. }
  462. /**
  463. * Removes a git config key from the local repository config.
  464. * @param configKey The git config key to remove
  465. */
  466. private async removeGitConfig(configKey: string): Promise<void> {
  467. if (
  468. (await this.git.configExists(configKey)) &&
  469. !(await this.git.tryConfigUnset(configKey))
  470. ) {
  471. // Load the config contents
  472. core.warning(`Failed to remove '${configKey}' from the git config`)
  473. }
  474. }
  475. /**
  476. * Removes a git config key from all submodule configs.
  477. * @param configKey The git config key to remove
  478. */
  479. private async removeSubmoduleGitConfig(configKey: string): Promise<void> {
  480. const pattern = regexpHelper.escape(configKey)
  481. await this.git.submoduleForeach(
  482. // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline.
  483. `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`,
  484. true
  485. )
  486. }
  487. /**
  488. * Removes includeIf entries that point to git-credentials-*.config files.
  489. * @param configPath Optional path to a specific git config file to operate on
  490. * @returns Array of unique credentials config file paths that were found and removed
  491. */
  492. private async removeIncludeIfCredentials(
  493. configPath?: string
  494. ): Promise<string[]> {
  495. const credentialsPaths = new Set<string>()
  496. try {
  497. // Get all includeIf.gitdir keys
  498. const keys = await this.git.tryGetConfigKeys(
  499. '^includeIf\\.gitdir:',
  500. false, // globalConfig?
  501. configPath
  502. )
  503. for (const key of keys) {
  504. // Get all values for this key
  505. const values = await this.git.tryGetConfigValues(
  506. key,
  507. false, // globalConfig?
  508. configPath
  509. )
  510. if (values.length > 0) {
  511. // Remove only values that match git-credentials-<uuid>.config pattern
  512. for (const value of values) {
  513. if (this.testCredentialsConfigPath(value)) {
  514. credentialsPaths.add(value)
  515. await this.git.tryConfigUnsetValue(key, value, false, configPath)
  516. }
  517. }
  518. }
  519. }
  520. } catch (err) {
  521. // Ignore errors - this is cleanup code
  522. if (configPath) {
  523. core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`)
  524. } else {
  525. core.debug(`Error during includeIf cleanup: ${err}`)
  526. }
  527. }
  528. return Array.from(credentialsPaths)
  529. }
  530. /**
  531. * Tests if a path matches the git-credentials-*.config pattern.
  532. * @param path The path to test
  533. * @returns True if the path matches the credentials config pattern
  534. */
  535. private testCredentialsConfigPath(path: string): boolean {
  536. return /git-credentials-[0-9a-f-]+\.config$/i.test(path)
  537. }
  538. }