/** * Agentic QE v3 - MCP Security: CVE Prevention Utilities * Security utilities for preventing common vulnerabilities (ADR-012) * * Features: * - Path traversal protection (no ../ in paths) * - ReDoS prevention with regex escaping * - Timing-safe authentication comparison * - Input sanitization utilities * - Command injection prevention */ import { createHash, timingSafeEqual, randomBytes } from 'crypto'; // ============================================================================ // Types and Interfaces // ============================================================================ /** * Path validation result */ export interface PathValidationResult { valid: boolean; normalizedPath?: string; error?: string; riskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical'; } /** * Regex safety result */ export interface RegexSafetyResult { safe: boolean; pattern?: string; escapedPattern?: string; error?: string; riskyPatterns: string[]; } /** * Command validation result */ export interface CommandValidationResult { valid: boolean; sanitizedCommand?: string; error?: string; blockedPatterns: string[]; } /** * Input sanitization options */ export interface SanitizationOptions { maxLength?: number; allowedChars?: RegExp; stripHtml?: boolean; stripSql?: boolean; escapeShell?: boolean; trim?: boolean; } /** * Path validation options */ export interface PathValidationOptions { basePath?: string; allowAbsolute?: boolean; allowedExtensions?: string[]; deniedExtensions?: string[]; maxDepth?: number; maxLength?: number; } // ============================================================================ // Path Traversal Protection // ============================================================================ /** * Path traversal patterns to detect */ const PATH_TRAVERSAL_PATTERNS = [ /\.\./, // Basic traversal /%2e%2e/i, // URL encoded .. /%252e%252e/i, // Double URL encoded /\.\.%2f/i, // Mixed encoding /%2f\.\./i, // Forward slash + .. /\.\.%5c/i, // Backslash + .. /\.\.\\/, // Windows backslash traversal /%c0%ae/i, // UTF-8 overlong encoding /%c0%2f/i, // UTF-8 overlong / /%c1%9c/i, // UTF-8 overlong \ /\0/, // Null byte injection /%00/i, // URL encoded null ]; /** * Dangerous path components */ const DANGEROUS_PATH_COMPONENTS = [ /^\/etc\//i, /^\/proc\//i, /^\/sys\//i, /^\/dev\//i, /^\/root\//i, /^\/home\/.+\/\./i, /^[A-Z]:\\Windows/i, /^[A-Z]:\\System/i, /^[A-Z]:\\Users\\.+\\AppData/i, ]; /** * Validate and sanitize a file path to prevent traversal attacks */ export function validatePath( path: string, options: PathValidationOptions = {} ): PathValidationResult { const { basePath = '', allowAbsolute = false, allowedExtensions = [], deniedExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.dll', '.so'], maxDepth = 10, maxLength = 4096, } = options; // Check length if (path.length > maxLength) { return { valid: false, error: `Path exceeds maximum length of ${maxLength}`, riskLevel: 'medium', }; } // Check for traversal patterns for (const pattern of PATH_TRAVERSAL_PATTERNS) { if (pattern.test(path)) { return { valid: false, error: 'Path traversal attempt detected', riskLevel: 'critical', }; } } // Check for absolute paths if (!allowAbsolute && (path.startsWith('/') || /^[A-Z]:/i.test(path))) { return { valid: false, error: 'Absolute paths are not allowed', riskLevel: 'high', }; } // Check for dangerous path components for (const pattern of DANGEROUS_PATH_COMPONENTS) { if (pattern.test(path)) { return { valid: false, error: 'Access to system paths is not allowed', riskLevel: 'critical', }; } } // Normalize the path const normalizedPath = normalizePath(path); // Re-check for traversal after normalization if (normalizedPath.includes('..')) { return { valid: false, error: 'Path traversal detected after normalization', riskLevel: 'critical', }; } // Check depth const depth = normalizedPath.split('/').filter(Boolean).length; if (depth > maxDepth) { return { valid: false, error: `Path depth exceeds maximum of ${maxDepth}`, riskLevel: 'low', }; } // Check extension const ext = getExtension(normalizedPath); if (ext) { const extWithDot = `.${ext.toLowerCase()}`; const extWithoutDot = ext.toLowerCase(); // Check denied extensions (support both .exe and exe formats) if (deniedExtensions.length > 0) { const isDenied = deniedExtensions.some(denied => denied.toLowerCase() === extWithDot || denied.toLowerCase() === extWithoutDot ); if (isDenied) { return { valid: false, error: `File extension '${ext}' is not allowed`, riskLevel: 'high', }; } } // Check allowed extensions (support both .ts and ts formats) if (allowedExtensions.length > 0) { const isAllowed = allowedExtensions.some(allowed => allowed.toLowerCase() === extWithDot || allowed.toLowerCase() === extWithoutDot ); if (!isAllowed) { return { valid: false, error: `File extension '${ext}' is not in allowed list`, riskLevel: 'medium', }; } } } // Combine with base path if provided const finalPath = basePath ? joinPathsAbsolute(basePath, normalizedPath) : normalizedPath; // Verify final path doesn't escape base (use normalized base for comparison) const normalizedBase = basePath.startsWith('/') ? `/${normalizePath(basePath)}` : normalizePath(basePath); if (basePath && !finalPath.startsWith(normalizedBase)) { return { valid: false, error: 'Path escapes base directory', riskLevel: 'critical', }; } return { valid: true, normalizedPath: finalPath, riskLevel: 'none', }; } /** * Normalize a path by resolving . and .. components */ export function normalizePath(path: string): string { // Replace backslashes with forward slashes let normalized = path.replace(/\\/g, '/'); // Remove multiple consecutive slashes normalized = normalized.replace(/\/+/g, '/'); // Split and resolve const parts = normalized.split('/'); const result: string[] = []; for (const part of parts) { if (part === '.' || part === '') { continue; } if (part === '..') { // Don't allow going above root if (result.length > 0 && result[result.length - 1] !== '..') { result.pop(); } } else { result.push(part); } } return result.join('/'); } /** * Safely join path components (strips leading/trailing slashes from all parts) */ export function joinPaths(...paths: string[]): string { if (paths.length === 0) return ''; return paths .map(p => p.replace(/^\/+|\/+$/g, '')) .filter(Boolean) .join('/'); } /** * Join paths preserving absolute path from first component */ export function joinPathsAbsolute(...paths: string[]): string { if (paths.length === 0) return ''; // Check if the first path is absolute const isAbsolute = paths[0].startsWith('/'); const result = paths .map(p => p.replace(/^\/+|\/+$/g, '')) .filter(Boolean) .join('/'); // Preserve leading slash for absolute paths return isAbsolute ? `/${result}` : result; } /** * Get file extension */ export function getExtension(path: string): string | null { const match = path.match(/\.([^./\\]+)$/); return match ? match[1] : null; } // ============================================================================ // ReDoS Prevention // ============================================================================ /** * Patterns that can cause ReDoS */ const REDOS_PATTERNS = [ /\(\.\*\)\+/, // (.*)+ /\(\.\+\)\+/, // (.+)+ /\([^)]*\?\)\+/, // (...?)+ /\([^)]*\*\)\+/, // (...*)+ /\([^)]*\+\)\+/, // (...+)+ /\(\[.*?\]\+\)\+/, // ([...]+)+ /\(\[.*?\]\*\)\+/, // ([...]*)+ /\(\[.*?\]\?\)\+/, // ([...]?)+ /\(\[.*?\]\*\)\*/, // ([...]*)* /\.\*\.\*/, // .*.* /\.\+\.\+/, // .+.+ /\(\.\|\.\)/, // (.|.) ]; /** * Maximum allowed regex complexity (nested quantifiers) */ const MAX_REGEX_COMPLEXITY = 3; /** * Check if a regex pattern is safe from ReDoS */ export function isRegexSafe(pattern: string): RegexSafetyResult { const riskyPatterns: string[] = []; // Check for known ReDoS patterns for (const redosPattern of REDOS_PATTERNS) { if (redosPattern.test(pattern)) { riskyPatterns.push(redosPattern.source); } } // Check nesting depth of quantifiers const quantifierDepth = countQuantifierNesting(pattern); if (quantifierDepth > MAX_REGEX_COMPLEXITY) { riskyPatterns.push(`Quantifier nesting depth: ${quantifierDepth} (max: ${MAX_REGEX_COMPLEXITY})`); } // Check for exponential backtracking potential if (hasExponentialBacktracking(pattern)) { riskyPatterns.push('Exponential backtracking potential detected'); } return { safe: riskyPatterns.length === 0, pattern, escapedPattern: escapeRegex(pattern), riskyPatterns, error: riskyPatterns.length > 0 ? 'Pattern may cause ReDoS' : undefined, }; } /** * Count nested quantifier depth */ function countQuantifierNesting(pattern: string): number { let maxDepth = 0; let currentDepth = 0; let inGroup = false; let escaped = false; for (let i = 0; i < pattern.length; i++) { const char = pattern[i]; if (escaped) { escaped = false; continue; } if (char === '\\') { escaped = true; continue; } if (char === '(') { inGroup = true; continue; } if (char === ')') { inGroup = false; // Check if followed by quantifier const next = pattern[i + 1]; if (next === '*' || next === '+' || next === '?' || next === '{') { currentDepth++; maxDepth = Math.max(maxDepth, currentDepth); } continue; } if ((char === '*' || char === '+' || char === '?') && !inGroup) { currentDepth = 1; maxDepth = Math.max(maxDepth, currentDepth); } } return maxDepth; } /** * Check for exponential backtracking potential */ function hasExponentialBacktracking(pattern: string): boolean { // Simplified check for common exponential patterns const dangerous = [ /\(\[^\\]*\]\+\)\+/, // ([...]+)+ /\(\[^\\]*\]\*\)\*/, // ([...]*)* /\([^)]+\|[^)]+\)\+/, // (a|b)+ /\(\.\*\)[*+]/, // (.*)+, (.*)* /\(\.\+\)[*+]/, // (.+)+, (.+)* ]; return dangerous.some(d => d.test(pattern)); } /** * Escape special regex characters in a string */ export function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Create a safe regex with timeout */ export function createSafeRegex( pattern: string, flags?: string, maxLength = 10000 ): RegExp | null { const safety = isRegexSafe(pattern); if (!safety.safe) { return null; } if (pattern.length > maxLength) { return null; } try { return new RegExp(pattern, flags); } catch { return null; } } // ============================================================================ // Timing-Safe Comparison // ============================================================================ /** * Perform a timing-safe string comparison */ export function timingSafeCompare(a: string, b: string): boolean { // Pad shorter string to prevent length-based timing attacks const maxLen = Math.max(a.length, b.length); const paddedA = a.padEnd(maxLen, '\0'); const paddedB = b.padEnd(maxLen, '\0'); try { return timingSafeEqual(Buffer.from(paddedA), Buffer.from(paddedB)); } catch { return false; } } /** * Timing-safe comparison for hashed values */ export function timingSafeHashCompare(value: string, expectedHash: string): boolean { const hash = createHash('sha256').update(value).digest('hex'); return timingSafeCompare(hash, expectedHash); } /** * Generate a secure random token */ export function generateSecureToken(length = 32): string { return randomBytes(length) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } /** * Hash a value securely */ export function secureHash(value: string, salt?: string): string { const data = salt ? `${salt}:${value}` : value; return createHash('sha256').update(data).digest('hex'); } // ============================================================================ // Input Sanitization // ============================================================================ /** * HTML escape characters */ const HTML_ESCAPE_MAP: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/', '`': '`', '=': '=', }; /** * SQL injection patterns */ const SQL_INJECTION_PATTERNS = [ /('|")\s*;\s*--/i, /'\s*OR\s+'1'\s*=\s*'1/i, /"\s*OR\s+"1"\s*=\s*"1/i, /UNION\s+SELECT/i, /INSERT\s+INTO/i, /DROP\s+TABLE/i, /DELETE\s+FROM/i, /UPDATE\s+.*\s+SET/i, /EXEC(\s+|\()sp_/i, /xp_cmdshell/i, ]; /** * Shell metacharacters (excludes parentheses which are common in normal text) */ const SHELL_METACHARACTERS = /[|;&$`<>{}[\]!#*?~]/g; /** * Sanitize input string */ export function sanitizeInput(input: string, options: SanitizationOptions = {}): string { const { maxLength = 10000, allowedChars, stripHtml = true, stripSql = true, escapeShell = true, trim = true, } = options; let result = input; // Trim if (trim) { result = result.trim(); } // Max length if (result.length > maxLength) { result = result.substring(0, maxLength); } // Strip HTML if (stripHtml) { result = stripHtmlTags(result); } // Strip SQL injection attempts if (stripSql) { for (const pattern of SQL_INJECTION_PATTERNS) { result = result.replace(pattern, ''); } } // Escape shell metacharacters if (escapeShell) { result = result.replace(SHELL_METACHARACTERS, ''); } // Filter to allowed characters if (allowedChars) { // Filter character by character to respect the provided regex result = result.split('').filter(char => allowedChars.test(char)).join(''); } return result; } /** * Escape HTML special characters */ export function escapeHtml(str: string): string { return str.replace(/[&<>"'`=/]/g, char => HTML_ESCAPE_MAP[char] || char); } /** * Strip HTML tags from a string */ export function stripHtmlTags(str: string): string { return str.replace(/<[^>]*>/g, ''); } // ============================================================================ // Command Injection Prevention // ============================================================================ /** * Allowed commands whitelist */ const DEFAULT_ALLOWED_COMMANDS = [ 'ls', 'cat', 'echo', 'grep', 'find', 'head', 'tail', 'wc', 'npm', 'node', 'yarn', 'pnpm', 'git', 'jest', 'vitest', 'playwright', ]; /** * Blocked command patterns */ const BLOCKED_COMMAND_PATTERNS = [ /;/, // Command chaining with semicolon /&&/, // Command chaining with AND /\|\|/, // Command chaining with OR /\|/, // Piping /`.*`/, // Backtick command substitution /\$\(.*\)/, // $() command substitution />\s*\/dev\/sd/i, // Writing to block devices />\s*\/etc\//i, // Writing to /etc ]; /** * Validate and sanitize a command */ export function validateCommand( command: string, allowedCommands: string[] = DEFAULT_ALLOWED_COMMANDS ): CommandValidationResult { const blockedPatterns: string[] = []; // Check for blocked patterns for (const pattern of BLOCKED_COMMAND_PATTERNS) { if (pattern.test(command)) { blockedPatterns.push(pattern.source); } } if (blockedPatterns.length > 0) { return { valid: false, error: 'Command contains blocked patterns', blockedPatterns, }; } // Extract base command const parts = command.trim().split(/\s+/); const baseCommand = parts[0].split('/').pop() || ''; // Check against whitelist if (!allowedCommands.includes(baseCommand)) { return { valid: false, error: `Command '${baseCommand}' is not in the allowed list`, blockedPatterns: [], }; } // Sanitize arguments const sanitizedParts = parts.map((part, i) => { if (i === 0) return part; // Remove shell metacharacters from arguments return part.replace(SHELL_METACHARACTERS, ''); }); return { valid: true, sanitizedCommand: sanitizedParts.join(' '), blockedPatterns: [], }; } /** * Escape a string for safe shell usage */ export function escapeShellArg(arg: string): string { // Wrap in single quotes and escape any internal single quotes return `'${arg.replace(/'/g, "'\\''")}'`; } // ============================================================================ // Export Utilities Object // ============================================================================ export const CVEPrevention = { // Path traversal validatePath, normalizePath, joinPaths, joinPathsAbsolute, getExtension, // ReDoS isRegexSafe, escapeRegex, createSafeRegex, // Timing-safe timingSafeCompare, timingSafeHashCompare, generateSecureToken, secureHash, // Input sanitization sanitizeInput, escapeHtml, stripHtmlTags, // Command injection validateCommand, escapeShellArg, }; export default CVEPrevention;