diff --git a/integrationTests/e2e/setup.js b/integrationTests/e2e/setup.js index 96f2295..33daf37 100644 --- a/integrationTests/e2e/setup.js +++ b/integrationTests/e2e/setup.js @@ -3,6 +3,8 @@ const got = require('got'); const vaultUrl = `${process.env.VAULT_HOST}:${process.env.VAULT_PORT}`; const vaultToken = `${process.env.VAULT_TOKEN}` === undefined ? `${process.env.VAULT_TOKEN}` : "testtoken"; +const jsonStringMultiline = '{"x": 1, "y": "q\\nux"}'; + (async () => { try { // Verify Connection @@ -36,6 +38,44 @@ const vaultToken = `${process.env.VAULT_TOKEN}` === undefined ? `${process.env.V } }); + await got(`http://${vaultUrl}/v1/secret/data/test-json-string`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken, + }, + json: { + data: { + // this is stored in Vault as a string + jsonString: '{"x":1,"y":"qux"}', + }, + }, + }); + + await got(`http://${vaultUrl}/v1/secret/data/test-json-data`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken, + }, + json: { + data: { + // this is stored in Vault as a map + jsonData: {"x":1,"y":"qux"}, + }, + }, + }); + + await got(`http://${vaultUrl}/v1/secret/data/test-json-string-multiline`, { + method: 'POST', + headers: { + 'X-Vault-Token': vaultToken, + }, + json: { + data: { + jsonStringMultiline, + }, + }, + }); + await got(`http://${vaultUrl}/v1/sys/mounts/my-secret`, { method: 'POST', headers: { diff --git a/scripts/parse.js b/scripts/parse.js new file mode 100644 index 0000000..35a6bf5 --- /dev/null +++ b/scripts/parse.js @@ -0,0 +1,50 @@ +// const core = require('@actions/core'); + +try { + let inputs = [ + process.env.JSONSTRING, + process.env.JSONSTRINGMULTILINE, + process.env.JSONDATA, + process.env.SINGLELINE, + process.env.MULTILINE, + ]; + + let names = [ + "test-json-string", + "test-json-string-multiline", + "test-json-data", + "singleline", + "multiline", + ]; + + let i = 0; + inputs.forEach(input => { + console.log(`processing: ${names[i]}`) + i++; + input = (input || '').trim(); + if (!input) { + throw new Error(`Missing service account key JSON (got empty value)`); + } + + // If the string doesn't start with a JSON object character, it is probably + // base64-encoded. + if (!input.startsWith('{')) { + let str = input.replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) str += '='; + input = Buffer.from(str, 'base64').toString('utf8'); + } + + try { + const creds = JSON.parse(input); + console.log('success!') + return creds; + } catch (err) { + console.log('error parsing') + console.log(err) + } + }) + +} catch (error) { + console.log(error) +} + diff --git a/src/action.test.js b/src/action.test.js index 49c33cd..ca6706c 100644 --- a/src/action.test.js +++ b/src/action.test.js @@ -220,6 +220,38 @@ describe('exportSecrets', () => { expect(core.setOutput).toBeCalledWith('key', '1'); }); + it('json secret retrieval', async () => { + const jsonString = '{"x":1,"y":2}'; + + mockInput('test key'); + mockVaultData({ + key: jsonString, + }); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('KEY', jsonString); + expect(core.setOutput).toBeCalledWith('key', jsonString); + }); + + it('multi-line json secret retrieval', async () => { + const jsonString = ` + { + "x":1, + "y":"bar" + } + `; + mockInput('test key'); + mockVaultData({ + key: jsonString, + }); + + await exportSecrets(); + + expect(core.exportVariable).toBeCalledWith('KEY', jsonString); + expect(core.setOutput).toBeCalledWith('key', jsonString); + }); + it('intl secret retrieval', async () => { mockInput('测试 测试'); mockVaultData({ diff --git a/src/retries.test.js b/src/retries.test.js index 132edd5..285a7f2 100644 --- a/src/retries.test.js +++ b/src/retries.test.js @@ -66,4 +66,4 @@ describe('exportSecrets retries', () => { done(); }); }); -}); \ No newline at end of file +}); diff --git a/src/secrets.js b/src/secrets.js index 45b26e0..ea976d9 100644 --- a/src/secrets.js +++ b/src/secrets.js @@ -72,7 +72,21 @@ async function getSecrets(secretRequests, client) { */ async function selectData(data, selector) { const ata = jsonata(selector); - let result = JSON.stringify(await ata.evaluate(data)); + let d = await ata.evaluate(data); + + // If we have a Javascript Object, then this data was stored in Vault as + // pure JSON (not a JSON string) + const storedAsJSONData = isObject(d); + + if (isJSONString(d)) { + // If we already have a JSON string we will not "stringify" it yet so + // that we don't end up calling JSON.parse. This would break the + // secrets that are stored as pure JSON. See: https://github.com/hashicorp/vault-action/issues/194 + result = d; + } else { + result = JSON.stringify(d); + } + // Compat for custom engines if (!result && ((ata.ast().type === "path" && ata.ast()['steps'].length === 1) || ata.ast().type === "string") && selector !== 'data' && 'data' in data) { result = JSON.stringify(await jsonata(`data.${selector}`).evaluate(data)); @@ -81,12 +95,57 @@ async function selectData(data, selector) { } if (result.startsWith(`"`)) { - result = JSON.parse(result); + // we need to strip the beginning and ending quotes otherwise it will + // always successfully parse as a JSON string + result = result.substring(1, result.length - 1); + if (!isJSONString(result)) { + // add the quotes back so we can parse it into a Javascript object + // to allow support for multi-line secrets. See https://github.com/hashicorp/vault-action/issues/160 + result = `"${result}"` + result = JSON.parse(result); + } + } else if (isJSONString(result)) { + if (storedAsJSONData) { + // Support secrets stored in Vault as pure JSON. + // See https://github.com/hashicorp/vault-action/issues/194 and https://github.com/hashicorp/vault-action/pull/173 + result = JSON.stringify(result); + result = result.substring(1, result.length - 1); + } else { + // Support secrets stored in Vault as JSON Strings + result = JSON.stringify(result); + result = JSON.parse(result); + } } return result; } +/** + * isOjbect returns true if target is a Javascript object + * @param {Type} target + */ +function isObject(target) { + return typeof target === 'object' && target !== null; +} + +/** + * isJSONString returns true if target parses as a valid JSON string + * @param {Type} target + */ +function isJSONString(target) { + if (typeof target !== "string"){ + return false; + } + + try { + let o = JSON.parse(target); + } catch (e) { + return false; + } + + return true; +} + module.exports = { getSecrets, selectData -} \ No newline at end of file +}