Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/tools/security_audit/security_logic.py

453 lines
14 KiB
Python

"""Security Module.
Path sanitization and security validation using standard library only.
Key Features:
- Path sanitization (prevent directory traversal)
- Path validation and normalization
- Safe filename generation
- Permission checking
- Symlink detection
- Standard library only (no external dependencies)
Dependencies:
- pathlib (standard library)
- os (standard library)
"""
import os
import re
from pathlib import Path
from typing import Optional, List
class SecurityError(Exception):
"""Security validation error"""
class Security:
"""Handle security operations.
Provides path sanitization, validation, and security checks
to prevent common vulnerabilities like path traversal attacks.
"""
# Dangerous path components that should be rejected
DANGEROUS_PATTERNS = [
'..', # Parent directory traversal
'~', # Home directory expansion
'///', # Multiple slashes
'\x00', # Null byte
'\r', # Carriage return
'\n', # Line feed
]
# Characters not allowed in filenames (Windows + Unix)
INVALID_FILENAME_CHARS = r'[<>:"|?*\x00-\x1f]'
# Reserved Windows filenames
RESERVED_NAMES = {
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
}
@staticmethod
def sanitize_path(
path: str,
allow_absolute: bool = True,
allow_parent: bool = False
) -> str:
"""Sanitize a file path to prevent directory traversal attacks.
Args:
path: Path to sanitize
allow_absolute: Allow absolute paths
allow_parent: Allow parent directory references (..)
Returns:
Sanitized path string
Raises:
SecurityError: If path contains dangerous patterns
"""
try:
# Convert to string if Path object
if isinstance(path, Path):
path = str(path)
# Check for null bytes
if '\x00' in path:
raise SecurityError("Path contains null bytes")
# Check for dangerous patterns
if not allow_parent:
if '..' in path:
raise SecurityError("Path contains parent directory reference (..)")
# Normalize path separators
path = path.replace('\\', '/')
# Remove multiple consecutive slashes
while '//' in path:
path = path.replace('//', '/')
# Convert to Path for normalization
path_obj = Path(path)
# Check if absolute path is allowed
if path_obj.is_absolute() and not allow_absolute:
raise SecurityError("Absolute paths not allowed")
# Normalize the path
try:
normalized = path_obj.resolve()
except (OSError, RuntimeError):
# If resolve fails, use manual normalization
normalized = Path(os.path.normpath(str(path_obj)))
return str(normalized)
except Exception as e:
if isinstance(e, SecurityError):
raise
raise SecurityError(f"Path sanitization failed: {e}") from e
@staticmethod
def validate_path(
path: str,
must_exist: bool = False,
must_be_file: bool = False,
must_be_dir: bool = False,
allowed_parent: Optional[Path] = None
) -> bool:
"""Validate a file path for security and existence.
Args:
path: Path to validate
must_exist: If True, path must exist
must_be_file: If True, path must be a file
must_be_dir: If True, path must be a directory
allowed_parent: If set, path must be within this directory
Returns:
True if path is valid
Raises:
SecurityError: If path is invalid or insecure
"""
try:
# Convert to Path object
if isinstance(path, str):
path_obj = Path(path)
else:
path_obj = path
# Resolve to absolute path
try:
resolved = path_obj.resolve()
except (OSError, RuntimeError) as e:
raise SecurityError(f"Cannot resolve path: {e}") from e
# Check if path must be within allowed parent
if allowed_parent is not None:
if isinstance(allowed_parent, str):
allowed_parent = Path(allowed_parent)
try:
allowed_resolved = allowed_parent.resolve()
# Check if path is relative to allowed parent
try:
resolved.relative_to(allowed_resolved)
except ValueError:
raise SecurityError(
f"Path {resolved} is outside allowed directory {allowed_resolved}"
)
except (OSError, RuntimeError) as e:
raise SecurityError(f"Cannot resolve allowed parent: {e}") from e
# Check existence
if must_exist and not resolved.exists():
raise SecurityError(f"Path does not exist: {resolved}")
# Check if file
if must_be_file:
if not resolved.exists():
raise SecurityError(f"File does not exist: {resolved}")
if not resolved.is_file():
raise SecurityError(f"Path is not a file: {resolved}")
# Check if directory
if must_be_dir:
if not resolved.exists():
raise SecurityError(f"Directory does not exist: {resolved}")
if not resolved.is_dir():
raise SecurityError(f"Path is not a directory: {resolved}")
return True
except SecurityError:
raise
except Exception as e:
raise SecurityError(f"Path validation failed: {e}") from e
@staticmethod
def sanitize_filename(
filename: str,
replacement: str = '_',
max_length: int = 255
) -> str:
"""Sanitize a filename to be safe across platforms.
Args:
filename: Filename to sanitize
replacement: Character to replace invalid characters with
max_length: Maximum filename length
Returns:
Sanitized filename
Raises:
SecurityError: If filename cannot be sanitized
"""
try:
# Remove path separators
filename = os.path.basename(filename)
# Check for empty filename
if not filename or filename in ('.', '..'):
raise SecurityError("Invalid filename")
# Replace invalid characters
filename = re.sub(Security.INVALID_FILENAME_CHARS, replacement, filename)
# Remove leading/trailing spaces and dots
filename = filename.strip('. ')
# Check for reserved names (Windows)
name_without_ext = filename.split('.')[0].upper()
if name_without_ext in Security.RESERVED_NAMES:
filename = f"{replacement}{filename}"
# Truncate to max length
if len(filename) > max_length:
# Try to preserve extension
parts = filename.rsplit('.', 1)
if len(parts) == 2:
name, ext = parts
max_name_length = max_length - len(ext) - 1
filename = f"{name[:max_name_length]}.{ext}"
else:
filename = filename[:max_length]
# Final check
if not filename:
raise SecurityError("Filename became empty after sanitization")
return filename
except Exception as e:
if isinstance(e, SecurityError):
raise
raise SecurityError(f"Filename sanitization failed: {e}") from e
@staticmethod
def is_safe_path(path: str, base_directory: str) -> bool:
"""Check if a path is safe (within base directory).
Args:
path: Path to check
base_directory: Base directory that path must be within
Returns:
True if path is safe
"""
try:
# Convert to Path objects
path_obj = Path(path).resolve()
base_obj = Path(base_directory).resolve()
# Check if path is relative to base
try:
path_obj.relative_to(base_obj)
return True
except ValueError:
return False
except (OSError, RuntimeError):
return False
@staticmethod
def check_permissions(
path: str,
readable: bool = False,
writable: bool = False,
executable: bool = False
) -> bool:
"""Check file permissions.
Args:
path: Path to check
readable: Check if readable
writable: Check if writable
executable: Check if executable
Returns:
True if all requested permissions are available
Raises:
SecurityError: If path doesn't exist or permissions insufficient
"""
try:
# Convert to Path object
if isinstance(path, str):
path_obj = Path(path)
else:
path_obj = path
# Check existence
if not path_obj.exists():
raise SecurityError(f"Path does not exist: {path}")
# Check read permission
if readable and not os.access(str(path_obj), os.R_OK):
raise SecurityError(f"Path not readable: {path}")
# Check write permission
if writable and not os.access(str(path_obj), os.W_OK):
raise SecurityError(f"Path not writable: {path}")
# Check execute permission
if executable and not os.access(str(path_obj), os.X_OK):
raise SecurityError(f"Path not executable: {path}")
return True
except SecurityError:
raise
except Exception as e:
raise SecurityError(f"Permission check failed: {e}") from e
@staticmethod
def is_symlink(path: str) -> bool:
"""Check if path is a symbolic link.
Args:
path: Path to check
Returns:
True if path is a symbolic link
"""
try:
if isinstance(path, str):
path = Path(path)
return path.is_symlink()
except (OSError, RuntimeError):
return False
@staticmethod
def resolve_symlink(path: str, follow_symlinks: bool = True) -> str:
"""Resolve symbolic links.
Args:
path: Path to resolve
follow_symlinks: If False, don't follow symlinks
Returns:
Resolved path
Raises:
SecurityError: If path cannot be resolved
"""
try:
if isinstance(path, str):
path = Path(path)
if follow_symlinks:
resolved = path.resolve()
else:
resolved = path
return str(resolved)
except (OSError, RuntimeError) as e:
raise SecurityError(f"Cannot resolve symlink: {e}") from e
@staticmethod
def validate_extension(
filename: str,
allowed_extensions: List[str]
) -> bool:
"""Validate file extension against allowed list.
Args:
filename: Filename to check
allowed_extensions: List of allowed extensions (with or without dot)
Returns:
True if extension is allowed
Raises:
SecurityError: If extension is not allowed
"""
try:
# Get file extension
path = Path(filename)
extension = path.suffix.lower()
# Normalize allowed extensions (ensure they start with dot)
normalized_allowed = [
ext if ext.startswith('.') else f'.{ext}'
for ext in allowed_extensions
]
if extension not in normalized_allowed:
raise SecurityError(
f"File extension '{extension}' not in allowed list: {normalized_allowed}"
)
return True
except SecurityError:
raise
except Exception as e:
raise SecurityError(f"Extension validation failed: {e}") from e
@staticmethod
def generate_safe_filename(
base_name: str,
extension: str = '',
add_timestamp: bool = False
) -> str:
"""Generate a safe filename.
Args:
base_name: Base filename
extension: File extension (with or without dot)
add_timestamp: Add timestamp to ensure uniqueness
Returns:
Safe filename
"""
try:
# Sanitize base name
safe_base = Security.sanitize_filename(base_name)
# Normalize extension
if extension and not extension.startswith('.'):
extension = f'.{extension}'
# Add timestamp if requested
if add_timestamp:
from datetime import datetime
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
safe_base = f"{safe_base}_{timestamp}"
# Combine
filename = f"{safe_base}{extension}"
# Final sanitization
return Security.sanitize_filename(filename)
except Exception as e:
raise SecurityError(f"Safe filename generation failed: {e}") from e