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 }