git-auth-helper.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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 {default as uuid} from 'uuid/v4'
  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()
  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. `git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`,
  144. this.settings.nestedSubmodules
  145. )
  146. // Replace the placeholder
  147. const configPaths: string[] =
  148. output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []
  149. for (const configPath of configPaths) {
  150. core.debug(`Replacing token placeholder in '${configPath}'`)
  151. await this.replaceTokenPlaceholder(configPath)
  152. }
  153. if (this.settings.sshKey) {
  154. // Configure core.sshCommand
  155. await this.git.submoduleForeach(
  156. `git config --local '${SSH_COMMAND_KEY}' '${this.sshCommand}'`,
  157. this.settings.nestedSubmodules
  158. )
  159. } else {
  160. // Configure HTTPS instead of SSH
  161. for (const insteadOfValue of this.insteadOfValues) {
  162. await this.git.submoduleForeach(
  163. `git config --local --add '${this.insteadOfKey}' '${insteadOfValue}'`,
  164. this.settings.nestedSubmodules
  165. )
  166. }
  167. }
  168. }
  169. }
  170. async removeAuth(): Promise<void> {
  171. await this.removeSsh()
  172. await this.removeToken()
  173. }
  174. async removeGlobalConfig(): Promise<void> {
  175. if (this.temporaryHomePath?.length > 0) {
  176. core.debug(`Unsetting HOME override`)
  177. this.git.removeEnvironmentVariable('HOME')
  178. await io.rmRF(this.temporaryHomePath)
  179. }
  180. }
  181. private async configureSsh(): Promise<void> {
  182. if (!this.settings.sshKey) {
  183. return
  184. }
  185. // Write key
  186. const runnerTemp = process.env['RUNNER_TEMP'] || ''
  187. assert.ok(runnerTemp, 'RUNNER_TEMP is not defined')
  188. const uniqueId = uuid()
  189. this.sshKeyPath = path.join(runnerTemp, uniqueId)
  190. stateHelper.setSshKeyPath(this.sshKeyPath)
  191. await fs.promises.mkdir(runnerTemp, {recursive: true})
  192. await fs.promises.writeFile(
  193. this.sshKeyPath,
  194. this.settings.sshKey.trim() + '\n',
  195. {mode: 0o600}
  196. )
  197. // Remove inherited permissions on Windows
  198. if (IS_WINDOWS) {
  199. const icacls = await io.which('icacls.exe')
  200. await exec.exec(
  201. `"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`
  202. )
  203. await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`)
  204. }
  205. // Write known hosts
  206. const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts')
  207. let userKnownHosts = ''
  208. try {
  209. userKnownHosts = (
  210. await fs.promises.readFile(userKnownHostsPath)
  211. ).toString()
  212. } catch (err) {
  213. if ((err as any)?.code !== 'ENOENT') {
  214. throw err
  215. }
  216. }
  217. let knownHosts = ''
  218. if (userKnownHosts) {
  219. knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`
  220. }
  221. if (this.settings.sshKnownHosts) {
  222. knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`
  223. }
  224. knownHosts += `# Begin implicitly added github.com\ngithub.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\n# End implicitly added github.com\n`
  225. this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`)
  226. stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath)
  227. await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts)
  228. // Configure GIT_SSH_COMMAND
  229. const sshPath = await io.which('ssh', true)
  230. this.sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
  231. this.sshKeyPath
  232. )}"`
  233. if (this.settings.sshStrict) {
  234. this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
  235. }
  236. this.sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
  237. this.sshKnownHostsPath
  238. )}"`
  239. core.info(`Temporarily overriding GIT_SSH_COMMAND=${this.sshCommand}`)
  240. this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand)
  241. // Configure core.sshCommand
  242. if (this.settings.persistCredentials) {
  243. await this.git.config(SSH_COMMAND_KEY, this.sshCommand)
  244. }
  245. }
  246. private async configureToken(
  247. configPath?: string,
  248. globalConfig?: boolean
  249. ): Promise<void> {
  250. // Validate args
  251. assert.ok(
  252. (configPath && globalConfig) || (!configPath && !globalConfig),
  253. 'Unexpected configureToken parameter combinations'
  254. )
  255. // Default config path
  256. if (!configPath && !globalConfig) {
  257. configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config')
  258. }
  259. // Configure a placeholder value. This approach avoids the credential being captured
  260. // by process creation audit events, which are commonly logged. For more information,
  261. // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
  262. await this.git.config(
  263. this.tokenConfigKey,
  264. this.tokenPlaceholderConfigValue,
  265. globalConfig
  266. )
  267. // Replace the placeholder
  268. await this.replaceTokenPlaceholder(configPath || '')
  269. }
  270. private async replaceTokenPlaceholder(configPath: string): Promise<void> {
  271. assert.ok(configPath, 'configPath is not defined')
  272. let content = (await fs.promises.readFile(configPath)).toString()
  273. const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue)
  274. if (
  275. placeholderIndex < 0 ||
  276. placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)
  277. ) {
  278. throw new Error(`Unable to replace auth placeholder in ${configPath}`)
  279. }
  280. assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
  281. content = content.replace(
  282. this.tokenPlaceholderConfigValue,
  283. this.tokenConfigValue
  284. )
  285. await fs.promises.writeFile(configPath, content)
  286. }
  287. private async removeSsh(): Promise<void> {
  288. // SSH key
  289. const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
  290. if (keyPath) {
  291. try {
  292. await io.rmRF(keyPath)
  293. } catch (err) {
  294. core.debug(`${(err as any)?.message ?? err}`)
  295. core.warning(`Failed to remove SSH key '${keyPath}'`)
  296. }
  297. }
  298. // SSH known hosts
  299. const knownHostsPath =
  300. this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
  301. if (knownHostsPath) {
  302. try {
  303. await io.rmRF(knownHostsPath)
  304. } catch {
  305. // Intentionally empty
  306. }
  307. }
  308. // SSH command
  309. await this.removeGitConfig(SSH_COMMAND_KEY)
  310. }
  311. private async removeToken(): Promise<void> {
  312. // HTTP extra header
  313. await this.removeGitConfig(this.tokenConfigKey)
  314. }
  315. private async removeGitConfig(
  316. configKey: string,
  317. submoduleOnly: boolean = false
  318. ): Promise<void> {
  319. if (!submoduleOnly) {
  320. if (
  321. (await this.git.configExists(configKey)) &&
  322. !(await this.git.tryConfigUnset(configKey))
  323. ) {
  324. // Load the config contents
  325. core.warning(`Failed to remove '${configKey}' from the git config`)
  326. }
  327. }
  328. const pattern = regexpHelper.escape(configKey)
  329. await this.git.submoduleForeach(
  330. `git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`,
  331. true
  332. )
  333. }
  334. }