UNPKG

ssh-bridge-ai

Version:

AI-Powered SSH Tool with Bulletproof Connections & Enterprise Sandbox Security + Cursor-like Confirmation - Enable AI assistants to securely SSH into your servers with persistent sessions, keepalive, automatic recovery, sandbox command testing, and user c

1,127 lines (960 loc) 36.3 kB
const fs = require('fs').promises; const fsSync = require('fs'); const path = require('path'); const os = require('os'); const chalk = require('chalk'); // Try to import node-ssh first, fallback to ssh2 if needed let Client; let SSH2Client; try { const nodeSSH = require('node-ssh'); Client = nodeSSH.NodeSSH; // Fix: node-ssh exports NodeSSH, not Client console.log('Using node-ssh for SSH connections'); } catch (error) { console.log('node-ssh not available, falling back to ssh2'); try { const ssh2 = require('ssh2'); SSH2Client = ssh2.Client; console.log('Using ssh2 for SSH connections'); } catch (ssh2Error) { throw new Error('No SSH client available. Please install node-ssh or ssh2: npm install node-ssh ssh2'); } } const { Config } = require('./config'); const { ValidationUtils } = require('./utils/validation'); const logger = require('./utils/logger'); const { SECURITY, NETWORK, FILESYSTEM, ERROR_CODES } = require('./utils/constants'); const SandboxExecutor = require('./sandbox/sandbox-executor'); class SSHClient { // Static password cache for session (in-memory only) static passwordCache = new Map(); static passwordCacheTimestamps = new Map(); // Track when passwords were cached constructor(connection, options = {}) { this.connection = connection; this.options = options; // Initialize appropriate SSH client if (Client) { this.ssh = new Client(); this.clientType = 'node-ssh'; } else if (SSH2Client) { this.ssh = new SSH2Client(); this.clientType = 'ssh2'; } else { throw new Error('No SSH client available'); } this.config = new Config(); this.secureBuffers = []; // Track buffers for secure cleanup this.failedAttempts = 0; // Track failed connection attempts // NEW: Connection management properties this.isConnected = false; this.connectionStartTime = null; this.lastActivityTime = null; this.keepaliveInterval = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = options.maxReconnectAttempts || 5; this.keepaliveEnabled = options.keepalive !== false; // Default to true this.keepaliveIntervalMs = (options.keepaliveInterval || 30) * 1000; // Default 30 seconds this.connectionTimeout = options.connectionTimeout || 60000; // Default 60 seconds this.sessionRecovery = options.sessionRecovery !== false; // Default to true this.fallbackShells = options.fallbackShells || ['bash', 'sh', 'dash', 'zsh', 'ksh']; this.useTemporaryHome = options.useTemporaryHome !== false; // Default to true // Initialize sandbox executor this.sandboxExecutor = new SandboxExecutor({ alwaysSandbox: options.sandbox !== false, // Default to true dryRun: options.dryRun || false, enabled: options.sandbox !== false }); // Parse connection string this.parseConnection(connection); } parseConnection(connection) { if (!connection || typeof connection !== 'string') { throw new Error('Connection string is required'); } // Parse user@host format const parts = connection.split('@'); if (parts.length !== 2) { throw new Error('Invalid connection format. Use: user@host'); } const username = parts[0].trim(); const host = parts[1].trim(); // Validate username and host if (!username || username.length === 0) { throw new Error('Username cannot be empty'); } if (!ValidationUtils.validateHostname(host)) { throw new Error(`Invalid hostname: ${host}`); } this.username = username; this.host = host; this.connectionString = connection; // Store for cache key logger.debug('Connection parsed', { username, host }); } getCacheKey() { // Create unique cache key for this connection const port = this.options.port || '22'; return `${this.username}@${this.host}:${port}`; } getCachedPassword() { const cacheKey = this.getCacheKey(); const password = SSHClient.passwordCache.get(cacheKey); const timestamp = SSHClient.passwordCacheTimestamps.get(cacheKey); // Check if password has expired if (password && timestamp) { const now = Date.now(); if (now - timestamp > SECURITY.PASSWORD_CACHE_TTL) { // Password expired, remove it this.clearCachedPassword(); return null; } } return password; } setCachedPassword(password) { if (!password || typeof password !== 'string') { logger.warn('Attempted to cache invalid password'); return; } const cacheKey = this.getCacheKey(); // Check cache size limit if (SSHClient.passwordCache.size >= SECURITY.MAX_PASSWORD_CACHE_SIZE) { // Remove oldest entry const oldestKey = SSHClient.passwordCache.keys().next().value; SSHClient.passwordCache.delete(oldestKey); SSHClient.passwordCacheTimestamps.delete(oldestKey); } SSHClient.passwordCache.set(cacheKey, password); SSHClient.passwordCacheTimestamps.set(cacheKey, Date.now()); logger.debug('Password cached for connection', { cacheKey: this.sanitizeCacheKey(cacheKey) }); } clearCachedPassword() { const cacheKey = this.getCacheKey(); SSHClient.passwordCache.delete(cacheKey); SSHClient.passwordCacheTimestamps.delete(cacheKey); } static clearAllCachedPasswords() { SSHClient.passwordCache.clear(); SSHClient.passwordCacheTimestamps.clear(); logger.info('All cached SSH passwords cleared'); } // Sanitize cache key for logging (remove sensitive parts) sanitizeCacheKey(cacheKey) { if (!cacheKey) return ''; const parts = cacheKey.split('@'); if (parts.length === 2) { return `${parts[0]}@***`; } return '***'; } // Sanitize connection string for logging sanitizeConnectionString() { if (!this.connectionString) return ''; const parts = this.connectionString.split('@'); if (parts.length === 2) { return `${parts[0]}@${parts[1]}`; } return this.connectionString; } // Sanitize file path for logging sanitizePath(filePath) { if (!filePath) return ''; const homeDir = process.env.HOME || os.homedir(); if (filePath.startsWith(homeDir)) { return filePath.replace(homeDir, '~'); } return filePath; } static getCacheStatus() { const keys = Array.from(SSHClient.passwordCache.keys()); return { count: keys.length, connections: keys }; } async loadAndValidateKey(keyPath) { try { // Validate file path if (!ValidationUtils.validateFilePath(keyPath)) { throw new Error('Invalid SSH key file path'); } // Use centralized validation const isValidKeyFile = await ValidationUtils.validateSSHKeyFile(keyPath); if (!isValidKeyFile) { throw new Error('SSH key file validation failed: unsafe permissions, ownership, or location'); } const keyContent = await fs.readFile(keyPath, 'utf8'); // SECURITY: Store buffer reference for secure cleanup const buffer = Buffer.from(keyContent, 'utf8'); this.secureBuffers.push(buffer); // Validate key format if (keyContent.includes('BEGIN OPENSSH PRIVATE KEY')) { // Modern OpenSSH format (Ed25519, newer RSA, ECDSA) logger.debug('SSH key loaded (OpenSSH format)', { keyPath: this.sanitizePath(keyPath) }); return keyContent; } else if (keyContent.includes('BEGIN RSA PRIVATE KEY')) { // Traditional RSA format return keyContent; } else if (keyContent.includes('BEGIN EC PRIVATE KEY')) { // ECDSA format return keyContent; } else if (keyContent.includes('BEGIN DSA PRIVATE KEY')) { // DSA format (legacy) return keyContent; } else if (keyContent.includes('BEGIN PRIVATE KEY')) { // PKCS#8 format return keyContent; } else { throw new Error(`Unsupported key format. Key must be in OpenSSH, RSA, ECDSA, or PKCS#8 format`); } } catch (error) { if (error.code === 'ENOENT') { throw new Error(`SSH key file not found: ${keyPath}`); } else if (error.code === 'EACCES') { throw new Error(`Permission denied reading SSH key: ${keyPath}`); } else { throw new Error(`Error reading SSH key: ${error.message}`); } } } // SECURITY: Secure memory cleanup method secureCleanup() { // Zero out all sensitive buffers this.secureBuffers.forEach(buffer => { if (Buffer.isBuffer(buffer)) { buffer.fill(0); } }); // Clear sensitive data from memory if (this.privateKey) { this.privateKey = null; } // Clear password cache for this connection this.clearCachedPassword(); // Clear connection options if (this.connectionOptions) { if (this.connectionOptions.privateKey) { this.connectionOptions.privateKey = null; } if (this.connectionOptions.password) { this.connectionOptions.password = null; } } // Clear secure buffers array this.secureBuffers.length = 0; logger.debug('Secure cleanup completed for SSH connection'); } async connect() { try { // Check if already connected and healthy if (this.isConnected && this.ssh.isConnected()) { // Check if connection is still responsive if (await this.isConnectionHealthy()) { logger.info('Already connected and healthy', { connection: this.sanitizeConnectionString() }); return true; } else { logger.warn('Connection exists but unhealthy, reconnecting...', { connection: this.sanitizeConnectionString() }); await this.disconnect(); } } // Check for rate limiting if (this.failedAttempts >= SECURITY.MAX_FAILED_ATTEMPTS) { const error = new Error('Too many failed connection attempts. Please wait before retrying.'); error.code = ERROR_CODES.SSH_CONNECTION_FAILED; throw error; } const connectOptions = { host: this.host, username: this.username, port: parseInt(this.options.port || NETWORK.DEFAULT_SSH_PORT.toString()), // NEW: Connection options for stability readyTimeout: this.connectionTimeout, keepaliveInterval: this.keepaliveEnabled ? this.keepaliveIntervalMs : 0, keepaliveCountMax: this.keepaliveEnabled ? 3 : 0, algorithms: { // Prefer modern, secure algorithms kex: ['curve25519-sha256', 'diffie-hellman-group14-sha256'], cipher: ['aes128-gcm', 'aes256-gcm', 'aes128-ctr', 'aes256-ctr'], mac: ['hmac-sha2-256', 'hmac-sha2-512'], compress: ['none'] } }; // SECURITY: Validate hostname and port if (!ValidationUtils.validateHostname(this.host)) { const error = new Error(`Invalid hostname: ${this.host}`); error.code = ERROR_CODES.SSH_CONNECTION_FAILED; throw error; } if (!ValidationUtils.validatePort(connectOptions.port)) { const error = new Error(`Invalid port number: ${connectOptions.port}. Must be between 1-65535`); error.code = ERROR_CODES.SSH_CONNECTION_FAILED; throw error; } // Check if password authentication is requested or cached if (this.options.password) { connectOptions.password = this.options.password; console.log(`🔐 Using password authentication for ${this.username}@${this.host}`); } else if (this.getCachedPassword()) { connectOptions.password = this.getCachedPassword(); console.log(`🔐 Using cached password for ${this.username}@${this.host}`); } else { // Use provided SSH key or default const keyPath = this.options.key || this.config.getDefaultSSHKey(); if (keyPath && fsSync.existsSync(keyPath)) { // Read and validate the key content const keyContent = await this.loadAndValidateKey(keyPath); connectOptions.privateKey = keyContent; // Add passphrase if provided for encrypted keys if (this.options.passphrase) { connectOptions.passphrase = this.options.passphrase; logger.debug('Passphrase added to connection options for encrypted key'); } } else { // Try common key locations in priority order const commonKeys = [ path.join(process.env.HOME || os.homedir(), '.ssh', 'id_ed25519'), path.join(process.env.HOME || os.homedir(), '.ssh', 'id_rsa'), path.join(process.env.HOME || os.homedir(), '.ssh', 'id_ecdsa'), path.join(process.env.HOME || os.homedir(), '.ssh', 'id_dsa') ]; let keyFound = false; for (const key of commonKeys) { if (fsSync.existsSync(key)) { try { const keyContent = await this.loadAndValidateKey(key); connectOptions.privateKey = keyContent; // Add passphrase if provided for encrypted keys if (this.options.passphrase) { connectOptions.passphrase = this.options.passphrase; logger.debug('Passphrase added to connection options for encrypted key'); } keyFound = true; console.log(`Using SSH key: ${key}`); break; } catch (error) { console.log(`Skipping ${key}: ${error.message}`); continue; } } } if (!keyFound) { throw new Error(`No valid SSH key found. Tried: ${commonKeys.map(k => ` - ${k}`).join('\n')} Supported formats: RSA, Ed25519, ECDSA, DSA, PKCS#8 Please ensure at least one key exists with proper permissions (600)`); } } } // DEBUG: Log connection options to find the bug logger.debug('Attempting SSH connection with options', { host: connectOptions.host, username: connectOptions.username, port: connectOptions.port, hasPrivateKey: !!connectOptions.privateKey, hasPassphrase: !!connectOptions.passphrase, hasPassword: !!connectOptions.password }); // Attempt connection await this.ssh.connect(connectOptions); // If we used a password (either provided or cached), cache it for future use if (connectOptions.password) { if (this.options.password) { // Cache the newly provided password this.setCachedPassword(this.options.password); } } // NEW: Initialize connection state this.isConnected = true; this.connectionStartTime = Date.now(); this.lastActivityTime = Date.now(); this.reconnectAttempts = 0; // NEW: Start keepalive if enabled if (this.keepaliveEnabled) { this.startKeepalive(); } // NEW: Test shell availability and set up fallbacks await this.initializeShell(); console.log(`✅ Connected to ${this.host}:${connectOptions.port} as ${this.username}`); // SECURITY: Clear sensitive data from memory after successful connection if (connectOptions.privateKey) { this.secureCleanup(); } return true; } catch (error) { // SECURITY: Always cleanup on connection failure this.secureCleanup(); this.failedAttempts++; throw error; } } // NEW: Check if connection is still healthy and responsive async isConnectionHealthy() { try { if (!this.ssh || !this.ssh.isConnected()) { return false; } // Test with a simple command to check responsiveness const result = await this.ssh.execCommand('echo "health_check"', { timeout: 5000 }); return result.code === 0; } catch (error) { logger.debug('Connection health check failed', { error: error.message }); return false; } } // NEW: Start keepalive mechanism startKeepalive() { if (this.keepaliveInterval) { clearInterval(this.keepaliveInterval); } this.keepaliveInterval = setInterval(async () => { try { if (this.isConnected && this.ssh.isConnected()) { // Send a keepalive ping await this.ssh.execCommand('echo "keepalive"', { timeout: 3000 }); this.lastActivityTime = Date.now(); logger.debug('Keepalive ping successful'); } else { // Connection lost, try to reconnect logger.warn('Connection lost during keepalive, attempting recovery'); await this.recoverConnection(); } } catch (error) { logger.warn('Keepalive failed, attempting recovery', { error: error.message }); await this.recoverConnection(); } }, this.keepaliveIntervalMs); logger.debug('Keepalive started', { interval: this.keepaliveIntervalMs }); } // NEW: Stop keepalive mechanism stopKeepalive() { if (this.keepaliveInterval) { clearInterval(this.keepaliveInterval); this.keepaliveInterval = null; logger.debug('Keepalive stopped'); } } // NEW: Initialize shell and test fallbacks async initializeShell() { try { // Test primary shell (usually bash) const testCommand = 'echo "shell_test" && pwd'; for (const shell of this.fallbackShells) { try { // Try to execute a command with this shell const result = await this.ssh.execCommand(`/bin/${shell} -c '${testCommand}'`, { timeout: 10000 }); if (result.code === 0) { this.activeShell = shell; logger.info(`Shell initialized: ${shell}`, { connection: this.sanitizeConnectionString() }); // Set up environment if needed if (this.useTemporaryHome) { await this.setupTemporaryEnvironment(); } return; } } catch (error) { logger.debug(`Shell ${shell} failed`, { error: error.message }); continue; } } // If no shell works, try without specifying shell try { const result = await this.ssh.execCommand(testCommand, { timeout: 10000 }); if (result.code === 0) { this.activeShell = 'default'; logger.info('Using default shell', { connection: this.sanitizeConnectionString() }); return; } } catch (error) { logger.debug('Default shell also failed', { error: error.message }); } throw new Error('No working shell found on server'); } catch (error) { logger.error('Failed to initialize shell', { error: error.message }); throw error; } } // NEW: Set up temporary environment for servers with missing home directories async setupTemporaryEnvironment() { try { // Check if home directory exists and is writable const homeCheck = await this.ssh.execCommand('test -d "$HOME" && test -w "$HOME" && echo "OK"', { timeout: 5000 }); if (homeCheck.code === 0 && homeCheck.stdout.trim() === 'OK') { logger.debug('Home directory is accessible, no temporary setup needed'); return; } // Create temporary home directory const tempHome = `/tmp/sshbridge_${this.username}_${Date.now()}`; const setupCommands = [ `mkdir -p ${tempHome}`, `chmod 700 ${tempHome}`, `export HOME=${tempHome}`, `export TMPDIR=${tempHome}/tmp`, `mkdir -p ${tempHome}/tmp`, `chmod 700 ${tempHome}/tmp` ]; for (const cmd of setupCommands) { try { await this.ssh.execCommand(cmd, { timeout: 5000 }); } catch (error) { logger.debug(`Temporary environment setup command failed: ${cmd}`, { error: error.message }); } } this.tempHome = tempHome; logger.info('Temporary environment set up', { tempHome }); } catch (error) { logger.warn('Failed to set up temporary environment', { error: error.message }); // Don't fail the connection for this } } // NEW: Attempt to recover lost connection async recoverConnection() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { logger.error('Max reconnection attempts reached', { attempts: this.reconnectAttempts, max: this.maxReconnectAttempts }); this.isConnected = false; return false; } this.reconnectAttempts++; logger.info(`Attempting connection recovery (${this.reconnectAttempts}/${this.maxReconnectAttempts})`); try { // Stop keepalive during recovery this.stopKeepalive(); // Wait a bit before reconnecting await new Promise(resolve => setTimeout(resolve, 1000 * this.reconnectAttempts)); // Try to reconnect await this.connect(); logger.info('Connection recovery successful'); return true; } catch (error) { logger.warn('Connection recovery failed', { attempt: this.reconnectAttempts, error: error.message }); if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.isConnected = false; throw new Error(`Failed to recover connection after ${this.maxReconnectAttempts} attempts`); } return false; } } // NEW: Disconnect method async disconnect() { try { this.stopKeepalive(); if (this.ssh && this.ssh.isConnected()) { await this.ssh.dispose(); } this.isConnected = false; this.connectionStartTime = null; this.lastActivityTime = null; // Clean up temporary environment if (this.tempHome) { try { await this.ssh.execCommand(`rm -rf ${this.tempHome}`, { timeout: 5000 }); } catch (error) { logger.debug('Failed to clean up temporary environment', { error: error.message }); } this.tempHome = null; } logger.info('Disconnected successfully'); } catch (error) { logger.warn('Error during disconnect', { error: error.message }); } } /** * Get sandbox status and statistics */ getSandboxStatus() { return this.sandboxExecutor.getStats(); } /** * Enable/disable sandbox mode */ setSandboxMode(enabled) { this.sandboxExecutor.setSandboxMode(enabled); logger.info(`🔒 Sandbox mode ${enabled ? 'enabled' : 'disabled'}`); } /** * Set dry run mode */ setDryRunMode(enabled) { this.sandboxExecutor.setDryRunMode(enabled); this.options.dryRun = enabled; logger.info(`🔍 Dry run mode ${enabled ? 'enabled' : 'disabled'}`); } /** * Get sandbox results for a specific command */ getSandboxResult(commandId) { return this.sandboxExecutor.getCommandResult(commandId); } /** * Clear sandbox execution history */ clearSandboxHistory() { this.sandboxExecutor.clearHistory(); } // SECURITY: Hostname validation to prevent SSRF isValidHostname(hostname) { if (!hostname || typeof hostname !== 'string') { return false; } // Block private/local network addresses unless explicitly allowed const privateRanges = [ /^127\./, // localhost /^10\./, // 10.0.0.0/8 /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12 /^192\.168\./, // 192.168.0.0/16 /^169\.254\./, // link-local /^::1$/, // IPv6 localhost /^fe80:/, // IPv6 link-local /^fc00:/, // IPv6 unique local /^fd00:/ // IPv6 unique local ]; // Check if it's an IP address const ipPattern = /^(\d{1,3}\.){3}\d{1,3}$/; if (ipPattern.test(hostname)) { // Block private IP ranges unless explicitly allowed for (const range of privateRanges) { if (range.test(hostname)) { // Allow if explicitly configured to allow private networks if (this.options.allowPrivateNetworks) { // SECURITY: Log private network access for audit purposes const logger = require('./utils/logger'); logger.securityEvent('PRIVATE_NETWORK_ACCESS', { hostname, timestamp: new Date().toISOString(), user: process.env.USER || 'unknown' }); return true; } throw new Error(`Access to private network ${hostname} is blocked for security. Use --allow-private-networks to override.`); } } return true; } // For hostnames, allow but validate format const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return hostnamePattern.test(hostname); } // SECURITY: Port validation isValidPort(port) { const portNum = parseInt(port); return !isNaN(portNum) && portNum >= 1 && portNum <= 65535; } async execWithToken(command, token) { // Validate execution token first if (!token) { throw new Error('Execution token required'); } // Execute with security checks return this.exec(command); } async exec(command) { // NEW: Always test commands in sandbox first (like Cursor) logger.info(`🔒 Testing command in sandbox: ${chalk.cyan(command)}`); try { // Step 1: Execute command in sandbox for safety validation const sandboxResult = await this.sandboxExecutor.executeWithSandbox(command, { target: { type: 'ssh', ssh: this, execute: (cmd) => this.executeOnRealServer(cmd) }, executeOnTarget: !this.options.dryRun, autoApprove: this.options.autoApprove || false }); // Step 2: Handle sandbox results if (!sandboxResult.success) { if (sandboxResult.blocked) { throw new Error(`Command blocked by sandbox: ${sandboxResult.reason}`); } else { throw new Error(`Sandbox execution failed: ${sandboxResult.error}`); } } // Step 3: If dry run, return sandbox results only if (sandboxResult.dryRun || this.options.dryRun) { logger.info(`🔍 Dry run completed - command would execute: ${chalk.green(command)}`); return { stdout: sandboxResult.sandboxResult.stdout, stderr: sandboxResult.sandboxResult.stderr, code: sandboxResult.sandboxResult.exitCode, dryRun: true, sandboxResult: sandboxResult.sandboxResult }; } // Step 4: Return real execution results if (sandboxResult.realResult) { return { stdout: sandboxResult.realResult.stdout, stderr: sandboxResult.realResult.stderr, code: sandboxResult.realResult.exitCode, sandboxResult: sandboxResult.sandboxResult }; } // Step 5: Fallback to sandbox results if no real execution return { stdout: sandboxResult.sandboxResult.stdout, stderr: sandboxResult.sandboxResult.stderr, code: sandboxResult.sandboxResult.exitCode, sandboxResult: sandboxResult.sandboxResult }; } catch (error) { logger.error(`❌ Command execution failed: ${error.message}`); throw error; } } /** * Execute command on real SSH server (after sandbox validation) */ async executeOnRealServer(command) { // Legacy security checks (now handled by sandbox, but kept for extra safety) const dangerousCommands = [ // Destructive file operations 'rm -rf /', 'rm -rf *', 'rm -rf ~', 'rm -rf /home', 'rm -rf /var', 'rm -rf /etc', 'rm -rf /usr', 'rm -rf /opt', 'rm -rf /root', 'rm -rf /boot', 'rm -rf /sys', 'rm -rf /proc', // Disk operations 'dd if=', 'dd of=/dev', 'mkfs.', 'format', 'fdisk', 'parted', // System destruction ':(){ :|:& };:', // Fork bomb 'sudo rm', 'chmod 777 /', 'chmod -R 777', 'chown -R root', // Network/Security risks 'wget http', 'curl http', 'nc -l', 'ncat -l', 'iptables -F', 'ufw disable', // Process/System control 'killall -9', 'pkill -9', 'shutdown', 'reboot', 'halt', 'init 0', 'init 6', // Package management risks 'apt-get remove', 'yum remove', 'dnf remove', 'pacman -R', 'pip uninstall', 'npm uninstall -g', // File permission risks 'chmod 000', 'chmod -x /bin', 'chmod -x /usr/bin' ]; // Check for dangerous patterns const commandLower = command.toLowerCase(); const isDangerous = dangerousCommands.some(dangerous => commandLower.includes(dangerous.toLowerCase()) ); // Additional pattern checks const dangerousPatterns = [ /rm\s+-rf?\s+\/[^\/\s]*/, // rm -rf /anything />\s*\/dev\/(sd|hd|nvme)/, // Redirect to disk devices /\/dev\/zero\s*>\s*\/dev/, // Zero out devices /chmod\s+000\s+\/\w+/, // Remove all permissions from system dirs /;\s*rm\s+-rf/, // Command chaining with rm /&&\s*rm\s+-rf/, // Command chaining with rm /\|\s*rm\s+-rf/ // Pipe to rm ]; const hasMatchingPattern = dangerousPatterns.some(pattern => pattern.test(commandLower) ); if (isDangerous || hasMatchingPattern) { throw new Error('Command blocked for safety. Contact support if this is a legitimate use case.'); } // SECURITY: Log the command for audit with enhanced security logging const logger = require('./utils/logger'); logger.securityEvent('COMMAND_EXECUTION', { command: this.sanitizeCommand(command), host: this.host, username: this.username, timestamp: new Date().toISOString() }); // Also log to console for immediate visibility console.log(`[AUDIT] User command: ${this.sanitizeCommand(command)}`); // NEW: Ensure connection is established and healthy await this.connect(); try { // NEW: Use active shell if available let execCommand = command; if (this.activeShell && this.activeShell !== 'default') { execCommand = `/bin/${this.activeShell} -c '${command.replace(/'/g, "'\"'\"'")}'`; } // NEW: Execute with timeout and retry logic const result = await this.executeWithRetry(execCommand); // NEW: Update activity timestamp this.lastActivityTime = Date.now(); return { stdout: result.stdout, stderr: result.stderr, code: result.code }; } catch (error) { // NEW: Try to recover connection if it failed if (error.message.includes('connection') || error.message.includes('timeout')) { logger.warn('Command execution failed, attempting connection recovery', { error: error.message }); try { await this.recoverConnection(); // Retry the command once after recovery const result = await this.executeWithRetry(command); this.lastActivityTime = Date.now(); return { stdout: result.stdout, stderr: result.stderr, code: result.code }; } catch (recoveryError) { logger.error('Command execution failed even after recovery', { error: recoveryError.message }); throw new Error(`Command execution failed: ${recoveryError.message}`); } } else { throw new Error(`Command execution failed: ${error.message}`); } } } // NEW: Execute command with retry logic async executeWithRetry(command, maxRetries = 2) { let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const result = await this.ssh.execCommand(command, { timeout: 30000, // 30 second timeout onStdout: (chunk) => { // Update activity timestamp on any output this.lastActivityTime = Date.now(); }, onStderr: (chunk) => { // Update activity timestamp on any output this.lastActivityTime = Date.now(); } }); return result; } catch (error) { lastError = error; logger.warn(`Command execution attempt ${attempt} failed`, { error: error.message, attempt, maxRetries }); if (attempt < maxRetries) { // Wait before retry with exponential backoff await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); // Check if connection is still healthy if (!(await this.isConnectionHealthy())) { logger.warn('Connection unhealthy, attempting recovery before retry'); await this.recoverConnection(); } } } } throw lastError; } async copyFile(localPath, remotePath) { // SECURITY: Validate file paths if (!this.isValidFilePath(localPath) || !this.isValidFilePath(remotePath)) { throw new Error('Invalid file path detected'); } await this.connect(); try { if (fsSync.existsSync(localPath)) { // Upload local to remote await this.ssh.putFile(localPath, remotePath); } else { // Download remote to local await this.ssh.getFile(localPath, remotePath); } await this.ssh.dispose(); } catch (error) { await this.ssh.dispose(); throw new Error(`File transfer failed: ${error.message}`); } } // SECURITY: Sanitize command for logging (remove sensitive parts) sanitizeCommand(command) { if (!command || typeof command !== 'string') { return '[INVALID_COMMAND]'; } // Remove or redact potentially sensitive command parts let sanitized = command; // Redact password arguments sanitized = sanitized.replace(/(--password|--pass|--pwd)\s+\S+/gi, '$1 [REDACTED]'); // Redact key file paths sanitized = sanitized.replace(/(-i|--identity)\s+\S+/gi, '$1 [REDACTED]'); // Redact environment variables with sensitive names sanitized = sanitized.replace(/(\w+=(?:password|secret|key|token)\S*)/gi, '[REDACTED]'); return sanitized; } // SECURITY: File path validation to prevent directory traversal isValidFilePath(filePath) { if (!filePath || typeof filePath !== 'string') { return false; } // Block absolute paths and directory traversal attempts const dangerousPatterns = [ /^\/etc\//, /^\/var\//, /^\/usr\//, /^\/root\//, /^\/home\/[^\/]+\/\.ssh\//, /\.\.\//, /\/\.\./, /^\/dev\//, /^\/proc\//, /^\/sys\//, // SECURITY: Additional dangerous paths /^\/etc\/shadow/, /^\/etc\/sudoers/, /^\/etc\/passwd/, /^\/boot\//, /^\/mnt\//, /^\/media\//, /^\/tmp\/\./, /^\/var\/log\//, /^\/var\/spool\//, /^\/var\/cache\// ]; return !dangerousPatterns.some(pattern => pattern.test(filePath)); } async dispose() { try { // NEW: Use enhanced disconnect method await this.disconnect(); // Clear password cache for this connection this.clearCachedPassword(); // Clear any remaining secure buffers this.secureBuffers.forEach(buffer => { if (buffer && typeof buffer.fill === 'function') { buffer.fill(0); } }); this.secureBuffers = []; logger.info('SSH client disposed successfully'); } catch (error) { logger.warn('Error during SSH client disposal', { error: error.message }); } } } module.exports = { SSHClient };