From 2f45b95930f7800873fb8f5d59512acb70b519e9 Mon Sep 17 00:00:00 2001 From: Anoop M D Date: Fri, 22 Sep 2023 01:08:35 +0530 Subject: [PATCH 1/6] feat(#199): bru lang updates to store environment secrets --- packages/bruno-lang/v2/src/envToJson.js | 53 ++++- packages/bruno-lang/v2/src/jsonToEnv.js | 34 ++- .../bruno-lang/v2/tests/envToJson.spec.js | 204 +++++++++++++++++- .../bruno-lang/v2/tests/jsonToEnv.spec.js | 83 +++++++ 4 files changed, 357 insertions(+), 17 deletions(-) diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/v2/src/envToJson.js index 63bd7f2bf..eef4de375 100644 --- a/packages/bruno-lang/v2/src/envToJson.js +++ b/packages/bruno-lang/v2/src/envToJson.js @@ -2,7 +2,7 @@ const ohm = require('ohm-js'); const _ = require('lodash'); const grammar = ohm.grammar(`Bru { - BruEnvFile = (vars)* + BruEnvFile = (vars | secretvars)* nl = "\\r"? "\\n" st = " " | "\\t" @@ -19,6 +19,13 @@ const grammar = ohm.grammar(`Bru { key = keychar* value = valuechar* + // Array Blocks + array = st* "[" stnl* valuelist stnl* "]" + valuelist = stnl* arrayvalue stnl* ("," stnl* arrayvalue)* + arrayvalue = arrayvaluechar* + arrayvaluechar = ~(nl | st | "[" | "]" | ",") any + + secretvars = "vars:secret" array vars = "vars" dictionary }`); @@ -44,6 +51,29 @@ const mapPairListToKeyValPairs = (pairList = []) => { }); }; +const mapArrayListToKeyValPairs = (arrayList = []) => { + arrayList = arrayList.filter((v) => v && v.length); + + if (!arrayList.length) { + return []; + } + + return _.map(arrayList, (value) => { + let name = value; + let enabled = true; + if (name && name.length && name.charAt(0) === '~') { + name = name.slice(1); + enabled = false; + } + + return { + name, + value: null, + enabled + }; + }); +}; + const concatArrays = (objValue, srcValue) => { if (_.isArray(objValue) && _.isArray(srcValue)) { return objValue.concat(srcValue); @@ -66,6 +96,15 @@ const sem = grammar.createSemantics().addAttribute('ast', { {} ); }, + array(_1, _2, _3, valuelist, _4, _5) { + return valuelist.ast; + }, + arrayvalue(chars) { + return chars.sourceString ? chars.sourceString.trim() : ''; + }, + valuelist(_1, value, _2, _3, _4, rest) { + return [value.ast, ...rest.ast]; + }, dictionary(_1, _2, pairlist, _3) { return pairlist.ast; }, @@ -97,6 +136,18 @@ const sem = grammar.createSemantics().addAttribute('ast', { }, vars(_1, dictionary) { const vars = mapPairListToKeyValPairs(dictionary.ast); + _.each(vars, (v) => { + v.secret = false; + }); + return { + variables: vars + }; + }, + secretvars: (_1, array) => { + const vars = mapArrayListToKeyValPairs(array.ast); + _.each(vars, (v) => { + v.secret = true; + }); return { variables: vars }; diff --git a/packages/bruno-lang/v2/src/jsonToEnv.js b/packages/bruno-lang/v2/src/jsonToEnv.js index 46106f336..42d0a4281 100644 --- a/packages/bruno-lang/v2/src/jsonToEnv.js +++ b/packages/bruno-lang/v2/src/jsonToEnv.js @@ -2,22 +2,42 @@ const _ = require('lodash'); const envToJson = (json) => { const variables = _.get(json, 'variables', []); - const vars = variables.map((variable) => { - const { name, value, enabled } = variable; - const prefix = enabled ? '' : '~'; - return ` ${prefix}${name}: ${value}`; - }); + const vars = variables + .filter((variable) => !variable.secret) + .map((variable) => { + const { name, value, enabled } = variable; + const prefix = enabled ? '' : '~'; + return ` ${prefix}${name}: ${value}`; + }); - if (!vars || !vars.length) { + const secretVars = variables + .filter((variable) => variable.secret) + .map((variable) => { + const { name, enabled } = variable; + const prefix = enabled ? '' : '~'; + return ` ${prefix}${name}`; + }); + + if (!variables || !variables.length) { return `vars { } `; } - const output = `vars { + let output = ''; + if (vars.length) { + output += `vars { ${vars.join('\n')} } `; + } + + if (secretVars.length) { + output += `vars:secret [ +${secretVars.join(',\n')} +] +`; + } return output; }; diff --git a/packages/bruno-lang/v2/tests/envToJson.spec.js b/packages/bruno-lang/v2/tests/envToJson.spec.js index a082e2cf4..fbb74f2b9 100644 --- a/packages/bruno-lang/v2/tests/envToJson.spec.js +++ b/packages/bruno-lang/v2/tests/envToJson.spec.js @@ -26,7 +26,8 @@ vars { { name: 'url', value: 'http://localhost:3000', - enabled: true + enabled: true, + secret: false } ] }; @@ -48,17 +49,20 @@ vars { { name: 'url', value: 'http://localhost:3000', - enabled: true + enabled: true, + secret: false }, { name: 'port', value: '3000', - enabled: true + enabled: true, + secret: false }, { name: 'token', value: 'secret', - enabled: false + enabled: false, + secret: false } ] }; @@ -82,12 +86,14 @@ vars { { name: 'url', value: 'http://localhost:3000', - enabled: true + enabled: true, + secret: false }, { name: 'port', value: '3000', - enabled: true + enabled: true, + secret: false } ] }; @@ -110,17 +116,197 @@ vars { { name: 'url', value: '', - enabled: true + enabled: true, + secret: false }, { name: 'phone', value: '', - enabled: true + enabled: true, + secret: false }, { name: 'api-key', value: '', - enabled: true + enabled: true, + secret: false + } + ] + }; + + expect(output).toEqual(expected); + }); + + it('should parse empty secret vars', () => { + const input = ` +vars { + url: http://localhost:3000 +} + +vars:secret [ + +] +`; + + const output = parser(input); + const expected = { + variables: [ + { + name: 'url', + value: 'http://localhost:3000', + enabled: true, + secret: false + } + ] + }; + + expect(output).toEqual(expected); + }); + + it('should parse secret vars', () => { + const input = ` +vars { + url: http://localhost:3000 +} + +vars:secret [ + token +] +`; + + const output = parser(input); + const expected = { + variables: [ + { + name: 'url', + value: 'http://localhost:3000', + enabled: true, + secret: false + }, + { + name: 'token', + value: null, + enabled: true, + secret: true + } + ] + }; + + expect(output).toEqual(expected); + }); + + it('should parse multiline secret vars', () => { + const input = ` +vars { + url: http://localhost:3000 +} + +vars:secret [ + access_token, + access_secret, + + ~access_password +] +`; + + const output = parser(input); + const expected = { + variables: [ + { + name: 'url', + value: 'http://localhost:3000', + enabled: true, + secret: false + }, + { + name: 'access_token', + value: null, + enabled: true, + secret: true + }, + { + name: 'access_secret', + value: null, + enabled: true, + secret: true + }, + { + name: 'access_password', + value: null, + enabled: false, + secret: true + } + ] + }; + + expect(output).toEqual(expected); + }); + + it('should parse inline secret vars', () => { + const input = ` +vars { + url: http://localhost:3000 +} + +vars:secret [access_key] +`; + + const output = parser(input); + const expected = { + variables: [ + { + name: 'url', + value: 'http://localhost:3000', + enabled: true, + secret: false + }, + { + name: 'access_key', + value: null, + enabled: true, + secret: true + } + ] + }; + + expect(output).toEqual(expected); + }); + + it('should parse inline multiple secret vars', () => { + const input = ` +vars { + url: http://localhost:3000 +} + +vars:secret [access_key,access_secret, access_password ] +`; + + const output = parser(input); + const expected = { + variables: [ + { + name: 'url', + value: 'http://localhost:3000', + enabled: true, + secret: false + }, + { + name: 'access_key', + value: null, + enabled: true, + secret: true + }, + { + name: 'access_secret', + value: null, + enabled: true, + secret: true + }, + { + name: 'access_password', + value: null, + enabled: true, + secret: true } ] }; diff --git a/packages/bruno-lang/v2/tests/jsonToEnv.spec.js b/packages/bruno-lang/v2/tests/jsonToEnv.spec.js index 7aee11428..62b7aa269 100644 --- a/packages/bruno-lang/v2/tests/jsonToEnv.spec.js +++ b/packages/bruno-lang/v2/tests/jsonToEnv.spec.js @@ -57,4 +57,87 @@ describe('env parser', () => { const output = parser(input); expect(output).toEqual(expected); }); + + it('should parse secret vars', () => { + const input = { + variables: [ + { + name: 'url', + value: 'http://localhost:3000', + enabled: true + }, + { + name: 'token', + value: 'abracadabra', + enabled: true, + secret: true + } + ] + }; + + const output = parser(input); + const expected = `vars { + url: http://localhost:3000 +} +vars:secret [ + token +] +`; + expect(output).toEqual(expected); + }); + + it('should parse multiple secret vars', () => { + const input = { + variables: [ + { + name: 'url', + value: 'http://localhost:3000', + enabled: true + }, + { + name: 'access_token', + value: 'abracadabra', + enabled: true, + secret: true + }, + { + name: 'access_secret', + value: 'abracadabra', + enabled: false, + secret: true + } + ] + }; + + const output = parser(input); + const expected = `vars { + url: http://localhost:3000 +} +vars:secret [ + access_token, + ~access_secret +] +`; + expect(output).toEqual(expected); + }); + + it('should parse even if the only secret vars are present', () => { + const input = { + variables: [ + { + name: 'token', + value: 'abracadabra', + enabled: true, + secret: true + } + ] + }; + + const output = parser(input); + const expected = `vars:secret [ + token +] +`; + expect(output).toEqual(expected); + }); }); From 5415e20d7e10865afea9b9cd5a50d6b6b074b9c7 Mon Sep 17 00:00:00 2001 From: Mirko Golze Date: Thu, 21 Sep 2023 22:17:46 +0200 Subject: [PATCH 2/6] #199 add CLI feature to use command line parameter '--env-var secret=xzy123' --- packages/bruno-cli/src/commands/run.js | 48 ++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 039932c58..45643ea5f 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -1,7 +1,7 @@ const fs = require('fs'); const chalk = require('chalk'); const path = require('path'); -const { exists, isFile, isDirectory, getSubDirectories } = require('../utils/filesystem'); +const { exists, isFile, isDirectory } = require('../utils/filesystem'); const { runSingleRequest } = require('../runner/run-single-request'); const { bruToEnvJson, getEnvVars } = require('../utils/bru'); const { rpad } = require('../utils/common'); @@ -103,8 +103,7 @@ const getBruFilesRecursively = (dir) => { return bruJsons; }; - const bruJsons = getFilesInOrder(dir); - return bruJsons; + return getFilesInOrder(dir); }; const builder = async (yargs) => { @@ -122,6 +121,10 @@ const builder = async (yargs) => { describe: 'Environment variables', type: 'string' }) + .option('env-var', { + describe: 'Overwrite a single environment variable, multiple usages possible', + type: 'string' + }) .option('insecure', { type: 'boolean', description: 'Allow insecure server connections' @@ -129,12 +132,16 @@ const builder = async (yargs) => { .example('$0 run request.bru', 'Run a request') .example('$0 run request.bru --env local', 'Run a request with the environment set to local') .example('$0 run folder', 'Run all requests in a folder') - .example('$0 run folder -r', 'Run all requests in a folder recursively'); + .example('$0 run folder -r', 'Run all requests in a folder recursively') + .example( + '$0 run request.bru --env local --env-var secret=xxx', + 'Run a request with the environment set to local and overwrite the variable secret with value xxx' + ); }; const handler = async function (argv) { try { - let { filename, cacert, env, insecure, r: recursive } = argv; + let { filename, cacert, env, envVar, insecure, r: recursive } = argv; const collectionPath = process.cwd(); // todo @@ -175,6 +182,35 @@ const handler = async function (argv) { envVars = getEnvVars(envJson); } + if (envVar) { + if (typeof envVar === 'string') { + let parts = envVar.split('='); + if (parts.length !== 2) { + console.error( + chalk.red(`overridable environment variable not correct: use name=value - presented: `) + + chalk.dim(`${envVar}`) + ); + return; + } + envVars[parts[0]] = parts[1]; + } else if (typeof envVar === 'object' && Array.isArray(envVar)) { + envVar.forEach((value) => { + let parts = value.split('='); + if (parts.length !== 2) { + console.error( + chalk.red(`overridable environment variable not correct: use name=value - presented: `) + + chalk.dim(`${value}`) + ); + return; + } + envVars[parts[0]] = parts[1]; + }); + } else { + console.error(chalk.red(`overridable environment variables not parsable: use name=value`)); + return; + } + } + const options = getOptions(); if (insecure) { options['insecure'] = true; @@ -240,7 +276,7 @@ const handler = async function (argv) { } else { console.log(chalk.yellow('Running Folder Recursively \n')); - bruJsons = await getBruFilesRecursively(filename); + bruJsons = getBruFilesRecursively(filename); } let assertionResults = []; From c83fce16dcd51d5894f52d006134024a58062c12 Mon Sep 17 00:00:00 2001 From: Mirko Golze Date: Fri, 22 Sep 2023 09:22:46 +0200 Subject: [PATCH 3/6] #199 small code refactoring --- packages/bruno-cli/src/commands/run.js | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 45643ea5f..420eb696d 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -183,18 +183,17 @@ const handler = async function (argv) { } if (envVar) { + let processVars; if (typeof envVar === 'string') { - let parts = envVar.split('='); - if (parts.length !== 2) { - console.error( - chalk.red(`overridable environment variable not correct: use name=value - presented: `) + - chalk.dim(`${envVar}`) - ); - return; - } - envVars[parts[0]] = parts[1]; + processVars = [envVar]; } else if (typeof envVar === 'object' && Array.isArray(envVar)) { - envVar.forEach((value) => { + processVars = envVar; + } else { + console.error(chalk.red(`overridable environment variables not parsable: use name=value`)); + return; + } + if (processVars && Array.isArray(processVars)) { + processVars.forEach((value) => { let parts = value.split('='); if (parts.length !== 2) { console.error( @@ -205,9 +204,6 @@ const handler = async function (argv) { } envVars[parts[0]] = parts[1]; }); - } else { - console.error(chalk.red(`overridable environment variables not parsable: use name=value`)); - return; } } From f78c1640e91a607e7ce751007c77bc9242a81a86 Mon Sep 17 00:00:00 2001 From: Anoop M D Date: Sun, 24 Sep 2023 17:49:28 +0530 Subject: [PATCH 4/6] feat(#199): electron safeStorage util for storing secrets with aes256 fallback --- packages/bruno-electron/package.json | 4 +- .../bruno-electron/src/utils/encryption.js | 73 +++++++++++++++++++ .../tests/utils/encryption.spec.js | 31 ++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/bruno-electron/src/utils/encryption.js create mode 100644 packages/bruno-electron/tests/utils/encryption.spec.js diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index d9f89baa3..2cacfda50 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -10,7 +10,8 @@ "clean": "rimraf dist", "dev": "electron .", "dist": "electron-builder --mac --config electron-builder-config.js", - "pack": "electron-builder --dir" + "pack": "electron-builder --dir", + "test": "jest" }, "dependencies": { "@usebruno/js": "0.4.0", @@ -31,6 +32,7 @@ "lodash": "^4.17.21", "mustache": "^4.2.0", "nanoid": "3.3.4", + "node-machine-id": "^1.1.12", "qs": "^6.11.0", "uuid": "^9.0.0", "vm2": "^3.9.13", diff --git a/packages/bruno-electron/src/utils/encryption.js b/packages/bruno-electron/src/utils/encryption.js new file mode 100644 index 000000000..820b37b74 --- /dev/null +++ b/packages/bruno-electron/src/utils/encryption.js @@ -0,0 +1,73 @@ +const crypto = require('crypto'); +const { machineIdSync } = require('node-machine-id'); +const { safeStorage } = require('electron'); + +// Constants for algorithm identification +const ELECTRONSAFESTORAGE_ALGO = '00'; +const AES256_ALGO = '01'; + +// AES-256 encryption and decryption functions +function aes256Encrypt(data, key) { + const cipher = crypto.createCipher('aes-256-cbc', key); + let encrypted = cipher.update(data, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + return encrypted; +} +function aes256Decrypt(data, key) { + const decipher = crypto.createDecipher('aes-256-cbc', key); + let decrypted = decipher.update(data, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + +function encryptString(str) { + if (!str || typeof str !== 'string' || str.length === 0) { + throw new Error('Encrypt failed: invalid string'); + } + + if (safeStorage && safeStorage.isEncryptionAvailable()) { + let encryptedString = safeStorage.encryptString(str); + + return `$${ELECTRONSAFESTORAGE_ALGO}:${encryptedString}`; + } + + // fallback to aes256 + const key = machineIdSync(); + let encryptedString = aes256Encrypt(str, key); + + return `$${AES256_ALGO}:${encryptedString}`; +} + +function decryptString(str) { + if (!str) { + throw new Error('Decrypt failed: unrecognized string format'); + } + + const match = str.match(/^\$(.*?):(.*)$/); + if (!match) { + throw new Error('Decrypt failed: unrecognized string format'); + } + + const algo = match[1]; + const encryptedString = match[2]; + + if ([ELECTRONSAFESTORAGE_ALGO, AES256_ALGO].indexOf(algo) === -1) { + throw new Error('Decrypt failed: Invalid algo'); + } + + if (algo === ELECTRONSAFESTORAGE_ALGO) { + return safeStorage.decryptString(encryptedString); + } + + if (algo === AES256_ALGO) { + const key = machineIdSync(); + return aes256Decrypt(encryptedString, key); + } +} + +module.exports = { + encryptString, + decryptString +}; diff --git a/packages/bruno-electron/tests/utils/encryption.spec.js b/packages/bruno-electron/tests/utils/encryption.spec.js new file mode 100644 index 000000000..b00772284 --- /dev/null +++ b/packages/bruno-electron/tests/utils/encryption.spec.js @@ -0,0 +1,31 @@ +const { encryptString, decryptString } = require('../../src/utils/encryption'); + +// We can only unit test aes 256 fallback as safeStorage is only available +// in the main process + +describe('Encryption and Decryption Tests', () => { + it('should encrypt and decrypt using AES-256', () => { + const plaintext = 'bruno is awesome'; + const encrypted = encryptString(plaintext); + const decrypted = decryptString(encrypted); + + expect(decrypted).toBe(plaintext); + }); + + it('encrypt should throw an error for invalid string', () => { + expect(() => encryptString(null)).toThrow('Encrypt failed: invalid string'); + expect(() => encryptString('')).toThrow('Encrypt failed: invalid string'); + }); + + it('decrypt should throw an error for invalid string', () => { + expect(() => decryptString(null)).toThrow('Decrypt failed: unrecognized string format'); + expect(() => decryptString('')).toThrow('Decrypt failed: unrecognized string format'); + expect(() => decryptString('garbage')).toThrow('Decrypt failed: unrecognized string format'); + }); + + it('decrypt should throw an error for invalid algorithm', () => { + const invalidAlgo = '$99:abcdefg'; + + expect(() => decryptString(invalidAlgo)).toThrow('Decrypt failed: Invalid algo'); + }); +}); From aa88aa73a25123643ae9ccc9fb302c927cdf85ce Mon Sep 17 00:00:00 2001 From: Mirko Golze Date: Sun, 24 Sep 2023 15:28:33 +0200 Subject: [PATCH 5/6] #199 improve code to check given envvars correctly --- packages/bruno-cli/src/commands/run.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 420eb696d..4b6aa3afa 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -193,17 +193,18 @@ const handler = async function (argv) { return; } if (processVars && Array.isArray(processVars)) { - processVars.forEach((value) => { - let parts = value.split('='); - if (parts.length !== 2) { + for (const value of processVars.values()) { + // split the string at the first equals sign + const match = value.match(/^([^=]+)=(.*)$/); + if (!match) { console.error( - chalk.red(`overridable environment variable not correct: use name=value - presented: `) + + chalk.red(`Overridable environment variable not correct: use name=value - presented: `) + chalk.dim(`${value}`) ); return; } - envVars[parts[0]] = parts[1]; - }); + envVars[match[1]] = match[2]; + } } } From 9c4c219b992462c4b499458f3e84e417dca1c285 Mon Sep 17 00:00:00 2001 From: Anoop M D Date: Sun, 24 Sep 2023 23:02:39 +0530 Subject: [PATCH 6/6] feat(#199): Env Secrets - UI and Electron Layer updates --- .../EnvironmentVariables/index.js | 38 ++++-- .../EnvironmentVariables/reducer.js | 2 + .../bruno-app/src/utils/collections/export.js | 11 ++ packages/bruno-electron/src/app/watcher.js | 41 +++++- packages/bruno-electron/src/bru/index.js | 6 +- packages/bruno-electron/src/index.js | 2 +- packages/bruno-electron/src/ipc/collection.js | 17 +++ .../bruno-electron/src/store/env-secrets.js | 126 ++++++++++++++++++ .../bruno-electron/src/utils/encryption.js | 53 ++++++-- .../bruno-schema/src/collections/index.js | 3 +- 10 files changed, 265 insertions(+), 34 deletions(-) create mode 100644 packages/bruno-electron/src/store/env-secrets.js diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index e3ad6e080..4f5aa4dfa 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -44,6 +44,10 @@ const EnvironmentVariables = ({ environment, collection }) => { variable.enabled = e.target.checked; break; } + case 'secret': { + variable.secret = e.target.checked; + break; + } } reducerDispatch({ type: 'UPDATE_VAR', @@ -63,8 +67,10 @@ const EnvironmentVariables = ({ environment, collection }) => { + + @@ -73,6 +79,14 @@ const EnvironmentVariables = ({ environment, collection }) => { ? variables.map((variable, index) => { return ( + + ); diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/reducer.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/reducer.js index c72bf7b24..a5aa3e0c1 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/reducer.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/reducer.js @@ -12,6 +12,7 @@ const reducer = (state, action) => { name: '', value: '', type: 'text', + secret: false, enabled: true }); draft.hasChanges = true; @@ -24,6 +25,7 @@ const reducer = (state, action) => { variable.name = action.variable.name; variable.value = action.variable.value; variable.enabled = action.variable.enabled; + variable.secret = action.variable.secret; draft.hasChanges = true; }); } diff --git a/packages/bruno-app/src/utils/collections/export.js b/packages/bruno-app/src/utils/collections/export.js index b3c7780ad..64fc0da91 100644 --- a/packages/bruno-app/src/utils/collections/export.js +++ b/packages/bruno-app/src/utils/collections/export.js @@ -54,11 +54,22 @@ const deleteUidsInEnvs = (envs) => { }); }; +const deleteSecretsInEnvs = (envs) => { + each(envs, (env) => { + each(env.variables, (variable) => { + if (variable.secret) { + variable.value = ''; + } + }); + }); +}; + const exportCollection = (collection) => { // delete uids delete collection.uid; deleteUidsInItems(collection.items); deleteUidsInEnvs(collection.environments); + deleteSecretsInEnvs(collection.environments); transformItem(collection.items); const fileName = `${collection.name}.json`; diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index 6edd0b617..84c959cba 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -9,6 +9,10 @@ const { isLegacyEnvFile, migrateLegacyEnvFile, isLegacyBruFile, migrateLegacyBru const { itemSchema } = require('@usebruno/schema'); const { uuid } = require('../utils/common'); const { getRequestUid } = require('../cache/requestUids'); +const { decryptString } = require('../utils/encryption'); +const EnvironmentSecretsStore = require('../store/env-secrets'); + +const environmentSecretsStore = new EnvironmentSecretsStore(); const isJsonEnvironmentConfig = (pathname, collectionPath) => { const dirname = path.dirname(pathname); @@ -47,7 +51,13 @@ const hydrateRequestWithUuid = (request, pathname) => { return request; }; -const addEnvironmentFile = async (win, pathname, collectionUid) => { +const envHasSecrets = (environment = {}) => { + const secrets = _.filter(environment.variables, (v) => v.secret); + + return secrets && secrets.length > 0; +}; + +const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath) => { try { const basename = path.basename(pathname); const file = { @@ -70,13 +80,25 @@ const addEnvironmentFile = async (win, pathname, collectionUid) => { file.data.uid = getRequestUid(pathname); _.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid())); + + // hydrate environment variables with secrets + if (envHasSecrets(file.data)) { + const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data); + _.each(envSecrets, (secret) => { + const variable = _.find(file.data.variables, (v) => v.name === secret.name); + if (variable) { + variable.value = decryptString(secret.value); + } + }); + } + win.webContents.send('main:collection-tree-updated', 'addEnvironmentFile', file); } catch (err) { console.error(err); } }; -const changeEnvironmentFile = async (win, pathname, collectionUid) => { +const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPath) => { try { const basename = path.basename(pathname); const file = { @@ -93,6 +115,17 @@ const changeEnvironmentFile = async (win, pathname, collectionUid) => { file.data.uid = getRequestUid(pathname); _.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid())); + // hydrate environment variables with secrets + if (envHasSecrets(file.data)) { + const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data); + _.each(envSecrets, (secret) => { + const variable = _.find(file.data.variables, (v) => v.name === secret.name); + if (variable) { + variable.value = decryptString(secret.value); + } + }); + } + // we are reusing the addEnvironmentFile event itself // this is because the uid of the pathname remains the same // and the collection tree will be able to update the existing environment @@ -152,7 +185,7 @@ const add = async (win, pathname, collectionUid, collectionPath) => { } if (isBruEnvironmentConfig(pathname, collectionPath)) { - return addEnvironmentFile(win, pathname, collectionUid); + return addEnvironmentFile(win, pathname, collectionUid, collectionPath); } // migrate old json files to bru @@ -221,7 +254,7 @@ const addDirectory = (win, pathname, collectionUid, collectionPath) => { const change = async (win, pathname, collectionUid, collectionPath) => { if (isBruEnvironmentConfig(pathname, collectionPath)) { - return changeEnvironmentFile(win, pathname, collectionUid); + return changeEnvironmentFile(win, pathname, collectionUid, collectionPath); } if (hasBruExtension(pathname)) { diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index 6b1d73cdf..2a0d21da5 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -15,7 +15,7 @@ const bruToEnvJson = (bru) => { return json; } catch (error) { - return Promise.reject(e); + return Promise.reject(error); } }; @@ -24,7 +24,7 @@ const envJsonToBru = (json) => { const bru = envJsonToBruV2(json); return bru; } catch (error) { - return Promise.reject(e); + return Promise.reject(error); } }; @@ -32,7 +32,7 @@ const envJsonToBru = (json) => { * The transformer function for converting a BRU file to JSON. * * We map the json response from the bru lang and transform it into the DSL - * format that the app users + * format that the app uses * * @param {string} bru The BRU file content. * @returns {object} The JSON representation of the BRU file. diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index e3aaae712..30a98da39 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -18,7 +18,7 @@ setContentSecurityPolicy(` connect-src * 'unsafe-inline'; base-uri 'none'; form-action 'none'; - img-src 'self' data:image/svg+xml + img-src 'self' data:image/svg+xml; `); const menu = Menu.buildFromTemplate(menuTemplate); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index fd8975d2e..cf87ab1fd 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -18,6 +18,15 @@ const { openCollectionDialog, openCollection } = require('../app/collections'); const { generateUidBasedOnHash } = require('../utils/common'); const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids'); const { setPreferences } = require('../app/preferences'); +const EnvironmentSecretsStore = require('../store/env-secrets'); + +const environmentSecretsStore = new EnvironmentSecretsStore(); + +const envHasSecrets = (environment = {}) => { + const secrets = _.filter(environment.variables, (v) => v.secret); + + return secrets && secrets.length > 0; +}; const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollections) => { // browse directory @@ -153,6 +162,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(`environment: ${envFilePath} does not exist`); } + if (envHasSecrets(environment)) { + environmentSecretsStore.storeEnvSecrets(collectionPathname, environment); + } + const content = envJsonToBru(environment); await writeFile(envFilePath, content); } catch (error) { @@ -175,6 +188,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } fs.renameSync(envFilePath, newEnvFilePath); + + environmentSecretsStore.renameEnvironment(collectionPathname, environmentName, newName); } catch (error) { return Promise.reject(error); } @@ -190,6 +205,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } fs.unlinkSync(envFilePath); + + environmentSecretsStore.deleteEnvironment(collectionPathname, environmentName); } catch (error) { return Promise.reject(error); } diff --git a/packages/bruno-electron/src/store/env-secrets.js b/packages/bruno-electron/src/store/env-secrets.js new file mode 100644 index 000000000..b3d26c723 --- /dev/null +++ b/packages/bruno-electron/src/store/env-secrets.js @@ -0,0 +1,126 @@ +const _ = require('lodash'); +const Store = require('electron-store'); +const { encryptString } = require('../utils/encryption'); + +/** + * Sample secrets store file + * + * { + * "collections": [{ + * "path": "/Users/anoop/Code/acme-acpi-collection", + * "environments" : [{ + * "name": "Local", + * "secrets": [{ + * "name": "token", + * "value": "abracadabra" + * }] + * }] + * }] + * } + */ + +class EnvironmentSecretsStore { + constructor() { + this.store = new Store({ + name: 'secrets', + clearInvalidConfig: true + }); + } + + isValidValue(val) { + return val && typeof val === 'string' && val.length > 0; + } + + storeEnvSecrets(collectionPathname, environment) { + const envVars = []; + _.each(environment.variables, (v) => { + if (v.secret) { + envVars.push({ + name: v.name, + value: this.isValidValue(v.value) ? encryptString(v.value) : '' + }); + } + }); + + const collections = this.store.get('collections') || []; + const collection = _.find(collections, (c) => c.path === collectionPathname); + + // if collection doesn't exist, create it, add the environment and save + if (!collection) { + collections.push({ + path: collectionPathname, + environments: [ + { + name: environment.name, + secrets: envVars + } + ] + }); + + this.store.set('collections', collections); + return; + } + + // if collection exists, check if environment exists + // if environment doesn't exist, add the environment and save + collection.environments = collection.environments || []; + const env = _.find(collection.environments, (e) => e.name === environment.name); + if (!env) { + collection.environments.push({ + name: environment.name, + secrets: envVars + }); + + this.store.set('collections', collections); + return; + } + + // if environment exists, update the secrets and save + env.secrets = envVars; + this.store.set('collections', collections); + } + + getEnvSecrets(collectionPathname, environment) { + const collections = this.store.get('collections') || []; + const collection = _.find(collections, (c) => c.path === collectionPathname); + if (!collection) { + return []; + } + + const env = _.find(collection.environments, (e) => e.name === environment.name); + if (!env) { + return []; + } + + return env.secrets || []; + } + + renameEnvironment(collectionPathname, oldName, newName) { + const collections = this.store.get('collections') || []; + const collection = _.find(collections, (c) => c.path === collectionPathname); + if (!collection) { + return; + } + + const env = _.find(collection.environments, (e) => e.name === oldName); + if (!env) { + return; + } + + env.name = newName; + this.store.set('collections', collections); + } + + deleteEnvironment(collectionPathname, environmentName) { + const collections = this.store.get('collections') || []; + const collection = _.find(collections, (c) => c.path === collectionPathname); + if (!collection) { + return; + } + + _.remove(collection.environments, (e) => e.name === environmentName); + this.store.set('collections', collections); + } +} + +module.exports = EnvironmentSecretsStore; diff --git a/packages/bruno-electron/src/utils/encryption.js b/packages/bruno-electron/src/utils/encryption.js index 820b37b74..980311ff9 100644 --- a/packages/bruno-electron/src/utils/encryption.js +++ b/packages/bruno-electron/src/utils/encryption.js @@ -7,14 +7,17 @@ const ELECTRONSAFESTORAGE_ALGO = '00'; const AES256_ALGO = '01'; // AES-256 encryption and decryption functions -function aes256Encrypt(data, key) { +function aes256Encrypt(data) { + const key = machineIdSync(); const cipher = crypto.createCipher('aes-256-cbc', key); let encrypted = cipher.update(data, 'utf8', 'hex'); encrypted += cipher.final('hex'); return encrypted; } -function aes256Decrypt(data, key) { + +function aes256Decrypt(data) { + const key = machineIdSync(); const decipher = crypto.createDecipher('aes-256-cbc', key); let decrypted = decipher.update(data, 'hex', 'utf8'); decrypted += decipher.final('utf8'); @@ -22,20 +25,42 @@ function aes256Decrypt(data, key) { return decrypted; } +// electron safe storage encryption and decryption functions +function safeStorageEncrypt(str) { + let encryptedStringBuffer = safeStorage.encryptString(str); + + // Convert the encrypted buffer to a hexadecimal string + const encryptedString = encryptedStringBuffer.toString('hex'); + + return encryptedString; +} +function safeStorageDecrypt(str) { + // Convert the hexadecimal string to a buffer + const encryptedStringBuffer = Buffer.from(str, 'hex'); + + // Decrypt the buffer + const decryptedStringBuffer = safeStorage.decryptString(encryptedStringBuffer); + + // Convert the decrypted buffer to a string + const decryptedString = decryptedStringBuffer.toString(); + + return decryptedString; +} + function encryptString(str) { if (!str || typeof str !== 'string' || str.length === 0) { throw new Error('Encrypt failed: invalid string'); } - if (safeStorage && safeStorage.isEncryptionAvailable()) { - let encryptedString = safeStorage.encryptString(str); + let encryptedString = ''; + if (safeStorage && safeStorage.isEncryptionAvailable()) { + encryptedString = safeStorageEncrypt(str); return `$${ELECTRONSAFESTORAGE_ALGO}:${encryptedString}`; } // fallback to aes256 - const key = machineIdSync(); - let encryptedString = aes256Encrypt(str, key); + encryptedString = aes256Encrypt(str); return `$${AES256_ALGO}:${encryptedString}`; } @@ -45,25 +70,27 @@ function decryptString(str) { throw new Error('Decrypt failed: unrecognized string format'); } - const match = str.match(/^\$(.*?):(.*)$/); - if (!match) { + // Find the index of the first colon + const colonIndex = str.indexOf(':'); + + if (colonIndex === -1) { throw new Error('Decrypt failed: unrecognized string format'); } - const algo = match[1]; - const encryptedString = match[2]; + // Extract algo and encryptedString based on the colon index + const algo = str.substring(1, colonIndex); + const encryptedString = str.substring(colonIndex + 1); if ([ELECTRONSAFESTORAGE_ALGO, AES256_ALGO].indexOf(algo) === -1) { throw new Error('Decrypt failed: Invalid algo'); } if (algo === ELECTRONSAFESTORAGE_ALGO) { - return safeStorage.decryptString(encryptedString); + return safeStorageDecrypt(encryptedString); } if (algo === AES256_ALGO) { - const key = machineIdSync(); - return aes256Decrypt(encryptedString, key); + return aes256Decrypt(encryptedString); } } diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index fb5195488..ba2256a53 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -6,7 +6,8 @@ const environmentVariablesSchema = Yup.object({ name: Yup.string().nullable(), value: Yup.string().nullable(), type: Yup.string().oneOf(['text']).required('type is required'), - enabled: Yup.boolean().defined() + enabled: Yup.boolean().defined(), + secret: Yup.boolean() }) .noUnknown(true) .strict();
Enabled Name ValueSecret
+ handleVarChange(e, variable, 'enabled')} + /> + { autoCorrect="off" autoCapitalize="off" spellCheck="false" - value={variable.value} + value={variable.value || ''} className="mousetrap" onChange={(e) => handleVarChange(e, variable, 'value')} /> -
- handleVarChange(e, variable, 'enabled')} - /> - -
+ handleVarChange(e, variable, 'secret')} + /> +
+