git-auth-helper.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  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 {v4 as uuid} from 'uuid'
  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. constructor(
  43. gitCommandManager: IGitCommandManager,
  44. gitSourceSettings: IGitSourceSettings | undefined
  45. ) {
  46. this.git = gitCommandManager
  47. this.settings = gitSourceSettings || ({} as unknown as IGitSourceSettings)
  48. // Token auth header
  49. const serverUrl = urlHelper.getServerUrl(this.settings.githubServerUrl)
  50. this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader` // "origin" is SCHEME://HOSTNAME[:PORT]
  51. const basicCredential = Buffer.from(
  52. `x-access-token:${this.settings.authToken}`,
  53. 'utf8'
  54. ).toString('base64')
  55. core.setSecret(basicCredential)
  56. this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***`
  57. this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}`
  58. // Instead of SSH URL
  59. this.insteadOfKey = `url.${serverUrl.origin}/.insteadOf` // "origin" is SCHEME://HOSTNAME[:PORT]
  60. this.insteadOfValues.push(`git@${serverUrl.hostname}:`)
  61. if (this.settings.workflowOrganizationId) {
  62. this.insteadOfValues.push(
  63. `org-${this.settings.workflowOrganizationId}@github.com:`
  64. )
  65. }
  66. }
  67. async configureAuth(): Promise<void> {
  68. // Remove possible previous values
  69. await this.removeAuth()
  70. // Configure new values
  71. await this.configureSsh()
  72. await this.configureToken()
  73. }
  74. async configureTempGlobalConfig(): Promise<string> {
  75. // Already setup global config
  76. if (this.temporaryHomePath?.length > 0) {
  77. return path.join(this.temporaryHomePath, '.gitconfig')
  78. }
  79. // Create a temp home directory
  80. const runnerTemp = process.env['RUNNER_TEMP'] || ''
  81. assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
  82. const uniqueId = uuid()
  83. this.temporaryHomePath = path.join(runnerTemp, uniqueId)
  84. await fs.promises.mkdir(this.temporaryHomePath, {recursive: true})
  85. // Copy the global git config
  86. const gitConfigPath = path.join(
  87. process.env['HOME'] || os.homedir(),
  88. '.gitconfig'
  89. )
  90. const newGitConfigPath = path.join(this.temporaryHomePath, '.gitconfig')
  91. let configExists = false
  92. try {
  93. await fs.promises.stat(gitConfigPath)
  94. configExists = true
  95. } catch (err) {
  96. if ((err as any)?.code !== 'ENOENT') {
  97. throw err
  98. }
  99. }
  100. if (configExists) {
  101. core.info(`Copying '${gitConfigPath}' to '${newGitConfigPath}'`)
  102. await io.cp(gitConfigPath, newGitConfigPath)
  103. } else {
  104. await fs.promises.writeFile(newGitConfigPath, '')
  105. }
  106. // Override HOME
  107. core.info(
  108. `Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes`
  109. )
  110. this.git.setEnvironmentVariable('HOME', this.temporaryHomePath)
  111. return newGitConfigPath
  112. }
  113. async configureGlobalAuth(): Promise<void> {
  114. // 'configureTempGlobalConfig' noops if already set, just returns the path
  115. const newGitConfigPath = await this.configureTempGlobalConfig()
  116. try {
  117. // Configure the token
  118. await this.configureToken(newGitConfigPath, true)
  119. // Configure HTTPS instead of SSH
  120. await this.git.tryConfigUnset(this.insteadOfKey, true)
  121. if (!this.settings.sshKey) {
  122. for (const insteadOfValue of this.insteadOfValues) {
  123. await this.git.config(this.insteadOfKey, insteadOfValue, true, true)
  124. }
  125. }
  126. } catch (err) {
  127. // Unset in case somehow written to the real global config
  128. core.info(
  129. 'Encountered an error when attempting to configure token. Attempting unconfigure.'
  130. )
  131. await this.git.tryConfigUnset(this.tokenConfigKey, true)
  132. throw err
  133. }
  134. }
  135. async configureSubmoduleAuth(): Promise<void> {
  136. // Remove possible previous HTTPS instead of SSH
  137. await this.removeGitConfig(this.insteadOfKey, true)
  138. if (this.settings.persistCredentials) {
  139. // Configure a placeholder value. This approach avoids the credential being captured
  140. // by process creation audit events, which are commonly logged. For more information,
  141. // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
  142. const output = await this.git.submoduleForeach(
  143. // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
  144. `sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`,
  145. this.settings.nestedSubmodules
  146. )
  147. // Replace the placeholder
  148. const configPaths: string[] =
  149. output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
  150. for (const configPath of configPaths) {
  151. core.debug(`Replacing token placeholder in '${configPath}'`)
  152. await this.replaceTokenPlaceholder(configPath)
  153. }
  154. if (this.settings.sshKey) {
  155. // Configure core.sshCommand
  156. await this.git.submoduleForeach(
  157. `git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`,
  158. this.settings.nestedSubmodules
  159. )
  160. } else {
  161. // Configure HTTPS instead of SSH
  162. for (const insteadOfValue of this.insteadOfValues) {
  163. await this.git.submoduleForeach(
  164. `git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`,
  165. this.settings.nestedSubmodules
  166. )
  167. }
  168. }
  169. }
  170. }
  171. async removeAuth(): Promise<void> {
  172. await this.removeSsh()
  173. await this.removeToken()
  174. }
  175. async removeGlobalConfig(): Promise<void> {
  176. if (this.temporaryHomePath?.length > 0) {
  177. core.debug(`Unsetting HOME override`)
  178. this.git.removeEnvironmentVariable('HOME')
  179. await io.rmRF(this.temporaryHomePath)
  180. }
  181. }
  182. private async configureSsh(): Promise<void> {
  183. if (!this.settings.sshKey) {
  184. return
  185. }
  186. // Write key
  187. const runnerTemp = process.env['RUNNER_TEMP'] || ''
  188. assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
  189. const uniqueId = uuid()
  190. this.sshKeyPath = path.join(runnerTemp, uniqueId)
  191. stateHelper.setSshKeyPath(this.sshKeyPath)
  192. await fs.promises.mkdir(runnerTemp, {recursive: true})
  193. await fs.promises.writeFile(
  194. this.sshKeyPath,
  195. this.settings.sshKey.trim() + '\n',
  196. {mode: 0o600}
  197. )
  198. // Remove inherited permissions on Windows
  199. if (IS_WINDOWS) {
  200. const icacls = await io.which('icacls.exe')
  201. await exec.exec(
  202. `"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`
  203. )
  204. await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`)
  205. }
  206. // Write known hosts
  207. const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts')
  208. let userKnownHosts = ''
  209. try {
  210. userKnownHosts = (
  211. await fs.promises.readFile(userKnownHostsPath)
  212. ).toString()
  213. } catch (err) {
  214. if ((err as any)?.code !== 'ENOENT') {
  215. throw err
  216. }
  217. }
  218. let knownHosts = ''
  219. if (userKnownHosts) {
  220. knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`
  221. }
  222. if (this.settings.sshKnownHosts) {
  223. knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`
  224. }
  225. 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`
  226. this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`)
  227. stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath)
  228. await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts)
  229. // Configure GIT_SSH_COMMAND
  230. const sshPath = await io.which('ssh', true)
  231. this.sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
  232. this.sshKeyPath
  233. )}"`
  234. if (this.settings.sshStrict) {
  235. this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
  236. }
  237. this.sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
  238. this.sshKnownHostsPath
  239. )}"`
  240. core.info(`Temporarily overriding GIT_SSH_COMMAND=${this.sshCommand}`)
  241. this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand)
  242. // Configure core.sshCommand
  243. if (this.settings.persistCredentials) {
  244. await this.git.config(SSH_COMMAND_KEY, this.sshCommand)
  245. }
  246. }
  247. private async configureToken(
  248. configPath?: string,
  249. globalConfig?: boolean
  250. ): Promise<void> {
  251. // Validate args
  252. assert.ok(
  253. (configPath && globalConfig) || (!configPath && !globalConfig),
  254. 'Unexpected configureToken parameter combinations'
  255. )
  256. // Default config path
  257. if (!configPath && !globalConfig) {
  258. configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config')
  259. }
  260. // Configure a placeholder value. This approach avoids the credential being captured
  261. // by process creation audit events, which are commonly logged. For more information,
  262. // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
  263. await this.git.config(
  264. this.tokenConfigKey,
  265. this.tokenPlaceholderConfigValue,
  266. globalConfig
  267. )
  268. // Replace the placeholder
  269. await this.replaceTokenPlaceholder(configPath || '')
  270. }
  271. private async replaceTokenPlaceholder(configPath: string): Promise<void> {
  272. assert.ok(configPath, 'configPath is not defined')
  273. let content = (await fs.promises.readFile(configPath)).toString()
  274. const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue)
  275. if (
  276. placeholderIndex < 0 ||
  277. placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)
  278. ) {
  279. throw new Error(`Unable to replace auth placeholder in ${configPath}`)
  280. }
  281. assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
  282. content = content.replace(
  283. this.tokenPlaceholderConfigValue,
  284. this.tokenConfigValue
  285. )
  286. await fs.promises.writeFile(configPath, content)
  287. }
  288. private async removeSsh(): Promise<void> {
  289. // SSH key
  290. const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
  291. if (keyPath) {
  292. try {
  293. await io.rmRF(keyPath)
  294. } catch (err) {
  295. core.debug(`${(err as any)?.message ?? err}`)
  296. core.warning(`Failed to remove SSH key '${keyPath}'`)
  297. }
  298. }
  299. // SSH known hosts
  300. const knownHostsPath =
  301. this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
  302. if (knownHostsPath) {
  303. try {
  304. await io.rmRF(knownHostsPath)
  305. } catch {
  306. // Intentionally empty
  307. }
  308. }
  309. // SSH command
  310. await this.removeGitConfig(SSH_COMMAND_KEY)
  311. }
  312. private async removeToken(): Promise<void> {
  313. // HTTP extra header
  314. await this.removeGitConfig(this.tokenConfigKey)
  315. }
  316. private async removeGitConfig(
  317. configKey: string,
  318. submoduleOnly: boolean = false
  319. ): Promise<void> {
  320. if (!submoduleOnly) {
  321. if (
  322. (await this.git.configExists(configKey)) &&
  323. !(await this.git.tryConfigUnset(configKey))
  324. ) {
  325. // Load the config contents
  326. core.warning(`Failed to remove '${configKey}' from the git config`)
  327. }
  328. }
  329. const pattern = regexpHelper.escape(configKey)
  330. await this.git.submoduleForeach(
  331. // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline
  332. `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`,
  333. true
  334. )
  335. }
  336. }