mirror of
https://github.com/usebruno/bruno.git
synced 2024-11-21 23:43:15 +01:00
feat(#199): Env Secrets - UI and Electron Layer updates
This commit is contained in:
parent
f78c1640e9
commit
9c4c219b99
@ -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 }) => {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Enabled</td>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td>Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -73,6 +79,14 @@ const EnvironmentVariables = ({ environment, collection }) => {
|
||||
? variables.map((variable, index) => {
|
||||
return (
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
@ -92,23 +106,23 @@ const EnvironmentVariables = ({ environment, collection }) => {
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={variable.value}
|
||||
value={variable.value || ''}
|
||||
className="mousetrap"
|
||||
onChange={(e) => handleVarChange(e, variable, 'value')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={variable.enabled}
|
||||
checked={variable.secret}
|
||||
className="mr-3 mousetrap"
|
||||
onChange={(e) => handleVarChange(e, variable, 'enabled')}
|
||||
onChange={(e) => handleVarChange(e, variable, 'secret')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button onClick={() => handleRemoveVars(variable)}>
|
||||
<IconTrash strokeWidth={1.5} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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`;
|
||||
|
@ -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)) {
|
||||
|
@ -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.
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
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;
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user