bws-secure
Version:
Secure environment management with Bitwarden Secrets Manager
709 lines (613 loc) • 27.4 kB
JavaScript
#!/usr/bin/env node
/**
* upload-secrets.js
*
* Tool for uploading secrets to Bitwarden Secrets Manager
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync, spawnSync } from 'node:child_process';
import readline from 'node:readline';
import { promises as fsPromises } from 'node:fs';
import crypto from 'node:crypto';
import dotenv from 'dotenv';
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Try multiple possible locations for .env files
const possibleEnvPaths = [
// 1. Local .env (same directory as script)
path.join(__dirname, '.env'),
// 2. Working directory .env
path.join(process.cwd(), '.env'),
// 3. Root .env (adjust path depth to your repo)
path.join(__dirname, '../../../.env')
];
// Find first existing .env file
let envFilePath = null;
for (const testPath of possibleEnvPaths) {
if (fs.existsSync(testPath)) {
envFilePath = testPath;
console.log(`Loading environment from .env at: ${envFilePath}`);
dotenv.config({ path: envFilePath });
break;
}
}
if (!envFilePath) {
console.log(
'No .env file found in any expected location. Relying on existing environment variables.'
);
}
// Automatically determine the folder of the script
const folderPath = path.dirname(__filename);
// Add near the top with other constants
const EXCLUDED_VARS = ['BWS_ACCESS_TOKEN'];
const DEBUG = process.env.DEBUG === 'true';
// Update the rate limit handling constants
const RATE_LIMIT_DELAYS = {
FIRST: 65_000, // 65 seconds (in milliseconds)
BETWEEN_FILES: 30_000, // 30 seconds between files
BETWEEN_OPERATIONS: 15_000, // 15 seconds between delete/upload operations
BETWEEN_DELETES: 500 // Reduce from 2000ms to 500ms - still safe but much faster
};
function debug(message, command = '') {
if (DEBUG) {
console.log('\x1b[36m[DEBUG]\x1b[0m', message);
if (command) {
console.log('\x1b[36m[CMD]\x1b[0m', command);
}
}
}
// Function to sanitize and escape environment variable values
const sanitizeValue = (value) => {
// Remove surrounding single or double quotes if present
const unquotedValue = value.replace(/^['"]|['"]$/g, '');
// Escape double quotes within the value
return `"${unquotedValue.replace(/"/g, '\\"')}"`;
};
/**
* Creates a colored ASCII box that fits any number of lines.
* @param {string[]} lines Lines of text to display inside the box
* @param {string} colorCode ANSI color code (e.g., "\x1b[92m" for green, "\x1b[31m" for red)
*/
function createBox(lines, colorCode) {
// Find the longest line to set box width
let maxLen = 0;
lines.forEach((line) => {
if (line.length > maxLen) {
maxLen = line.length;
}
});
// Build top/bottom borders
const top = '┌' + '─'.repeat(maxLen + 2) + '┐';
const bottom = '└' + '─'.repeat(maxLen + 2) + '┘';
// Print the box in the specified color
console.log(colorCode + top);
lines.forEach((line) => {
// Pad each line so they are all the same width
const paddedLine = line.padEnd(maxLen, ' ');
console.log(`│ ${paddedLine} │`);
});
console.log(bottom + '\x1b[0m'); // reset color at the end
}
function createErrorBox(lines) {
// Red color
createBox(lines, '\x1b[31m');
}
function createSuccessBox(successes, uploadResults) {
// Create header lines using the passed parameters
const headerLines = [
'',
`SUCCESS! ${successes.length} of ${uploadResults.length} file(s) uploaded correctly.`,
'',
'Successfully uploaded the below project ID(s).',
'Visit the management URLs below to verify the uploaded contents:',
''
];
// Find max width for the box
const maxLen = Math.max(...headerLines.map((line) => line.length));
// Build box borders
const top = '┌' + '─'.repeat(maxLen + 2) + '┐';
const bottom = '└' + '─'.repeat(maxLen + 2) + '┘';
// Print header box
console.log('\x1b[92m'); // Start green color
console.log(top);
headerLines.forEach((line) => {
const paddedLine = line.padEnd(maxLen, ' ');
console.log(`│ ${paddedLine} │`);
});
console.log(bottom);
// Print project IDs and URLs below
console.log(''); // Add spacing
successes.forEach((s) => {
console.log(`Project ID: ${s.projectId} (${s.count} secrets)`);
console.log(
`https://vault.bitwarden.com/#/sm/22479128-f194-460a-884b-b24a015686c6/projects/${s.projectId}/secrets`
);
console.log(''); // Add spacing between projects
});
console.log('\x1b[0m'); // Reset color
}
/**
* Print a bright red error message, then exit.
*/
function showErrorAndExit(msg) {
console.error(`\x1b[31m${msg}\x1b[0m`);
process.exit(1);
}
/**
* Attempt to parse and simplify the BWS error message that often includes Rust stack traces.
* We remove location references, backtrace hints, etc., and if there is JSON with "message",
* we extract that for a cleaner final output.
*/
function parseBwsErrorMessage(rawErrorMessage) {
let simplified = rawErrorMessage.trim();
// Use a non-greedy, more specific regex pattern to find JSON
// Limit the JSON search to reasonable size and complexity
const MAX_JSON_LENGTH = 1000; // Reasonable limit for error messages
const truncated = simplified.slice(0, MAX_JSON_LENGTH);
// Use a more specific pattern that won't cause catastrophic backtracking
// [\s\S] is used instead of . to match newlines, and +? makes it non-greedy
// NOSONAR: Safe regex pattern with size limit and non-greedy matching
/* sonar-disable-next-line sonar:S5852 */
const matchJSON = truncated.match(/\{[\s\S]+?\}/);
if (matchJSON) {
try {
const errorObj = JSON.parse(matchJSON[0]);
if (errorObj.message) {
simplified = `Server responded with: "${errorObj.message}"`;
}
} catch {
// If JSON parse fails, just keep going
}
}
// Remove any location/backtrace lines
simplified = simplified
.replace(/Location:.*?\n/g, '')
.replace(/Backtrace omitted.*?\n/g, '')
.replace(/Run with.*?\n/g, '');
return simplified;
}
// Track how many times we've been rate-limited:
let timesRateLimited = 0;
/**
* Sleep synchronously for the given milliseconds (blocks Node's event loop).
* For a script like this, blocking is usually acceptable.
*/
function syncSleep(ms) {
const start = Date.now();
while (Date.now() - start < ms) {
// Busy-wait until ms have elapsed
}
}
/**
* Check if the error text appears to be a 429 or "too many requests".
*/
function isRateLimitError(errorText) {
return /429|too\s+many\s+requests/i.test(errorText);
}
/**
* Generic rate limit handler that can be used for both delete and upload operations
*/
async function handleRateLimit(operation = 'operation') {
console.log(`\x1b[33mRate-limit detected during ${operation}; sleeping 65 seconds...\x1b[0m`);
const waitTime = RATE_LIMIT_DELAYS.FIRST;
const startTime = Date.now();
const endTime = startTime + waitTime;
while (Date.now() < endTime) {
const remainingSeconds = Math.ceil((endTime - Date.now()) / 1000);
process.stdout.write(`\r\x1b[33mWaiting... ${remainingSeconds} seconds remaining\x1b[0m`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
process.stdout.write('\n');
}
/**
* Delete a single secret with retry logic
*/
async function deleteSecretWithRetry(secret, projectId, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const deleteCmd = `./node_modules/.bin/bws secret delete --output none -t ${process.env.BWS_ACCESS_TOKEN} ${secret.id}`;
debug(`Deleting secret ${secret.key} with command:`, deleteCmd);
execSync(deleteCmd, { stdio: 'pipe' });
console.log(`Deleted secret: ${secret.key}`);
// Only add small delay if there are more secrets to delete
if (attempt === maxRetries) {
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAYS.BETWEEN_DELETES));
}
return;
} catch (deleteError) {
const prettyError = parseBwsErrorMessage(deleteError.message);
if (isRateLimitError(prettyError) && attempt < maxRetries) {
await handleRateLimit('delete');
continue;
}
throw deleteError;
}
}
}
// Update the clearProjectSecrets function
async function clearProjectSecrets(projectId) {
try {
console.log(`\nClearing existing secrets for project: ${projectId}...`);
const listCmd = `./node_modules/.bin/bws secret list -o json -t ${process.env.BWS_ACCESS_TOKEN} ${projectId}`;
debug('Listing secrets with command:', listCmd);
try {
const result = execSync(listCmd, { encoding: 'utf8' });
debug('List command result:', result);
const secrets = JSON.parse(result || '[]');
debug(`Found ${secrets.length} secrets to delete`);
if (secrets.length === 0) {
console.log('\x1b[36m'); // Cyan color
console.log('----------------------------------------');
console.log(`Project ID: ${projectId}`);
console.log('Status: No existing secrets to clear');
console.log('----------------------------------------');
console.log('\x1b[0m'); // Reset color
return;
}
// Delete each secret with retry logic
for (const secret of secrets) {
await deleteSecretWithRetry(secret, projectId);
}
console.log(`Cleared ${secrets.length} secrets from project ${projectId}`);
// Add delay after clearing before starting uploads
console.log(
`Waiting ${RATE_LIMIT_DELAYS.BETWEEN_OPERATIONS / 1000} seconds before starting uploads...`
);
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAYS.BETWEEN_OPERATIONS));
} catch (listError) {
// If we get a 404, it means no secrets exist - that's okay
if (listError.message.includes('404 Not Found')) {
console.log('\x1b[33m'); // Yellow color
console.log('=========================================================');
console.log('No secrets found for this project (404).');
console.log('This could mean:');
console.log(' • Project is empty');
console.log(' • Project was recently cleared');
console.log(' • Project ID might be incorrect');
console.log('');
console.log(`Verify the BWS ProjectID at the following URL: ${projectId}`);
console.log('');
console.log(
`https://vault.bitwarden.com/#/sm/22479128-f194-460a-884b-b24a015686c6/projects/${projectId}/secrets`
);
console.log('');
console.log('Will continue with upload in 10 seconds...');
console.log('Press Ctrl+C to cancel if something looks wrong.');
console.log('=========================================================');
console.log('\x1b[0m'); // Reset color
// Wait 10 seconds
await new Promise((resolve) => setTimeout(resolve, 10000));
return;
}
throw listError;
}
} catch (error) {
debug('Clear project secrets error:', error.message);
throw new Error(`Failed to clear secrets for project ${projectId}: ${error.message}`);
}
}
// Move these functions to before they're used
function warnEmptyValue(key, value, file) {
console.log(
`\x1b[31mWarning: Empty or unresolved variable "${key}" in ${file}${
value ? `: '${value}'` : ''
}\x1b[0m`
);
}
function transformEnvFile(filePath) {
try {
const fileContent = fs.readFileSync(filePath, 'utf8');
const parsed = dotenv.parse(fileContent);
const filename = path.basename(filePath);
return Object.fromEntries(
Object.entries(parsed)
.filter(([key]) => !EXCLUDED_VARS.includes(key))
.map(([key, value]) => {
// Check for empty values before interpolation
if (!value || value.trim() === '') {
warnEmptyValue(key, null, filename);
return [key, ''];
}
// Let dotenv handle the variable interpolation
const interpolated = value.replace(/\${([^}]+)}/g, (match, varName) => {
const resolvedValue = parsed[varName] || '';
if (!resolvedValue) {
warnEmptyValue(key, value, filename);
}
return resolvedValue || match;
});
return [key, interpolated];
})
);
} catch (error) {
console.error(`Failed to transform ${filePath}:`, error.message);
throw error;
}
}
function uploadSecretWithRetry(key, sanitizedValue, projectId, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// NOSONAR: BWS CLI execution with controlled token and sanitized values - no user input
/* sonar-disable-next-line sonar:S4721 */
const cmd = `./node_modules/.bin/bws secret create -t ${process.env.BWS_ACCESS_TOKEN} ${key} -- ${sanitizedValue} ${projectId}`;
debug(`Attempt ${attempt}: Creating secret ${key} with command:`, cmd);
execSync(cmd, { stdio: 'pipe' });
return;
} catch (execError) {
const rawErrorOutput = execError.stderr?.toString() || execError.stdout?.toString() || '';
debug(`Upload error on attempt ${attempt}:`, rawErrorOutput);
const prettyError = parseBwsErrorMessage(rawErrorOutput);
if (isRateLimitError(prettyError)) {
// Always use the 65 second delay for rate limits
console.log(`\x1b[33mRate-limit detected; sleeping 65 seconds...\x1b[0m`);
syncSleep(RATE_LIMIT_DELAYS.FIRST);
timesRateLimited++;
// Then continue (i.e. retry) if we haven't exceeded maxRetries
if (attempt < maxRetries) {
continue; // move on to next attempt
}
}
// If it's not a rate-limit error (or we ran out of attempts), throw a real error
throw new Error(`Failed to upload secret "${key}". BWS CLI said:\n${prettyError}`);
}
}
}
/**
* Main function to process all .env.bws.* files in the directory
*/
const processEnvFiles = async (options = { clearFirst: false }) => {
const files = fs.readdirSync(folderPath);
const envFiles = files.filter((file) => file.startsWith('.env.bws.'));
if (envFiles.length === 0) {
console.error('No .env.bws.* files found in this folder.');
process.exit(1);
}
const uploadResults = [];
for (const file of envFiles) {
const envFilePath = path.join(folderPath, file);
const projectId = file.split('.').pop();
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) {
showErrorAndExit(`
[ERROR] Invalid or inaccessible project ID: "${projectId}"
Please check:
1. Project ID format should be: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
Current file: ${file}
2. Verify BWS_ACCESS_TOKEN permissions:
• Token must have WRITE access to this project
• Check permissions at: https://vault.bitwarden.com/#/sm/access-policies
3. Confirm project exists and is accessible:
• Visit: https://vault.bitwarden.com/#/sm/projects
• Ensure project ID matches exactly
• Verify you have proper access to this project
4. File naming convention:
• File should be named: .env.bws.<project-id>
• Example: .env.bws.12345678-1234-1234-1234-123456789abc
Need help? Visit: https://bitwarden.com/help/secrets-manager-overview/
`);
}
try {
// Clear existing secrets if option is enabled
if (options.clearFirst) {
await clearProjectSecrets(projectId);
// Add pause after clearing secrets
console.log('\x1b[33m'); // Yellow color
console.log('=========================================================');
console.log('Secrets have been cleared. Pausing for verification...');
console.log('You can verify deletion by checking the Bitwarden vault.');
console.log(`Verify the BWS Project ID at the following URL: ${projectId}`);
console.log('');
console.log(
`https://vault.bitwarden.com/#/sm/22479128-f194-460a-884b-b24a015686c6/projects/${projectId}/secrets`
);
console.log('');
console.log('Will continue with upload in 10 seconds...');
console.log('Press Ctrl+C to cancel if something looks wrong.');
console.log('=========================================================');
console.log('\x1b[0m'); // Reset color
// Wait 10 seconds
await new Promise((resolve) => setTimeout(resolve, 10000));
}
// Transform the file contents first
const secrets = transformEnvFile(envFilePath);
const secretList = Object.entries(secrets).map(([key, value]) => ({ key, value }));
if (!secretList.length) {
console.log(`No valid secrets found in ${file}. Skipping upload.`);
uploadResults.push({ file, projectId, success: true, count: 0 });
return;
}
console.log(
`\nFound ${secretList.length} secrets in ${file}. Uploading to projectId: ${projectId}...`
);
// Then upload each secret
secretList.forEach((secret, idx) => {
const { key, value } = secret;
const sanitizedValue = sanitizeValue(value);
uploadSecretWithRetry(key, sanitizedValue, projectId);
const current = idx + 1;
console.log(`Uploaded secret "${key}" (${current}/${secretList.length})...`);
});
uploadResults.push({ file, projectId, success: true, count: secretList.length });
// Add longer delay between files
if (envFiles.length > 1 && envFiles.indexOf(file) < envFiles.length - 1) {
console.log(
`Upload complete for this file; waiting ${
RATE_LIMIT_DELAYS.BETWEEN_FILES / 1000
} seconds before the next file...`
);
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAYS.BETWEEN_FILES));
} else {
console.log('Upload complete for this file; no further wait needed.');
}
} catch (error) {
const prettyError = parseBwsErrorMessage(error.message);
uploadResults.push({ file, projectId, success: false, error: prettyError });
}
}
printFinalSummary(uploadResults);
};
/**
* Prints a consolidated summary at the very end.
* Shows a green success box listing any successful files,
* and a red error box listing any failures.
*/
function printFinalSummary(uploadResults) {
const successes = uploadResults.filter((r) => r.success);
const failures = uploadResults.filter((r) => !r.success);
if (successes.length > 0) {
createSuccessBox(successes, uploadResults);
}
if (failures.length > 0) {
let errorLines = [];
errorLines.push('');
errorLines.push(
`ERROR! ${failures.length} of ${uploadResults.length} file(s) failed to upload.`
);
errorLines.push('');
failures.forEach((f) => {
errorLines.push(`File: ${f.file}`);
errorLines.push(`Project ID: ${f.projectId}`);
errorLines.push('Reason:');
if (f.error.includes('Resource not found')) {
errorLines.push(' Project ID not found or no access. Please check:');
errorLines.push(' • BWS_ACCESS_TOKEN has WRITE access to this project');
errorLines.push(' • Project ID exists and is accessible');
errorLines.push(' • Visit: https://vault.bitwarden.com/#/sm/projects');
} else {
errorLines.push(...f.error.split('\n').map((l) => ` ${l}`));
}
errorLines.push('');
});
errorLines.push('Please fix the issues above and re-run the script.');
// Calculate dynamic separator width based on the longest line
const maxLineLength = Math.max(...errorLines.map((line) => line.length));
const separator = '='.repeat(maxLineLength);
// Insert the separator dynamically
errorLines.unshift(separator);
errorLines.push(separator);
createErrorBox(errorLines);
}
if (successes.length === 0 && failures.length === 0) {
console.log('No files were processed.');
}
}
// Add a warning if BWS_ACCESS_TOKEN is found in any file
function checkForSensitiveVars(fileContent, fileName) {
const lines = fileContent.split(/\r?\n/);
const sensitiveVars = [];
lines.forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return;
const key = trimmed.split('=')[0].trim();
if (EXCLUDED_VARS.includes(key)) {
sensitiveVars.push(key);
}
});
if (sensitiveVars.length > 0) {
createBox(
[
'',
'⚠️ WARNING: Sensitive variables found!',
'',
`Found in file: ${fileName}`,
'',
'The following variables will be skipped:',
...sensitiveVars.map((v) => ` - ${v}`),
'',
'These variables should not be uploaded to BWS.',
''
],
'\x1b[33m'
); // Yellow warning box
}
}
// For missing token
if (!process.env.BWS_ACCESS_TOKEN) {
// prettier-ignore
{
console.error('\x1b[33m╔════════════════════════════════════════════════════════╗\x1b[0m');
console.error('\x1b[33m║ ║\x1b[0m');
console.error('\x1b[33m║ WARNING: BWS TOKEN MISSING ║\x1b[0m');
console.error('\x1b[33m║ ║\x1b[0m');
console.error('\x1b[33m║ To use BWS features: ║\x1b[0m');
console.error('\x1b[33m║ 1. Log in to vault.bitwarden.com ║\x1b[0m');
console.error('\x1b[33m║ 2. Go to Secrets Manager > Machine Accounts ║\x1b[0m');
console.error('\x1b[33m║ 3. Create or copy your machine access token ║\x1b[0m');
console.error('\x1b[33m║ 4. Add to .env: BWS_ACCESS_TOKEN=your_token ║\x1b[0m');
console.error('\x1b[33m║ ║\x1b[0m');
console.error('\x1b[33m║ For now, continuing with only .env values... ║\x1b[0m');
console.error('\x1b[33m║ ║\x1b[0m');
console.error('\x1b[33m╚════════════════════════════════════════════════════════╝\x1b[0m');
console.error(
'\nVisit the link below to create your token: \n' +
'\nhttps://vault.bitwarden.com/#/sm/22479128-f194-460a-884b-b24a015686c6/machine-accounts\n'
);
}
process.exit(1);
}
// Add this try/catch block for token validation
try {
// NOSONAR: BWS CLI execution for token validation - no user input
/* sonar-disable-next-line sonar:S4721 */
execSync(`./node_modules/.bin/bws project list -t ${process.env.BWS_ACCESS_TOKEN}`, {
stdio: 'ignore',
env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }
});
} catch (err) {
// prettier-ignore
{
console.error('\x1b[31m╔════════════════════════════════════════════════════════╗\x1b[0m');
console.error('\x1b[31m║ ║\x1b[0m');
console.error('\x1b[31m║ CRITICAL BWS TOKEN ERROR ║\x1b[0m');
console.error('\x1b[31m║ ║\x1b[0m');
console.error('\x1b[31m║ Your BWS_ACCESS_TOKEN appears to be invalid: ║\x1b[0m');
console.error('\x1b[31m║ 1. Check if token has expired ║\x1b[0m');
console.error('\x1b[31m║ 2. Verify token permissions in vault.bitwarden.com ║\x1b[0m');
console.error('\x1b[31m║ 3. Generate new token if needed ║\x1b[0m');
console.error('\x1b[31m║ 4. Ensure token has read access to required projects ║\x1b[0m');
console.error('\x1b[31m║ ║\x1b[0m');
console.error('\x1b[31m║ For now, continuing with only .env values... ║\x1b[0m');
console.error('\x1b[31m║ ║\x1b[0m');
console.error('\x1b[31m╚════════════════════════════════════════════════════════╝\x1b[0m');
console.error(
'\nVisit the link below to check or regenerate your token: \n' +
'\nhttps://vault.bitwarden.com/#/sm/22479128-f194-460a-884b-b24a015686c6/machine-accounts\n'
);
}
process.exit(1);
}
// Add near the top, before processing starts
function showInitialWarning() {
// prettier-ignore
{
console.log('\x1b[33m╔══════════════════════════════════════════════════════════════════╗\x1b[0m');
console.log('\x1b[33m║ ║\x1b[0m');
console.log('\x1b[33m║ IMPORTANT NOTE ║\x1b[0m');
console.log('\x1b[33m║ ║\x1b[0m');
console.log('\x1b[33m║ When uploading secrets: ║\x1b[0m');
console.log('\x1b[33m║ ║\x1b[0m');
console.log('\x1b[33m║ 1. Use --clear-vars to remove existing secrets first ║\x1b[0m');
console.log('\x1b[33m║ Example: pnpm secure-run --upload-secrets --clear-vars ║\x1b[0m');
console.log('\x1b[33m║ ║\x1b[0m');
console.log('\x1b[33m║ 2. Or manually update values in Bitwarden if you ║\x1b[0m');
console.log('\x1b[33m║ want to preserve other existing secrets ║\x1b[0m');
console.log('\x1b[33m║ ║\x1b[0m');
console.log('\x1b[33m║ Continuing in 5 seconds... ║\x1b[0m');
console.log('\x1b[33m║ Press Ctrl+C to cancel ║\x1b[0m');
console.log('\x1b[33m║ ║\x1b[0m');
console.log('\x1b[33m╚══════════════════════════════════════════════════════════════════╝\x1b[0m');
}
// Give user time to read and potentially cancel
syncSleep(5000);
}
// Change the check for clearvars to accept both formats
const shouldClearFirst =
process.argv.includes('--clearvars') || process.argv.includes('--clear-vars');
// Add initial warning if not using clear-vars
if (!shouldClearFirst) {
showInitialWarning();
}
// Start processing with options
processEnvFiles({ clearFirst: shouldClearFirst });