bws-secure
Version:
Secure environment management with Bitwarden Secrets Manager
486 lines (432 loc) • 15.2 kB
JavaScript
/**
* utils.js
*
* Provides shared helper functions used by both Netlify and Vercel modules, as well
* as the updateEnvVars.js entry point. This includes logging, error handling, reading .env
* files, variable validation, and other common utilities.
*/
import fs from 'node:fs';
import crypto from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Get the directory name in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Logs a message with a specific log level and timestamp.
* Supports optional metadata object for additional context.
*
* @param {string} level - The log level: "info", "debug", "warn", or "error"
* @param {string} message - The log message
* @param {Object} [metadata] - Optional metadata to include in the log
*/
function log(level, message) {
// Only show info, warn and error logs by default
if (level === 'debug' && !process.env.DEBUG) {
return;
}
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`);
}
/**
* Handles errors by logging them and optionally terminating the process.
* Provides more context in development environment.
*
* @param {Error} error - The error thrown
* @param {string} message - Additional context for the error
* @param {boolean} [fatal=true] - Whether to exit process
*/
function handleError(error, message, fatal = true) {
const errorContext = {
message: error.message,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
response: error.response?.data,
status: error.response?.status
};
log('error', `${message}: ${error.message}`, errorContext);
if (fatal) {
process.exit(1);
}
}
/**
* Reads and parses lines in a .env file, returning an array of non-comment lines.
*
* @param {string} filePath - The path to the .env file
* @returns {Promise<string[]>} Array of env file lines containing KEY=VALUE
*/
async function readEnvFile(filePath) {
try {
const data = await fs.promises.readFile(filePath, 'utf8');
const lines = [];
data.split('\n').forEach((line) => {
const trimmed = line.trim();
// Ignore blank lines and comment lines
if (trimmed && !trimmed.startsWith('#')) {
lines.push(trimmed);
}
});
log('debug', `Read env file ${filePath} with ${lines.length} lines of data.`);
return lines;
} catch (error) {
handleError(error, `Failed to read required vars file at ${filePath}`);
}
}
/**
* Returns a Netlify auth token from the environment or secure files
*/
function getBuildOrAuthToken() {
try {
// For platform tokens, always check both dev and prod files first
if (!process.env.NETLIFY_AUTH_TOKEN && process.env.NETLIFY === 'true') {
const prodFile = '.env.secure.prod';
const devFile = '.env.secure.dev';
// Try prod file first
if (fs.existsSync(prodFile)) {
try {
const prodVars = decryptContent(
fs.readFileSync(prodFile, 'utf-8'),
process.env.BWS_EPHEMERAL_KEY
);
const token = prodVars
.split('\n')
.find((line) => line.startsWith('NETLIFY_AUTH_TOKEN='))
?.split('=')[1];
if (token) {
log('debug', 'Found Netlify token in prod vars');
process.env.NETLIFY_AUTH_TOKEN = token.trim();
return `Bearer ${token.trim()}`;
}
} catch (error) {
log('warn', `Failed to decrypt prod vars: ${error.message}`);
}
}
// If not found in prod, try dev file
if (!process.env.NETLIFY_AUTH_TOKEN && fs.existsSync(devFile)) {
try {
const devVars = decryptContent(
fs.readFileSync(devFile, 'utf-8'),
process.env.BWS_EPHEMERAL_KEY
);
const token = devVars
.split('\n')
.find((line) => line.startsWith('NETLIFY_AUTH_TOKEN='))
?.split('=')[1];
if (token) {
log('debug', 'Found Netlify token in dev vars');
process.env.NETLIFY_AUTH_TOKEN = token.trim();
return `Bearer ${token.trim()}`;
}
} catch (error) {
log('warn', `Failed to decrypt dev vars: ${error.message}`);
}
}
}
// If we already have a token in the environment, use that
const buildToken = process.env.NETLIFY_API_TOKEN;
const authToken = process.env.NETLIFY_AUTH_TOKEN;
const token = buildToken || authToken;
if (!token) {
throw new Error(
'Neither NETLIFY_API_TOKEN nor NETLIFY_AUTH_TOKEN available. ' +
'Please set one of these environment variables.'
);
}
// If token already includes 'Bearer', return as is
if (token.startsWith('Bearer ')) {
return token;
}
return `Bearer ${token}`;
} catch (error) {
log('error', `Failed to get Netlify token: ${error.message}`);
throw error;
}
}
/**
* Checks if a variable should be preserved. Usually, if it's listed in
* project.preserveVars and it already exists in the remote environment, we skip updating it.
*
* @param {string} varName - Environment variable name
* @param {Object} project - The project config containing preserveVars
* @returns {boolean} true if var should be preserved
*/
function shouldPreserveVar(varName, project) {
if (project.preserveVars && project.preserveVars.includes(varName)) {
return true;
}
return false;
}
/**
* Validates the value of a given environment variable, applying basic checks based
* on the type of variable name (e.g., tokens, URLs, etc.).
*
* @param {string} key - The environment variable name
* @param {string} value - The environment variable value
* @returns {Object} result with properties { isValid: boolean, error?: string, value: string }
*/
function validateValue(key, value) {
// Remove any surrounding quotes and whitespace
value = value.replace(/^['"]|['"]$/g, '').trim();
if (!value) {
return { isValid: false, error: 'Empty value' };
}
// If it's some kind of token, check length and format
if (key.toUpperCase().includes('TOKEN')) {
if (value.length < 10) {
return { isValid: false, error: 'Token seems too short (min 10 chars)' };
}
// Basic format check for common token patterns
if (!value.match(/^[A-Za-z0-9_\-\.]+$/)) {
return {
isValid: false,
error: 'Token contains invalid characters (should be alphanumeric with _ - .)'
};
}
}
// If it's a URL, attempt to parse and validate
if (key.toUpperCase().includes('URL')) {
try {
const url = new URL(value);
// Additional URL validation
if (!['http:', 'https:'].includes(url.protocol)) {
return { isValid: false, error: 'URL must use http or https protocol' };
}
} catch {
return { isValid: false, error: 'Invalid URL format' };
}
}
// If it's an API endpoint, ensure it starts with http(s)://
if (key.toUpperCase().includes('API') || key.toUpperCase().includes('ENDPOINT')) {
if (!value.match(/^https?:\/\//)) {
return {
isValid: false,
error: 'API/Endpoint must start with http:// or https://'
};
}
}
return { isValid: true, value: value.trim() };
}
/**
* Determines which environment variables to use based on context
* @param {Object} project - Project configuration from bwsconfig.json
* @returns {Object} Environment mapping configuration
*/
function determineEnvironmentMapping(project) {
let environment = 'dev'; // Default to dev environment
// For Netlify, handle specific contexts
if (process.env.NETLIFY === 'true') {
if (process.env.CONTEXT === 'production') {
environment = 'prod';
} else if (process.env.CONTEXT === 'deploy-preview' && project.bwsProjectIds.deploy_preview) {
environment = 'deploy_preview';
} else if (process.env.CONTEXT === 'branch-deploy' && project.bwsProjectIds.branch_deploy) {
environment = 'branch_deploy';
}
log('debug', `Using ${environment} environment for Netlify ${process.env.CONTEXT} context`);
}
// For Vercel, keep existing logic
else if (process.env.VERCEL === '1') {
environment = process.env.VERCEL_ENV === 'production' ? 'prod' : 'dev';
}
// For local development
else if (process.env.BWS_ENV && project.bwsProjectIds[process.env.BWS_ENV]) {
environment = process.env.BWS_ENV;
}
const mapping = {
environment,
source: `.env.secure.${environment}`,
contexts: environment === 'prod' ? ['production'] : ['deploy-preview', 'branch-deploy', 'dev']
};
log('debug', `Environment mapping: ${JSON.stringify(mapping)}`);
return mapping;
}
/**
* Loads environment variables from the appropriate secure file
* @param {string} filePath - Path to the secure environment file
* @param {string} encryptionKey - Key for decrypting the file
* @returns {Object} Decrypted environment variables
*/
function loadEnvironmentVariables(filePath, encryptionKey) {
try {
// Add environment-specific key logging
const envType = filePath.includes('.prod')
? 'prod'
: filePath.includes('.dev')
? 'dev'
: filePath.includes('.local')
? 'local'
: 'unknown';
log('debug', `Loading ${envType} environment from: ${filePath}`);
if (!fs.existsSync(filePath)) {
log('debug', `Environment file not found: ${filePath}`);
return null;
}
const encryptedText = fs.readFileSync(filePath, 'utf-8');
if (!encryptedText) {
log('warn', `Empty file: ${filePath}`);
return null;
}
// Log environment-specific key details
log('debug', {
environment: envType,
keyAvailable: Boolean(encryptionKey),
keyLength: encryptionKey?.length,
filePath
});
const decrypted = decryptContent(encryptedText, encryptionKey);
if (!decrypted) {
log('warn', 'Decryption returned empty result');
return null;
}
// Parse the decrypted content into an object
const vars = {};
decrypted.split('\n').forEach((line) => {
const [k, ...rest] = line.split('=');
if (k && rest.length > 0) {
const key = k.trim();
const value = rest.join('=').trim();
vars[key] = value;
}
});
return vars;
} catch (error) {
log('error', `Failed to load environment variables: ${error.message}`);
return null;
}
}
function decryptContent(encrypted, encryptionKey) {
try {
log('debug', '=== Decryption Debug ===');
log('debug', `Raw encrypted length: ${encrypted.length}`);
// Only show first 20 chars of encrypted data, not the full value
log('debug', `Raw encrypted start: ${encrypted.substring(0, 20)}...`);
const [ivBase64, authTagBase64, data] = encrypted.split(':');
log('debug', `IV (base64) length: ${ivBase64?.length}`);
log('debug', `Auth tag length: ${authTagBase64?.length}`);
log('debug', `Data (base64) length: ${data?.length}`);
if (!ivBase64 || !authTagBase64 || !data) {
throw new Error(
`Invalid encrypted content format - IV: ${Boolean(ivBase64)}, Data: ${Boolean(data)}`
);
}
const iv = Buffer.from(ivBase64, 'base64');
if (!encryptionKey) {
throw new Error('No encryption key provided');
}
const keyBuffer = Buffer.from(encryptionKey, 'hex');
log('debug', {
ivLength: iv.length,
keyLength: keyBuffer.length,
keyFirstBytes: keyBuffer.slice(0, 4).toString('hex'),
ivFirstBytes: iv.slice(0, 4).toString('hex'),
isValidHex: /^[0-9a-f]+$/i.test(encryptionKey)
});
const authTag = Buffer.from(authTagBase64, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', keyBuffer, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(data, 'base64', 'utf8');
decrypted += decipher.final('utf8');
log('debug', 'Decryption successful!');
log('debug', `Decrypted length: ${decrypted.length}`);
return decrypted;
} catch (error) {
log('error', `Decryption failed: ${error.message}`);
throw error;
}
}
/**
* Validates platform and context for deployment
* @param {Object} project - Project configuration
* @returns {Object} - { isValid: boolean, environment: string, error?: string }
*/
function validateDeployment(project) {
if (!project || !project.platform) {
return {
isValid: false,
error: 'Invalid project configuration - missing platform'
};
}
const platform = project.platform.toLowerCase();
let environment = 'dev'; // Default environment
// If project is specified via BWS_PROJECT, use that platform's validation
if (process.env.BWS_PROJECT === project.projectName) {
// For Netlify
if (process.env.NETLIFY === 'true') {
environment = process.env.CONTEXT === 'production' ? 'prod' : 'dev';
return { isValid: true, environment, platform: 'netlify' };
}
// For Vercel
if (process.env.VERCEL === '1') {
environment = process.env.VERCEL_ENV === 'production' ? 'prod' : 'dev';
return { isValid: true, environment, platform: 'vercel' };
}
}
// Otherwise use project's default platform
if (platform === 'netlify') {
if (process.env.NETLIFY !== 'true') {
return {
isValid: false,
error: 'Not running on Netlify platform'
};
}
environment = process.env.CONTEXT === 'production' ? 'prod' : 'dev';
}
if (platform === 'vercel') {
if (process.env.VERCEL !== '1') {
return {
isValid: false,
error: 'Not running on Vercel platform'
};
}
environment = process.env.VERCEL_ENV === 'production' ? 'prod' : 'dev';
}
return {
isValid: true,
environment,
platform
};
}
/**
* Gets the bwsconfig.json path, checking multiple possible locations
* @returns {string} Path to bwsconfig.json
*/
function getBwsConfigPath() {
// Try current working directory first (./bwsconfig.json)
const cwdPath = path.join(process.cwd(), 'bwsconfig.json');
if (fs.existsSync(cwdPath)) {
log('debug', `Found bwsconfig.json at: ${cwdPath}`);
return cwdPath;
}
// Try relative to script location (./bwsconfig.json)
const scriptPath = path.join(__dirname, '..', 'bwsconfig.json');
if (fs.existsSync(scriptPath)) {
log('debug', `Found bwsconfig.json at: ${scriptPath}`);
return scriptPath;
}
// Try one level up from script (./scripts/bwsconfig.json)
const scriptParentPath = path.join(__dirname, '..', '..', 'bwsconfig.json');
if (fs.existsSync(scriptParentPath)) {
log('debug', `Found bwsconfig.json at: ${scriptParentPath}`);
return scriptParentPath;
}
log(
'debug',
`Searched paths:
- ${cwdPath}
- ${scriptPath}
- ${scriptParentPath}`
);
throw new Error('Could not find bwsconfig.json in any expected location');
}
export {
log,
handleError,
readEnvFile,
getBuildOrAuthToken,
shouldPreserveVar,
validateValue,
determineEnvironmentMapping,
loadEnvironmentVariables,
decryptContent,
validateDeployment,
getBwsConfigPath
};