""" Security utilities for SuperClaude installation system Path validation and input sanitization """ import re import os from pathlib import Path from typing import List, Optional, Tuple, Set import urllib.parse class SecurityValidator: """Security validation utilities""" # Dangerous path patterns DANGEROUS_PATTERNS = [ r'\.\./', # Directory traversal r'\.\.\.', # Directory traversal r'//+', # Multiple slashes r'/etc/', # System directories r'/bin/', r'/sbin/', r'/usr/bin/', r'/usr/sbin/', r'/var/', r'/tmp/', r'/dev/', r'/proc/', r'/sys/', r'c:\\windows\\', # Windows system dirs r'c:\\program files\\', # Note: Removed c:\\users\\ to allow installation in user directories # Claude Code installs to user home directory by default ] # Dangerous filename patterns DANGEROUS_FILENAMES = [ r'\.exe$', # Executables r'\.bat$', r'\.cmd$', r'\.scr$', r'\.dll$', r'\.so$', r'\.dylib$', r'passwd', # System files r'shadow', r'hosts', r'\.ssh/', r'\.aws/', r'\.env', # Environment files r'\.secret', ] # Allowed file extensions for installation ALLOWED_EXTENSIONS = { '.md', '.json', '.py', '.js', '.ts', '.jsx', '.tsx', '.txt', '.yml', '.yaml', '.toml', '.cfg', '.conf', '.sh', '.ps1', '.html', '.css', '.svg', '.png', '.jpg', '.gif' } # Maximum path lengths MAX_PATH_LENGTH = 4096 MAX_FILENAME_LENGTH = 255 @classmethod def validate_path(cls, path: Path, base_dir: Optional[Path] = None) -> Tuple[bool, str]: """ Validate path for security issues Args: path: Path to validate base_dir: Base directory that path should be within Returns: Tuple of (is_safe: bool, error_message: str) """ try: # Convert to absolute path abs_path = path.resolve() path_str = str(abs_path).lower() # Check path length if len(str(abs_path)) > cls.MAX_PATH_LENGTH: return False, f"Path too long: {len(str(abs_path))} > {cls.MAX_PATH_LENGTH}" # Check filename length if len(abs_path.name) > cls.MAX_FILENAME_LENGTH: return False, f"Filename too long: {len(abs_path.name)} > {cls.MAX_FILENAME_LENGTH}" # Check for dangerous patterns for pattern in cls.DANGEROUS_PATTERNS: if re.search(pattern, path_str, re.IGNORECASE): return False, f"Dangerous path pattern detected: {pattern}" # Check for dangerous filenames for pattern in cls.DANGEROUS_FILENAMES: if re.search(pattern, abs_path.name, re.IGNORECASE): return False, f"Dangerous filename pattern detected: {pattern}" # Check if path is within base directory if base_dir: base_abs = base_dir.resolve() try: abs_path.relative_to(base_abs) except ValueError: return False, f"Path outside allowed directory: {abs_path} not in {base_abs}" # Check for null bytes if '\x00' in str(path): return False, "Null byte detected in path" # Check for Windows reserved names if os.name == 'nt': reserved_names = [ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' ] name_without_ext = abs_path.stem.upper() if name_without_ext in reserved_names: return False, f"Reserved Windows filename: {name_without_ext}" return True, "Path is safe" except Exception as e: return False, f"Path validation error: {e}" @classmethod def validate_file_extension(cls, path: Path) -> Tuple[bool, str]: """ Validate file extension is allowed Args: path: Path to validate Returns: Tuple of (is_allowed: bool, message: str) """ extension = path.suffix.lower() if not extension: return True, "No extension (allowed)" if extension in cls.ALLOWED_EXTENSIONS: return True, f"Extension {extension} is allowed" else: return False, f"Extension {extension} is not allowed" @classmethod def sanitize_filename(cls, filename: str) -> str: """ Sanitize filename by removing dangerous characters Args: filename: Original filename Returns: Sanitized filename """ # Remove null bytes filename = filename.replace('\x00', '') # Remove or replace dangerous characters dangerous_chars = r'[<>:"/\\|?*\x00-\x1f]' filename = re.sub(dangerous_chars, '_', filename) # Remove leading/trailing dots and spaces filename = filename.strip('. ') # Ensure not empty if not filename: filename = 'unnamed' # Truncate if too long if len(filename) > cls.MAX_FILENAME_LENGTH: name, ext = os.path.splitext(filename) max_name_len = cls.MAX_FILENAME_LENGTH - len(ext) filename = name[:max_name_len] + ext # Check for Windows reserved names if os.name == 'nt': name_without_ext = os.path.splitext(filename)[0].upper() reserved_names = [ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' ] if name_without_ext in reserved_names: filename = f"safe_{filename}" return filename @classmethod def sanitize_input(cls, user_input: str, max_length: int = 1000) -> str: """ Sanitize user input Args: user_input: Raw user input max_length: Maximum allowed length Returns: Sanitized input """ if not user_input: return "" # Remove null bytes and control characters sanitized = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', user_input) # Trim whitespace sanitized = sanitized.strip() # Truncate if too long if len(sanitized) > max_length: sanitized = sanitized[:max_length] return sanitized @classmethod def validate_url(cls, url: str) -> Tuple[bool, str]: """ Validate URL for security issues Args: url: URL to validate Returns: Tuple of (is_safe: bool, message: str) """ try: parsed = urllib.parse.urlparse(url) # Check scheme if parsed.scheme not in ['http', 'https']: return False, f"Invalid scheme: {parsed.scheme}" # Check for localhost/private IPs (basic check) hostname = parsed.hostname if hostname: if hostname.lower() in ['localhost', '127.0.0.1', '::1']: return False, "Localhost URLs not allowed" # Basic private IP check if hostname.startswith('192.168.') or hostname.startswith('10.') or hostname.startswith('172.'): return False, "Private IP addresses not allowed" # Check URL length if len(url) > 2048: return False, "URL too long" return True, "URL is safe" except Exception as e: return False, f"URL validation error: {e}" @classmethod def check_permissions(cls, path: Path, required_permissions: Set[str]) -> Tuple[bool, List[str]]: """ Check file/directory permissions Args: path: Path to check required_permissions: Set of required permissions ('read', 'write', 'execute') Returns: Tuple of (has_permissions: bool, missing_permissions: List[str]) """ missing = [] try: if not path.exists(): # For non-existent paths, check parent directory parent = path.parent if not parent.exists(): missing.append("path does not exist") return False, missing path = parent if 'read' in required_permissions: if not os.access(path, os.R_OK): missing.append('read') if 'write' in required_permissions: if not os.access(path, os.W_OK): missing.append('write') if 'execute' in required_permissions: if not os.access(path, os.X_OK): missing.append('execute') return len(missing) == 0, missing except Exception as e: missing.append(f"permission check error: {e}") return False, missing @classmethod def validate_installation_target(cls, target_dir: Path) -> Tuple[bool, List[str]]: """ Validate installation target directory Args: target_dir: Target installation directory Returns: Tuple of (is_safe: bool, error_messages: List[str]) """ errors = [] # Special handling for Claude installation directory abs_target = target_dir.resolve() abs_target_str = str(abs_target).lower() # Allow installation to .claude directory in user home if abs_target_str.endswith('.claude') or abs_target_str.endswith('.claude' + os.sep): home_path = Path.home() try: # Check if it's under user home directory abs_target.relative_to(home_path) # If we reach here, it's under home directory - allow it # Still check permissions has_perms, missing = cls.check_permissions(target_dir, {'read', 'write'}) if not has_perms: errors.append(f"Insufficient permissions: missing {missing}") return len(errors) == 0, errors except ValueError: # Not under home directory, continue with normal validation pass # Validate path for non-.claude directories is_safe, msg = cls.validate_path(target_dir) if not is_safe: errors.append(f"Invalid target path: {msg}") # Check permissions has_perms, missing = cls.check_permissions(target_dir, {'read', 'write'}) if not has_perms: errors.append(f"Insufficient permissions: missing {missing}") # Check if it's a system directory system_dirs = [ Path('/etc'), Path('/bin'), Path('/sbin'), Path('/usr/bin'), Path('/usr/sbin'), Path('/var'), Path('/tmp'), Path('/dev'), Path('/proc'), Path('/sys') ] if os.name == 'nt': system_dirs.extend([ Path('C:\\Windows'), Path('C:\\Program Files'), Path('C:\\Program Files (x86)') ]) for sys_dir in system_dirs: try: if abs_target.is_relative_to(sys_dir): errors.append(f"Cannot install to system directory: {sys_dir}") break except (ValueError, AttributeError): # is_relative_to not available in older Python versions try: abs_target.relative_to(sys_dir) errors.append(f"Cannot install to system directory: {sys_dir}") break except ValueError: continue return len(errors) == 0, errors @classmethod def validate_component_files(cls, file_list: List[Tuple[Path, Path]], base_source_dir: Path, base_target_dir: Path) -> Tuple[bool, List[str]]: """ Validate list of files for component installation Args: file_list: List of (source, target) path tuples base_source_dir: Base source directory base_target_dir: Base target directory Returns: Tuple of (all_safe: bool, error_messages: List[str]) """ errors = [] for source, target in file_list: # Validate source path is_safe, msg = cls.validate_path(source, base_source_dir) if not is_safe: errors.append(f"Invalid source path {source}: {msg}") # Validate target path is_safe, msg = cls.validate_path(target, base_target_dir) if not is_safe: errors.append(f"Invalid target path {target}: {msg}") # Validate file extension is_allowed, msg = cls.validate_file_extension(source) if not is_allowed: errors.append(f"File {source}: {msg}") return len(errors) == 0, errors @classmethod def create_secure_temp_dir(cls, prefix: str = "superclaude_") -> Path: """ Create secure temporary directory Args: prefix: Prefix for temp directory name Returns: Path to secure temporary directory """ import tempfile # Create with secure permissions (0o700) temp_dir = Path(tempfile.mkdtemp(prefix=prefix)) temp_dir.chmod(0o700) return temp_dir @classmethod def secure_delete(cls, path: Path) -> bool: """ Securely delete file or directory Args: path: Path to delete Returns: True if successful, False otherwise """ try: if not path.exists(): return True if path.is_file(): # Overwrite file with random data before deletion try: import secrets file_size = path.stat().st_size with open(path, 'r+b') as f: # Overwrite with random data f.write(secrets.token_bytes(file_size)) f.flush() os.fsync(f.fileno()) except Exception: pass # If overwrite fails, still try to delete path.unlink() elif path.is_dir(): # Recursively delete directory contents import shutil shutil.rmtree(path) return True except Exception: return False