git-auth-helper.ts 13 KB

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