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
JavaScript
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 };