git-auth-helper.ts 12 KB

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