From f3f9f6b112572fe5f07dc541904374c20271e3cb Mon Sep 17 00:00:00 2001 From: vikas-pundir-learnings Date: Sun, 16 Jul 2023 19:06:34 +0100 Subject: [PATCH] feature request: write secrets to vault feature request: write secrets to vault --- .github/workflows/local-test.yaml | 12 + README.md | 49 +++ action.yml | 3 + dist/index.js | 123 ++++++- integrationTests/basic/integration.test.js | 89 ++++- .../enterprise/enterprise.test.js | 48 +++ src/action.js | 87 ++++- src/action.test.js | 336 +++++++++++++++++- src/secrets.js | 39 ++ 9 files changed, 754 insertions(+), 32 deletions(-) diff --git a/.github/workflows/local-test.yaml b/.github/workflows/local-test.yaml index 4bb0613..ea392be 100644 --- a/.github/workflows/local-test.yaml +++ b/.github/workflows/local-test.yaml @@ -46,8 +46,20 @@ jobs: url: http://localhost:8200 method: token token: testtoken + secretsMethod: read secrets: | secret/data/test-json-string jsonString; + + # Write Secret examples + + # Write Simple Secret + # secret/data/writetest secret=TEST ; + + # Write Mulitple Secrets at one path + # secret/data/writetest secret=TEST secret1=TEST1 secret2=TEST2 ; + + # Json String Secret + # secret/data/writetest secret={"url":"https://google.com/hello","key":"EQWQASAMSADAD"}; - name: Check Secrets run: | diff --git a/README.md b/README.md index 4dd5c48..600204c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ A helper action for easily pulling secrets from HashiCorp Vault™. - [Simple Key](#simple-key) - [Set Output Variable Name](#set-output-variable-name) - [Multiple Secrets](#multiple-secrets) + - [Write Secrets](#write-secrets) + - [Write Multiple Secrets](#write-multiple-secrets) + - [Write Json Secrets](#write-json-secrets) - [Other Secret Engines](#other-secret-engines) - [Adding Extra Headers](#adding-extra-headers) - [HashiCorp Cloud Platform or Vault Enterprise](#hashicorp-cloud-platform-or-vault-enterprise) @@ -374,6 +377,51 @@ with: secret/data/ci/aws secretKey | AWS_SECRET_ACCESS_KEY ``` +### Write Secrets + +This action can write secrets to vault, so say you had your AWS access Key and you want them to write to vault. You can provide `secretsMethod: write` and provide the secret data as below: + +```yaml +with: + secretsMethod: write + secrets: | + secret/data/ci/aws accessKey=someAccessKey; +``` + +`vault-action` create the secret at provided vault path. You will get `SUCCESS` in response for you saved secrets. + +You can also write the multiple secrets at a single path. You can do: + +```yaml +with: + secretsMethod: write + secrets: | + secret/data/ci/aws accessKey=someAccessKey secretKey=someSecretKey; +``` + +### Write Multiple Secrets + +This action can take multi-line input, so say you had your AWS keys to be saved to vault. You can do: + +```yaml +with: + secretsMethod: write + secrets: | + secret/data/ci/aws/key accessKey=someAccessKey ; + secret/data/ci/aws/secret secretKey=someAccessKey ; +``` + +### Write Json Secrets + +This action can take json string input as a secret value and save it to vault as a json string. You can do: + +```yaml +with: + secretsMethod: write + secrets: | + secret/data/ci/aws/ secret={"accessKey":"someAccessKey","secretKey":"someAccessKey"} ; +``` + ## Other Secret Engines Vault Action currently supports retrieving secrets from any engine where secrets @@ -461,6 +509,7 @@ Here are all the inputs available through `with`: | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------- | | `url` | The URL for the vault endpoint | | ✔ | | `secrets` | A semicolon-separated list of secrets to retrieve. These will automatically be converted to environmental variable keys. See README for more details | | | +| `secretsMethod` | The secretsMethod indicates if you want to read or write secrets to vault. Supported values are `"read"` and `"write"`. If not provided, `default` is `"read"` | | | | `namespace` | The Vault namespace from which to query secrets. Vault Enterprise only, unset by default | | | | `method` | The method to use to authenticate with Vault. | `token` | | | `role` | Vault role for specified auth method | | | diff --git a/action.yml b/action.yml index 94d358e..fd12ab9 100644 --- a/action.yml +++ b/action.yml @@ -4,6 +4,9 @@ inputs: url: description: 'The URL for the vault endpoint' required: true + secretsMethod: + description: 'The secretsMethod indicates if you want to read or write to vault. Supported values are "read" and "write". If not provided default is "read"' + required: false secrets: description: 'A semicolon-separated list of secrets to retrieve. These will automatically be converted to environmental variable keys. See README for more details' required: false diff --git a/dist/index.js b/dist/index.js index 1fa24f4..d2eb54f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -18516,10 +18516,11 @@ const core = __nccwpck_require__(2186); const command = __nccwpck_require__(7351); const got = (__nccwpck_require__(3061)["default"]); const jsonata = __nccwpck_require__(4245); -const { auth: { retrieveToken }, secrets: { getSecrets } } = __nccwpck_require__(4351); +const { auth: { retrieveToken }, secrets: { getSecrets, writeSecrets } } = __nccwpck_require__(4351); const AUTH_METHODS = ['approle', 'token', 'github', 'jwt', 'kubernetes', 'ldap', 'userpass']; const ENCODING_TYPES = ['base64', 'hex', 'utf8']; +const SECRETS_METHOD = { Read: "read", Write: "write" }; async function exportSecrets() { const vaultUrl = core.getInput('url', { required: true }); @@ -18528,6 +18529,7 @@ async function exportSecrets() { const exportEnv = core.getInput('exportEnv', { required: false }) != 'false'; const outputToken = (core.getInput('outputToken', { required: false }) || 'false').toLowerCase() != 'false'; const exportToken = (core.getInput('exportToken', { required: false }) || 'false').toLowerCase() != 'false'; + const secretsMethod = core.getInput('secretsMethod', { required: false }); const secretsInput = core.getInput('secrets', { required: false }); const secretRequests = parseSecretsInput(secretsInput); @@ -18599,7 +18601,18 @@ async function exportSecrets() { return request; }); - const results = await getSecrets(requests, client); + let results = null; + switch (secretsMethod) { + case SECRETS_METHOD.Read: + results = await getSecrets(requests, client); + break; + case SECRETS_METHOD.Write: + results = await writeSecrets(requests, client); + break; + default: + results = await getSecrets(requests, client); + break; + } for (const result of results) { @@ -18636,13 +18649,16 @@ async function exportSecrets() { * @property {string} envVarName * @property {string} outputVarName * @property {string} selector + * @property {string} secretsMethod + * @property {Map} secretsData */ /** * Parses a secrets input string into key paths and their resulting environment variable name. * @param {string} secretsInput + * @param {string} secretsMethod */ -function parseSecretsInput(secretsInput) { +function parseSecretsInput(secretsInput, secretsMethod) { if (!secretsInput) { return [] } @@ -18674,18 +18690,58 @@ function parseSecretsInput(secretsInput) { .map(part => part.trim()) .filter(part => part.length !== 0); - if (pathParts.length !== 2) { - throw Error(`You must provide a valid path and key. Input: "${secret}"`); - } + let path = null; + let selector = ''; + let secretsData = new Map(); + if(secretsMethod === SECRETS_METHOD.Write) { + if (pathParts.length < 2) { + throw Error(`You must provide a valid path and key. Input: "${secret}"`); + } + let writeSelectorParts = null; + let finalSelector = []; + for (let index = 0; index < pathParts.length; index++) { + const element = pathParts[index]; + if(index == 0) { + path = element; + continue; + } + //if a secret is for write, it should be saperated by "=" + writeSelectorParts = element + .split("=") + .map(part => part.trim()) + .filter(part => part.length !== 0); + + const [writeSelectorKey, writeSelectorValue] = writeSelectorParts; - const [path, selectorQuoted] = pathParts; + /** @type {any} */ + const selectorAst = jsonata(writeSelectorKey).ast(); + const writeSelector = writeSelectorKey.replace(new RegExp('"', 'g'), ''); - /** @type {any} */ - const selectorAst = jsonata(selectorQuoted).ast(); - const selector = selectorQuoted.replace(new RegExp('"', 'g'), ''); + if ((selectorAst.type !== "path" || selectorAst.steps[0].stages) && selectorAst.type !== "string" && !outputVarName) { + throw Error(`Write Secret: You must provide a name for the output key when using json selectors. Input: "${secret}"`); + } - if ((selectorAst.type !== "path" || selectorAst.steps[0].stages) && selectorAst.type !== "string" && !outputVarName) { - throw Error(`You must provide a name for the output key when using json selectors. Input: "${secret}"`); + if(writeSelector !=='\\') { + finalSelector.push(writeSelector); + secretsData.set(writeSelector, writeSelectorValue); + } + } + selector = finalSelector.join('__'); + } else { + if (pathParts.length !== 2) { + throw Error(`You must provide a valid path and key. Input: "${secret}"`); + } + + path = pathParts[0]; + const selectorQuoted = pathParts[1]; + + /** @type {any} */ + const selectorAst = jsonata(selectorQuoted).ast(); + selector = selectorQuoted.replace(new RegExp('"', 'g'), ''); + + if ((selectorAst.type !== "path" || selectorAst.steps[0].stages) && selectorAst.type !== "string" && !outputVarName) { + throw Error(`Read Secret: You must provide a name for the output key when using json selectors. Input: "${secret}"`); + } } let envVarName = outputVarName; @@ -18698,7 +18754,9 @@ function parseSecretsInput(secretsInput) { path, envVarName, outputVarName, - selector + selector, + secretsMethod, + secretsData }); } return output; @@ -18942,6 +19000,7 @@ const jsonata = __nccwpck_require__(4245); * @typedef {Object} SecretRequest * @property {string} path * @property {string} selector + * @property {Map} secretsData */ /** @@ -19002,6 +19061,43 @@ async function getSecrets(secretRequests, client) { return results; } + /** + * @template TRequest + * @param {Array} secretRequests + * @param {import('got').Got} client + * @return {Promise[]>} + */ + async function writeSecrets(secretRequests, client) { + const results = []; + for (const secretRequest of secretRequests) { + let { path, selector, secretsData } = secretRequest; + const requestPath = `v1/${path}`; + let body; + const jsonata = {}; + for (const [key, value] of secretsData) { + jsonata[key] = value; + } + + try { + const result = await client.post(requestPath,{ + json: { + data: jsonata + } + }); + body = result.body; + } catch (error) { + throw error + } + //body = JSON.parse(body); //body.request_id + results.push({ + request: secretRequest, + value: 'SUCCESS', + cachedResponse: false + }); + } + return results; +} + /** * Uses a Jsonata selector retrieve a bit of data from the result * @param {object} data @@ -19037,6 +19133,7 @@ async function selectData(data, selector) { module.exports = { getSecrets, + writeSecrets, selectData } diff --git a/integrationTests/basic/integration.test.js b/integrationTests/basic/integration.test.js index ed0bbd8..5cd2134 100644 --- a/integrationTests/basic/integration.test.js +++ b/integrationTests/basic/integration.test.js @@ -9,10 +9,12 @@ const { exportSecrets } = require('../../src/action'); const vaultUrl = `http://${process.env.VAULT_HOST || 'localhost'}:${process.env.VAULT_PORT || '8200'}`; const vaultToken = `${process.env.VAULT_TOKEN || 'testtoken'}` +const secretsMethod = { Read: "read", Write: "write" }; describe('integration', () => { beforeAll(async () => { // Verify Connection + console.log('before all'); await got(`${vaultUrl}/v1/secret/config`, { headers: { 'X-Vault-Token': vaultToken, @@ -75,7 +77,7 @@ describe('integration', () => { } } - await got(`${vaultUrl}/v1/secret-kv1/test`, { + await got(`${vaultUrl}/v1/secret-kv1/test`, { method: 'POST', headers: { 'X-Vault-Token': vaultToken, @@ -124,6 +126,12 @@ describe('integration', () => { .mockReturnValueOnce(secrets); } + function mockSecretsMethod(method) { + when(core.getInput) + .calledWith('secretsMethod', expect.anything()) + .mockReturnValueOnce(method); + } + it('prints a nice error message when secret not found', async () => { mockInput(`secret/data/test secret ; secret/data/test secret | NAMED_SECRET ; @@ -140,6 +148,16 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('SECRET', 'SUPERSECRET'); }); + it('write secret: simple secret', async () => { + mockInput('secret/data/writetest secret=TEST'); + mockSecretsMethod(secretsMethod.Write); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(1); + expect(core.exportVariable).toBeCalledWith('SECRET', 'SUCCESS'); + }); + it('re-map secret', async () => { mockInput('secret/data/test secret | TEST_KEY'); @@ -148,6 +166,15 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('TEST_KEY', 'SUPERSECRET'); }); + it('write secret: re-map secret', async () => { + mockInput('secret/data/writetest secret=TEST | TEST_KEY'); + mockSecretsMethod(secretsMethod.Write); + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(1); + expect(core.exportVariable).toBeCalledWith('TEST_KEY', 'SUCCESS'); + }); + it('get nested secret', async () => { mockInput(`secret/data/nested/test "other-Secret-dash"`); @@ -171,6 +198,18 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('OTHERSECRETDASH', 'OTHERSUPERSECRET'); }); + it('write secrets: multiple secrets', async () => { + mockInput(` + secret/data/writetest secret=TEST ; + secret/data/writetest secret=TEST | NAMED_SECRET ;`); + mockSecretsMethod(secretsMethod.Write); + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(2); + expect(core.exportVariable).toBeCalledWith('SECRET', 'SUCCESS'); + expect(core.exportVariable).toBeCalledWith('NAMED_SECRET', 'SUCCESS'); + }); + it('leading slash kvv2', async () => { mockInput('/secret/data/foobar fookv2'); @@ -179,6 +218,15 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('FOOKV2', 'bar'); }); + it('write secrets: leading slash kvv2', async () => { + mockInput('/secret/data/foobar fookv2=bar'); + mockSecretsMethod(secretsMethod.Write); + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(1); + expect(core.exportVariable).toBeCalledWith('FOOKV2', 'SUCCESS'); + }); + it('get secret from K/V v1', async () => { mockInput('secret-kv1/test secret'); @@ -187,6 +235,15 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('SECRET', 'CUSTOMSECRET'); }); + it('write secrets: secret from K/V v1', async () => { + mockInput('secret-kv1/test secret=CUSTOMSECRET'); + mockSecretsMethod(secretsMethod.Write); + + await exportSecrets(); + expect(core.exportVariable).toBeCalledTimes(1); + expect(core.exportVariable).toBeCalledWith('SECRET', 'SUCCESS'); + }); + it('get nested secret from K/V v1', async () => { mockInput('secret-kv1/nested/test "other-Secret-dash"'); @@ -203,6 +260,15 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('FOOKV1', 'bar'); }); + it('write secrets: leading slash kvv1', async () => { + mockInput('/secret-kv1/foobar fookv1=bar'); + mockSecretsMethod(secretsMethod.Write); + + await exportSecrets(); + expect(core.exportVariable).toBeCalledTimes(1); + expect(core.exportVariable).toBeCalledWith('FOOKV1', 'SUCCESS'); + }); + describe('generic engines', () => { beforeAll(async () => { await got(`${vaultUrl}/v1/cubbyhole/test`, { @@ -237,5 +303,26 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('FOO', 'bar'); expect(core.exportVariable).toBeCalledWith('ZIP', 'zap'); }); + + it('write secrets: supports cubbyhole', async () => { + mockInput('/cubbyhole/test foo=foo'); + mockSecretsMethod(secretsMethod.Write); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('FOO', 'SUCCESS'); + }); + + it('write secrets: multiple secrets', async () => { + mockInput(` + /cubbyhole/test foo=foo ; + /cubbyhole/test zip=zip`); + mockSecretsMethod(secretsMethod.Write); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('FOO', 'SUCCESS'); + expect(core.exportVariable).toBeCalledWith('ZIP', 'SUCCESS'); + }); }) }); diff --git a/integrationTests/enterprise/enterprise.test.js b/integrationTests/enterprise/enterprise.test.js index 83d6dd5..2420adb 100644 --- a/integrationTests/enterprise/enterprise.test.js +++ b/integrationTests/enterprise/enterprise.test.js @@ -9,6 +9,7 @@ const { exportSecrets } = require('../../src/action'); const vaultUrl = `http://${process.env.VAULT_HOST || 'localhost'}:${process.env.VAULT_PORT || '8201'}`; const vaultToken = `${process.env.VAULT_TOKEN || 'testtoken'}` +const secretsMethod = { Read: "read", Write: "write" }; describe('integration', () => { beforeAll(async () => { @@ -64,6 +65,16 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('SECRET', 'SUPERSECRET_IN_NAMESPACE'); }); + it('write secret: simple secret', async () => { + mockInput('secret/data/writetest secret=TEST'); + mockSecretsMethod(secretsMethod.Write); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(1); + expect(core.exportVariable).toBeCalledWith('SECRET', 'SUCCESS'); + }); + it('re-map secret', async () => { mockInput('secret/data/test secret | TEST_KEY'); @@ -72,6 +83,15 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('TEST_KEY', 'SUPERSECRET_IN_NAMESPACE'); }); + it('write secret: re-map secret', async () => { + mockInput('secret/data/writetest secret=TEST | TEST_KEY'); + mockSecretsMethod(secretsMethod.Write); + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(1); + expect(core.exportVariable).toBeCalledWith('TEST_KEY', 'SUCCESS'); + }); + it('get nested secret', async () => { mockInput('secret/data/nested/test otherSecret'); @@ -95,6 +115,18 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('OTHERSECRET', 'OTHERSUPERSECRET_IN_NAMESPACE'); }); + it('write secrets: multiple secrets', async () => { + mockInput(` + secret/data/writetest secret=TEST ; + secret/data/writetest secret=TEST | NAMED_SECRET ;`); + mockSecretsMethod(secretsMethod.Write); + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(2); + expect(core.exportVariable).toBeCalledWith('SECRET', 'SUCCESS'); + expect(core.exportVariable).toBeCalledWith('NAMED_SECRET', 'SUCCESS'); + }); + it('get secret from K/V v1', async () => { mockInput('my-secret/test secret'); @@ -103,6 +135,15 @@ describe('integration', () => { expect(core.exportVariable).toBeCalledWith('SECRET', 'CUSTOMSECRET_IN_NAMESPACE'); }); + it('write secrets: secret from K/V v1', async () => { + mockInput('secret-kv1/test secret=CUSTOMSECRET'); + mockSecretsMethod(secretsMethod.Write); + + await exportSecrets(); + expect(core.exportVariable).toBeCalledTimes(1); + expect(core.exportVariable).toBeCalledWith('SECRET', 'SUCCESS'); + }); + it('get nested secret from K/V v1', async () => { mockInput('my-secret/nested/test otherSecret'); @@ -290,3 +331,10 @@ function mockInput(secrets) { .calledWith('secrets', expect.anything()) .mockReturnValueOnce(secrets); } + +function mockSecretsMethod(method) { + when(core.getInput) + .calledWith('secretsMethod', expect.anything()) + .mockReturnValueOnce(method); +} + diff --git a/src/action.js b/src/action.js index e193650..ab63c6f 100644 --- a/src/action.js +++ b/src/action.js @@ -3,10 +3,11 @@ const core = require('@actions/core'); const command = require('@actions/core/lib/command'); const got = require('got').default; const jsonata = require('jsonata'); -const { auth: { retrieveToken }, secrets: { getSecrets } } = require('./index'); +const { auth: { retrieveToken }, secrets: { getSecrets, writeSecrets } } = require('./index'); const AUTH_METHODS = ['approle', 'token', 'github', 'jwt', 'kubernetes', 'ldap', 'userpass']; const ENCODING_TYPES = ['base64', 'hex', 'utf8']; +const SECRETS_METHOD = { Read: "read", Write: "write" }; async function exportSecrets() { const vaultUrl = core.getInput('url', { required: true }); @@ -15,9 +16,10 @@ async function exportSecrets() { const exportEnv = core.getInput('exportEnv', { required: false }) != 'false'; const outputToken = (core.getInput('outputToken', { required: false }) || 'false').toLowerCase() != 'false'; const exportToken = (core.getInput('exportToken', { required: false }) || 'false').toLowerCase() != 'false'; + const secretsMethod = core.getInput('secretsMethod', { required: false }); const secretsInput = core.getInput('secrets', { required: false }); - const secretRequests = parseSecretsInput(secretsInput); + const secretRequests = parseSecretsInput(secretsInput, secretsMethod); const secretEncodingType = core.getInput('secretEncodingType', { required: false }); @@ -86,8 +88,18 @@ async function exportSecrets() { return request; }); - const results = await getSecrets(requests, client); - + let results = null; + switch (secretsMethod) { + case SECRETS_METHOD.Read: + results = await getSecrets(requests, client); + break; + case SECRETS_METHOD.Write: + results = await writeSecrets(requests, client); + break; + default: + results = await getSecrets(requests, client); + break; + } for (const result of results) { // Output the result @@ -123,13 +135,16 @@ async function exportSecrets() { * @property {string} envVarName * @property {string} outputVarName * @property {string} selector + * @property {string} secretsMethod + * @property {Map} secretsData */ /** * Parses a secrets input string into key paths and their resulting environment variable name. * @param {string} secretsInput + * @param {string} secretsMethod */ -function parseSecretsInput(secretsInput) { +function parseSecretsInput(secretsInput, secretsMethod) { if (!secretsInput) { return [] } @@ -161,18 +176,58 @@ function parseSecretsInput(secretsInput) { .map(part => part.trim()) .filter(part => part.length !== 0); - if (pathParts.length !== 2) { - throw Error(`You must provide a valid path and key. Input: "${secret}"`); - } + let path = null; + let selector = ''; + let secretsData = new Map(); + if(secretsMethod === SECRETS_METHOD.Write) { + if (pathParts.length < 2) { + throw Error(`You must provide a valid path and key. Input: "${secret}"`); + } + let writeSelectorParts = null; + let finalSelector = []; + for (let index = 0; index < pathParts.length; index++) { + const element = pathParts[index]; + if(index == 0) { + path = element; + continue; + } + //if a secret is for write, it should be saperated by "=" + writeSelectorParts = element + .split("=") + .map(part => part.trim()) + .filter(part => part.length !== 0); + + const [writeSelectorKey, writeSelectorValue] = writeSelectorParts; - const [path, selectorQuoted] = pathParts; + /** @type {any} */ + const selectorAst = jsonata(writeSelectorKey).ast(); + const writeSelector = writeSelectorKey.replace(new RegExp('"', 'g'), ''); - /** @type {any} */ - const selectorAst = jsonata(selectorQuoted).ast(); - const selector = selectorQuoted.replace(new RegExp('"', 'g'), ''); + if ((selectorAst.type !== "path" || selectorAst.steps[0].stages) && selectorAst.type !== "string" && !outputVarName) { + throw Error(`Write Secret: You must provide a name for the output key when using json selectors. Input: "${secret}"`); + } - if ((selectorAst.type !== "path" || selectorAst.steps[0].stages) && selectorAst.type !== "string" && !outputVarName) { - throw Error(`You must provide a name for the output key when using json selectors. Input: "${secret}"`); + if(writeSelector !=='\\') { + finalSelector.push(writeSelector); + secretsData.set(writeSelector, writeSelectorValue); + } + } + selector = finalSelector.join('__'); + } else { + if (pathParts.length !== 2) { + throw Error(`You must provide a valid path and key. Input: "${secret}"`); + } + + path = pathParts[0]; + const selectorQuoted = pathParts[1]; + + /** @type {any} */ + const selectorAst = jsonata(selectorQuoted).ast(); + selector = selectorQuoted.replace(new RegExp('"', 'g'), ''); + + if ((selectorAst.type !== "path" || selectorAst.steps[0].stages) && selectorAst.type !== "string" && !outputVarName) { + throw Error(`Read Secret: You must provide a name for the output key when using json selectors. Input: "${secret}"`); + } } let envVarName = outputVarName; @@ -185,7 +240,9 @@ function parseSecretsInput(secretsInput) { path, envVarName, outputVarName, - selector + selector, + secretsMethod, + secretsData }); } return output; diff --git a/src/action.test.js b/src/action.test.js index 498ab51..9a3decc 100644 --- a/src/action.test.js +++ b/src/action.test.js @@ -5,6 +5,8 @@ jest.mock('@actions/core/lib/command'); const command = require('@actions/core/lib/command'); const core = require('@actions/core'); const got = require('got'); +const secretsMethod = { Read: "read", Write: "write" } + const { exportSecrets, parseSecretsInput, @@ -15,12 +17,14 @@ const { when } = require('jest-when'); describe('parseSecretsInput', () => { it('parses simple secret', () => { - const output = parseSecretsInput('test key'); + const output = parseSecretsInput('test key', secretsMethod.Read); expect(output).toContainEqual({ path: 'test', selector: 'key', outputVarName: 'key', - envVarName: 'KEY' + envVarName: 'KEY', + secretsMethod: secretsMethod.Read, + secretsData: new Map() }); }); @@ -29,7 +33,7 @@ describe('parseSecretsInput', () => { expect(output).toHaveLength(1); expect(output[0]).toMatchObject({ outputVarName: 'testName', - envVarName: 'testName', + envVarName: 'testName' }); }); @@ -90,6 +94,109 @@ describe('parseSecretsInput', () => { }); }); +describe('write secret: parseSecretsInput', () => { + it('write secret: parses simple secret', () => { + const output = parseSecretsInput('test key=123', secretsMethod.Write); + expect(output).toContainEqual({ + path: 'test', + selector: 'key', + outputVarName: 'key', + envVarName: 'KEY', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('key', '123') + }); + }); + + it('write secret: parses mapped secret', () => { + const output = parseSecretsInput('test key=123|testName', secretsMethod.Write); + expect(output).toHaveLength(1); + expect(output[0]).toMatchObject({ + outputVarName: 'testName', + envVarName: 'testName', + selector: 'key', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('key', '123') + }); + }); + + it('write secret: fails on invalid mapped name', () => { + expect(() => parseSecretsInput('test key|', secretsMethod.Write)) + .toThrowError(`You must provide a value when mapping a secret to a name. Input: "test key|"`) + }); + + it('write secret: fails on invalid path for mapped', () => { + expect(() => parseSecretsInput('|testName', secretsMethod.Write)) + .toThrowError(`You must provide a valid path and key. Input: "|testName"`) + }); + + it('write secret: parses multiple secrets', () => { + const output = parseSecretsInput('first a=aaa;second b=bbb;', secretsMethod.Write); + + expect(output).toHaveLength(2); + expect(output[0]).toMatchObject({ + path: 'first', + selector: 'a', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('a', 'aaa') + }); + expect(output[1]).toMatchObject({ + path: 'second', + selector: 'b', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('b', 'bbb') + }); + }); + + it('write secrets: parses multiple complex secret input', () => { + const output = parseSecretsInput('first a=aaa;second b=bbb|secondName', secretsMethod.Write); + + expect(output).toHaveLength(2); + expect(output[0]).toMatchObject({ + outputVarName: 'a', + envVarName: 'A', + selector: 'a', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('a', 'aaa') + }); + expect(output[1]).toMatchObject({ + outputVarName: 'secondName', + envVarName: 'secondName', + selector: 'b', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('b', 'bbb') + }); + }); + + it('write secrets: parses multiline input', () => { + const output = parseSecretsInput(` + first a=aaa; + second b=bbb; + third c=ccc | SOME_C;`, secretsMethod.Write); + + expect(output).toHaveLength(3); + expect(output[0]).toMatchObject({ + path: 'first', + selector: 'a', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('a', 'aaa') + }); + expect(output[1]).toMatchObject({ + outputVarName: 'b', + envVarName: 'B', + selector: 'b', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('b', 'bbb') + }); + expect(output[2]).toMatchObject({ + outputVarName: 'SOME_C', + envVarName: 'SOME_C', + selector: 'c', + secretsMethod: secretsMethod.Write, + secretsData: new Map().set('c', 'ccc') + }); + }); +}); + describe('parseHeaders', () => { it('parses simple header', () => { when(core.getInput) @@ -448,3 +555,226 @@ with blank lines expect(core.setOutput).toBeCalledWith('vault_token', 'EXAMPLE'); }) }); + +describe('write secrets: exportSecrets', () => { + beforeEach(() => { + jest.resetAllMocks(); + + when(core.getInput) + .calledWith('url', expect.anything()) + .mockReturnValueOnce('http://vault:8200'); + + when(core.getInput) + .calledWith('token', expect.anything()) + .mockReturnValueOnce('EXAMPLE'); + }); + + function mockInput(key) { + when(core.getInput) + .calledWith('secrets', expect.anything()) + .mockReturnValueOnce(key); + } + + function mockSecretsMethod(method) { + when(core.getInput) + .calledWith('secretsMethod', expect.anything()) + .mockReturnValueOnce(method); + } + + function mockVersion(version) { + when(core.getInput) + .calledWith('kv-version', expect.anything()) + .mockReturnValueOnce(version); + } + + function mockExtraHeaders(headerString) { + when(core.getInput) + .calledWith('extraHeaders', expect.anything()) + .mockReturnValueOnce(headerString); + } + + function mockVaultData(data, version='2') { + switch(version) { + case '1': + got.extend.mockReturnValue({ + post: async () => ({ body: JSON.stringify({ data }) }) + }); + break; + case '2': + got.extend.mockReturnValue({ + post: async () => ({ body: JSON.stringify({ data: { + data + } }) }) + }); + break; + } + } + + function mockExportToken(doExport) { + when(core.getInput) + .calledWith('exportToken', expect.anything()) + .mockReturnValueOnce(doExport); + } + + function mockOutputToken(doOutput) { + when(core.getInput) + .calledWith('outputToken', expect.anything()) + .mockReturnValueOnce(doOutput); + } + function mockEncodeType(doEncode) { + when(core.getInput) + .calledWith('secretEncodingType', expect.anything()) + .mockReturnValueOnce(doEncode); + } + + it('write secrets: simple secret retrieval', async () => { + mockInput('test key=1'); + mockSecretsMethod(secretsMethod.Write); + mockVaultData({ + key: 1 + }); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('KEY', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('key', 'SUCCESS'); + }); + + it('write secrets: JSON string secret retrieval', async () => { + const jsonString = '{"x":1,"y":2}'; + + mockInput('test key={"x":1,"y":2}'); + mockSecretsMethod(secretsMethod.Write); + mockVaultData({ + key: jsonString, + }); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('KEY', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('key', 'SUCCESS'); + }); + + + it('write secrets: intl secret retrieval', async () => { + mockInput('测试 测试=1'); + mockSecretsMethod(secretsMethod.Write); + mockVaultData({ + 测试: 1 + }); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('测试', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('测试', 'SUCCESS'); + }); + + it('write secrets: mapped secret retrieval', async () => { + mockInput('test key=1|TEST_NAME'); + mockSecretsMethod(secretsMethod.Write); + mockVaultData({ + key: 1 + }); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('TEST_NAME', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('TEST_NAME', 'SUCCESS'); + }); + + it('write secrets: simple secret retrieval from K/V v1', async () => { + const version = '1'; + + mockInput('test key=1'); + mockSecretsMethod(secretsMethod.Write); + mockExtraHeaders(` + TEST: 1 + `); + mockVaultData({ + key: 1 + }); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('KEY', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('key', 'SUCCESS'); + }); + + it('write secrets: simple secret retrieval with extra headers', async () => { + const version = '1'; + + mockInput('test key=1'); + mockSecretsMethod(secretsMethod.Write); + mockVersion(version); + mockVaultData({ + key: 1 + }, version); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('KEY', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('key', 'SUCCESS'); + }); + + it('write secrets: nested secret retrieval', async () => { + mockInput('test key.value=1'); + mockSecretsMethod(secretsMethod.Write); + mockVaultData({ + key: { value: 1 } + }); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('KEY__VALUE', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('key__value', 'SUCCESS'); + }); + + it('write secrets: export Vault token', async () => { + mockInput('test key=1'); + mockSecretsMethod(secretsMethod.Write); + mockVaultData({ + key: 1 + }); + mockExportToken("true") + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(2); + + expect(core.exportVariable).toBeCalledWith('VAULT_TOKEN', 'EXAMPLE'); + expect(core.exportVariable).toBeCalledWith('KEY', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('key', 'SUCCESS'); + }); + + it('write secrets: not export Vault token', async () => { + mockInput('test key=1'); + mockSecretsMethod(secretsMethod.Write); + mockVaultData({ + key: 1 + }); + mockExportToken("false") + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledTimes(1); + + expect(core.exportVariable).toBeCalledWith('KEY', 'SUCCESS'); + expect(core.setOutput).toBeCalledWith('key', 'SUCCESS'); + }); + + it('write secrets: single-line secret gets masked', async () => { + mockInput('test key=secret'); + mockSecretsMethod(secretsMethod.Write); + mockVaultData({ + key: 'secret' + }); + mockExportToken("false") + + await exportSecrets(); + + expect(core.setSecret).toBeCalledTimes(2); + + expect(core.setSecret).toBeCalledWith('SUCCESS'); + expect(core.setOutput).toBeCalledWith('key', 'SUCCESS'); + }) +}); diff --git a/src/secrets.js b/src/secrets.js index 34d2867..0d40fd0 100644 --- a/src/secrets.js +++ b/src/secrets.js @@ -5,6 +5,7 @@ const jsonata = require("jsonata"); * @typedef {Object} SecretRequest * @property {string} path * @property {string} selector + * @property {Map} secretsData */ /** @@ -65,6 +66,43 @@ async function getSecrets(secretRequests, client) { return results; } + /** + * @template TRequest + * @param {Array} secretRequests + * @param {import('got').Got} client + * @return {Promise[]>} + */ + async function writeSecrets(secretRequests, client) { + const results = []; + for (const secretRequest of secretRequests) { + let { path, selector, secretsData } = secretRequest; + const requestPath = `v1/${path}`; + let body; + const jsonata = {}; + for (const [key, value] of secretsData) { + jsonata[key] = value; + } + + try { + const result = await client.post(requestPath,{ + json: { + data: jsonata + } + }); + body = result.body; + } catch (error) { + throw error + } + //body = JSON.parse(body); //body.request_id + results.push({ + request: secretRequest, + value: 'SUCCESS', + cachedResponse: false + }); + } + return results; +} + /** * Uses a Jsonata selector retrieve a bit of data from the result * @param {object} data @@ -100,5 +138,6 @@ async function selectData(data, selector) { module.exports = { getSecrets, + writeSecrets, selectData }