add ssh support (#163)
This commit is contained in:
		
							parent
							
								
									80602fafba
								
							
						
					
					
						commit
						b2e6b7ed13
					
				
							
								
								
									
										40
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										40
									
								
								README.md
									
									
									
									
									
								
							|  | @ -45,14 +45,40 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous | |||
|     # Otherwise, defaults to `master`. | ||||
|     ref: '' | ||||
| 
 | ||||
|     # Auth token used to fetch the repository. The token is stored in the local git | ||||
|     # config, which enables your scripts to run authenticated git commands. The | ||||
|     # post-job step removes the token from the git config. [Learn more about creating | ||||
|     # and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | ||||
|     # Personal access token (PAT) used to fetch the repository. The PAT is configured | ||||
|     # with the local git config, which enables your scripts to run authenticated git | ||||
|     # commands. The post-job step removes the PAT. | ||||
|     # | ||||
|     # We recommend creating a service account with the least permissions necessary. | ||||
|     # Also when generating a new PAT, select the least scopes necessary. | ||||
|     # | ||||
|     # [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | ||||
|     # | ||||
|     # Default: ${{ github.token }} | ||||
|     token: '' | ||||
| 
 | ||||
|     # Whether to persist the token in the git config | ||||
|     # SSH key used to fetch the repository. SSH key is configured with the local git | ||||
|     # config, which enables your scripts to run authenticated git commands. The | ||||
|     # post-job step removes the SSH key. | ||||
|     # | ||||
|     # We recommend creating a service account with the least permissions necessary. | ||||
|     # | ||||
|     # [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | ||||
|     ssh-key: '' | ||||
| 
 | ||||
|     # Known hosts in addition to the user and global host key database. The public SSH | ||||
|     # keys for a host may be obtained using the utility `ssh-keyscan`. For example, | ||||
|     # `ssh-keyscan github.com`. The public key for github.com is always implicitly | ||||
|     # added. | ||||
|     ssh-known-hosts: '' | ||||
| 
 | ||||
|     # Whether to perform strict host key checking. When true, adds the options | ||||
|     # `StrictHostKeyChecking=yes` and `CheckHostIP=no` to the SSH command line. Use | ||||
|     # the input `ssh-known-hosts` to configure additional hosts. | ||||
|     # Default: true | ||||
|     ssh-strict: '' | ||||
| 
 | ||||
|     # Whether to configure the token or SSH key with the local git config | ||||
|     # Default: true | ||||
|     persist-credentials: '' | ||||
| 
 | ||||
|  | @ -73,6 +99,10 @@ Refer [here](https://github.com/actions/checkout/blob/v1/README.md) for previous | |||
| 
 | ||||
|     # Whether to checkout submodules: `true` to checkout submodules or `recursive` to | ||||
|     # recursively checkout submodules. | ||||
|     # | ||||
|     # When the `ssh-key` input is not provided, SSH URLs beginning with | ||||
|     # `git@github.com:` are converted to HTTPS. | ||||
|     # | ||||
|     # Default: false | ||||
|     submodules: '' | ||||
| ``` | ||||
|  |  | |||
|  | @ -2,10 +2,13 @@ import * as core from '@actions/core' | |||
| import * as fs from 'fs' | ||||
| import * as gitAuthHelper from '../lib/git-auth-helper' | ||||
| import * as io from '@actions/io' | ||||
| import * as os from 'os' | ||||
| import * as path from 'path' | ||||
| import * as stateHelper from '../lib/state-helper' | ||||
| import {IGitCommandManager} from '../lib/git-command-manager' | ||||
| import {IGitSourceSettings} from '../lib/git-source-settings' | ||||
| 
 | ||||
| const isWindows = process.platform === 'win32' | ||||
| const testWorkspace = path.join(__dirname, '_temp', 'git-auth-helper') | ||||
| const originalRunnerTemp = process.env['RUNNER_TEMP'] | ||||
| const originalHome = process.env['HOME'] | ||||
|  | @ -16,9 +19,13 @@ let runnerTemp: string | |||
| let tempHomedir: string | ||||
| let git: IGitCommandManager & {env: {[key: string]: string}} | ||||
| let settings: IGitSourceSettings | ||||
| let sshPath: string | ||||
| 
 | ||||
| describe('git-auth-helper tests', () => { | ||||
|   beforeAll(async () => { | ||||
|     // SSH
 | ||||
|     sshPath = await io.which('ssh') | ||||
| 
 | ||||
|     // Clear test workspace
 | ||||
|     await io.rmRF(testWorkspace) | ||||
|   }) | ||||
|  | @ -32,6 +39,12 @@ describe('git-auth-helper tests', () => { | |||
|     jest.spyOn(core, 'warning').mockImplementation(jest.fn()) | ||||
|     jest.spyOn(core, 'info').mockImplementation(jest.fn()) | ||||
|     jest.spyOn(core, 'debug').mockImplementation(jest.fn()) | ||||
| 
 | ||||
|     // Mock state helper
 | ||||
|     jest.spyOn(stateHelper, 'setSshKeyPath').mockImplementation(jest.fn()) | ||||
|     jest | ||||
|       .spyOn(stateHelper, 'setSshKnownHostsPath') | ||||
|       .mockImplementation(jest.fn()) | ||||
|   }) | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|  | @ -108,6 +121,52 @@ describe('git-auth-helper tests', () => { | |||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const configureAuth_copiesUserKnownHosts = | ||||
|     'configureAuth copies user known hosts' | ||||
|   it(configureAuth_copiesUserKnownHosts, async () => { | ||||
|     if (!sshPath) { | ||||
|       process.stdout.write( | ||||
|         `Skipped test "${configureAuth_copiesUserKnownHosts}". Executable 'ssh' not found in the PATH.\n` | ||||
|       ) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // Arange
 | ||||
|     await setup(configureAuth_copiesUserKnownHosts) | ||||
|     expect(settings.sshKey).toBeTruthy() // sanity check
 | ||||
| 
 | ||||
|     // Mock fs.promises.readFile
 | ||||
|     const realReadFile = fs.promises.readFile | ||||
|     jest.spyOn(fs.promises, 'readFile').mockImplementation( | ||||
|       async (file: any, options: any): Promise<Buffer> => { | ||||
|         const userKnownHostsPath = path.join( | ||||
|           os.homedir(), | ||||
|           '.ssh', | ||||
|           'known_hosts' | ||||
|         ) | ||||
|         if (file === userKnownHostsPath) { | ||||
|           return Buffer.from('some-domain.com ssh-rsa ABCDEF') | ||||
|         } | ||||
| 
 | ||||
|         return await realReadFile(file, options) | ||||
|       } | ||||
|     ) | ||||
| 
 | ||||
|     // Act
 | ||||
|     const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
|     await authHelper.configureAuth() | ||||
| 
 | ||||
|     // Assert known hosts
 | ||||
|     const actualSshKnownHostsPath = await getActualSshKnownHostsPath() | ||||
|     const actualSshKnownHostsContent = ( | ||||
|       await fs.promises.readFile(actualSshKnownHostsPath) | ||||
|     ).toString() | ||||
|     expect(actualSshKnownHostsContent).toMatch( | ||||
|       /some-domain\.com ssh-rsa ABCDEF/ | ||||
|     ) | ||||
|     expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/) | ||||
|   }) | ||||
| 
 | ||||
|   const configureAuth_registersBasicCredentialAsSecret = | ||||
|     'configureAuth registers basic credential as secret' | ||||
|   it(configureAuth_registersBasicCredentialAsSecret, async () => { | ||||
|  | @ -129,6 +188,173 @@ describe('git-auth-helper tests', () => { | |||
|     expect(setSecretSpy).toHaveBeenCalledWith(expectedSecret) | ||||
|   }) | ||||
| 
 | ||||
|   const setsSshCommandEnvVarWhenPersistCredentialsFalse = | ||||
|     'sets SSH command env var when persist-credentials false' | ||||
|   it(setsSshCommandEnvVarWhenPersistCredentialsFalse, async () => { | ||||
|     if (!sshPath) { | ||||
|       process.stdout.write( | ||||
|         `Skipped test "${setsSshCommandEnvVarWhenPersistCredentialsFalse}". Executable 'ssh' not found in the PATH.\n` | ||||
|       ) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // Arrange
 | ||||
|     await setup(setsSshCommandEnvVarWhenPersistCredentialsFalse) | ||||
|     settings.persistCredentials = false | ||||
|     const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
| 
 | ||||
|     // Act
 | ||||
|     await authHelper.configureAuth() | ||||
| 
 | ||||
|     // Assert git env var
 | ||||
|     const actualKeyPath = await getActualSshKeyPath() | ||||
|     const actualKnownHostsPath = await getActualSshKnownHostsPath() | ||||
|     const expectedSshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename( | ||||
|       actualKeyPath | ||||
|     )}" -o StrictHostKeyChecking=yes -o CheckHostIP=no -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename( | ||||
|       actualKnownHostsPath | ||||
|     )}"` | ||||
|     expect(git.setEnvironmentVariable).toHaveBeenCalledWith( | ||||
|       'GIT_SSH_COMMAND', | ||||
|       expectedSshCommand | ||||
|     ) | ||||
| 
 | ||||
|     // Asserty git config
 | ||||
|     const gitConfigLines = (await fs.promises.readFile(localGitConfigPath)) | ||||
|       .toString() | ||||
|       .split('\n') | ||||
|       .filter(x => x) | ||||
|     expect(gitConfigLines).toHaveLength(1) | ||||
|     expect(gitConfigLines[0]).toMatch(/^http\./) | ||||
|   }) | ||||
| 
 | ||||
|   const configureAuth_setsSshCommandWhenPersistCredentialsTrue = | ||||
|     'sets SSH command when persist-credentials true' | ||||
|   it(configureAuth_setsSshCommandWhenPersistCredentialsTrue, async () => { | ||||
|     if (!sshPath) { | ||||
|       process.stdout.write( | ||||
|         `Skipped test "${configureAuth_setsSshCommandWhenPersistCredentialsTrue}". Executable 'ssh' not found in the PATH.\n` | ||||
|       ) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // Arrange
 | ||||
|     await setup(configureAuth_setsSshCommandWhenPersistCredentialsTrue) | ||||
|     const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
| 
 | ||||
|     // Act
 | ||||
|     await authHelper.configureAuth() | ||||
| 
 | ||||
|     // Assert git env var
 | ||||
|     const actualKeyPath = await getActualSshKeyPath() | ||||
|     const actualKnownHostsPath = await getActualSshKnownHostsPath() | ||||
|     const expectedSshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename( | ||||
|       actualKeyPath | ||||
|     )}" -o StrictHostKeyChecking=yes -o CheckHostIP=no -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename( | ||||
|       actualKnownHostsPath | ||||
|     )}"` | ||||
|     expect(git.setEnvironmentVariable).toHaveBeenCalledWith( | ||||
|       'GIT_SSH_COMMAND', | ||||
|       expectedSshCommand | ||||
|     ) | ||||
| 
 | ||||
|     // Asserty git config
 | ||||
|     expect(git.config).toHaveBeenCalledWith( | ||||
|       'core.sshCommand', | ||||
|       expectedSshCommand | ||||
|     ) | ||||
|   }) | ||||
| 
 | ||||
|   const configureAuth_writesExplicitKnownHosts = 'writes explicit known hosts' | ||||
|   it(configureAuth_writesExplicitKnownHosts, async () => { | ||||
|     if (!sshPath) { | ||||
|       process.stdout.write( | ||||
|         `Skipped test "${configureAuth_writesExplicitKnownHosts}". Executable 'ssh' not found in the PATH.\n` | ||||
|       ) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // Arrange
 | ||||
|     await setup(configureAuth_writesExplicitKnownHosts) | ||||
|     expect(settings.sshKey).toBeTruthy() // sanity check
 | ||||
|     settings.sshKnownHosts = 'my-custom-host.com ssh-rsa ABC123' | ||||
|     const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
| 
 | ||||
|     // Act
 | ||||
|     await authHelper.configureAuth() | ||||
| 
 | ||||
|     // Assert known hosts
 | ||||
|     const actualSshKnownHostsPath = await getActualSshKnownHostsPath() | ||||
|     const actualSshKnownHostsContent = ( | ||||
|       await fs.promises.readFile(actualSshKnownHostsPath) | ||||
|     ).toString() | ||||
|     expect(actualSshKnownHostsContent).toMatch( | ||||
|       /my-custom-host\.com ssh-rsa ABC123/ | ||||
|     ) | ||||
|     expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/) | ||||
|   }) | ||||
| 
 | ||||
|   const configureAuth_writesSshKeyAndImplicitKnownHosts = | ||||
|     'writes SSH key and implicit known hosts' | ||||
|   it(configureAuth_writesSshKeyAndImplicitKnownHosts, async () => { | ||||
|     if (!sshPath) { | ||||
|       process.stdout.write( | ||||
|         `Skipped test "${configureAuth_writesSshKeyAndImplicitKnownHosts}". Executable 'ssh' not found in the PATH.\n` | ||||
|       ) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // Arrange
 | ||||
|     await setup(configureAuth_writesSshKeyAndImplicitKnownHosts) | ||||
|     expect(settings.sshKey).toBeTruthy() // sanity check
 | ||||
|     const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
| 
 | ||||
|     // Act
 | ||||
|     await authHelper.configureAuth() | ||||
| 
 | ||||
|     // Assert SSH key
 | ||||
|     const actualSshKeyPath = await getActualSshKeyPath() | ||||
|     expect(actualSshKeyPath).toBeTruthy() | ||||
|     const actualSshKeyContent = ( | ||||
|       await fs.promises.readFile(actualSshKeyPath) | ||||
|     ).toString() | ||||
|     expect(actualSshKeyContent).toBe(settings.sshKey + '\n') | ||||
|     if (!isWindows) { | ||||
|       expect((await fs.promises.stat(actualSshKeyPath)).mode & 0o777).toBe( | ||||
|         0o600 | ||||
|       ) | ||||
|     } | ||||
| 
 | ||||
|     // Assert known hosts
 | ||||
|     const actualSshKnownHostsPath = await getActualSshKnownHostsPath() | ||||
|     const actualSshKnownHostsContent = ( | ||||
|       await fs.promises.readFile(actualSshKnownHostsPath) | ||||
|     ).toString() | ||||
|     expect(actualSshKnownHostsContent).toMatch(/github\.com ssh-rsa AAAAB3N/) | ||||
|   }) | ||||
| 
 | ||||
|   const configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet = | ||||
|     'configureGlobalAuth configures URL insteadOf when SSH key not set' | ||||
|   it(configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet, async () => { | ||||
|     // Arrange
 | ||||
|     await setup(configureGlobalAuth_configuresUrlInsteadOfWhenSshKeyNotSet) | ||||
|     settings.sshKey = '' | ||||
|     const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
| 
 | ||||
|     // Act
 | ||||
|     await authHelper.configureAuth() | ||||
|     await authHelper.configureGlobalAuth() | ||||
| 
 | ||||
|     // Assert temporary global config
 | ||||
|     expect(git.env['HOME']).toBeTruthy() | ||||
|     const configContent = ( | ||||
|       await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig')) | ||||
|     ).toString() | ||||
|     expect( | ||||
|       configContent.indexOf(`url.https://github.com/.insteadOf git@github.com`) | ||||
|     ).toBeGreaterThanOrEqual(0) | ||||
|   }) | ||||
| 
 | ||||
|   const configureGlobalAuth_copiesGlobalGitConfig = | ||||
|     'configureGlobalAuth copies global git config' | ||||
|   it(configureGlobalAuth_copiesGlobalGitConfig, async () => { | ||||
|  | @ -211,6 +437,67 @@ describe('git-auth-helper tests', () => { | |||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeyNotSet = | ||||
|     'configureSubmoduleAuth configures token when persist credentials true and SSH key not set' | ||||
|   it( | ||||
|     configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeyNotSet, | ||||
|     async () => { | ||||
|       // Arrange
 | ||||
|       await setup( | ||||
|         configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeyNotSet | ||||
|       ) | ||||
|       settings.sshKey = '' | ||||
|       const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
|       await authHelper.configureAuth() | ||||
|       const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any> | ||||
|       mockSubmoduleForeach.mockClear() // reset calls
 | ||||
| 
 | ||||
|       // Act
 | ||||
|       await authHelper.configureSubmoduleAuth() | ||||
| 
 | ||||
|       // Assert
 | ||||
|       expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3) | ||||
|       expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( | ||||
|         /unset-all.*insteadOf/ | ||||
|       ) | ||||
|       expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/) | ||||
|       expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/url.*insteadOf/) | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeySet = | ||||
|     'configureSubmoduleAuth configures token when persist credentials true and SSH key set' | ||||
|   it( | ||||
|     configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeySet, | ||||
|     async () => { | ||||
|       if (!sshPath) { | ||||
|         process.stdout.write( | ||||
|           `Skipped test "${configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeySet}". Executable 'ssh' not found in the PATH.\n` | ||||
|         ) | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       // Arrange
 | ||||
|       await setup( | ||||
|         configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrueAndSshKeySet | ||||
|       ) | ||||
|       const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
|       await authHelper.configureAuth() | ||||
|       const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any> | ||||
|       mockSubmoduleForeach.mockClear() // reset calls
 | ||||
| 
 | ||||
|       // Act
 | ||||
|       await authHelper.configureSubmoduleAuth() | ||||
| 
 | ||||
|       // Assert
 | ||||
|       expect(mockSubmoduleForeach).toHaveBeenCalledTimes(2) | ||||
|       expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( | ||||
|         /unset-all.*insteadOf/ | ||||
|       ) | ||||
|       expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/) | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const configureSubmoduleAuth_doesNotConfigureTokenWhenPersistCredentialsFalse = | ||||
|     'configureSubmoduleAuth does not configure token when persist credentials false' | ||||
|   it( | ||||
|  | @ -223,37 +510,135 @@ describe('git-auth-helper tests', () => { | |||
|       settings.persistCredentials = false | ||||
|       const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
|       await authHelper.configureAuth() | ||||
|       ;(git.submoduleForeach as jest.Mock<any, any>).mockClear() // reset calls
 | ||||
|       const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any> | ||||
|       mockSubmoduleForeach.mockClear() // reset calls
 | ||||
| 
 | ||||
|       // Act
 | ||||
|       await authHelper.configureSubmoduleAuth() | ||||
| 
 | ||||
|       // Assert
 | ||||
|       expect(git.submoduleForeach).not.toHaveBeenCalled() | ||||
|       expect(mockSubmoduleForeach).toBeCalledTimes(1) | ||||
|       expect(mockSubmoduleForeach.mock.calls[0][0] as string).toMatch( | ||||
|         /unset-all.*insteadOf/ | ||||
|       ) | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue = | ||||
|     'configureSubmoduleAuth configures token when persist credentials true' | ||||
|   const configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet = | ||||
|     'configureSubmoduleAuth does not configure URL insteadOf when persist credentials true and SSH key set' | ||||
|   it( | ||||
|     configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue, | ||||
|     configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet, | ||||
|     async () => { | ||||
|       if (!sshPath) { | ||||
|         process.stdout.write( | ||||
|           `Skipped test "${configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet}". Executable 'ssh' not found in the PATH.\n` | ||||
|         ) | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       // Arrange
 | ||||
|       await setup( | ||||
|         configureSubmoduleAuth_configuresTokenWhenPersistCredentialsTrue | ||||
|         configureSubmoduleAuth_doesNotConfigureUrlInsteadOfWhenPersistCredentialsTrueAndSshKeySet | ||||
|       ) | ||||
|       const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
|       await authHelper.configureAuth() | ||||
|       ;(git.submoduleForeach as jest.Mock<any, any>).mockClear() // reset calls
 | ||||
|       const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any> | ||||
|       mockSubmoduleForeach.mockClear() // reset calls
 | ||||
| 
 | ||||
|       // Act
 | ||||
|       await authHelper.configureSubmoduleAuth() | ||||
| 
 | ||||
|       // Assert
 | ||||
|       expect(git.submoduleForeach).toHaveBeenCalledTimes(1) | ||||
|       expect(mockSubmoduleForeach).toHaveBeenCalledTimes(2) | ||||
|       expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( | ||||
|         /unset-all.*insteadOf/ | ||||
|       ) | ||||
|       expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/) | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse = | ||||
|     'configureSubmoduleAuth removes URL insteadOf when persist credentials false' | ||||
|   it( | ||||
|     configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse, | ||||
|     async () => { | ||||
|       // Arrange
 | ||||
|       await setup( | ||||
|         configureSubmoduleAuth_removesUrlInsteadOfWhenPersistCredentialsFalse | ||||
|       ) | ||||
|       settings.persistCredentials = false | ||||
|       const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
|       await authHelper.configureAuth() | ||||
|       const mockSubmoduleForeach = git.submoduleForeach as jest.Mock<any, any> | ||||
|       mockSubmoduleForeach.mockClear() // reset calls
 | ||||
| 
 | ||||
|       // Act
 | ||||
|       await authHelper.configureSubmoduleAuth() | ||||
| 
 | ||||
|       // Assert
 | ||||
|       expect(mockSubmoduleForeach).toBeCalledTimes(1) | ||||
|       expect(mockSubmoduleForeach.mock.calls[0][0] as string).toMatch( | ||||
|         /unset-all.*insteadOf/ | ||||
|       ) | ||||
|     } | ||||
|   ) | ||||
| 
 | ||||
|   const removeAuth_removesSshCommand = 'removeAuth removes SSH command' | ||||
|   it(removeAuth_removesSshCommand, async () => { | ||||
|     if (!sshPath) { | ||||
|       process.stdout.write( | ||||
|         `Skipped test "${removeAuth_removesSshCommand}". Executable 'ssh' not found in the PATH.\n` | ||||
|       ) | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // Arrange
 | ||||
|     await setup(removeAuth_removesSshCommand) | ||||
|     const authHelper = gitAuthHelper.createAuthHelper(git, settings) | ||||
|     await authHelper.configureAuth() | ||||
|     let gitConfigContent = ( | ||||
|       await fs.promises.readFile(localGitConfigPath) | ||||
|     ).toString() | ||||
|     expect(gitConfigContent.indexOf('core.sshCommand')).toBeGreaterThanOrEqual( | ||||
|       0 | ||||
|     ) // sanity check
 | ||||
|     const actualKeyPath = await getActualSshKeyPath() | ||||
|     expect(actualKeyPath).toBeTruthy() | ||||
|     await fs.promises.stat(actualKeyPath) | ||||
|     const actualKnownHostsPath = await getActualSshKnownHostsPath() | ||||
|     expect(actualKnownHostsPath).toBeTruthy() | ||||
|     await fs.promises.stat(actualKnownHostsPath) | ||||
| 
 | ||||
|     // Act
 | ||||
|     await authHelper.removeAuth() | ||||
| 
 | ||||
|     // Assert git config
 | ||||
|     gitConfigContent = ( | ||||
|       await fs.promises.readFile(localGitConfigPath) | ||||
|     ).toString() | ||||
|     expect(gitConfigContent.indexOf('core.sshCommand')).toBeLessThan(0) | ||||
| 
 | ||||
|     // Assert SSH key file
 | ||||
|     try { | ||||
|       await fs.promises.stat(actualKeyPath) | ||||
|       throw new Error('SSH key should have been deleted') | ||||
|     } catch (err) { | ||||
|       if (err.code !== 'ENOENT') { | ||||
|         throw err | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // Assert known hosts file
 | ||||
|     try { | ||||
|       await fs.promises.stat(actualKnownHostsPath) | ||||
|       throw new Error('SSH known hosts should have been deleted') | ||||
|     } catch (err) { | ||||
|       if (err.code !== 'ENOENT') { | ||||
|         throw err | ||||
|       } | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   const removeAuth_removesToken = 'removeAuth removes token' | ||||
|   it(removeAuth_removesToken, async () => { | ||||
|     // Arrange
 | ||||
|  | @ -401,6 +786,36 @@ async function setup(testName: string): Promise<void> { | |||
|     ref: 'refs/heads/master', | ||||
|     repositoryName: 'my-repo', | ||||
|     repositoryOwner: 'my-org', | ||||
|     repositoryPath: '' | ||||
|     repositoryPath: '', | ||||
|     sshKey: sshPath ? 'some ssh private key' : '', | ||||
|     sshKnownHosts: '', | ||||
|     sshStrict: true | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| async function getActualSshKeyPath(): Promise<string> { | ||||
|   let actualTempFiles = (await fs.promises.readdir(runnerTemp)) | ||||
|     .sort() | ||||
|     .map(x => path.join(runnerTemp, x)) | ||||
|   if (actualTempFiles.length === 0) { | ||||
|     return '' | ||||
|   } | ||||
| 
 | ||||
|   expect(actualTempFiles).toHaveLength(2) | ||||
|   expect(actualTempFiles[0].endsWith('_known_hosts')).toBeFalsy() | ||||
|   return actualTempFiles[0] | ||||
| } | ||||
| 
 | ||||
| async function getActualSshKnownHostsPath(): Promise<string> { | ||||
|   let actualTempFiles = (await fs.promises.readdir(runnerTemp)) | ||||
|     .sort() | ||||
|     .map(x => path.join(runnerTemp, x)) | ||||
|   if (actualTempFiles.length === 0) { | ||||
|     return '' | ||||
|   } | ||||
| 
 | ||||
|   expect(actualTempFiles).toHaveLength(2) | ||||
|   expect(actualTempFiles[1].endsWith('_known_hosts')).toBeTruthy() | ||||
|   expect(actualTempFiles[1].startsWith(actualTempFiles[0])).toBeTruthy() | ||||
|   return actualTempFiles[1] | ||||
| } | ||||
|  |  | |||
							
								
								
									
										43
									
								
								action.yml
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								action.yml
									
									
									
									
									
								
							|  | @ -11,13 +11,42 @@ inputs: | |||
|       event.  Otherwise, defaults to `master`. | ||||
|   token: | ||||
|     description: > | ||||
|       Auth token used to fetch the repository. The token is stored in the local | ||||
|       git config, which enables your scripts to run authenticated git commands. | ||||
|       The post-job step removes the token from the git config. [Learn more about | ||||
|       creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | ||||
|       Personal access token (PAT) used to fetch the repository. The PAT is configured | ||||
|       with the local git config, which enables your scripts to run authenticated git | ||||
|       commands. The post-job step removes the PAT. | ||||
| 
 | ||||
| 
 | ||||
|       We recommend creating a service account with the least permissions necessary. | ||||
|       Also when generating a new PAT, select the least scopes necessary. | ||||
| 
 | ||||
| 
 | ||||
|       [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | ||||
|     default: ${{ github.token }} | ||||
|   ssh-key: | ||||
|     description: > | ||||
|       SSH key used to fetch the repository. SSH key is configured with the local | ||||
|       git config, which enables your scripts to run authenticated git commands. | ||||
|       The post-job step removes the SSH key. | ||||
| 
 | ||||
| 
 | ||||
|       We recommend creating a service account with the least permissions necessary. | ||||
| 
 | ||||
| 
 | ||||
|       [Learn more about creating and using | ||||
|       encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) | ||||
|   ssh-known-hosts: | ||||
|     description: > | ||||
|       Known hosts in addition to the user and global host key database. The public | ||||
|       SSH keys for a host may be obtained using the utility `ssh-keyscan`. For example, | ||||
|       `ssh-keyscan github.com`. The public key for github.com is always implicitly added. | ||||
|   ssh-strict: | ||||
|     description: > | ||||
|       Whether to perform strict host key checking. When true, adds the options `StrictHostKeyChecking=yes` | ||||
|       and `CheckHostIP=no` to the SSH command line. Use the input `ssh-known-hosts` to | ||||
|       configure additional hosts. | ||||
|     default: true | ||||
|   persist-credentials: | ||||
|     description: 'Whether to persist the token in the git config' | ||||
|     description: 'Whether to configure the token or SSH key with the local git config' | ||||
|     default: true | ||||
|   path: | ||||
|     description: 'Relative path under $GITHUB_WORKSPACE to place the repository' | ||||
|  | @ -34,6 +63,10 @@ inputs: | |||
|     description: > | ||||
|       Whether to checkout submodules: `true` to checkout submodules or `recursive` to | ||||
|       recursively checkout submodules. | ||||
| 
 | ||||
| 
 | ||||
|       When the `ssh-key` input is not provided, SSH URLs beginning with `git@github.com:` are | ||||
|       converted to HTTPS. | ||||
|     default: false | ||||
| runs: | ||||
|   using: node12 | ||||
|  |  | |||
							
								
								
									
										142
									
								
								dist/index.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										142
									
								
								dist/index.js
									
									
									
									
										vendored
									
									
								
							|  | @ -2621,6 +2621,14 @@ exports.IsPost = !!process.env['STATE_isPost']; | |||
|  * The repository path for the POST action. The value is empty during the MAIN action. | ||||
|  */ | ||||
| exports.RepositoryPath = process.env['STATE_repositoryPath'] || ''; | ||||
| /** | ||||
|  * The SSH key path for the POST action. The value is empty during the MAIN action. | ||||
|  */ | ||||
| exports.SshKeyPath = process.env['STATE_sshKeyPath'] || ''; | ||||
| /** | ||||
|  * The SSH known hosts path for the POST action. The value is empty during the MAIN action. | ||||
|  */ | ||||
| exports.SshKnownHostsPath = process.env['STATE_sshKnownHostsPath'] || ''; | ||||
| /** | ||||
|  * Save the repository path so the POST action can retrieve the value. | ||||
|  */ | ||||
|  | @ -2628,6 +2636,20 @@ function setRepositoryPath(repositoryPath) { | |||
|     coreCommand.issueCommand('save-state', { name: 'repositoryPath' }, repositoryPath); | ||||
| } | ||||
| exports.setRepositoryPath = setRepositoryPath; | ||||
| /** | ||||
|  * Save the SSH key path so the POST action can retrieve the value. | ||||
|  */ | ||||
| function setSshKeyPath(sshKeyPath) { | ||||
|     coreCommand.issueCommand('save-state', { name: 'sshKeyPath' }, sshKeyPath); | ||||
| } | ||||
| exports.setSshKeyPath = setSshKeyPath; | ||||
| /** | ||||
|  * Save the SSH known hosts path so the POST action can retrieve the value. | ||||
|  */ | ||||
| function setSshKnownHostsPath(sshKnownHostsPath) { | ||||
|     coreCommand.issueCommand('save-state', { name: 'sshKnownHostsPath' }, sshKnownHostsPath); | ||||
| } | ||||
| exports.setSshKnownHostsPath = setSshKnownHostsPath; | ||||
| // Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic.
 | ||||
| // This is necessary since we don't have a separate entry point.
 | ||||
| if (!exports.IsPost) { | ||||
|  | @ -5080,14 +5102,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) { | |||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||||
| const assert = __importStar(__webpack_require__(357)); | ||||
| const core = __importStar(__webpack_require__(470)); | ||||
| const exec = __importStar(__webpack_require__(986)); | ||||
| const fs = __importStar(__webpack_require__(747)); | ||||
| const io = __importStar(__webpack_require__(1)); | ||||
| const os = __importStar(__webpack_require__(87)); | ||||
| const path = __importStar(__webpack_require__(622)); | ||||
| const regexpHelper = __importStar(__webpack_require__(528)); | ||||
| const stateHelper = __importStar(__webpack_require__(153)); | ||||
| const v4_1 = __importDefault(__webpack_require__(826)); | ||||
| const IS_WINDOWS = process.platform === 'win32'; | ||||
| const HOSTNAME = 'github.com'; | ||||
| const SSH_COMMAND_KEY = 'core.sshCommand'; | ||||
| function createAuthHelper(git, settings) { | ||||
|     return new GitAuthHelper(git, settings); | ||||
| } | ||||
|  | @ -5097,6 +5122,8 @@ class GitAuthHelper { | |||
|         this.tokenConfigKey = `http.https://${HOSTNAME}/.extraheader`; | ||||
|         this.insteadOfKey = `url.https://${HOSTNAME}/.insteadOf`; | ||||
|         this.insteadOfValue = `git@${HOSTNAME}:`; | ||||
|         this.sshKeyPath = ''; | ||||
|         this.sshKnownHostsPath = ''; | ||||
|         this.temporaryHomePath = ''; | ||||
|         this.git = gitCommandManager; | ||||
|         this.settings = gitSourceSettings || {}; | ||||
|  | @ -5111,6 +5138,7 @@ class GitAuthHelper { | |||
|             // Remove possible previous values
 | ||||
|             yield this.removeAuth(); | ||||
|             // Configure new values
 | ||||
|             yield this.configureSsh(); | ||||
|             yield this.configureToken(); | ||||
|         }); | ||||
|     } | ||||
|  | @ -5150,8 +5178,10 @@ class GitAuthHelper { | |||
|                 yield this.configureToken(newGitConfigPath, true); | ||||
|                 // Configure HTTPS instead of SSH
 | ||||
|                 yield this.git.tryConfigUnset(this.insteadOfKey, true); | ||||
|                 if (!this.settings.sshKey) { | ||||
|                     yield this.git.config(this.insteadOfKey, this.insteadOfValue, true); | ||||
|                 } | ||||
|             } | ||||
|             catch (err) { | ||||
|                 // Unset in case somehow written to the real global config
 | ||||
|                 core.info('Encountered an error when attempting to configure token. Attempting unconfigure.'); | ||||
|  | @ -5162,27 +5192,29 @@ class GitAuthHelper { | |||
|     } | ||||
|     configureSubmoduleAuth() { | ||||
|         return __awaiter(this, void 0, void 0, function* () { | ||||
|             // Remove possible previous HTTPS instead of SSH
 | ||||
|             yield this.removeGitConfig(this.insteadOfKey, true); | ||||
|             if (this.settings.persistCredentials) { | ||||
|                 // Configure a placeholder value. This approach avoids the credential being captured
 | ||||
|                 // by process creation audit events, which are commonly logged. For more information,
 | ||||
|                 // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
 | ||||
|                 const commands = [ | ||||
|                     `git config --local "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}"`, | ||||
|                     `git config --local "${this.insteadOfKey}" "${this.insteadOfValue}"`, | ||||
|                     `git config --local --show-origin --name-only --get-regexp remote.origin.url` | ||||
|                 ]; | ||||
|                 const output = yield this.git.submoduleForeach(commands.join(' && '), this.settings.nestedSubmodules); | ||||
|                 const output = yield this.git.submoduleForeach(`git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`, this.settings.nestedSubmodules); | ||||
|                 // Replace the placeholder
 | ||||
|                 const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; | ||||
|                 for (const configPath of configPaths) { | ||||
|                     core.debug(`Replacing token placeholder in '${configPath}'`); | ||||
|                     this.replaceTokenPlaceholder(configPath); | ||||
|                 } | ||||
|                 // Configure HTTPS instead of SSH
 | ||||
|                 if (!this.settings.sshKey) { | ||||
|                     yield this.git.submoduleForeach(`git config --local '${this.insteadOfKey}' '${this.insteadOfValue}'`, this.settings.nestedSubmodules); | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|     removeAuth() { | ||||
|         return __awaiter(this, void 0, void 0, function* () { | ||||
|             yield this.removeSsh(); | ||||
|             yield this.removeToken(); | ||||
|         }); | ||||
|     } | ||||
|  | @ -5193,6 +5225,62 @@ class GitAuthHelper { | |||
|             yield io.rmRF(this.temporaryHomePath); | ||||
|         }); | ||||
|     } | ||||
|     configureSsh() { | ||||
|         return __awaiter(this, void 0, void 0, function* () { | ||||
|             if (!this.settings.sshKey) { | ||||
|                 return; | ||||
|             } | ||||
|             // Write key
 | ||||
|             const runnerTemp = process.env['RUNNER_TEMP'] || ''; | ||||
|             assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); | ||||
|             const uniqueId = v4_1.default(); | ||||
|             this.sshKeyPath = path.join(runnerTemp, uniqueId); | ||||
|             stateHelper.setSshKeyPath(this.sshKeyPath); | ||||
|             yield fs.promises.mkdir(runnerTemp, { recursive: true }); | ||||
|             yield fs.promises.writeFile(this.sshKeyPath, this.settings.sshKey.trim() + '\n', { mode: 0o600 }); | ||||
|             // Remove inherited permissions on Windows
 | ||||
|             if (IS_WINDOWS) { | ||||
|                 const icacls = yield io.which('icacls.exe'); | ||||
|                 yield exec.exec(`"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"`); | ||||
|                 yield exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`); | ||||
|             } | ||||
|             // Write known hosts
 | ||||
|             const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts'); | ||||
|             let userKnownHosts = ''; | ||||
|             try { | ||||
|                 userKnownHosts = (yield fs.promises.readFile(userKnownHostsPath)).toString(); | ||||
|             } | ||||
|             catch (err) { | ||||
|                 if (err.code !== 'ENOENT') { | ||||
|                     throw err; | ||||
|                 } | ||||
|             } | ||||
|             let knownHosts = ''; | ||||
|             if (userKnownHosts) { | ||||
|                 knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n`; | ||||
|             } | ||||
|             if (this.settings.sshKnownHosts) { | ||||
|                 knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n`; | ||||
|             } | ||||
|             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`; | ||||
|             this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`); | ||||
|             stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath); | ||||
|             yield fs.promises.writeFile(this.sshKnownHostsPath, knownHosts); | ||||
|             // Configure GIT_SSH_COMMAND
 | ||||
|             const sshPath = yield io.which('ssh', true); | ||||
|             let sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename(this.sshKeyPath)}"`; | ||||
|             if (this.settings.sshStrict) { | ||||
|                 sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no'; | ||||
|             } | ||||
|             sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename(this.sshKnownHostsPath)}"`; | ||||
|             core.info(`Temporarily overriding GIT_SSH_COMMAND=${sshCommand}`); | ||||
|             this.git.setEnvironmentVariable('GIT_SSH_COMMAND', sshCommand); | ||||
|             // Configure core.sshCommand
 | ||||
|             if (this.settings.persistCredentials) { | ||||
|                 yield this.git.config(SSH_COMMAND_KEY, sshCommand); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|     configureToken(configPath, globalConfig) { | ||||
|         return __awaiter(this, void 0, void 0, function* () { | ||||
|             // Validate args
 | ||||
|  | @ -5223,21 +5311,50 @@ class GitAuthHelper { | |||
|             yield fs.promises.writeFile(configPath, content); | ||||
|         }); | ||||
|     } | ||||
|     removeSsh() { | ||||
|         return __awaiter(this, void 0, void 0, function* () { | ||||
|             // SSH key
 | ||||
|             const keyPath = this.sshKeyPath || stateHelper.SshKeyPath; | ||||
|             if (keyPath) { | ||||
|                 try { | ||||
|                     yield io.rmRF(keyPath); | ||||
|                 } | ||||
|                 catch (err) { | ||||
|                     core.debug(err.message); | ||||
|                     core.warning(`Failed to remove SSH key '${keyPath}'`); | ||||
|                 } | ||||
|             } | ||||
|             // SSH known hosts
 | ||||
|             const knownHostsPath = this.sshKnownHostsPath || stateHelper.SshKnownHostsPath; | ||||
|             if (knownHostsPath) { | ||||
|                 try { | ||||
|                     yield io.rmRF(knownHostsPath); | ||||
|                 } | ||||
|                 catch (_a) { | ||||
|                     // Intentionally empty
 | ||||
|                 } | ||||
|             } | ||||
|             // SSH command
 | ||||
|             yield this.removeGitConfig(SSH_COMMAND_KEY); | ||||
|         }); | ||||
|     } | ||||
|     removeToken() { | ||||
|         return __awaiter(this, void 0, void 0, function* () { | ||||
|             // HTTP extra header
 | ||||
|             yield this.removeGitConfig(this.tokenConfigKey); | ||||
|         }); | ||||
|     } | ||||
|     removeGitConfig(configKey) { | ||||
|     removeGitConfig(configKey, submoduleOnly = false) { | ||||
|         return __awaiter(this, void 0, void 0, function* () { | ||||
|             if (!submoduleOnly) { | ||||
|                 if ((yield this.git.configExists(configKey)) && | ||||
|                     !(yield this.git.tryConfigUnset(configKey))) { | ||||
|                     // Load the config contents
 | ||||
|                     core.warning(`Failed to remove '${configKey}' from the git config`); | ||||
|                 } | ||||
|             } | ||||
|             const pattern = regexpHelper.escape(configKey); | ||||
|             yield this.git.submoduleForeach(`git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`, true); | ||||
|             yield this.git.submoduleForeach(`git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`, true); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
|  | @ -5680,7 +5797,9 @@ function getSource(settings) { | |||
|     return __awaiter(this, void 0, void 0, function* () { | ||||
|         // Repository URL
 | ||||
|         core.info(`Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}`); | ||||
|         const repositoryUrl = `https://${hostname}/${encodeURIComponent(settings.repositoryOwner)}/${encodeURIComponent(settings.repositoryName)}`; | ||||
|         const repositoryUrl = settings.sshKey | ||||
|             ? `git@${hostname}:${encodeURIComponent(settings.repositoryOwner)}/${encodeURIComponent(settings.repositoryName)}.git` | ||||
|             : `https://${hostname}/${encodeURIComponent(settings.repositoryOwner)}/${encodeURIComponent(settings.repositoryName)}`; | ||||
|         // Remove conflicting file path
 | ||||
|         if (fsHelper.fileExistsSync(settings.repositoryPath)) { | ||||
|             yield io.rmRF(settings.repositoryPath); | ||||
|  | @ -13940,6 +14059,11 @@ function getInputs() { | |||
|     core.debug(`recursive submodules = ${result.nestedSubmodules}`); | ||||
|     // Auth token
 | ||||
|     result.authToken = core.getInput('token'); | ||||
|     // SSH
 | ||||
|     result.sshKey = core.getInput('ssh-key'); | ||||
|     result.sshKnownHosts = core.getInput('ssh-known-hosts'); | ||||
|     result.sshStrict = | ||||
|         (core.getInput('ssh-strict') || 'true').toUpperCase() === 'TRUE'; | ||||
|     // Persist credentials
 | ||||
|     result.persistCredentials = | ||||
|         (core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE'; | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ import {IGitSourceSettings} from './git-source-settings' | |||
| 
 | ||||
| const IS_WINDOWS = process.platform === 'win32' | ||||
| const HOSTNAME = 'github.com' | ||||
| const SSH_COMMAND_KEY = 'core.sshCommand' | ||||
| 
 | ||||
| export interface IGitAuthHelper { | ||||
|   configureAuth(): Promise<void> | ||||
|  | @ -36,6 +37,8 @@ class GitAuthHelper { | |||
|   private readonly tokenPlaceholderConfigValue: string | ||||
|   private readonly insteadOfKey: string = `url.https://${HOSTNAME}/.insteadOf` | ||||
|   private readonly insteadOfValue: string = `git@${HOSTNAME}:` | ||||
|   private sshKeyPath = '' | ||||
|   private sshKnownHostsPath = '' | ||||
|   private temporaryHomePath = '' | ||||
|   private tokenConfigValue: string | ||||
| 
 | ||||
|  | @ -61,6 +64,7 @@ class GitAuthHelper { | |||
|     await this.removeAuth() | ||||
| 
 | ||||
|     // Configure new values
 | ||||
|     await this.configureSsh() | ||||
|     await this.configureToken() | ||||
|   } | ||||
| 
 | ||||
|  | @ -106,7 +110,9 @@ class GitAuthHelper { | |||
| 
 | ||||
|       // Configure HTTPS instead of SSH
 | ||||
|       await this.git.tryConfigUnset(this.insteadOfKey, true) | ||||
|       if (!this.settings.sshKey) { | ||||
|         await this.git.config(this.insteadOfKey, this.insteadOfValue, true) | ||||
|       } | ||||
|     } catch (err) { | ||||
|       // Unset in case somehow written to the real global config
 | ||||
|       core.info( | ||||
|  | @ -118,17 +124,15 @@ class GitAuthHelper { | |||
|   } | ||||
| 
 | ||||
|   async configureSubmoduleAuth(): Promise<void> { | ||||
|     // Remove possible previous HTTPS instead of SSH
 | ||||
|     await this.removeGitConfig(this.insteadOfKey, true) | ||||
| 
 | ||||
|     if (this.settings.persistCredentials) { | ||||
|       // Configure a placeholder value. This approach avoids the credential being captured
 | ||||
|       // by process creation audit events, which are commonly logged. For more information,
 | ||||
|       // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing
 | ||||
|       const commands = [ | ||||
|         `git config --local "${this.tokenConfigKey}" "${this.tokenPlaceholderConfigValue}"`, | ||||
|         `git config --local "${this.insteadOfKey}" "${this.insteadOfValue}"`, | ||||
|         `git config --local --show-origin --name-only --get-regexp remote.origin.url` | ||||
|       ] | ||||
|       const output = await this.git.submoduleForeach( | ||||
|         commands.join(' && '), | ||||
|         `git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url`, | ||||
|         this.settings.nestedSubmodules | ||||
|       ) | ||||
| 
 | ||||
|  | @ -139,10 +143,19 @@ class GitAuthHelper { | |||
|         core.debug(`Replacing token placeholder in '${configPath}'`) | ||||
|         this.replaceTokenPlaceholder(configPath) | ||||
|       } | ||||
| 
 | ||||
|       // Configure HTTPS instead of SSH
 | ||||
|       if (!this.settings.sshKey) { | ||||
|         await this.git.submoduleForeach( | ||||
|           `git config --local '${this.insteadOfKey}' '${this.insteadOfValue}'`, | ||||
|           this.settings.nestedSubmodules | ||||
|         ) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   async removeAuth(): Promise<void> { | ||||
|     await this.removeSsh() | ||||
|     await this.removeToken() | ||||
|   } | ||||
| 
 | ||||
|  | @ -152,6 +165,77 @@ class GitAuthHelper { | |||
|     await io.rmRF(this.temporaryHomePath) | ||||
|   } | ||||
| 
 | ||||
|   private async configureSsh(): Promise<void> { | ||||
|     if (!this.settings.sshKey) { | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // Write key
 | ||||
|     const runnerTemp = process.env['RUNNER_TEMP'] || '' | ||||
|     assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') | ||||
|     const uniqueId = uuid() | ||||
|     this.sshKeyPath = path.join(runnerTemp, uniqueId) | ||||
|     stateHelper.setSshKeyPath(this.sshKeyPath) | ||||
|     await fs.promises.mkdir(runnerTemp, {recursive: true}) | ||||
|     await fs.promises.writeFile( | ||||
|       this.sshKeyPath, | ||||
|       this.settings.sshKey.trim() + '\n', | ||||
|       {mode: 0o600} | ||||
|     ) | ||||
| 
 | ||||
|     // Remove inherited permissions on Windows
 | ||||
|     if (IS_WINDOWS) { | ||||
|       const icacls = await io.which('icacls.exe') | ||||
|       await exec.exec( | ||||
|         `"${icacls}" "${this.sshKeyPath}" /grant:r "${process.env['USERDOMAIN']}\\${process.env['USERNAME']}:F"` | ||||
|       ) | ||||
|       await exec.exec(`"${icacls}" "${this.sshKeyPath}" /inheritance:r`) | ||||
|     } | ||||
| 
 | ||||
|     // Write known hosts
 | ||||
|     const userKnownHostsPath = path.join(os.homedir(), '.ssh', 'known_hosts') | ||||
|     let userKnownHosts = '' | ||||
|     try { | ||||
|       userKnownHosts = ( | ||||
|         await fs.promises.readFile(userKnownHostsPath) | ||||
|       ).toString() | ||||
|     } catch (err) { | ||||
|       if (err.code !== 'ENOENT') { | ||||
|         throw err | ||||
|       } | ||||
|     } | ||||
|     let knownHosts = '' | ||||
|     if (userKnownHosts) { | ||||
|       knownHosts += `# Begin from ${userKnownHostsPath}\n${userKnownHosts}\n# End from ${userKnownHostsPath}\n` | ||||
|     } | ||||
|     if (this.settings.sshKnownHosts) { | ||||
|       knownHosts += `# Begin from input known hosts\n${this.settings.sshKnownHosts}\n# end from input known hosts\n` | ||||
|     } | ||||
|     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` | ||||
|     this.sshKnownHostsPath = path.join(runnerTemp, `${uniqueId}_known_hosts`) | ||||
|     stateHelper.setSshKnownHostsPath(this.sshKnownHostsPath) | ||||
|     await fs.promises.writeFile(this.sshKnownHostsPath, knownHosts) | ||||
| 
 | ||||
|     // Configure GIT_SSH_COMMAND
 | ||||
|     const sshPath = await io.which('ssh', true) | ||||
|     let sshCommand = `"${sshPath}" -i "$RUNNER_TEMP/${path.basename( | ||||
|       this.sshKeyPath | ||||
|     )}"` | ||||
|     if (this.settings.sshStrict) { | ||||
|       sshCommand += ' -o StrictHostKeyChecking=yes -o CheckHostIP=no' | ||||
|     } | ||||
|     sshCommand += ` -o "UserKnownHostsFile=$RUNNER_TEMP/${path.basename( | ||||
|       this.sshKnownHostsPath | ||||
|     )}"` | ||||
|     core.info(`Temporarily overriding GIT_SSH_COMMAND=${sshCommand}`) | ||||
|     this.git.setEnvironmentVariable('GIT_SSH_COMMAND', sshCommand) | ||||
| 
 | ||||
|     // Configure core.sshCommand
 | ||||
|     if (this.settings.persistCredentials) { | ||||
|       await this.git.config(SSH_COMMAND_KEY, sshCommand) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private async configureToken( | ||||
|     configPath?: string, | ||||
|     globalConfig?: boolean | ||||
|  | @ -198,12 +282,43 @@ class GitAuthHelper { | |||
|     await fs.promises.writeFile(configPath, content) | ||||
|   } | ||||
| 
 | ||||
|   private async removeSsh(): Promise<void> { | ||||
|     // SSH key
 | ||||
|     const keyPath = this.sshKeyPath || stateHelper.SshKeyPath | ||||
|     if (keyPath) { | ||||
|       try { | ||||
|         await io.rmRF(keyPath) | ||||
|       } catch (err) { | ||||
|         core.debug(err.message) | ||||
|         core.warning(`Failed to remove SSH key '${keyPath}'`) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // SSH known hosts
 | ||||
|     const knownHostsPath = | ||||
|       this.sshKnownHostsPath || stateHelper.SshKnownHostsPath | ||||
|     if (knownHostsPath) { | ||||
|       try { | ||||
|         await io.rmRF(knownHostsPath) | ||||
|       } catch { | ||||
|         // Intentionally empty
 | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // SSH command
 | ||||
|     await this.removeGitConfig(SSH_COMMAND_KEY) | ||||
|   } | ||||
| 
 | ||||
|   private async removeToken(): Promise<void> { | ||||
|     // HTTP extra header
 | ||||
|     await this.removeGitConfig(this.tokenConfigKey) | ||||
|   } | ||||
| 
 | ||||
|   private async removeGitConfig(configKey: string): Promise<void> { | ||||
|   private async removeGitConfig( | ||||
|     configKey: string, | ||||
|     submoduleOnly: boolean = false | ||||
|   ): Promise<void> { | ||||
|     if (!submoduleOnly) { | ||||
|       if ( | ||||
|         (await this.git.configExists(configKey)) && | ||||
|         !(await this.git.tryConfigUnset(configKey)) | ||||
|  | @ -211,10 +326,11 @@ class GitAuthHelper { | |||
|         // Load the config contents
 | ||||
|         core.warning(`Failed to remove '${configKey}' from the git config`) | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const pattern = regexpHelper.escape(configKey) | ||||
|     await this.git.submoduleForeach( | ||||
|       `git config --local --name-only --get-regexp ${pattern} && git config --local --unset-all ${configKey} || :`, | ||||
|       `git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :`, | ||||
|       true | ||||
|     ) | ||||
|   } | ||||
|  |  | |||
|  | @ -18,7 +18,11 @@ export async function getSource(settings: IGitSourceSettings): Promise<void> { | |||
|   core.info( | ||||
|     `Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}` | ||||
|   ) | ||||
|   const repositoryUrl = `https://${hostname}/${encodeURIComponent( | ||||
|   const repositoryUrl = settings.sshKey | ||||
|     ? `git@${hostname}:${encodeURIComponent( | ||||
|         settings.repositoryOwner | ||||
|       )}/${encodeURIComponent(settings.repositoryName)}.git` | ||||
|     : `https://${hostname}/${encodeURIComponent( | ||||
|         settings.repositoryOwner | ||||
|       )}/${encodeURIComponent(settings.repositoryName)}` | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,5 +10,8 @@ export interface IGitSourceSettings { | |||
|   submodules: boolean | ||||
|   nestedSubmodules: boolean | ||||
|   authToken: string | ||||
|   sshKey: string | ||||
|   sshKnownHosts: string | ||||
|   sshStrict: boolean | ||||
|   persistCredentials: boolean | ||||
| } | ||||
|  |  | |||
|  | @ -112,6 +112,12 @@ export function getInputs(): IGitSourceSettings { | |||
|   // Auth token
 | ||||
|   result.authToken = core.getInput('token') | ||||
| 
 | ||||
|   // SSH
 | ||||
|   result.sshKey = core.getInput('ssh-key') | ||||
|   result.sshKnownHosts = core.getInput('ssh-known-hosts') | ||||
|   result.sshStrict = | ||||
|     (core.getInput('ssh-strict') || 'true').toUpperCase() === 'TRUE' | ||||
| 
 | ||||
|   // Persist credentials
 | ||||
|   result.persistCredentials = | ||||
|     (core.getInput('persist-credentials') || 'false').toUpperCase() === 'TRUE' | ||||
|  |  | |||
|  | @ -59,13 +59,17 @@ function updateUsage( | |||
| 
 | ||||
|     // Constrain the width of the description
 | ||||
|     const width = 80 | ||||
|     let description = input.description as string | ||||
|     let description = (input.description as string) | ||||
|       .trimRight() | ||||
|       .replace(/\r\n/g, '\n') // Convert CR to LF
 | ||||
|       .replace(/ +/g, ' ') //    Squash consecutive spaces
 | ||||
|       .replace(/ \n/g, '\n') //  Squash space followed by newline
 | ||||
|     while (description) { | ||||
|       // Longer than width? Find a space to break apart
 | ||||
|       let segment: string = description | ||||
|       if (description.length > width) { | ||||
|         segment = description.substr(0, width + 1) | ||||
|         while (!segment.endsWith(' ') && segment) { | ||||
|         while (!segment.endsWith(' ') && !segment.endsWith('\n') && segment) { | ||||
|           segment = segment.substr(0, segment.length - 1) | ||||
|         } | ||||
| 
 | ||||
|  | @ -77,15 +81,30 @@ function updateUsage( | |||
|         segment = description | ||||
|       } | ||||
| 
 | ||||
|       description = description.substr(segment.length) // Remaining
 | ||||
|       segment = segment.trimRight() // Trim the trailing space
 | ||||
|       newReadme.push(`    # ${segment}`) | ||||
|       // Check for newline
 | ||||
|       const newlineIndex = segment.indexOf('\n') | ||||
|       if (newlineIndex >= 0) { | ||||
|         segment = segment.substr(0, newlineIndex + 1) | ||||
|       } | ||||
| 
 | ||||
|       // Append segment
 | ||||
|       newReadme.push(`    # ${segment}`.trimRight()) | ||||
| 
 | ||||
|       // Remaining
 | ||||
|       description = description.substr(segment.length) | ||||
|     } | ||||
| 
 | ||||
|     // Input and default
 | ||||
|     if (input.default !== undefined) { | ||||
|       // Append blank line if description had paragraphs
 | ||||
|       if ((input.description as string).trimRight().match(/\n[ ]*\r?\n/)) { | ||||
|         newReadme.push(`    #`) | ||||
|       } | ||||
| 
 | ||||
|       // Default
 | ||||
|       newReadme.push(`    # Default: ${input.default}`) | ||||
|     } | ||||
| 
 | ||||
|     // Input name
 | ||||
|     newReadme.push(`    ${key}: ''`) | ||||
| 
 | ||||
|     firstInput = false | ||||
|  |  | |||
|  | @ -11,6 +11,17 @@ export const IsPost = !!process.env['STATE_isPost'] | |||
| export const RepositoryPath = | ||||
|   (process.env['STATE_repositoryPath'] as string) || '' | ||||
| 
 | ||||
| /** | ||||
|  * The SSH key path for the POST action. The value is empty during the MAIN action. | ||||
|  */ | ||||
| export const SshKeyPath = (process.env['STATE_sshKeyPath'] as string) || '' | ||||
| 
 | ||||
| /** | ||||
|  * The SSH known hosts path for the POST action. The value is empty during the MAIN action. | ||||
|  */ | ||||
| export const SshKnownHostsPath = | ||||
|   (process.env['STATE_sshKnownHostsPath'] as string) || '' | ||||
| 
 | ||||
| /** | ||||
|  * Save the repository path so the POST action can retrieve the value. | ||||
|  */ | ||||
|  | @ -22,6 +33,24 @@ export function setRepositoryPath(repositoryPath: string) { | |||
|   ) | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Save the SSH key path so the POST action can retrieve the value. | ||||
|  */ | ||||
| export function setSshKeyPath(sshKeyPath: string) { | ||||
|   coreCommand.issueCommand('save-state', {name: 'sshKeyPath'}, sshKeyPath) | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Save the SSH known hosts path so the POST action can retrieve the value. | ||||
|  */ | ||||
| export function setSshKnownHostsPath(sshKnownHostsPath: string) { | ||||
|   coreCommand.issueCommand( | ||||
|     'save-state', | ||||
|     {name: 'sshKnownHostsPath'}, | ||||
|     sshKnownHostsPath | ||||
|   ) | ||||
| } | ||||
| 
 | ||||
| // Publish a variable so that when the POST action runs, it can determine it should run the cleanup logic.
 | ||||
| // This is necessary since we don't have a separate entry point.
 | ||||
| if (!IsPost) { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user
	 eric sciple
						eric sciple