diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 163fee9..9938ba8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -130,6 +130,14 @@ jobs: test altSecret ; test altSecret | NAMED_ALTSECRET ; nested/test otherAltSecret ; + - name: use vault action (using cubbyhole engine) + uses: ./ + with: + url: http://localhost:${{ job.services.vault.ports[8200] }} + token: testtoken + secrets: | + /cubbyhole/test foo ; + /cubbyhole/test zip | NAMED_CUBBYSECRET ; - name: verify run: npm run test:e2e diff --git a/README.md b/README.md index a0ff1cc..c8ea34f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # vault-action -A helper action for easily pulling secrets from the K/V backend of vault. +A helper action for easily pulling secrets from HashiCorp Vault™. -Expects [Version 2](https://www.vaultproject.io/docs/secrets/kv/kv-v2/) of the KV Secrets Engine by default. +By default, this action pulls from [Version 2](https://www.vaultproject.io/docs/secrets/kv/kv-v2/) of the K/V Engine. See examples below for how to [use v1](#using-kv-version-1) as well as non-K/V engines. ## Example Usage @@ -26,7 +26,8 @@ jobs: ## Authentication method -The `method` parameter can have these value : +While most workflows will likely use a vault token, you can also use an `approle` to authenticate with vaule. You can configure which by using the `method` parameter: + - **token**: (by default) you must provide a token parameter ```yaml ... @@ -106,7 +107,7 @@ with: kv-version: 1 ``` -### Custom Engine Path +### Custom K/V Engine Path When you enable the K/V Engine, by default it's placed at the path `secret`, so a secret named `ci` will be accessed from `secret/ci`. However, [if you enabled the secrets engine using a custom `path`](https://www.vaultproject.io/docs/commands/secrets/enable/#inlinecode--path-4), you can pass it as follows: @@ -119,9 +120,56 @@ with: This way, the `ci` secret in the example above will be retrieved from `my-secrets/ci`. +### Other Secret Engines + +While this action primarily supports the K/V engine, it is possible to request secrets from other engines in Vault. + +To do so when specifying the `Secret Path`, just append a leading formard slash (`/`) and specify the path as described in the Vault API documentation. + +For example, to retrieve a stored secret from the [`cubbyhole` engine](https://www.vaultproject.io/api-docs/secret/cubbyhole/), assuming you have a stored secret at the path `foo` with the contents: + +```json +{ + "foo": "bar", + "zip": "zap" +} +``` + +You could request the contents like so: + +```yaml +with: + secrets: | + /cubbyhole/foo foo ; + /cubbyhole/foo zip | MY_KEY ; +``` + +Resulting in: + +```bash +FOO=bar MY_KEY=zap +``` + +Secrets pulled from the same `Secret Path` are cached by default. So if you, for example, are using the `aws` engine and retrieve a key, only a single key for a given path is returned. + +e.g.: + +```yaml +with: + secrets: | + /aws/creds/ci access_key | AWS_ACCESS_KEY_ID ; + /aws/creds/ci secret_key | AWS_SECRET_ACCESS_KEY +``` + +would work fine. + +NOTE: The `Secret Key` is pulled from the `data` property of the response. + +## Vault Enterprise Features + ### Namespace -This action could be use with namespace Vault Enterprise feature. You can specify namespace in request : +If you need to retrieve secrets from a specific vault namespace, all that's required is an additional parameter specifying the namespace. ```yaml steps: diff --git a/action.js b/action.js index 226c18d..cc36793 100644 --- a/action.js +++ b/action.js @@ -3,6 +3,8 @@ const command = require('@actions/core/lib/command'); const got = require('got'); const AUTH_METHODS = ['approle', 'token']; +const VALID_KV_VERSION = [-1, 1, 2]; + async function exportSecrets() { const vaultUrl = core.getInput('url', { required: true }); const vaultNamespace = core.getInput('namespace', { required: false }); @@ -11,7 +13,7 @@ async function exportSecrets() { let kvVersion = core.getInput('kv-version', { required: false }); const secretsInput = core.getInput('secrets', { required: true }); - const secrets = parseSecretsInput(secretsInput); + const secretRequests = parseSecretsInput(secretsInput); const vaultMethod = core.getInput('method', { required: false }) || 'token'; if (!AUTH_METHODS.includes(vaultMethod)) { @@ -52,17 +54,17 @@ async function exportSecrets() { } if (!kvVersion) { - kvVersion = '2'; + kvVersion = 2; + } + kvVersion = +kvVersion; + + if (Number.isNaN(kvVersion) || !VALID_KV_VERSION.includes(kvVersion)) { + throw Error(`You must provide a valid K/V version (${VALID_KV_VERSION.slice(1).join(', ')}). Input: "${kvVersion}"`); } - if (kvVersion !== '1' && kvVersion !== '2') { - throw Error(`You must provide a valid K/V version (1 or 2). Input: "${kvVersion}"`); - } - - kvVersion = parseInt(kvVersion); - - for (const secret of secrets) { - const { secretPath, outputName, secretKey } = secret; + const responseCache = new Map(); + for (const secretRequest of secretRequests) { + const { secretPath, outputName, secretSelector, isJSONPath } = secretRequest; const requestOptions = { headers: { 'X-Vault-Token': vaultToken @@ -73,13 +75,30 @@ async function exportSecrets() { requestOptions.headers["X-Vault-Namespace"] = vaultNamespace; } - const requestPath = (kvVersion === 1) - ? `${vaultUrl}/v1/${enginePath}/${secretPath}` - : `${vaultUrl}/v1/${enginePath}/data/${secretPath}`; - const result = await got(requestPath, requestOptions); + let requestPath = `${vaultUrl}/v1`; + const kvRequest = !secretPath.startsWith('/') + if (!kvRequest) { + requestPath += secretPath; + } else { + requestPath += (kvVersion === 2) + ? `/${enginePath}/data/${secretPath}` + : `/${enginePath}/${secretPath}`; + } - const secretData = parseResponse(result.body, kvVersion); - const value = secretData[secretKey]; + let body; + if (responseCache.has(requestPath)) { + body = responseCache.get(requestPath); + core.debug('ℹ using cached response'); + } else { + const result = await got(requestPath, requestOptions); + body = result.body; + responseCache.set(requestPath, body); + } + + let dataDepth = isJSONPath === true ? 0 : kvRequest === false ? 1 : kvVersion; + + const secretData = getResponseData(body, dataDepth); + const value = selectData(secretData, secretSelector); command.issue('add-mask', value); core.exportVariable(outputName, `${value}`); core.debug(`✔ ${secretPath} => ${outputName}`); @@ -122,17 +141,18 @@ function parseSecretsInput(secretsInput) { throw Error(`You must provide a valid path and key. Input: "${secret}"`) } - const [secretPath, secretKey] = pathParts; + const [secretPath, secretSelector] = pathParts; // If we're not using a mapped name, normalize the key path into a variable name. if (!outputName) { - outputName = normalizeOutputKey(secretKey); + outputName = normalizeOutputKey(secretSelector); } output.push({ secretPath, outputName, - secretKey + secretSelector, + isJSONPath: secretSelector.startsWith('$') }); } return output; @@ -143,22 +163,26 @@ function parseSecretsInput(secretsInput) { * @param {string} responseBody * @param {number} kvVersion */ -function parseResponse(responseBody, kvVersion) { - const parsedResponse = JSON.parse(responseBody); - let secretData; +function getResponseData(responseBody, dataLevel) { + let secretData = JSON.parse(responseBody); - switch(kvVersion) { - case 1: { - secretData = parsedResponse.data; - } break; + for (let i = 0; i < dataLevel; i++) { + secretData = secretData['data']; + } + return secretData; +} - case 2: { - const vaultKeyData = parsedResponse.data; - secretData = vaultKeyData.data; - } break; +/** + * Parses a JSON response and returns the secret data + * @param {Object} data + * @param {string} selector + */ +function selectData(data, selector, isJSONPath) { + if (!isJSONPath) { + return data[selector]; } - return secretData; + // TODO: JSONPath } /** @@ -169,9 +193,16 @@ function normalizeOutputKey(dataKey) { return dataKey.replace('/', '__').replace(/[^\w-]/, '').toUpperCase(); } +function parseBoolInput(input) { + if (input === null || input === undefined || input.trim() === '') { + return null; + } + return Boolean(input); +} + module.exports = { exportSecrets, parseSecretsInput, - parseResponse, + parseResponse: getResponseData, normalizeOutputKey }; diff --git a/action.test.js b/action.test.js index 9c6a30b..b53d08a 100644 --- a/action.test.js +++ b/action.test.js @@ -17,8 +17,9 @@ describe('parseSecretsInput', () => { const output = parseSecretsInput('test key'); expect(output).toContainEqual({ secretPath: 'test', - secretKey: 'key', + secretSelector: 'key', outputName: 'KEY', + isJSONPath: false }); }); diff --git a/dist/index.js b/dist/index.js index d822679..c9e7ffe 100644 --- a/dist/index.js +++ b/dist/index.js @@ -4066,6 +4066,8 @@ const command = __webpack_require__(431); const got = __webpack_require__(77); const AUTH_METHODS = ['approle', 'token']; +const VALID_KV_VERSION = [-1, 1, 2]; + async function exportSecrets() { const vaultUrl = core.getInput('url', { required: true }); const vaultNamespace = core.getInput('namespace', { required: false }); @@ -4074,7 +4076,7 @@ async function exportSecrets() { let kvVersion = core.getInput('kv-version', { required: false }); const secretsInput = core.getInput('secrets', { required: true }); - const secrets = parseSecretsInput(secretsInput); + const secretRequests = parseSecretsInput(secretsInput); const vaultMethod = core.getInput('method', { required: false }) || 'token'; if (!AUTH_METHODS.includes(vaultMethod)) { @@ -4115,17 +4117,17 @@ async function exportSecrets() { } if (!kvVersion) { - kvVersion = '2'; + kvVersion = 2; + } + kvVersion = +kvVersion; + + if (Number.isNaN(kvVersion) || !VALID_KV_VERSION.includes(kvVersion)) { + throw Error(`You must provide a valid K/V version (${VALID_KV_VERSION.slice(1).join(', ')}). Input: "${kvVersion}"`); } - if (kvVersion !== '1' && kvVersion !== '2') { - throw Error(`You must provide a valid K/V version (1 or 2). Input: "${kvVersion}"`); - } - - kvVersion = parseInt(kvVersion); - - for (const secret of secrets) { - const { secretPath, outputName, secretKey } = secret; + const responseCache = new Map(); + for (const secretRequest of secretRequests) { + const { secretPath, outputName, secretSelector, isJSONPath } = secretRequest; const requestOptions = { headers: { 'X-Vault-Token': vaultToken @@ -4136,13 +4138,30 @@ async function exportSecrets() { requestOptions.headers["X-Vault-Namespace"] = vaultNamespace; } - const requestPath = (kvVersion === 1) - ? `${vaultUrl}/v1/${enginePath}/${secretPath}` - : `${vaultUrl}/v1/${enginePath}/data/${secretPath}`; - const result = await got(requestPath, requestOptions); + let requestPath = `${vaultUrl}/v1`; + const kvRequest = !secretPath.startsWith('/') + if (!kvRequest) { + requestPath += secretPath; + } else { + requestPath += (kvVersion === 2) + ? `/${enginePath}/data/${secretPath}` + : `/${enginePath}/${secretPath}`; + } - const secretData = parseResponse(result.body, kvVersion); - const value = secretData[secretKey]; + let body; + if (responseCache.has(requestPath)) { + body = responseCache.get(requestPath); + core.debug('ℹ using cached response'); + } else { + const result = await got(requestPath, requestOptions); + body = result.body; + responseCache.set(requestPath, body); + } + + let dataDepth = isJSONPath === true ? 0 : kvRequest === false ? 1 : kvVersion; + + const secretData = getResponseData(body, dataDepth); + const value = selectData(secretData, secretSelector); command.issue('add-mask', value); core.exportVariable(outputName, `${value}`); core.debug(`✔ ${secretPath} => ${outputName}`); @@ -4185,17 +4204,18 @@ function parseSecretsInput(secretsInput) { throw Error(`You must provide a valid path and key. Input: "${secret}"`) } - const [secretPath, secretKey] = pathParts; + const [secretPath, secretSelector] = pathParts; // If we're not using a mapped name, normalize the key path into a variable name. if (!outputName) { - outputName = normalizeOutputKey(secretKey); + outputName = normalizeOutputKey(secretSelector); } output.push({ secretPath, outputName, - secretKey + secretSelector, + isJSONPath: secretSelector.startsWith('$') }); } return output; @@ -4206,22 +4226,26 @@ function parseSecretsInput(secretsInput) { * @param {string} responseBody * @param {number} kvVersion */ -function parseResponse(responseBody, kvVersion) { - const parsedResponse = JSON.parse(responseBody); - let secretData; +function getResponseData(responseBody, dataLevel) { + let secretData = JSON.parse(responseBody); - switch(kvVersion) { - case 1: { - secretData = parsedResponse.data; - } break; + for (let i = 0; i < dataLevel; i++) { + secretData = secretData['data']; + } + return secretData; +} - case 2: { - const vaultKeyData = parsedResponse.data; - secretData = vaultKeyData.data; - } break; +/** + * Parses a JSON response and returns the secret data + * @param {Object} data + * @param {string} selector + */ +function selectData(data, selector, isJSONPath) { + if (!isJSONPath) { + return data[selector]; } - return secretData; + // TODO: JSONPath } /** @@ -4232,10 +4256,17 @@ function normalizeOutputKey(dataKey) { return dataKey.replace('/', '__').replace(/[^\w-]/, '').toUpperCase(); } +function parseBoolInput(input) { + if (input === null || input === undefined || input.trim() === '') { + return null; + } + return Boolean(input); +} + module.exports = { exportSecrets, parseSecretsInput, - parseResponse, + parseResponse: getResponseData, normalizeOutputKey }; diff --git a/integrationTests/basic/integration.test.js b/integrationTests/basic/integration.test.js index 22b52d6..00c601a 100644 --- a/integrationTests/basic/integration.test.js +++ b/integrationTests/basic/integration.test.js @@ -171,4 +171,40 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('OTHERSECRET', 'OTHERCUSTOMSECRET'); }); + + describe('generic engines', () => { + beforeAll(async () => { + await got(`${vaultUrl}/v1/cubbyhole/test`, { + method: 'POST', + headers: { + 'X-Vault-Token': 'testtoken', + }, + json: { + foo: "bar", + zip: "zap" + }, + }); + }); + + it('supports cubbyhole', async () => { + mockInput('/cubbyhole/test foo'); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('FOO', 'bar'); + }); + + it('caches responses', async () => { + mockInput(` + /cubbyhole/test foo ; + /cubbyhole/test zip`); + + await exportSecrets(); + + expect(core.debug).toBeCalledWith('ℹ using cached response'); + + expect(core.exportVariable).toBeCalledWith('FOO', 'bar'); + expect(core.exportVariable).toBeCalledWith('ZIP', 'zap'); + }); + }) }); diff --git a/integrationTests/e2e/e2e.test.js b/integrationTests/e2e/e2e.test.js index bfa4987..56421ba 100644 --- a/integrationTests/e2e/e2e.test.js +++ b/integrationTests/e2e/e2e.test.js @@ -6,5 +6,7 @@ describe('e2e', () => { expect(process.env.ALTSECRET).toBe("CUSTOMSECRET"); expect(process.env.NAMED_ALTSECRET).toBe("CUSTOMSECRET"); expect(process.env.OTHERALTSECRET).toBe("OTHERCUSTOMSECRET"); + expect(process.env.FOO).toBe("bar"); + expect(process.env.NAMED_CUBBYSECRET).toBe("zap"); }); }); diff --git a/integrationTests/e2e/setup.js b/integrationTests/e2e/setup.js index 1cc73e2..13625dc 100644 --- a/integrationTests/e2e/setup.js +++ b/integrationTests/e2e/setup.js @@ -64,6 +64,17 @@ const vaultUrl = `${process.env.VAULT_HOST}:${process.env.VAULT_PORT}`; otherAltSecret: 'OTHERCUSTOMSECRET', }, }); + + await got(`http://${vaultUrl}/v1/cubbyhole/test`, { + method: 'POST', + headers: { + 'X-Vault-Token': 'testtoken', + }, + json: { + foo: 'bar', + zip: 'zap', + }, + }); } catch (error) { console.log(error); process.exit(1);