diff --git a/README.md b/README.md index 9233a25d5..743a8ca39 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,32 @@ 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. [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. [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: '' diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 68926f29f..8090a8b18 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -16,9 +16,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) }) @@ -108,6 +112,51 @@ 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 => { + 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 +178,151 @@ 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(gitConfigPath)) + .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_copiesGlobalGitConfig = 'configureGlobalAuth copies global git config' it(configureGlobalAuth_copiesGlobalGitConfig, async () => { @@ -254,6 +448,60 @@ describe('git-auth-helper tests', () => { } ) + 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(gitConfigPath) + ).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(gitConfigPath)).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 +649,36 @@ async function setup(testName: string): Promise { 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 { + 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 { + 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] +} \ No newline at end of file diff --git a/action.yml b/action.yml index a411037f5..5da86c7e3 100644 --- a/action.yml +++ b/action.yml @@ -11,13 +11,30 @@ 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. [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. [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' diff --git a/dist/index.js b/dist/index.js index 308294604..ffbfe002e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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,18 @@ 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 EXTRA_HEADER_KEY = `http.https://${HOSTNAME}/.extraheader`; +const SSH_COMMAND_KEY = 'core.sshCommand'; function createAuthHelper(git, settings) { return new GitAuthHelper(git, settings); } @@ -5097,6 +5123,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 +5139,7 @@ class GitAuthHelper { // Remove possible previous values yield this.removeAuth(); // Configure new values + yield this.configureSsh(); yield this.configureToken(); }); } @@ -5183,6 +5212,7 @@ class GitAuthHelper { } removeAuth() { return __awaiter(this, void 0, void 0, function* () { + yield this.removeSsh(); yield this.removeToken(); }); } @@ -5193,6 +5223,60 @@ 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}" /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)}"`; + 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,6 +5307,32 @@ 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.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 @@ -5680,7 +5790,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 + ? `ssh://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 +14052,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'; diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index 3f36ff8d4..456daff2e 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -13,6 +13,8 @@ import {IGitSourceSettings} from './git-source-settings' const IS_WINDOWS = process.platform === 'win32' const HOSTNAME = 'github.com' +const EXTRA_HEADER_KEY = `http.https://${HOSTNAME}/.extraheader` +const SSH_COMMAND_KEY = 'core.sshCommand' export interface IGitAuthHelper { configureAuth(): Promise @@ -36,6 +38,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 +65,7 @@ class GitAuthHelper { await this.removeAuth() // Configure new values + await this.configureSsh() await this.configureToken() } @@ -143,6 +148,7 @@ class GitAuthHelper { } async removeAuth(): Promise { + await this.removeSsh() await this.removeToken() } @@ -152,6 +158,73 @@ class GitAuthHelper { await io.rmRF(this.temporaryHomePath) } + private async configureSsh(): Promise { + 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}" /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 + )}"` + 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,6 +271,32 @@ class GitAuthHelper { await fs.promises.writeFile(configPath, content) } + private async removeSsh(): Promise { + // SSH key + const keyPath = this.sshKeyPath || stateHelper.SshKeyPath + if (keyPath) { + try { + await io.rmRF(keyPath) + } catch (err) { + 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 { // HTTP extra header await this.removeGitConfig(this.tokenConfigKey) diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index 90f97c961..8dc14bcfd 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -18,9 +18,13 @@ export async function getSource(settings: IGitSourceSettings): Promise { core.info( `Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}` ) - const repositoryUrl = `https://${hostname}/${encodeURIComponent( - settings.repositoryOwner - )}/${encodeURIComponent(settings.repositoryName)}` + const repositoryUrl = settings.sshKey + ? `ssh://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)) { diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts index e411fadbc..04d548c0c 100644 --- a/src/git-source-settings.ts +++ b/src/git-source-settings.ts @@ -10,5 +10,8 @@ export interface IGitSourceSettings { submodules: boolean nestedSubmodules: boolean authToken: string + sshKey: string + sshKnownHosts: string + sshStrict: boolean persistCredentials: boolean } diff --git a/src/input-helper.ts b/src/input-helper.ts index 376935014..11a1ab672 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -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' diff --git a/src/state-helper.ts b/src/state-helper.ts index da15d862a..3c657b1dd 100644 --- a/src/state-helper.ts +++ b/src/state-helper.ts @@ -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) {