mirror of
https://github.com/usebruno/bruno.git
synced 2025-01-11 00:18:46 +01:00
Merge pull request #209 from usebruno/feature/env-secrets
Feature/env secrets
This commit is contained in:
commit
7a0322d09e
@ -47,6 +47,10 @@ const EnvironmentVariables = ({ environment, collection }) => {
|
|||||||
variable.enabled = e.target.checked;
|
variable.enabled = e.target.checked;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'secret': {
|
||||||
|
variable.secret = e.target.checked;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reducerDispatch({
|
reducerDispatch({
|
||||||
type: 'UPDATE_VAR',
|
type: 'UPDATE_VAR',
|
||||||
@ -66,8 +70,10 @@ const EnvironmentVariables = ({ environment, collection }) => {
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<td>Enabled</td>
|
||||||
<td>Name</td>
|
<td>Name</td>
|
||||||
<td>Value</td>
|
<td>Value</td>
|
||||||
|
<td>Secret</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -76,6 +82,14 @@ const EnvironmentVariables = ({ environment, collection }) => {
|
|||||||
? variables.map((variable, index) => {
|
? variables.map((variable, index) => {
|
||||||
return (
|
return (
|
||||||
<tr key={variable.uid}>
|
<tr key={variable.uid}>
|
||||||
|
<td className="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={variable.enabled}
|
||||||
|
className="mr-3 mousetrap"
|
||||||
|
onChange={(e) => handleVarChange(e, variable, 'enabled')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -97,17 +111,17 @@ const EnvironmentVariables = ({ environment, collection }) => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="flex items-center">
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
type="checkbox"
|
checked={variable.secret}
|
||||||
checked={variable.enabled}
|
className="mr-3 mousetrap"
|
||||||
className="mr-3 mousetrap"
|
onChange={(e) => handleVarChange(e, variable, 'secret')}
|
||||||
onChange={(e) => handleVarChange(e, variable, 'enabled')}
|
/>
|
||||||
/>
|
</td>
|
||||||
<button onClick={() => handleRemoveVars(variable)}>
|
<td>
|
||||||
<IconTrash strokeWidth={1.5} size={20} />
|
<button onClick={() => handleRemoveVars(variable)}>
|
||||||
</button>
|
<IconTrash strokeWidth={1.5} size={20} />
|
||||||
</div>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
@ -12,6 +12,7 @@ const reducer = (state, action) => {
|
|||||||
name: '',
|
name: '',
|
||||||
value: '',
|
value: '',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
secret: false,
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
draft.hasChanges = true;
|
draft.hasChanges = true;
|
||||||
@ -24,6 +25,7 @@ const reducer = (state, action) => {
|
|||||||
variable.name = action.variable.name;
|
variable.name = action.variable.name;
|
||||||
variable.value = action.variable.value;
|
variable.value = action.variable.value;
|
||||||
variable.enabled = action.variable.enabled;
|
variable.enabled = action.variable.enabled;
|
||||||
|
variable.secret = action.variable.secret;
|
||||||
draft.hasChanges = true;
|
draft.hasChanges = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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) => {
|
const exportCollection = (collection) => {
|
||||||
// delete uids
|
// delete uids
|
||||||
delete collection.uid;
|
delete collection.uid;
|
||||||
deleteUidsInItems(collection.items);
|
deleteUidsInItems(collection.items);
|
||||||
deleteUidsInEnvs(collection.environments);
|
deleteUidsInEnvs(collection.environments);
|
||||||
|
deleteSecretsInEnvs(collection.environments);
|
||||||
transformItem(collection.items);
|
transformItem(collection.items);
|
||||||
|
|
||||||
const fileName = `${collection.name}.json`;
|
const fileName = `${collection.name}.json`;
|
||||||
|
@ -10,7 +10,8 @@
|
|||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"dev": "electron .",
|
"dev": "electron .",
|
||||||
"dist": "electron-builder --mac --config electron-builder-config.js",
|
"dist": "electron-builder --mac --config electron-builder-config.js",
|
||||||
"pack": "electron-builder --dir"
|
"pack": "electron-builder --dir",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@usebruno/js": "0.4.0",
|
"@usebruno/js": "0.4.0",
|
||||||
@ -32,6 +33,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"nanoid": "3.3.4",
|
"nanoid": "3.3.4",
|
||||||
|
"node-machine-id": "^1.1.12",
|
||||||
"qs": "^6.11.0",
|
"qs": "^6.11.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"vm2": "^3.9.13",
|
"vm2": "^3.9.13",
|
||||||
|
@ -10,7 +10,11 @@ const { isLegacyEnvFile, migrateLegacyEnvFile, isLegacyBruFile, migrateLegacyBru
|
|||||||
const { itemSchema } = require('@usebruno/schema');
|
const { itemSchema } = require('@usebruno/schema');
|
||||||
const { uuid } = require('../utils/common');
|
const { uuid } = require('../utils/common');
|
||||||
const { getRequestUid } = require('../cache/requestUids');
|
const { getRequestUid } = require('../cache/requestUids');
|
||||||
|
const { decryptString } = require('../utils/encryption');
|
||||||
const { setDotEnvVars } = require('../store/process-env');
|
const { setDotEnvVars } = require('../store/process-env');
|
||||||
|
const EnvironmentSecretsStore = require('../store/env-secrets');
|
||||||
|
|
||||||
|
const environmentSecretsStore = new EnvironmentSecretsStore();
|
||||||
|
|
||||||
const isJsonEnvironmentConfig = (pathname, collectionPath) => {
|
const isJsonEnvironmentConfig = (pathname, collectionPath) => {
|
||||||
const dirname = path.dirname(pathname);
|
const dirname = path.dirname(pathname);
|
||||||
@ -56,7 +60,13 @@ const hydrateRequestWithUuid = (request, pathname) => {
|
|||||||
return request;
|
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 {
|
try {
|
||||||
const basename = path.basename(pathname);
|
const basename = path.basename(pathname);
|
||||||
const file = {
|
const file = {
|
||||||
@ -79,13 +89,25 @@ const addEnvironmentFile = async (win, pathname, collectionUid) => {
|
|||||||
file.data.uid = getRequestUid(pathname);
|
file.data.uid = getRequestUid(pathname);
|
||||||
|
|
||||||
_.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid()));
|
_.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);
|
win.webContents.send('main:collection-tree-updated', 'addEnvironmentFile', file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeEnvironmentFile = async (win, pathname, collectionUid) => {
|
const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPath) => {
|
||||||
try {
|
try {
|
||||||
const basename = path.basename(pathname);
|
const basename = path.basename(pathname);
|
||||||
const file = {
|
const file = {
|
||||||
@ -102,6 +124,17 @@ const changeEnvironmentFile = async (win, pathname, collectionUid) => {
|
|||||||
file.data.uid = getRequestUid(pathname);
|
file.data.uid = getRequestUid(pathname);
|
||||||
_.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid()));
|
_.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
|
// we are reusing the addEnvironmentFile event itself
|
||||||
// this is because the uid of the pathname remains the same
|
// this is because the uid of the pathname remains the same
|
||||||
// and the collection tree will be able to update the existing environment
|
// and the collection tree will be able to update the existing environment
|
||||||
@ -180,7 +213,7 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isBruEnvironmentConfig(pathname, collectionPath)) {
|
if (isBruEnvironmentConfig(pathname, collectionPath)) {
|
||||||
return addEnvironmentFile(win, pathname, collectionUid);
|
return addEnvironmentFile(win, pathname, collectionUid, collectionPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate old json files to bru
|
// migrate old json files to bru
|
||||||
@ -268,7 +301,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isBruEnvironmentConfig(pathname, collectionPath)) {
|
if (isBruEnvironmentConfig(pathname, collectionPath)) {
|
||||||
return changeEnvironmentFile(win, pathname, collectionUid);
|
return changeEnvironmentFile(win, pathname, collectionUid, collectionPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasBruExtension(pathname)) {
|
if (hasBruExtension(pathname)) {
|
||||||
|
@ -15,7 +15,7 @@ const bruToEnvJson = (bru) => {
|
|||||||
|
|
||||||
return json;
|
return json;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(e);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,7 +24,7 @@ const envJsonToBru = (json) => {
|
|||||||
const bru = envJsonToBruV2(json);
|
const bru = envJsonToBruV2(json);
|
||||||
return bru;
|
return bru;
|
||||||
} catch (error) {
|
} 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.
|
* 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
|
* 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.
|
* @param {string} bru The BRU file content.
|
||||||
* @returns {object} The JSON representation of the BRU file.
|
* @returns {object} The JSON representation of the BRU file.
|
||||||
|
@ -18,7 +18,7 @@ setContentSecurityPolicy(`
|
|||||||
connect-src * 'unsafe-inline';
|
connect-src * 'unsafe-inline';
|
||||||
base-uri 'none';
|
base-uri 'none';
|
||||||
form-action 'none';
|
form-action 'none';
|
||||||
img-src 'self' data:image/svg+xml
|
img-src 'self' data:image/svg+xml;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const menu = Menu.buildFromTemplate(menuTemplate);
|
const menu = Menu.buildFromTemplate(menuTemplate);
|
||||||
|
@ -18,6 +18,15 @@ const { openCollectionDialog, openCollection } = require('../app/collections');
|
|||||||
const { generateUidBasedOnHash } = require('../utils/common');
|
const { generateUidBasedOnHash } = require('../utils/common');
|
||||||
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
|
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
|
||||||
const { setPreferences } = require('../store/preferences');
|
const { setPreferences } = require('../store/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) => {
|
const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
|
||||||
// browse directory
|
// browse directory
|
||||||
@ -153,6 +162,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
|||||||
throw new Error(`environment: ${envFilePath} does not exist`);
|
throw new Error(`environment: ${envFilePath} does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (envHasSecrets(environment)) {
|
||||||
|
environmentSecretsStore.storeEnvSecrets(collectionPathname, environment);
|
||||||
|
}
|
||||||
|
|
||||||
const content = envJsonToBru(environment);
|
const content = envJsonToBru(environment);
|
||||||
await writeFile(envFilePath, content);
|
await writeFile(envFilePath, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -175,6 +188,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.renameSync(envFilePath, newEnvFilePath);
|
fs.renameSync(envFilePath, newEnvFilePath);
|
||||||
|
|
||||||
|
environmentSecretsStore.renameEnvironment(collectionPathname, environmentName, newName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
@ -190,6 +205,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs.unlinkSync(envFilePath);
|
fs.unlinkSync(envFilePath);
|
||||||
|
|
||||||
|
environmentSecretsStore.deleteEnvironment(collectionPathname, environmentName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
126
packages/bruno-electron/src/store/env-secrets.js
Normal file
126
packages/bruno-electron/src/store/env-secrets.js
Normal file
@ -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;
|
100
packages/bruno-electron/src/utils/encryption.js
Normal file
100
packages/bruno-electron/src/utils/encryption.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
const key = machineIdSync();
|
||||||
|
const decipher = crypto.createDecipher('aes-256-cbc', key);
|
||||||
|
let decrypted = decipher.update(data, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
let encryptedString = '';
|
||||||
|
|
||||||
|
if (safeStorage && safeStorage.isEncryptionAvailable()) {
|
||||||
|
encryptedString = safeStorageEncrypt(str);
|
||||||
|
return `$${ELECTRONSAFESTORAGE_ALGO}:${encryptedString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback to aes256
|
||||||
|
encryptedString = aes256Encrypt(str);
|
||||||
|
|
||||||
|
return `$${AES256_ALGO}:${encryptedString}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptString(str) {
|
||||||
|
if (!str) {
|
||||||
|
throw new Error('Decrypt failed: unrecognized string format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the index of the first colon
|
||||||
|
const colonIndex = str.indexOf(':');
|
||||||
|
|
||||||
|
if (colonIndex === -1) {
|
||||||
|
throw new Error('Decrypt failed: unrecognized string format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 safeStorageDecrypt(encryptedString);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (algo === AES256_ALGO) {
|
||||||
|
return aes256Decrypt(encryptedString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
encryptString,
|
||||||
|
decryptString
|
||||||
|
};
|
31
packages/bruno-electron/tests/utils/encryption.spec.js
Normal file
31
packages/bruno-electron/tests/utils/encryption.spec.js
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -2,7 +2,7 @@ const ohm = require('ohm-js');
|
|||||||
const _ = require('lodash');
|
const _ = require('lodash');
|
||||||
|
|
||||||
const grammar = ohm.grammar(`Bru {
|
const grammar = ohm.grammar(`Bru {
|
||||||
BruEnvFile = (vars)*
|
BruEnvFile = (vars | secretvars)*
|
||||||
|
|
||||||
nl = "\\r"? "\\n"
|
nl = "\\r"? "\\n"
|
||||||
st = " " | "\\t"
|
st = " " | "\\t"
|
||||||
@ -19,6 +19,13 @@ const grammar = ohm.grammar(`Bru {
|
|||||||
key = keychar*
|
key = keychar*
|
||||||
value = valuechar*
|
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
|
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) => {
|
const concatArrays = (objValue, srcValue) => {
|
||||||
if (_.isArray(objValue) && _.isArray(srcValue)) {
|
if (_.isArray(objValue) && _.isArray(srcValue)) {
|
||||||
return objValue.concat(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) {
|
dictionary(_1, _2, pairlist, _3) {
|
||||||
return pairlist.ast;
|
return pairlist.ast;
|
||||||
},
|
},
|
||||||
@ -97,6 +136,18 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
|||||||
},
|
},
|
||||||
vars(_1, dictionary) {
|
vars(_1, dictionary) {
|
||||||
const vars = mapPairListToKeyValPairs(dictionary.ast);
|
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 {
|
return {
|
||||||
variables: vars
|
variables: vars
|
||||||
};
|
};
|
||||||
|
@ -2,22 +2,42 @@ const _ = require('lodash');
|
|||||||
|
|
||||||
const envToJson = (json) => {
|
const envToJson = (json) => {
|
||||||
const variables = _.get(json, 'variables', []);
|
const variables = _.get(json, 'variables', []);
|
||||||
const vars = variables.map((variable) => {
|
const vars = variables
|
||||||
const { name, value, enabled } = variable;
|
.filter((variable) => !variable.secret)
|
||||||
const prefix = enabled ? '' : '~';
|
.map((variable) => {
|
||||||
return ` ${prefix}${name}: ${value}`;
|
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 {
|
return `vars {
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = `vars {
|
let output = '';
|
||||||
|
if (vars.length) {
|
||||||
|
output += `vars {
|
||||||
${vars.join('\n')}
|
${vars.join('\n')}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secretVars.length) {
|
||||||
|
output += `vars:secret [
|
||||||
|
${secretVars.join(',\n')}
|
||||||
|
]
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
@ -26,7 +26,8 @@ vars {
|
|||||||
{
|
{
|
||||||
name: 'url',
|
name: 'url',
|
||||||
value: 'http://localhost:3000',
|
value: 'http://localhost:3000',
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
secret: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@ -48,17 +49,20 @@ vars {
|
|||||||
{
|
{
|
||||||
name: 'url',
|
name: 'url',
|
||||||
value: 'http://localhost:3000',
|
value: 'http://localhost:3000',
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
secret: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'port',
|
name: 'port',
|
||||||
value: '3000',
|
value: '3000',
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
secret: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'token',
|
name: 'token',
|
||||||
value: 'secret',
|
value: 'secret',
|
||||||
enabled: false
|
enabled: false,
|
||||||
|
secret: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@ -82,12 +86,14 @@ vars {
|
|||||||
{
|
{
|
||||||
name: 'url',
|
name: 'url',
|
||||||
value: 'http://localhost:3000',
|
value: 'http://localhost:3000',
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
secret: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'port',
|
name: 'port',
|
||||||
value: '3000',
|
value: '3000',
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
secret: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@ -110,17 +116,197 @@ vars {
|
|||||||
{
|
{
|
||||||
name: 'url',
|
name: 'url',
|
||||||
value: '',
|
value: '',
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
secret: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'phone',
|
name: 'phone',
|
||||||
value: '',
|
value: '',
|
||||||
enabled: true
|
enabled: true,
|
||||||
|
secret: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'api-key',
|
name: 'api-key',
|
||||||
value: '',
|
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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
@ -57,4 +57,87 @@ describe('env parser', () => {
|
|||||||
const output = parser(input);
|
const output = parser(input);
|
||||||
expect(output).toEqual(expected);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,8 @@ const environmentVariablesSchema = Yup.object({
|
|||||||
name: Yup.string().nullable(),
|
name: Yup.string().nullable(),
|
||||||
value: Yup.string().nullable(),
|
value: Yup.string().nullable(),
|
||||||
type: Yup.string().oneOf(['text']).required('type is required'),
|
type: Yup.string().oneOf(['text']).required('type is required'),
|
||||||
enabled: Yup.boolean().defined()
|
enabled: Yup.boolean().defined(),
|
||||||
|
secret: Yup.boolean()
|
||||||
})
|
})
|
||||||
.noUnknown(true)
|
.noUnknown(true)
|
||||||
.strict();
|
.strict();
|
||||||
|
Loading…
Reference in New Issue
Block a user