370 lines
11 KiB
JavaScript
370 lines
11 KiB
JavaScript
/**
|
|
* Safe Executor - HIGH-1 Remediation
|
|
*
|
|
* Fixes command injection vulnerabilities by:
|
|
* - Using execFile instead of exec with shell
|
|
* - Validating all command arguments
|
|
* - Implementing command allowlist
|
|
* - Sanitizing command inputs
|
|
*
|
|
* Security Properties:
|
|
* - No shell interpretation
|
|
* - Argument validation
|
|
* - Command allowlist enforcement
|
|
* - Timeout controls
|
|
* - Resource limits
|
|
*
|
|
* @module v3/security/safe-executor
|
|
*/
|
|
import { execFile, spawn } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import * as path from 'path';
|
|
const execFileAsync = promisify(execFile);
|
|
export class SafeExecutorError extends Error {
|
|
code;
|
|
command;
|
|
args;
|
|
constructor(message, code, command, args) {
|
|
super(message);
|
|
this.code = code;
|
|
this.command = command;
|
|
this.args = args;
|
|
this.name = 'SafeExecutorError';
|
|
}
|
|
}
|
|
/**
|
|
* Default blocked argument patterns.
|
|
* These patterns indicate potential command injection attempts.
|
|
*/
|
|
const DEFAULT_BLOCKED_PATTERNS = [
|
|
// Shell metacharacters
|
|
';',
|
|
'&&',
|
|
'||',
|
|
'|',
|
|
'`',
|
|
'$(',
|
|
'${',
|
|
// Redirection
|
|
'>',
|
|
'<',
|
|
'>>',
|
|
// Background execution
|
|
'&',
|
|
// Newlines (command chaining)
|
|
'\n',
|
|
'\r',
|
|
// Null byte injection
|
|
'\0',
|
|
// Command substitution
|
|
'$()',
|
|
];
|
|
/**
|
|
* Commands that are inherently dangerous and should never be allowed.
|
|
*/
|
|
const DANGEROUS_COMMANDS = [
|
|
'rm',
|
|
'rmdir',
|
|
'del',
|
|
'format',
|
|
'mkfs',
|
|
'dd',
|
|
'chmod',
|
|
'chown',
|
|
'kill',
|
|
'killall',
|
|
'pkill',
|
|
'reboot',
|
|
'shutdown',
|
|
'init',
|
|
'poweroff',
|
|
'halt',
|
|
];
|
|
/**
|
|
* Safe command executor that prevents command injection.
|
|
*
|
|
* This class replaces unsafe exec() and spawn({shell: true}) calls
|
|
* with validated execFile() calls.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const executor = new SafeExecutor({
|
|
* allowedCommands: ['git', 'npm', 'node']
|
|
* });
|
|
*
|
|
* const result = await executor.execute('git', ['status']);
|
|
* ```
|
|
*/
|
|
export class SafeExecutor {
|
|
config;
|
|
blockedPatterns;
|
|
constructor(config) {
|
|
this.config = {
|
|
allowedCommands: config.allowedCommands,
|
|
blockedPatterns: config.blockedPatterns ?? DEFAULT_BLOCKED_PATTERNS,
|
|
timeout: config.timeout ?? 30000,
|
|
maxBuffer: config.maxBuffer ?? 10 * 1024 * 1024, // 10MB
|
|
cwd: config.cwd ?? process.cwd(),
|
|
env: config.env ?? process.env,
|
|
allowSudo: config.allowSudo ?? false,
|
|
};
|
|
// Compile blocked patterns for performance
|
|
this.blockedPatterns = this.config.blockedPatterns.map(pattern => new RegExp(this.escapeRegExp(pattern), 'i'));
|
|
this.validateConfig();
|
|
}
|
|
/**
|
|
* Escapes special regex characters.
|
|
*/
|
|
escapeRegExp(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
/**
|
|
* Validates executor configuration.
|
|
*/
|
|
validateConfig() {
|
|
if (this.config.allowedCommands.length === 0) {
|
|
throw new SafeExecutorError('At least one allowed command must be specified', 'EMPTY_ALLOWLIST');
|
|
}
|
|
// Check for dangerous commands in allowlist
|
|
const dangerousAllowed = this.config.allowedCommands.filter(cmd => DANGEROUS_COMMANDS.includes(path.basename(cmd)));
|
|
if (dangerousAllowed.length > 0) {
|
|
throw new SafeExecutorError(`Dangerous commands cannot be allowed: ${dangerousAllowed.join(', ')}`, 'DANGEROUS_COMMAND_ALLOWED');
|
|
}
|
|
}
|
|
/**
|
|
* Validates a command against the allowlist.
|
|
*
|
|
* @param command - Command to validate
|
|
* @throws SafeExecutorError if command is not allowed
|
|
*/
|
|
validateCommand(command) {
|
|
const basename = path.basename(command);
|
|
// Check if command is allowed
|
|
const isAllowed = this.config.allowedCommands.some(allowed => {
|
|
const allowedBasename = path.basename(allowed);
|
|
return command === allowed || basename === allowedBasename;
|
|
});
|
|
if (!isAllowed) {
|
|
throw new SafeExecutorError(`Command not in allowlist: ${command}`, 'COMMAND_NOT_ALLOWED', command);
|
|
}
|
|
// Check for sudo
|
|
if (!this.config.allowSudo && (command === 'sudo' || basename === 'sudo')) {
|
|
throw new SafeExecutorError('Sudo commands are not allowed', 'SUDO_NOT_ALLOWED', command);
|
|
}
|
|
}
|
|
/**
|
|
* Validates command arguments for injection patterns.
|
|
*
|
|
* @param args - Arguments to validate
|
|
* @throws SafeExecutorError if arguments contain dangerous patterns
|
|
*/
|
|
validateArguments(args) {
|
|
for (const arg of args) {
|
|
// Check for null bytes
|
|
if (arg.includes('\0')) {
|
|
throw new SafeExecutorError('Null byte detected in argument', 'NULL_BYTE_INJECTION', undefined, args);
|
|
}
|
|
// Check against blocked patterns
|
|
for (const pattern of this.blockedPatterns) {
|
|
if (pattern.test(arg)) {
|
|
throw new SafeExecutorError(`Dangerous pattern detected in argument: ${arg}`, 'DANGEROUS_PATTERN', undefined, args);
|
|
}
|
|
}
|
|
// Check for command chaining attempts
|
|
if (/^-.*[;&|]/.test(arg)) {
|
|
throw new SafeExecutorError(`Potential command chaining in argument: ${arg}`, 'COMMAND_CHAINING', undefined, args);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Sanitizes a single argument.
|
|
*
|
|
* @param arg - Argument to sanitize
|
|
* @returns Sanitized argument
|
|
*/
|
|
sanitizeArgument(arg) {
|
|
// Remove null bytes
|
|
let sanitized = arg.replace(/\0/g, '');
|
|
// Remove shell metacharacters
|
|
sanitized = sanitized.replace(/[;&|`$(){}><\n\r]/g, '');
|
|
return sanitized;
|
|
}
|
|
/**
|
|
* Executes a command safely.
|
|
*
|
|
* @param command - Command to execute (must be in allowlist)
|
|
* @param args - Command arguments
|
|
* @returns Execution result
|
|
* @throws SafeExecutorError on validation failure or execution error
|
|
*/
|
|
async execute(command, args = []) {
|
|
const startTime = Date.now();
|
|
// Validate command
|
|
this.validateCommand(command);
|
|
// Validate arguments
|
|
this.validateArguments(args);
|
|
try {
|
|
// Execute command WITHOUT shell
|
|
const { stdout, stderr } = await execFileAsync(command, args, {
|
|
cwd: this.config.cwd,
|
|
env: this.config.env,
|
|
timeout: this.config.timeout,
|
|
maxBuffer: this.config.maxBuffer,
|
|
shell: false, // CRITICAL: Never use shell
|
|
windowsHide: true,
|
|
});
|
|
return {
|
|
stdout: stdout.toString(),
|
|
stderr: stderr.toString(),
|
|
exitCode: 0,
|
|
command,
|
|
args,
|
|
duration: Date.now() - startTime,
|
|
};
|
|
}
|
|
catch (error) {
|
|
// Handle execution errors
|
|
if (error.killed) {
|
|
throw new SafeExecutorError('Command execution timed out', 'TIMEOUT', command, args);
|
|
}
|
|
if (error.code === 'ENOENT') {
|
|
throw new SafeExecutorError(`Command not found: ${command}`, 'COMMAND_NOT_FOUND', command, args);
|
|
}
|
|
// Return result with non-zero exit code
|
|
return {
|
|
stdout: error.stdout?.toString() ?? '',
|
|
stderr: error.stderr?.toString() ?? error.message,
|
|
exitCode: error.code ?? 1,
|
|
command,
|
|
args,
|
|
duration: Date.now() - startTime,
|
|
};
|
|
}
|
|
}
|
|
/**
|
|
* Executes a command with streaming output.
|
|
*
|
|
* @param command - Command to execute
|
|
* @param args - Command arguments
|
|
* @returns Streaming executor with process handles
|
|
*/
|
|
executeStreaming(command, args = []) {
|
|
const startTime = Date.now();
|
|
// Validate command
|
|
this.validateCommand(command);
|
|
// Validate arguments
|
|
this.validateArguments(args);
|
|
// Spawn process WITHOUT shell
|
|
const childProcess = spawn(command, args, {
|
|
cwd: this.config.cwd,
|
|
env: this.config.env,
|
|
timeout: this.config.timeout,
|
|
shell: false, // CRITICAL: Never use shell
|
|
windowsHide: true,
|
|
});
|
|
const promise = new Promise((resolve, reject) => {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
childProcess.stdout?.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
childProcess.stderr?.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
childProcess.on('close', (code) => {
|
|
resolve({
|
|
stdout,
|
|
stderr,
|
|
exitCode: code ?? 0,
|
|
command,
|
|
args,
|
|
duration: Date.now() - startTime,
|
|
});
|
|
});
|
|
childProcess.on('error', (error) => {
|
|
reject(new SafeExecutorError(error.message, 'EXECUTION_ERROR', command, args));
|
|
});
|
|
});
|
|
return {
|
|
process: childProcess,
|
|
stdout: childProcess.stdout,
|
|
stderr: childProcess.stderr,
|
|
promise,
|
|
};
|
|
}
|
|
/**
|
|
* Adds a command to the allowlist at runtime.
|
|
*
|
|
* @param command - Command to add
|
|
*/
|
|
allowCommand(command) {
|
|
const basename = path.basename(command);
|
|
if (DANGEROUS_COMMANDS.includes(basename)) {
|
|
throw new SafeExecutorError(`Cannot allow dangerous command: ${command}`, 'DANGEROUS_COMMAND');
|
|
}
|
|
if (!this.config.allowedCommands.includes(command)) {
|
|
this.config.allowedCommands.push(command);
|
|
}
|
|
}
|
|
/**
|
|
* Checks if a command is allowed.
|
|
*
|
|
* @param command - Command to check
|
|
* @returns True if command is allowed
|
|
*/
|
|
isCommandAllowed(command) {
|
|
const basename = path.basename(command);
|
|
return this.config.allowedCommands.some(allowed => {
|
|
const allowedBasename = path.basename(allowed);
|
|
return command === allowed || basename === allowedBasename;
|
|
});
|
|
}
|
|
/**
|
|
* Returns the current allowlist.
|
|
*/
|
|
getAllowedCommands() {
|
|
return [...this.config.allowedCommands];
|
|
}
|
|
}
|
|
/**
|
|
* Factory function to create a safe executor for common development tasks.
|
|
*
|
|
* @returns Configured SafeExecutor for git, npm, and node
|
|
*/
|
|
export function createDevelopmentExecutor() {
|
|
return new SafeExecutor({
|
|
allowedCommands: [
|
|
'git',
|
|
'npm',
|
|
'npx',
|
|
'node',
|
|
'tsc',
|
|
'vitest',
|
|
'eslint',
|
|
'prettier',
|
|
],
|
|
});
|
|
}
|
|
/**
|
|
* Factory function to create a read-only executor.
|
|
* Only allows commands that read without modifying.
|
|
*
|
|
* @returns Configured SafeExecutor for read operations
|
|
*/
|
|
export function createReadOnlyExecutor() {
|
|
return new SafeExecutor({
|
|
allowedCommands: [
|
|
'git',
|
|
'cat',
|
|
'head',
|
|
'tail',
|
|
'ls',
|
|
'find',
|
|
'grep',
|
|
'which',
|
|
'echo',
|
|
],
|
|
timeout: 10000,
|
|
});
|
|
}
|
|
//# sourceMappingURL=safe-executor.js.map
|