UNPKG

cfn-forge

Version:

CloudFormation deployment automation tool with git-based workflows

865 lines (742 loc) • 26.3 kB
/** * Enhanced Config Command * Handles both regular configuration and secure AWS credential management */ const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const { spawn } = require('child_process'); const inquirer = require('inquirer'); const { logger } = require('../utils/logger'); const CONFIG_DIR = path.join(os.homedir(), '.cfn-forge'); const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); const AWS_CONFIG_FILE = path.join(CONFIG_DIR, 'aws-config.json'); const AWS_PROFILES_FILE = path.join(CONFIG_DIR, 'aws-profiles.json'); const ENCRYPTION_KEY_FILE = path.join(CONFIG_DIR, '.encryption-key'); /** * Ensure config directory exists */ function ensureConfigDir() { if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); } } /** * Get or create encryption key */ function getEncryptionKey() { ensureConfigDir(); if (fs.existsSync(ENCRYPTION_KEY_FILE)) { return fs.readFileSync(ENCRYPTION_KEY_FILE, 'utf8').trim(); } const key = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(ENCRYPTION_KEY_FILE, key, { mode: 0o600 }); return key; } /** * Encrypt sensitive data */ function encrypt(text) { if (!text) return text; const key = crypto.createHash('sha256').update(getEncryptionKey()).digest(); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); return `${iv.toString('hex')}:${encrypted}`; } /** * Decrypt sensitive data */ function decrypt(encryptedText) { if (!encryptedText || !encryptedText.includes(':')) { return encryptedText; } try { const key = crypto.createHash('sha256').update(getEncryptionKey()).digest(); const [ivHex, encrypted] = encryptedText.split(':'); const iv = Buffer.from(ivHex, 'hex'); const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } catch (error) { logger.debug(`Decryption failed: ${error.message}`); return encryptedText; } } /** * Load configuration file */ function loadConfig(filePath) { if (!fs.existsSync(filePath)) { return {}; } try { const content = fs.readFileSync(filePath, 'utf8'); return JSON.parse(content); } catch (error) { logger.debug(`Failed to load config from ${filePath}: ${error.message}`); return {}; } } /** * Save configuration file */ function saveConfig(filePath, config) { ensureConfigDir(); fs.writeFileSync(filePath, JSON.stringify(config, null, 2), { mode: 0o600 }); } /** * List available AWS profiles */ function listAWSProfiles() { const profiles = loadConfig(AWS_PROFILES_FILE); const currentConfig = loadConfig(AWS_CONFIG_FILE); logger.info('ā˜ļø AWS Profiles'); logger.info(''); if (Object.keys(profiles).length === 0) { logger.info('No AWS profiles configured.'); logger.info(''); logger.info('Create a profile:'); logger.info(' cfn-forge config --aws --profile my-profile'); return; } Object.entries(profiles).forEach(([profileName, profile]) => { const isDefault = currentConfig.defaultProfile === profileName; const status = isDefault ? 'šŸ”ø (default)' : ' '; const credentials = profile.credentials ? '[encrypted]' : '[not set]'; console.log(`${status} ${profileName}`); console.log(` Region: ${profile.region || '(not set)'}`); console.log(` Credentials: ${credentials} ${profile.credentials ? 'stored (encrypted)' : 'external'}`); if (profile.accountId) { console.log(` Account: ${profile.accountId}`); } if (profile.updatedAt) { console.log(` Updated: ${new Date(profile.updatedAt).toLocaleString()}`); } console.log(''); }); } /** * Interactive AWS configuration setup */ async function configureAWS(profileName = null) { logger.info('AWS Configuration Setup'); logger.info(''); // Load existing profiles and current config const profiles = loadConfig(AWS_PROFILES_FILE); const currentConfig = loadConfig(AWS_CONFIG_FILE); // If no profile name provided, ask for it if (!profileName) { const existingProfiles = Object.keys(profiles); const profileQuestions = [ { type: 'input', name: 'profileName', message: 'Profile name:', default: 'default', validate: (input) => { if (!input || !/^[a-zA-Z0-9-_]+$/.test(input)) { return 'Please enter a valid profile name (alphanumeric, hyphens, underscores only)'; } return true; }, }, ]; if (existingProfiles.length > 0) { logger.info(`Existing profiles: ${existingProfiles.join(', ')}`); logger.info(''); } const profileAnswer = await inquirer.prompt(profileQuestions); profileName = profileAnswer.profileName; // eslint-disable-line no-param-reassign } const existingProfile = profiles[profileName] || {}; logger.info(`Configuring profile: ${profileName}`); logger.info(''); const questions = [ { type: 'input', name: 'region', message: 'AWS region:', default: existingProfile.region || 'us-east-1', validate: (input) => { if (!/^[a-z0-9-]+$/.test(input)) { return 'Please enter a valid AWS region (e.g., us-east-1)'; } return true; }, }, { type: 'input', name: 'accountId', message: 'AWS Account ID (optional):', default: existingProfile.accountId || '', validate: (input) => { if (input && !/^\d{12}$/.test(input)) { return 'Please enter a valid 12-digit AWS Account ID or leave empty'; } return true; }, }, { type: 'input', name: 'description', message: 'Profile description (optional):', default: existingProfile.description || '', }, { type: 'confirm', name: 'storeCredentials', message: 'Store AWS credentials securely in this profile?', default: !!existingProfile.credentials, }, ]; const answers = await inquirer.prompt(questions); const profileConfig = { region: answers.region, accountId: answers.accountId || null, description: answers.description || null, updatedAt: new Date().toISOString(), }; if (answers.storeCredentials) { const credentialQuestions = [ { type: 'password', name: 'accessKeyId', message: 'AWS Access Key ID:', validate: (input) => { if (!input || input.length < 16) { return 'Please enter a valid AWS Access Key ID'; } return true; }, }, { type: 'password', name: 'secretAccessKey', message: 'AWS Secret Access Key:', validate: (input) => { if (!input || input.length < 32) { return 'Please enter a valid AWS Secret Access Key'; } return true; }, }, { type: 'password', name: 'sessionToken', message: 'AWS Session Token (optional, for temporary credentials):', default: '', }, ]; const credentials = await inquirer.prompt(credentialQuestions); // Encrypt sensitive credentials profileConfig.credentials = { accessKeyId: encrypt(credentials.accessKeyId), secretAccessKey: encrypt(credentials.secretAccessKey), sessionToken: credentials.sessionToken ? encrypt(credentials.sessionToken) : null, }; logger.warn('Warning: Credentials stored encrypted. Keep your ~/.cfn-forge directory secure!'); } else { logger.info('You can configure AWS credentials using:'); logger.info(` • AWS CLI: aws configure --profile ${profileName}`); logger.info(' • Environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY'); logger.info(' • IAM roles (recommended for EC2/Lambda)'); } // Save profile to profiles file profiles[profileName] = profileConfig; saveConfig(AWS_PROFILES_FILE, profiles); // Ask if this should be the default profile const defaultQuestion = { type: 'confirm', name: 'setAsDefault', message: 'Set this as the default profile for CFN-Forge?', default: !currentConfig.defaultProfile || currentConfig.defaultProfile === profileName, }; const defaultAnswer = await inquirer.prompt([defaultQuestion]); if (defaultAnswer.setAsDefault) { const updatedConfig = { ...currentConfig, defaultProfile: profileName, updatedAt: new Date().toISOString(), }; saveConfig(AWS_CONFIG_FILE, updatedConfig); logger.success(`Profile '${profileName}' set as default`); } logger.success(`AWS profile '${profileName}' saved successfully!`); return profileConfig; } /** * Get AWS configuration for a specific profile with decrypted credentials */ function getAWSConfig(profileName = null) { const config = loadConfig(AWS_CONFIG_FILE); const profiles = loadConfig(AWS_PROFILES_FILE); // Determine which profile to use const targetProfile = profileName || config.defaultProfile || 'default'; const profile = profiles[targetProfile]; if (!profile) { return null; } const result = { profileName: targetProfile, region: profile.region, accountId: profile.accountId, description: profile.description, updatedAt: profile.updatedAt, }; if (profile.credentials) { result.credentials = { accessKeyId: decrypt(profile.credentials.accessKeyId), secretAccessKey: decrypt(profile.credentials.secretAccessKey), sessionToken: profile.credentials.sessionToken ? decrypt(profile.credentials.sessionToken) : null, }; } return result; } /** * Switch default profile */ async function switchDefaultProfile(profileName) { const profiles = loadConfig(AWS_PROFILES_FILE); if (!profiles[profileName]) { throw new Error(`Profile '${profileName}' does not exist`); } const config = loadConfig(AWS_CONFIG_FILE); const updatedConfig = { ...config, defaultProfile: profileName, updatedAt: new Date().toISOString(), }; saveConfig(AWS_CONFIG_FILE, updatedConfig); logger.success(`Switched default profile to '${profileName}'`); return updatedConfig; } /** * Delete an AWS profile */ async function deleteAWSProfile(profileName) { const profiles = loadConfig(AWS_PROFILES_FILE); const config = loadConfig(AWS_CONFIG_FILE); if (!profiles[profileName]) { throw new Error(`Profile '${profileName}' does not exist`); } // Confirm deletion const confirmQuestion = { type: 'confirm', name: 'confirmDelete', message: `Delete AWS profile '${profileName}'? This cannot be undone.`, default: false, }; const answer = await inquirer.prompt([confirmQuestion]); if (!answer.confirmDelete) { logger.info('Profile deletion cancelled.'); return; } delete profiles[profileName]; saveConfig(AWS_PROFILES_FILE, profiles); // If this was the default profile, clear it if (config.defaultProfile === profileName) { const remainingProfiles = Object.keys(profiles); const updatedConfig = { ...config, defaultProfile: remainingProfiles.length > 0 ? remainingProfiles[0] : null, updatedAt: new Date().toISOString(), }; saveConfig(AWS_CONFIG_FILE, updatedConfig); if (remainingProfiles.length > 0) { logger.info(`Default profile switched to '${remainingProfiles[0]}'`); } else { logger.info('No default profile set (no profiles remaining)'); } } logger.success(`Deleted AWS profile '${profileName}'`); } /** * Set AWS environment variables from stored config */ function setAWSEnvironmentVariables(profileName = null) { const awsConfig = getAWSConfig(profileName); if (!awsConfig) { logger.debug(`No AWS configuration found for profile: ${profileName || 'default'}`); return; } if (awsConfig.region) { process.env.AWS_DEFAULT_REGION = awsConfig.region; process.env.AWS_REGION = awsConfig.region; } // Set profile name for AWS CLI compatibility if (awsConfig.profileName && awsConfig.profileName !== 'default') { process.env.AWS_PROFILE = awsConfig.profileName; } if (awsConfig.credentials) { if (awsConfig.credentials.accessKeyId) { process.env.AWS_ACCESS_KEY_ID = awsConfig.credentials.accessKeyId; } if (awsConfig.credentials.secretAccessKey) { process.env.AWS_SECRET_ACCESS_KEY = awsConfig.credentials.secretAccessKey; } if (awsConfig.credentials.sessionToken) { process.env.AWS_SESSION_TOKEN = awsConfig.credentials.sessionToken; } } logger.debug(`AWS environment configured for profile: ${awsConfig.profileName}`); } /** * List configuration values */ function listConfiguration(_showAWS = false) { const regularConfig = loadConfig(CONFIG_FILE); const awsConfig = loadConfig(AWS_CONFIG_FILE); const profiles = loadConfig(AWS_PROFILES_FILE); logger.info('Configuration Values'); logger.info(''); // Regular configuration if (Object.keys(regularConfig).length > 0) { logger.info('General Configuration:'); Object.entries(regularConfig).forEach(([key, value]) => { if (key === 'defaultProfile') return; // Skip internal keys console.log(` ${key}: ${value}`); }); logger.info(''); } // AWS configuration if (Object.keys(profiles).length > 0) { logger.info('ā˜ļø AWS Profiles:'); const { defaultProfile } = awsConfig; Object.entries(profiles).forEach(([profileName, profile]) => { const isDefault = defaultProfile === profileName; const status = isDefault ? 'šŸ”ø (default)' : ' '; const credentials = profile.credentials ? '[encrypted]' : '[not set]'; console.log(`${status} ${profileName}`); console.log(` Region: ${profile.region || '(not set)'}`); console.log(` Credentials: ${credentials} ${profile.credentials ? 'stored (encrypted)' : 'external'}`); if (profile.accountId) { console.log(` Account: ${profile.accountId}`); } if (profile.description) { console.log(` Description: ${profile.description}`); } if (profile.updatedAt) { console.log(` Updated: ${new Date(profile.updatedAt).toLocaleString()}`); } }); logger.info(''); } if (Object.keys(regularConfig).length === 0 && Object.keys(profiles).length === 0) { logger.info('No configuration values set.'); logger.info(''); logger.info('Quick setup:'); logger.info(' cfn-forge config --aws --profile default # Configure AWS profile'); logger.info(' cfn-forge config key value # Set general config'); } } /** * Main config command handler */ async function configCommand(key, value, options) { logger.setContext('command', 'config'); logger.setContext('options', options); try { if (options.aws && options.profile) { await configureAWS(options.profile); return; } if (options.aws && !options.profile) { await configureAWS(); return; } if (options.listProfiles) { listAWSProfiles(); return; } if (options.switchProfile) { await switchDefaultProfile(options.switchProfile); return; } if (options.deleteProfile) { await deleteAWSProfile(options.deleteProfile); return; } if (options.list) { listConfiguration(true); return; } const configFile = CONFIG_FILE; const config = loadConfig(configFile); if (key && value !== undefined) { // Set a configuration value const finalValue = options.encrypt ? encrypt(value) : value; config[key] = finalValue; saveConfig(configFile, config); const displayValue = options.encrypt ? '(encrypted)' : value; logger.success(`Set ${key} = ${displayValue}`); } else if (key && options.unset) { // Unset a configuration value if (config[key] !== undefined) { delete config[key]; saveConfig(configFile, config); logger.success(`Unset ${key}`); } else { logger.warn(`Key '${key}' not found`); } } else if (key) { // Get a configuration value const configValue = config[key]; if (configValue !== undefined) { // Try to decrypt if it looks encrypted if (typeof configValue === 'string' && configValue.includes(':')) { const decrypted = decrypt(configValue); if (decrypted !== configValue) { console.log('(encrypted value - use with caution)'); return; } } console.log(configValue); } else { console.log('(not set)'); } } else { // No arguments provided - show help listConfiguration(true); } } catch (error) { logger.errorWithContext(error, 'Config Command'); logger.reportErrorSummary(); process.exit(1); } finally { logger.clearContext(); } } module.exports = { configCommand, getAWSConfig, setAWSEnvironmentVariables, switchDefaultProfile, deleteAWSProfile, listAWSProfiles, viewCredentials, editCredentials, encrypt, decrypt, }; /** * Securely view AWS credentials in a temporary file */ async function viewCredentials(profileName = 'default') { try { const profiles = loadConfig(AWS_PROFILES_FILE); if (!profiles[profileName]) { logger.error(`Profile '${profileName}' not found`); return; } const profile = profiles[profileName]; if (!profile.credentials) { logger.info(`Profile '${profileName}' uses external credentials (AWS CLI/environment)`); logger.info('No stored credentials to view'); return; } // Create temporary file with decrypted credentials const tempDir = path.join(os.tmpdir(), 'cfn-forge-creds'); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 }); } const tempFile = path.join(tempDir, `${profileName}-credentials.txt`); const credentialContent = [ `CFN-Forge AWS Credentials - Profile: ${profileName}`, `Generated: ${new Date().toISOString()}`, '', 'SECURITY WARNING: This file contains sensitive credentials!', 'Delete this file when done viewing.', '', `Profile Name: ${profileName}`, `Region: ${profile.region || 'us-east-1'}`, `Access Key ID: ${decrypt(profile.credentials.accessKeyId)}`, `Secret Access Key: ${decrypt(profile.credentials.secretAccessKey)}`, ]; if (profile.credentials.sessionToken) { credentialContent.push(`Session Token: ${decrypt(profile.credentials.sessionToken)}`); } credentialContent.push( '', 'AWS CLI Format:', `[${profileName}]`, `aws_access_key_id = ${decrypt(profile.credentials.accessKeyId)}`, `aws_secret_access_key = ${decrypt(profile.credentials.secretAccessKey)}`, ); if (profile.credentials.sessionToken) { credentialContent.push(`aws_session_token = ${decrypt(profile.credentials.sessionToken)}`); } // Write to temporary file with restricted permissions fs.writeFileSync(tempFile, credentialContent.join('\n'), { mode: 0o600 }); logger.info(`\nšŸ“„ Credentials written to temporary file: ${tempFile}`); logger.warn('Warning: File contains sensitive data - delete when done!'); // Ask if user wants to open in editor const { openEditor } = await inquirer.prompt([{ type: 'confirm', name: 'openEditor', message: 'Open credentials file in default text editor?', default: true, }]); if (openEditor) { let editor = process.env.EDITOR; if (!editor) { if (process.platform === 'win32') { editor = 'notepad'; } else if (process.platform === 'darwin') { editor = 'open -t'; } else { editor = 'nano'; } } logger.info(`Opening with: ${editor}`); const child = spawn(editor, [tempFile], { stdio: 'inherit', shell: true, }); child.on('close', async () => { // Ask if user wants to delete the file const { deleteFile } = await inquirer.prompt([{ type: 'confirm', name: 'deleteFile', message: 'Delete the temporary credentials file?', default: true, }]); if (deleteFile) { fs.unlinkSync(tempFile); logger.success('Temporary credentials file deleted'); } else { logger.warn(`Warning: Credentials file remains at: ${tempFile}`); logger.warn('Warning: Remember to delete it manually when done!'); } }); } } catch (error) { logger.error('Failed to view credentials:', error.message); } } /** * Edit AWS credentials through a secure temporary file */ async function editCredentials(profileName = 'default') { try { const profiles = loadConfig(AWS_PROFILES_FILE); const profile = profiles[profileName] || { region: 'us-east-1' }; const isNewProfile = !profiles[profileName]; // Create temporary file with current credentials for editing const tempDir = path.join(os.tmpdir(), 'cfn-forge-creds'); if (!fs.existsSync(tempDir)) { fs.mkdirSync(tempDir, { recursive: true, mode: 0o700 }); } const tempFile = path.join(tempDir, `${profileName}-edit.txt`); const editContent = [ `# CFN-Forge AWS Credentials Editor - Profile: ${profileName}`, '# Save and close this file to update credentials', '# Delete any line to leave that field unchanged', '# Lines starting with # are comments and will be ignored', '', '# Profile Configuration', `PROFILE_NAME=${profileName}`, `REGION=${profile.region || 'us-east-1'}`, '', '# AWS Credentials (leave blank to use external credentials)', `ACCESS_KEY_ID=${profile.credentials ? decrypt(profile.credentials.accessKeyId) : ''}`, `SECRET_ACCESS_KEY=${profile.credentials ? decrypt(profile.credentials.secretAccessKey) : ''}`, `SESSION_TOKEN=${profile.credentials && profile.credentials.sessionToken ? decrypt(profile.credentials.sessionToken) : ''}`, '', '# Instructions:', '# - Fill in your AWS credentials above', '# - Save and close the file to apply changes', '# - Leave ACCESS_KEY_ID empty to remove stored credentials', '# - CFN-Forge will fall back to AWS CLI/environment credentials', ]; // Write to temporary file with restricted permissions fs.writeFileSync(tempFile, editContent.join('\n'), { mode: 0o600 }); logger.info(`\n${isNewProfile ? 'Creating new' : 'Editing'} credentials for profile: ${profileName}`); logger.info(`šŸ“„ Edit file: ${tempFile}`); logger.warn('Warning: File contains sensitive data - will be deleted after editing'); let editor = process.env.EDITOR; if (!editor) { if (process.platform === 'win32') { editor = 'notepad'; } else if (process.platform === 'darwin') { editor = 'open -W -t'; } else { editor = 'nano'; } } logger.info(`Opening with: ${editor}`); logger.info('Save and close the file to apply changes'); const child = spawn(editor, [tempFile], { stdio: 'inherit', shell: true, }); child.on('close', async () => { try { // Read and parse the edited file const editedContent = fs.readFileSync(tempFile, 'utf8'); const credentials = parseCredentialFile(editedContent); // Apply changes if (credentials.ACCESS_KEY_ID && credentials.SECRET_ACCESS_KEY) { // Store encrypted credentials profiles[profileName] = { region: credentials.REGION || 'us-east-1', credentials: { accessKeyId: encrypt(credentials.ACCESS_KEY_ID), secretAccessKey: encrypt(credentials.SECRET_ACCESS_KEY), sessionToken: credentials.SESSION_TOKEN ? encrypt(credentials.SESSION_TOKEN) : null, }, updatedAt: new Date().toISOString(), description: profile.description || null, accountId: profile.accountId || null, }; saveConfig(AWS_PROFILES_FILE, profiles); logger.success(`Credentials updated for profile: ${profileName}`); logger.warn('Warning: Credentials stored encrypted in ~/.cfn-forge/'); } else if (credentials.ACCESS_KEY_ID === '' && !isNewProfile) { // Remove stored credentials (fall back to external) delete profiles[profileName].credentials; profiles[profileName].region = credentials.REGION || profile.region || 'us-east-1'; profiles[profileName].updatedAt = new Date().toISOString(); saveConfig(AWS_PROFILES_FILE, profiles); logger.success(`Stored credentials removed for profile: ${profileName}`); logger.info('Profile will now use AWS CLI/environment credentials'); } else { logger.info('ā„¹ļø No changes made to credentials'); } // Clean up temporary file fs.unlinkSync(tempFile); logger.success('Temporary edit file deleted'); } catch (error) { logger.error('Failed to process edited credentials:', error.message); // Still clean up the temp file try { fs.unlinkSync(tempFile); } catch (cleanupError) { logger.warn('Could not delete temporary file:', tempFile); } } }); } catch (error) { logger.error('Failed to edit credentials:', error.message); } } /** * Parse credential file content */ function parseCredentialFile(content) { const credentials = {}; const lines = content.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { const [key, ...valueParts] = trimmed.split('='); if (key && valueParts.length > 0) { credentials[key.trim()] = valueParts.join('=').trim(); } } } return credentials; }