/ src / git-auth-helper.ts
git-auth-helper.ts
  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  
 15  const IS_WINDOWS = process.platform === 'win32'
 16  const SSH_COMMAND_KEY = 'core.sshCommand'
 17  
 18  export interface IGitAuthHelper {
 19    configureAuth(): Promise<void>
 20    configureGlobalAuth(): Promise<void>
 21    configureSubmoduleAuth(): Promise<void>
 22    removeAuth(): Promise<void>
 23    removeGlobalAuth(): Promise<void>
 24  }
 25  
 26  export function createAuthHelper(
 27    git: IGitCommandManager,
 28    settings?: IGitSourceSettings
 29  ): IGitAuthHelper {
 30    return new GitAuthHelper(git, settings)
 31  }
 32  
 33  class GitAuthHelper {
 34    private readonly git: IGitCommandManager
 35    private readonly settings: IGitSourceSettings
 36    private readonly tokenConfigKey: string
 37    private readonly tokenConfigValue: string
 38    private readonly tokenPlaceholderConfigValue: string
 39    private readonly insteadOfKey: string
 40    private readonly insteadOfValue: string
 41    private sshCommand = ''
 42    private sshKeyPath = ''
 43    private sshKnownHostsPath = ''
 44    private temporaryHomePath = ''
 45  
 46    constructor(
 47      gitCommandManager: IGitCommandManager,
 48      gitSourceSettings?: IGitSourceSettings
 49    ) {
 50      this.git = gitCommandManager
 51      this.settings = gitSourceSettings || (({} as unknown) as IGitSourceSettings)
 52  
 53      // Token auth header
 54      const serverUrl = urlHelper.getServerUrl()
 55      this.tokenConfigKey = `http.${serverUrl.origin}/.extraheader` // "origin" is SCHEME://HOSTNAME[:PORT]
 56      const basicCredential = Buffer.from(
 57        `x-access-token:${this.settings.authToken}`,
 58        'utf8'
 59      ).toString('base64')
 60      core.setSecret(basicCredential)
 61      this.tokenPlaceholderConfigValue = `AUTHORIZATION: basic ***`
 62      this.tokenConfigValue = `AUTHORIZATION: basic ${basicCredential}`
 63  
 64      // Instead of SSH URL
 65      this.insteadOfKey = `url.${serverUrl.origin}/.insteadOf` // "origin" is SCHEME://HOSTNAME[:PORT]
 66      this.insteadOfValue = `git@${serverUrl.hostname}:`
 67    }
 68  
 69    async configureAuth(): Promise<void> {
 70      // Remove possible previous values
 71      await this.removeAuth()
 72  
 73      // Configure new values
 74      await this.configureSsh()
 75      await this.configureToken()
 76    }
 77  
 78    async configureGlobalAuth(): Promise<void> {
 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  
 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.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  
108      try {
109        // Override HOME
110        core.info(
111          `Temporarily overriding HOME='${this.temporaryHomePath}' before making global git config changes`
112        )
113        this.git.setEnvironmentVariable('HOME', this.temporaryHomePath)
114  
115        // Configure the token
116        await this.configureToken(newGitConfigPath, true)
117  
118        // Configure HTTPS instead of SSH
119        await this.git.tryConfigUnset(this.insteadOfKey, true)
120        if (!this.settings.sshKey) {
121          await this.git.config(this.insteadOfKey, this.insteadOfValue, true)
122        }
123      } catch (err) {
124        // Unset in case somehow written to the real global config
125        core.info(
126          'Encountered an error when attempting to configure token. Attempting unconfigure.'
127        )
128        await this.git.tryConfigUnset(this.tokenConfigKey, true)
129        throw err
130      }
131    }
132  
133    async configureSubmoduleAuth(): Promise<void> {
134      // Remove possible previous HTTPS instead of SSH
135      await this.removeGitConfig(this.insteadOfKey, true)
136  
137      if (this.settings.persistCredentials) {
138        // Configure a placeholder value. This approach avoids the credential being captured
139        // by process creation audit events, which are commonly logged. For more information,
140        // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
141        const output = await this.git.submoduleForeach(
142          `git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`,
143          this.settings.nestedSubmodules
144        )
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  
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          await this.git.submoduleForeach(
163            `git config --local '${this.insteadOfKey}' '${this.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  
175    async removeGlobalAuth(): Promise<void> {
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  
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  
199      // Remove inherited permissions on Windows
200      if (IS_WINDOWS) {
201        const icacls = await io.which('icacls.exe')
202        await exec.exec(
203          `"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`
204        )
205        await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`)
206      }
207  
208      // Write known hosts
209      const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts')
210      let userKnownHosts = ''
211      try {
212        userKnownHosts = (
213          await fs.promises.readFile(userKnownHostsPath)
214        ).toString()
215      } catch (err) {
216        if (err.code !== 'ENOENT') {
217          throw err
218        }
219      }
220      let knownHosts = ''
221      if (userKnownHosts) {
222        knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`
223      }
224      if (this.settings.sshKnownHosts) {
225        knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`
226      }
227      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`
228      this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`)
229      stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath)
230      await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts)
231  
232      // Configure GIT_SSH_COMMAND
233      const sshPath = await io.which('ssh', true)
234      this.sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(
235        this.sshKeyPath
236      )}"`
237      if (this.settings.sshStrict) {
238        this.sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'
239      }
240      this.sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(
241        this.sshKnownHostsPath
242      )}"`
243      core.info(`Temporarily overriding GIT_SSH_COMMAND=${this.sshCommand}`)
244      this.git.setEnvironmentVariable('GIT_SSH_COMMAND', this.sshCommand)
245  
246      // Configure core.sshCommand
247      if (this.settings.persistCredentials) {
248        await this.git.config(SSH_COMMAND_KEY, this.sshCommand)
249      }
250    }
251  
252    private async configureToken(
253      configPath?: string,
254      globalConfig?: boolean
255    ): Promise<void> {
256      // Validate args
257      assert.ok(
258        (configPath && globalConfig) || (!configPath && !globalConfig),
259        'Unexpected configureToken parameter combinations'
260      )
261  
262      // Default config path
263      if (!configPath && !globalConfig) {
264        configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config')
265      }
266  
267      // Configure a placeholder value. This approach avoids the credential being captured
268      // by process creation audit events, which are commonly logged. For more information,
269      // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
270      await this.git.config(
271        this.tokenConfigKey,
272        this.tokenPlaceholderConfigValue,
273        globalConfig
274      )
275  
276      // Replace the placeholder
277      await this.replaceTokenPlaceholder(configPath || '')
278    }
279  
280    private async replaceTokenPlaceholder(configPath: string): Promise<void> {
281      assert.ok(configPath, 'configPath is not defined')
282      let content = (await fs.promises.readFile(configPath)).toString()
283      const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue)
284      if (
285        placeholderIndex < 0 ||
286        placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)
287      ) {
288        throw new Error(`Unable to replace auth placeholder in ${configPath}`)
289      }
290      assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
291      content = content.replace(
292        this.tokenPlaceholderConfigValue,
293        this.tokenConfigValue
294      )
295      await fs.promises.writeFile(configPath, content)
296    }
297  
298    private async removeSsh(): Promise<void> {
299      // SSH key
300      const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
301      if (keyPath) {
302        try {
303          await io.rmRF(keyPath)
304        } catch (err) {
305          core.debug(err.message)
306          core.warning(`Failed to remove SSH key '${keyPath}'`)
307        }
308      }
309  
310      // SSH known hosts
311      const knownHostsPath =
312        this.sshKnownHostsPath || stateHelper.SshKnownHostsPath
313      if (knownHostsPath) {
314        try {
315          await io.rmRF(knownHostsPath)
316        } catch {
317          // Intentionally empty
318        }
319      }
320  
321      // SSH command
322      await this.removeGitConfig(SSH_COMMAND_KEY)
323    }
324  
325    private async removeToken(): Promise<void> {
326      // HTTP extra header
327      await this.removeGitConfig(this.tokenConfigKey)
328    }
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  
344      const pattern = regexpHelper.escape(configKey)
345      await this.git.submoduleForeach(
346        `git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`,
347        true
348      )
349    }
350  }