diff --git a/package-lock.json b/package-lock.json index 12c7c4a..a468089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7693,6 +7693,11 @@ "minimist": "^1.2.5" } }, + "jsonata": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-1.8.2.tgz", + "integrity": "sha512-ma5F/Bs47dZfJfDZ0Dt37eIbzVBVKZIDqsZSqdCCAPNHxKn+s3+CfMA6ahVVlf8Y1hyIjXkVLFU7yv4XxRfihA==" + }, "jsonfile": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.0.1.tgz", diff --git a/package.json b/package.json index c9de6e2..b1e6c56 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,17 @@ "test:integration:enterprise": "jest -c integrationTests/enterprise/jest.config.js", "test:e2e": "jest -c integrationTests/e2e/jest.config.js" }, + "files": [ + "src/**/*", + "dist/**/*" + ], "release": { "branch": "master", "plugins": [ "@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", - "@semantic-release/github" + "@semantic-release/github", + "@semantic-release/npm" ], "ci": false }, @@ -38,8 +43,9 @@ }, "homepage": "https://github.com/RichiCoder1/vault-action#readme", "dependencies": { + "@actions/core": "^1.2.3", "got": "^10.2.2", - "@actions/core": "^1.2.3" + "jsonata": "^1.8.2" }, "devDependencies": { "@types/got": "^9.6.9", diff --git a/src/action.js b/src/action.js index 9bfea2c..8dabae2 100644 --- a/src/action.js +++ b/src/action.js @@ -2,7 +2,8 @@ const core = require('@actions/core'); const command = require('@actions/core/lib/command'); const got = require('got').default; -const { retrieveToken } = require('./auth'); +const jsonata = require('jsonata'); +const { auth: { retrieveToken }, secrets: { getSecrets } } = require('./index'); const AUTH_METHODS = ['approle', 'token', 'github']; const VALID_KV_VERSION = [-1, 1, 2]; @@ -39,8 +40,9 @@ async function exportSecrets() { defaultOptions.headers["X-Vault-Namespace"] = vaultNamespace; } + const vaultToken = await retrieveToken(vaultMethod, got.extend(defaultOptions)); + defaultOptions.headers['X-Vault-Token'] = vaultToken; const client = got.extend(defaultOptions); - const vaultToken = await retrieveToken(vaultMethod, client); if (!enginePath) { enginePath = 'secret'; @@ -55,62 +57,39 @@ async function exportSecrets() { throw Error(`You must provide a valid K/V version (${VALID_KV_VERSION.slice(1).join(', ')}). Input: "${kvVersion}"`); } - const responseCache = new Map(); - for (const secretRequest of secretRequests) { - const { secretPath, outputVarName, envVarName, secretSelector, isJSONPath } = secretRequest; - const requestOptions = { - headers: { - 'X-Vault-Token': vaultToken - }, - }; + const requests = secretRequests.map(request => { + const { path, selector } = request; - for (const [headerName, headerValue] of extraHeaders) { - requestOptions.headers[headerName] = headerValue; + if (path.startsWith('/')) { + return request; } + const kvPath = (kvVersion === 2) + ? `/${enginePath}/data/${path}` + : `/${enginePath}/${path}`; + const kvSelector = (kvVersion === 2) + ? `data.data.${selector}` + : `data.${selector}`; + return { ...request, path: kvPath, selector: kvSelector }; + }); - if (vaultNamespace != null) { - requestOptions.headers["X-Vault-Namespace"] = vaultNamespace; - } + const results = await getSecrets(requests, client); - let requestPath = `v1`; - const kvRequest = !secretPath.startsWith('/') - if (!kvRequest) { - requestPath += secretPath; - } else { - requestPath += (kvVersion === 2) - ? `/${enginePath}/data/${secretPath}` - : `/${enginePath}/${secretPath}`; - } - - let body; - if (responseCache.has(requestPath)) { - body = responseCache.get(requestPath); - core.debug('ℹ using cached response'); - } else { - const result = await client.get(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, isJSONPath); + for (const result of results) { + const { value, request } = result; command.issue('add-mask', value); if (exportEnv) { - core.exportVariable(envVarName, `${value}`); + core.exportVariable(request.envVarName, `${value}`); } - core.setOutput(outputVarName, `${value}`); - core.debug(`✔ ${secretPath} => outputs.${outputVarName}${exportEnv ? ` | env.${envVarName}` : ''}`); + core.setOutput(request.outputVarName, `${value}`); + core.debug(`✔ ${request.path} => outputs.${request.outputVarName}${exportEnv ? ` | env.${request.envVarName}` : ''}`); } }; /** @typedef {Object} SecretRequest - * @property {string} secretPath + * @property {string} path * @property {string} envVarName * @property {string} outputVarName - * @property {string} secretSelector - * @property {boolean} isJSONPath + * @property {string} selector */ /** @@ -127,12 +106,12 @@ function parseSecretsInput(secretsInput) { /** @type {SecretRequest[]} */ const output = []; for (const secret of secrets) { - let path = secret; + let pathSpec = secret; let outputVarName = null; const renameSigilIndex = secret.lastIndexOf('|'); if (renameSigilIndex > -1) { - path = secret.substring(0, renameSigilIndex).trim(); + pathSpec = secret.substring(0, renameSigilIndex).trim(); outputVarName = secret.substring(renameSigilIndex + 1).trim(); if (outputVarName.length < 1) { @@ -140,7 +119,7 @@ function parseSecretsInput(secretsInput) { } } - const pathParts = path + const pathParts = pathSpec .split(/\s+/) .map(part => part.trim()) .filter(part => part.length !== 0); @@ -149,64 +128,41 @@ function parseSecretsInput(secretsInput) { throw Error(`You must provide a valid path and key. Input: "${secret}"`); } - const [secretPath, secretSelector] = pathParts; + const [path, selector] = pathParts; - const isJSONPath = secretSelector.includes('.'); + /** @type {any} */ + const selectorAst = jsonata(selector).ast(); - if (isJSONPath && !outputVarName) { + if (selectorAst.type !== "path") { + throw Error(`Invalid path selector.`); + } + + if ((selectorAst.steps > 1 || selectorAst.steps[0].stages) && !outputVarName) { throw Error(`You must provide a name for the output key when using json selectors. Input: "${secret}"`); } let envVarName = outputVarName; if (!outputVarName) { - outputVarName = secretSelector; + outputVarName = selector; envVarName = normalizeOutputKey(outputVarName); } output.push({ - secretPath, + path, envVarName, outputVarName, - secretSelector, - isJSONPath + selector }); } return output; } -/** - * Parses a JSON response and returns the secret data - * @param {string} responseBody - * @param {number} dataLevel - */ -function getResponseData(responseBody, dataLevel) { - let secretData = JSON.parse(responseBody); - - for (let i = 0; i < dataLevel; i++) { - secretData = secretData['data']; - } - return secretData; -} - -/** - * 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]; - } - - // TODO: JSONPath -} - /** * Replaces any forward-slash characters to * @param {string} dataKey */ function normalizeOutputKey(dataKey) { - return dataKey.replace('/', '__').replace(/[^\w-]/, '').toUpperCase(); + return dataKey.replace('/', '__').replace('.', '__').replace(/[^\w-]/, '').toUpperCase(); } /** @@ -237,7 +193,6 @@ function parseHeadersInput(inputKey, inputOptions) { module.exports = { exportSecrets, parseSecretsInput, - parseResponse: getResponseData, normalizeOutputKey, parseHeadersInput -}; +}; \ No newline at end of file diff --git a/src/action.test.js b/src/action.test.js index e400f0c..d979e7f 100644 --- a/src/action.test.js +++ b/src/action.test.js @@ -17,11 +17,10 @@ describe('parseSecretsInput', () => { it('parses simple secret', () => { const output = parseSecretsInput('test key'); expect(output).toContainEqual({ - secretPath: 'test', - secretSelector: 'key', + path: 'test', + selector: 'key', outputVarName: 'key', - envVarName: 'KEY', - isJSONPath: false + envVarName: 'KEY' }); }); @@ -49,10 +48,10 @@ describe('parseSecretsInput', () => { expect(output).toHaveLength(2); expect(output[0]).toMatchObject({ - secretPath: 'first', + path: 'first', }); expect(output[1]).toMatchObject({ - secretPath: 'second', + path: 'second', }); }); @@ -78,7 +77,7 @@ describe('parseSecretsInput', () => { expect(output).toHaveLength(3); expect(output[0]).toMatchObject({ - secretPath: 'first', + path: 'first', }); expect(output[1]).toMatchObject({ outputVarName: 'b', @@ -129,41 +128,8 @@ describe('parseHeaders', () => { const result = parseHeadersInput('extraHeaders'); expect(Array.from(result)).toHaveLength(0); }); -}) - -describe('parseResponse', () => { - // https://www.vaultproject.io/api/secret/kv/kv-v1.html#sample-response - it('parses K/V version 1 response', () => { - const response = JSON.stringify({ - data: { - foo: 'bar' - } - }) - const output = parseResponse(response, 1); - - expect(output).toEqual({ - foo: 'bar' - }); - }); - - // https://www.vaultproject.io/api/secret/kv/kv-v2.html#read-secret-version - it('parses K/V version 2 response', () => { - const response = JSON.stringify({ - data: { - data: { - foo: 'bar' - } - } - }) - const output = parseResponse(response, 2); - - expect(output).toEqual({ - foo: 'bar' - }); - }); }); - describe('exportSecrets', () => { beforeEach(() => { jest.resetAllMocks(); diff --git a/src/entry.js b/src/entry.js new file mode 100644 index 0000000..44b0e04 --- /dev/null +++ b/src/entry.js @@ -0,0 +1,10 @@ +const core = require('@actions/core'); +const { exportSecrets } = require('./action'); + +(async () => { + try { + await core.group('Get Vault Secrets', exportSecrets); + } catch (error) { + core.setFailed(error.message); + } +})(); \ No newline at end of file diff --git a/src/index.js b/src/index.js index 44b0e04..d1d673b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,7 @@ -const core = require('@actions/core'); -const { exportSecrets } = require('./action'); +const auth = require('./auth'); +const secrets = require('./secrets'); -(async () => { - try { - await core.group('Get Vault Secrets', exportSecrets); - } catch (error) { - core.setFailed(error.message); - } -})(); \ No newline at end of file +module.exports = { + auth, + secrets +}; \ No newline at end of file diff --git a/src/secrets.js b/src/secrets.js new file mode 100644 index 0000000..e2dfbbd --- /dev/null +++ b/src/secrets.js @@ -0,0 +1,68 @@ +const jsonata = require("jsonata"); + + +/** + * @typedef {Object} SecretRequest + * @property {string} path + * @property {string} selector + */ + +/** + * @template {SecretRequest} TRequest + * @typedef {Object} SecretResponse + * @property {TRequest} request + * @property {string} value + * @property {boolean} cachedResponse + */ + + /** + * @template TRequest + * @param {Array} secretRequests + * @param {import('got').Got} client + * @return {Promise[]>} + */ +async function getSecrets(secretRequests, client) { + const responseCache = new Map(); + const results = []; + for (const secretRequest of secretRequests) { + const { path, selector } = secretRequest; + + const requestPath = `v1${path}`; + let body; + let cachedResponse = false; + if (responseCache.has(requestPath)) { + body = responseCache.get(requestPath); + cachedResponse = true; + } else { + const result = await client.get(requestPath); + body = result.body; + responseCache.set(requestPath, body); + } + + const value = selectData(JSON.parse(body), selector); + results.push({ + request: secretRequest, + value, + cachedResponse + }); + } + return results; +} + +/** + * Uses a Jsonata selector retrieve a bit of data from the result + * @param {object} data + * @param {string} selector + */ +function selectData(data, selector) { + let result = JSON.stringify(jsonata(selector).evaluate(data)); + if (result.startsWith(`"`)) { + result = result.substring(1, result.length - 1); + } + return result; +} + +module.exports = { + getSecrets, + selectData +} \ No newline at end of file