mirror of
https://github.com/SuperClaude-Org/SuperClaude_Framework.git
synced 2025-12-17 17:56:46 +00:00
462 lines
16 KiB
Python
462 lines
16 KiB
Python
"""
|
|
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 |