From 4fcb25e7ef89d0ad885e957928ddfe0a78aad59e Mon Sep 17 00:00:00 2001 From: Thorben Nissen Date: Mon, 18 May 2020 09:08:29 +0200 Subject: [PATCH] Randomize SSH auth socket, kill agent to support non-ephemeral, self hosted runners (@thommyhh, #27) Thanks to @thommyhh for this contribution! Unless the `SSH_AUTH_SOCK` is configured explicitly, this change will make the SSH agent use a random file name for the socket. That way, multiple, concurrent SSH agents can be used on non-ephemeral, self-hosted runners. A new post-action step will automatically clean up the running agent at the end of a job. Be aware of the possible security implications: Two jobs running on the same runner might be able to access each other's socket and thus access repositories and/or hosts. --- README.md | 7 +- action.yml | 2 +- cleanup.js | 10 ++ dist/cleanup.js | 332 ++++++++++++++++++++++++++++++++++++++++++++++ dist/index.js | 21 ++- index.js | 17 ++- package-lock.json | 12 +- package.json | 8 +- scripts/build.js | 35 +++++ 9 files changed, 424 insertions(+), 20 deletions(-) create mode 100644 cleanup.js create mode 100644 dist/cleanup.js create mode 100644 scripts/build.js diff --git a/README.md b/README.md index df220e1..ac87662 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ The `ssh-agent` will load all of the keys and try each one in order when establi There's one **caveat**, though: SSH servers may abort the connection attempt after a number of mismatching keys have been presented. So if, for example, you have six different keys loaded into the `ssh-agent`, but the server aborts after five unknown keys, the last key (which might be the right one) will never even be tried. +## Exported variables +The action exports `SSH_AUTH_SOCK` and `SSH_AGENT_PID` through the Github Actions core module. +The `$SSH_AUTH_SOCK` is used by several applications like git or rsync to connect to the SSH authentication agent. +The `$SSH_AGENT_PID` contains the process id of the agent. This is used to kill the agent in post job action. + ## Known issues and limitations ### Currently OS X and Linux only @@ -116,7 +121,7 @@ As a note to my future self, in order to work on this repo: * Run `npm install` to fetch dependencies * _hack hack hack_ * `node index.js`. Inputs are passed through `INPUT_` env vars with their names uppercased. Use `env "INPUT_SSH-PRIVATE-KEY=\`cat file\`" node index.js` for this action. -* Run `./node_modules/.bin/ncc build index.js` to update `dist/index.js`, which is the file actually run +* Run `npm run build` to update `dist/*`, which holds the files actually run * Read https://help.github.com/en/articles/creating-a-javascript-action if unsure. * Maybe update the README example when publishing a new version. diff --git a/action.yml b/action.yml index bdeeaa6..e43c44f 100644 --- a/action.yml +++ b/action.yml @@ -6,10 +6,10 @@ inputs: required: true ssh-auth-sock: description: 'Where to place the SSH Agent auth socket' - default: /tmp/ssh-auth.sock runs: using: 'node12' main: 'dist/index.js' + post: 'dist/cleanup.js' branding: icon: loader color: 'yellow' diff --git a/cleanup.js b/cleanup.js new file mode 100644 index 0000000..89955a6 --- /dev/null +++ b/cleanup.js @@ -0,0 +1,10 @@ +const core = require('@actions/core') +const { execSync } = require('child_process') + +try { + // Kill the started SSH agent + console.log('Stopping SSH agent') + execSync('kill ${SSH_AGENT_PID}', { stdio: 'inherit' }) +} catch (error) { + core.setFailed(error.message) +} diff --git a/dist/cleanup.js b/dist/cleanup.js new file mode 100644 index 0000000..a678e24 --- /dev/null +++ b/dist/cleanup.js @@ -0,0 +1,332 @@ +module.exports = +/******/ (function(modules, runtime) { // webpackBootstrap +/******/ "use strict"; +/******/ // The module cache +/******/ var installedModules = {}; +/******/ +/******/ // The require function +/******/ function __webpack_require__(moduleId) { +/******/ +/******/ // Check if module is in cache +/******/ if(installedModules[moduleId]) { +/******/ return installedModules[moduleId].exports; +/******/ } +/******/ // Create a new module (and put it into the cache) +/******/ var module = installedModules[moduleId] = { +/******/ i: moduleId, +/******/ l: false, +/******/ exports: {} +/******/ }; +/******/ +/******/ // Execute the module function +/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); +/******/ +/******/ // Flag the module as loaded +/******/ module.l = true; +/******/ +/******/ // Return the exports of the module +/******/ return module.exports; +/******/ } +/******/ +/******/ +/******/ __webpack_require__.ab = __dirname + "/"; +/******/ +/******/ // the startup function +/******/ function startup() { +/******/ // Load entry module and return exports +/******/ return __webpack_require__(175); +/******/ }; +/******/ +/******/ // run startup +/******/ return startup(); +/******/ }) +/************************************************************************/ +/******/ ({ + +/***/ 87: +/***/ (function(module) { + +module.exports = require("os"); + +/***/ }), + +/***/ 129: +/***/ (function(module) { + +module.exports = require("child_process"); + +/***/ }), + +/***/ 175: +/***/ (function(__unusedmodule, __unusedexports, __webpack_require__) { + +const core = __webpack_require__(470) +const { execSync } = __webpack_require__(129) + +try { + // Kill the started SSH agent + console.log('Stopping SSH agent') + execSync('kill ${SSH_AGENT_PID}', { stdio: 'inherit' }) +} catch (error) { + core.setFailed(error.message) +} + + +/***/ }), + +/***/ 431: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +Object.defineProperty(exports, "__esModule", { value: true }); +const os = __webpack_require__(87); +/** + * Commands + * + * Command Format: + * ##[name key=value;key=value]message + * + * Examples: + * ##[warning]This is the user warning message + * ##[set-secret name=mypassword]definitelyNotAPassword! + */ +function issueCommand(command, properties, message) { + const cmd = new Command(command, properties, message); + process.stdout.write(cmd.toString() + os.EOL); +} +exports.issueCommand = issueCommand; +function issue(name, message = '') { + issueCommand(name, {}, message); +} +exports.issue = issue; +const CMD_PREFIX = '##['; +class Command { + constructor(command, properties, message) { + if (!command) { + command = 'missing.command'; + } + this.command = command; + this.properties = properties; + this.message = message; + } + toString() { + let cmdStr = CMD_PREFIX + this.command; + if (this.properties && Object.keys(this.properties).length > 0) { + cmdStr += ' '; + for (const key in this.properties) { + if (this.properties.hasOwnProperty(key)) { + const val = this.properties[key]; + if (val) { + // safely append the val - avoid blowing up when attempting to + // call .replace() if message is not a string for some reason + cmdStr += `${key}=${escape(`${val || ''}`)};`; + } + } + } + } + cmdStr += ']'; + // safely append the message - avoid blowing up when attempting to + // call .replace() if message is not a string for some reason + const message = `${this.message || ''}`; + cmdStr += escapeData(message); + return cmdStr; + } +} +function escapeData(s) { + return s.replace(/\r/g, '%0D').replace(/\n/g, '%0A'); +} +function escape(s) { + return s + .replace(/\r/g, '%0D') + .replace(/\n/g, '%0A') + .replace(/]/g, '%5D') + .replace(/;/g, '%3B'); +} +//# sourceMappingURL=command.js.map + +/***/ }), + +/***/ 470: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const command_1 = __webpack_require__(431); +const path = __webpack_require__(622); +/** + * The code to exit an action + */ +var ExitCode; +(function (ExitCode) { + /** + * A code indicating that the action was successful + */ + ExitCode[ExitCode["Success"] = 0] = "Success"; + /** + * A code indicating that the action was a failure + */ + ExitCode[ExitCode["Failure"] = 1] = "Failure"; +})(ExitCode = exports.ExitCode || (exports.ExitCode = {})); +//----------------------------------------------------------------------- +// Variables +//----------------------------------------------------------------------- +/** + * sets env variable for this action and future actions in the job + * @param name the name of the variable to set + * @param val the value of the variable + */ +function exportVariable(name, val) { + process.env[name] = val; + command_1.issueCommand('set-env', { name }, val); +} +exports.exportVariable = exportVariable; +/** + * exports the variable and registers a secret which will get masked from logs + * @param name the name of the variable to set + * @param val value of the secret + */ +function exportSecret(name, val) { + exportVariable(name, val); + // the runner will error with not implemented + // leaving the function but raising the error earlier + command_1.issueCommand('set-secret', {}, val); + throw new Error('Not implemented.'); +} +exports.exportSecret = exportSecret; +/** + * Prepends inputPath to the PATH (for this action and future actions) + * @param inputPath + */ +function addPath(inputPath) { + command_1.issueCommand('add-path', {}, inputPath); + process.env['PATH'] = `${inputPath}${path.delimiter}${process.env['PATH']}`; +} +exports.addPath = addPath; +/** + * Gets the value of an input. The value is also trimmed. + * + * @param name name of the input to get + * @param options optional. See InputOptions. + * @returns string + */ +function getInput(name, options) { + const val = process.env[`INPUT_${name.replace(' ', '_').toUpperCase()}`] || ''; + if (options && options.required && !val) { + throw new Error(`Input required and not supplied: ${name}`); + } + return val.trim(); +} +exports.getInput = getInput; +/** + * Sets the value of an output. + * + * @param name name of the output to set + * @param value value to store + */ +function setOutput(name, value) { + command_1.issueCommand('set-output', { name }, value); +} +exports.setOutput = setOutput; +//----------------------------------------------------------------------- +// Results +//----------------------------------------------------------------------- +/** + * Sets the action status to failed. + * When the action exits it will be with an exit code of 1 + * @param message add error issue message + */ +function setFailed(message) { + process.exitCode = ExitCode.Failure; + error(message); +} +exports.setFailed = setFailed; +//----------------------------------------------------------------------- +// Logging Commands +//----------------------------------------------------------------------- +/** + * Writes debug message to user log + * @param message debug message + */ +function debug(message) { + command_1.issueCommand('debug', {}, message); +} +exports.debug = debug; +/** + * Adds an error issue + * @param message error issue message + */ +function error(message) { + command_1.issue('error', message); +} +exports.error = error; +/** + * Adds an warning issue + * @param message warning issue message + */ +function warning(message) { + command_1.issue('warning', message); +} +exports.warning = warning; +/** + * Begin an output group. + * + * Output until the next `groupEnd` will be foldable in this group + * + * @param name The name of the output group + */ +function startGroup(name) { + command_1.issue('group', name); +} +exports.startGroup = startGroup; +/** + * End an output group. + */ +function endGroup() { + command_1.issue('endgroup'); +} +exports.endGroup = endGroup; +/** + * Wrap an asynchronous function call in a group. + * + * Returns the same type as the function itself. + * + * @param name The name of the group + * @param fn The function to wrap in the group + */ +function group(name, fn) { + return __awaiter(this, void 0, void 0, function* () { + startGroup(name); + let result; + try { + result = yield fn(); + } + finally { + endGroup(); + } + return result; + }); +} +exports.group = group; +//# sourceMappingURL=core.js.map + +/***/ }), + +/***/ 622: +/***/ (function(module) { + +module.exports = require("path"); + +/***/ }) + +/******/ }); \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 5ee5d2f..f7a65cd 100644 --- a/dist/index.js +++ b/dist/index.js @@ -62,7 +62,7 @@ try { const home = process.env['HOME']; const homeSsh = home + '/.ssh'; - const privateKey = core.getInput('ssh-private-key').trim(); + const privateKey = core.getInput('ssh-private-key'); if (!privateKey) { core.setFailed("The ssh-private-key argument is empty. Maybe the secret has not been configured, or you are using a wrong secret name in your workflow file."); @@ -71,14 +71,27 @@ try { } console.log(`Adding GitHub.com keys to ${homeSsh}/known_hosts`); - fs.mkdirSync(homeSsh, { recursive: true}); + fs.mkdirSync(homeSsh, { recursive: true }); fs.appendFileSync(`${homeSsh}/known_hosts`, '\ngithub.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==\n'); fs.appendFileSync(`${homeSsh}/known_hosts`, '\ngithub.com ssh-dss AAAAB3NzaC1kc3MAAACBANGFW2P9xlGU3zWrymJgI/lKo//ZW2WfVtmbsUZJ5uyKArtlQOT2+WRhcg4979aFxgKdcsqAYW3/LS1T2km3jYW/vr4Uzn+dXWODVk5VlUiZ1HFOHf6s6ITcZvjvdbp6ZbpM+DuJT7Bw+h5Fx8Qt8I16oCZYmAPJRtu46o9C2zk1AAAAFQC4gdFGcSbp5Gr0Wd5Ay/jtcldMewAAAIATTgn4sY4Nem/FQE+XJlyUQptPWMem5fwOcWtSXiTKaaN0lkk2p2snz+EJvAGXGq9dTSWHyLJSM2W6ZdQDqWJ1k+cL8CARAqL+UMwF84CR0m3hj+wtVGD/J4G5kW2DBAf4/bqzP4469lT+dF2FRQ2L9JKXrCWcnhMtJUvua8dvnwAAAIB6C4nQfAA7x8oLta6tT+oCk2WQcydNsyugE8vLrHlogoWEicla6cWPk7oXSspbzUcfkjN3Qa6e74PhRkc7JdSdAlFzU3m7LMkXo1MHgkqNX8glxWNVqBSc0YRdbFdTkL0C6gtpklilhvuHQCdbgB3LBAikcRkDp+FCVkUgPC/7Rw==\n'); console.log("Starting ssh-agent"); const authSock = core.getInput('ssh-auth-sock'); - child_process.execFileSync('ssh-agent', ['-a', authSock]); - core.exportVariable('SSH_AUTH_SOCK', authSock); + let sshAgentOutput = '' + if (authSock && authSock.length > 0) { + sshAgentOutput = child_process.execFileSync('ssh-agent', ['-a', authSock]); + } else { + sshAgentOutput = child_process.execFileSync('ssh-agent') + } + + // Extract auth socket path and agent pid and set them as job variables + const lines = sshAgentOutput.toString().split("\n") + for (const lineNumber in lines) { + const matches = /^(SSH_AUTH_SOCK|SSH_AGENT_PID)=(.*); export \1/.exec(lines[lineNumber]) + if (matches && matches.length > 0) { + core.exportVariable(matches[1], matches[2]) + } + } console.log("Adding private key to agent"); privateKey.split(/(?=-----BEGIN)/).forEach(function(key) { diff --git a/index.js b/index.js index e141876..7ee6fe7 100644 --- a/index.js +++ b/index.js @@ -22,8 +22,21 @@ try { console.log("Starting ssh-agent"); const authSock = core.getInput('ssh-auth-sock'); - child_process.execFileSync('ssh-agent', ['-a', authSock]); - core.exportVariable('SSH_AUTH_SOCK', authSock); + let sshAgentOutput = '' + if (authSock && authSock.length > 0) { + sshAgentOutput = child_process.execFileSync('ssh-agent', ['-a', authSock]); + } else { + sshAgentOutput = child_process.execFileSync('ssh-agent') + } + + // Extract auth socket path and agent pid and set them as job variables + const lines = sshAgentOutput.toString().split("\n") + for (const lineNumber in lines) { + const matches = /^(SSH_AUTH_SOCK|SSH_AGENT_PID)=(.*); export \1/.exec(lines[lineNumber]) + if (matches && matches.length > 0) { + core.exportVariable(matches[1], matches[2]) + } + } console.log("Adding private key to agent"); privateKey.split(/(?=-----BEGIN)/).forEach(function(key) { diff --git a/package-lock.json b/package-lock.json index 829664d..12699b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,9 @@ "requires": true, "dependencies": { "@actions/core": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.1.0.tgz", - "integrity": "sha512-KKpo3xzo0Zsikni9tbOsEQkxZBGDsYSJZNkTvmo0gPSXrc98TBOcdTvKwwjitjkjHkreTggWdB1ACiAFVgsuzA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.4.tgz", + "integrity": "sha512-YJCEq8BE3CdN8+7HPZ/4DxJjk/OkZV2FFIf+DlZTC/4iBlzYCD5yjRR6eiOS5llO11zbRltIRuKAjMKaWTE6cg==", "dev": true }, "@zeit/ncc": { @@ -15,12 +15,6 @@ "resolved": "https://registry.npmjs.org/@zeit/ncc/-/ncc-0.20.5.tgz", "integrity": "sha512-XU6uzwvv95DqxciQx+aOLhbyBx/13ky+RK1y88Age9Du3BlA4mMPCy13BGjayOrrumOzlq1XV3SD/BWiZENXlw==", "dev": true - }, - "child_process": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", - "integrity": "sha1-sffn/HPSXn/R1FWtyU4UODAYK1o=", - "dev": true } } } diff --git a/package.json b/package.json index 35a0c5c..bb68cef 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "author": "webfactory GmbH ", "license": "MIT", "devDependencies": { - "@actions/core": "^1.1.0", - "@zeit/ncc": "^0.20.5", - "child_process": "^1.0.2" + "@actions/core": "^1.2.4", + "@zeit/ncc": "^0.20.5" + }, + "scripts": { + "build": "node scripts/build.js" } } diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..eecd192 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,35 @@ +const { execSync } = require('child_process') +const path = require('path') +const fs = require('fs') + +const buildDir = path.join(process.cwd(), 'build') +const distDir = path.join(process.cwd(), 'dist') + +const buildIndexJs = path.join(buildDir, 'index.js') +const distIndexJs = path.join(distDir, 'index.js') +const distCleanupJs = path.join(distDir, 'cleanup.js') + +if (!fs.existsSync(buildDir)) { + fs.mkdirSync(buildDir) +} + +// Build the main index.js file +console.log('Building index.js...') +execSync(`./node_modules/.bin/ncc build index.js -q -o ${buildDir}`) +if (fs.existsSync(distIndexJs)) { + fs.unlinkSync(distIndexJs) +} +fs.renameSync(buildIndexJs, distIndexJs) + +// Build the cleanup.js file +console.log('Building cleanup.js...') +execSync(`./node_modules/.bin/ncc build cleanup.js -q -o ${buildDir}`) +if (fs.existsSync(distCleanupJs)) { + fs.unlinkSync(distCleanupJs) +} +fs.renameSync(buildIndexJs, distCleanupJs) + +console.log('Cleaning up...') +fs.rmdirSync(buildDir) + +console.log('Done')