restructure of dirs, huge docs update

This commit is contained in:
2025-11-17 16:29:14 -06:00
parent 456e052389
commit cd840cb8ca
87 changed files with 2827 additions and 1094 deletions

View File

158
app/web/utils/pagination.py Normal file
View File

@@ -0,0 +1,158 @@
"""
Pagination utilities for SneakyScanner web application.
Provides helper functions for paginating SQLAlchemy queries.
"""
from typing import Any, Dict, List
from sqlalchemy.orm import Query
class PaginatedResult:
"""Container for paginated query results."""
def __init__(self, items: List[Any], total: int, page: int, per_page: int):
"""
Initialize paginated result.
Args:
items: List of items for current page
total: Total number of items across all pages
page: Current page number (1-indexed)
per_page: Number of items per page
"""
self.items = items
self.total = total
self.page = page
self.per_page = per_page
@property
def pages(self) -> int:
"""Calculate total number of pages."""
if self.per_page == 0:
return 0
return (self.total + self.per_page - 1) // self.per_page
@property
def has_prev(self) -> bool:
"""Check if there is a previous page."""
return self.page > 1
@property
def has_next(self) -> bool:
"""Check if there is a next page."""
return self.page < self.pages
@property
def prev_page(self) -> int:
"""Get previous page number."""
return self.page - 1 if self.has_prev else None
@property
def next_page(self) -> int:
"""Get next page number."""
return self.page + 1 if self.has_next else None
def to_dict(self) -> Dict[str, Any]:
"""
Convert to dictionary for API responses.
Returns:
Dictionary with pagination metadata and items
"""
return {
'items': self.items,
'total': self.total,
'page': self.page,
'per_page': self.per_page,
'pages': self.pages,
'has_prev': self.has_prev,
'has_next': self.has_next,
'prev_page': self.prev_page,
'next_page': self.next_page,
}
def paginate(query: Query, page: int = 1, per_page: int = 20,
max_per_page: int = 100) -> PaginatedResult:
"""
Paginate a SQLAlchemy query.
Args:
query: SQLAlchemy query to paginate
page: Page number (1-indexed, default: 1)
per_page: Items per page (default: 20)
max_per_page: Maximum items per page (default: 100)
Returns:
PaginatedResult with items and pagination metadata
Examples:
>>> from web.models import Scan
>>> query = db.query(Scan).order_by(Scan.timestamp.desc())
>>> result = paginate(query, page=1, per_page=20)
>>> scans = result.items
>>> total_pages = result.pages
"""
# Validate and sanitize parameters
page = max(1, page) # Page must be at least 1
per_page = max(1, min(per_page, max_per_page)) # Clamp per_page
# Get total count
total = query.count()
# Calculate offset
offset = (page - 1) * per_page
# Execute query with limit and offset
items = query.limit(per_page).offset(offset).all()
return PaginatedResult(
items=items,
total=total,
page=page,
per_page=per_page
)
def validate_page_params(page: Any, per_page: Any,
max_per_page: int = 100) -> tuple[int, int]:
"""
Validate and sanitize pagination parameters.
Args:
page: Page number (any type, will be converted to int)
per_page: Items per page (any type, will be converted to int)
max_per_page: Maximum items per page (default: 100)
Returns:
Tuple of (validated_page, validated_per_page)
Examples:
>>> validate_page_params('2', '50')
(2, 50)
>>> validate_page_params(-1, 200)
(1, 100)
>>> validate_page_params(None, None)
(1, 20)
"""
# Default values
default_page = 1
default_per_page = 20
# Convert to int, use default if invalid
try:
page = int(page) if page is not None else default_page
except (ValueError, TypeError):
page = default_page
try:
per_page = int(per_page) if per_page is not None else default_per_page
except (ValueError, TypeError):
per_page = default_per_page
# Validate ranges
page = max(1, page)
per_page = max(1, min(per_page, max_per_page))
return page, per_page

323
app/web/utils/settings.py Normal file
View File

@@ -0,0 +1,323 @@
"""
Settings management system for SneakyScanner.
Provides secure storage and retrieval of application settings with encryption
for sensitive values like passwords and API tokens.
"""
import json
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
import bcrypt
from cryptography.fernet import Fernet
from sqlalchemy.orm import Session
from web.models import Setting
class SettingsManager:
"""
Manages application settings with encryption support.
Handles CRUD operations for settings stored in the database, with automatic
encryption/decryption for sensitive values.
"""
# Keys that should be encrypted when stored
ENCRYPTED_KEYS = {
'smtp_password',
'api_token',
'encryption_key',
}
def __init__(self, db_session: Session, encryption_key: Optional[bytes] = None):
"""
Initialize the settings manager.
Args:
db_session: SQLAlchemy database session
encryption_key: Fernet encryption key (32 url-safe base64-encoded bytes)
If not provided, will generate or load from environment
"""
self.db = db_session
self._encryption_key = encryption_key or self._get_or_create_encryption_key()
self._cipher = Fernet(self._encryption_key)
def _get_or_create_encryption_key(self) -> bytes:
"""
Get encryption key from environment or generate new one.
Returns:
Fernet encryption key (32 url-safe base64-encoded bytes)
"""
# Try to get from environment variable
key_str = os.environ.get('SNEAKYSCANNER_ENCRYPTION_KEY')
if key_str:
return key_str.encode()
# Try to get from settings table (for persistence)
existing_key = self.get('encryption_key', decrypt=False)
if existing_key:
return existing_key.encode()
# Generate new key if none exists
new_key = Fernet.generate_key()
# Store it in settings (unencrypted, as it's the key itself)
self._store_raw('encryption_key', new_key.decode())
return new_key
def _store_raw(self, key: str, value: str) -> None:
"""Store a setting without encryption (internal use only)."""
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
setting.value = value
setting.updated_at = datetime.utcnow()
else:
setting = Setting(key=key, value=value)
self.db.add(setting)
self.db.commit()
def _should_encrypt(self, key: str) -> bool:
"""Check if a setting key should be encrypted."""
return key in self.ENCRYPTED_KEYS
def _encrypt(self, value: str) -> str:
"""Encrypt a string value."""
return self._cipher.encrypt(value.encode()).decode()
def _decrypt(self, encrypted_value: str) -> str:
"""Decrypt an encrypted value."""
return self._cipher.decrypt(encrypted_value.encode()).decode()
def get(self, key: str, default: Any = None, decrypt: bool = True) -> Any:
"""
Get a setting value by key.
Args:
key: Setting key to retrieve
default: Default value if key not found
decrypt: Whether to decrypt if value is encrypted
Returns:
Setting value (automatically decrypts if needed and decrypt=True)
"""
setting = self.db.query(Setting).filter_by(key=key).first()
if not setting:
return default
value = setting.value
if value is None:
return default
# Decrypt if needed
if decrypt and self._should_encrypt(key):
try:
value = self._decrypt(value)
except Exception:
# If decryption fails, return as-is (might be legacy unencrypted value)
pass
# Try to parse JSON for complex types
if value.startswith('[') or value.startswith('{'):
try:
return json.loads(value)
except json.JSONDecodeError:
pass
return value
def set(self, key: str, value: Any, encrypt: bool = None) -> None:
"""
Set a setting value.
Args:
key: Setting key
value: Setting value (will be JSON-encoded if dict/list)
encrypt: Force encryption on/off (None = auto-detect from ENCRYPTED_KEYS)
"""
# Convert complex types to JSON
if isinstance(value, (dict, list)):
value_str = json.dumps(value)
else:
value_str = str(value)
# Determine if we should encrypt
should_encrypt = encrypt if encrypt is not None else self._should_encrypt(key)
if should_encrypt:
value_str = self._encrypt(value_str)
# Store in database
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
setting.value = value_str
setting.updated_at = datetime.utcnow()
else:
setting = Setting(key=key, value=value_str)
self.db.add(setting)
self.db.commit()
def delete(self, key: str) -> bool:
"""
Delete a setting.
Args:
key: Setting key to delete
Returns:
True if deleted, False if key not found
"""
setting = self.db.query(Setting).filter_by(key=key).first()
if setting:
self.db.delete(setting)
self.db.commit()
return True
return False
def get_all(self, decrypt: bool = False, sanitize: bool = True) -> Dict[str, Any]:
"""
Get all settings as a dictionary.
Args:
decrypt: Whether to decrypt encrypted values
sanitize: If True, replaces encrypted values with '***' for security
Returns:
Dictionary of all settings
"""
settings = self.db.query(Setting).all()
result = {}
for setting in settings:
key = setting.key
value = setting.value
if value is None:
result[key] = None
continue
# Handle sanitization for sensitive keys
if sanitize and self._should_encrypt(key):
result[key] = '***ENCRYPTED***'
continue
# Decrypt if requested
if decrypt and self._should_encrypt(key):
try:
value = self._decrypt(value)
except Exception:
pass
# Try to parse JSON
if value and (value.startswith('[') or value.startswith('{')):
try:
value = json.loads(value)
except json.JSONDecodeError:
pass
result[key] = value
return result
def init_defaults(self) -> None:
"""
Initialize default settings if they don't exist.
This should be called on first app startup to populate default values.
"""
defaults = {
# SMTP settings
'smtp_server': 'localhost',
'smtp_port': 587,
'smtp_username': '',
'smtp_password': '',
'smtp_from_email': 'noreply@sneakyscanner.local',
'smtp_to_emails': [],
# Authentication
'app_password': '', # Will need to be set by user
# Retention policy
'retention_days': 0, # 0 = keep forever
# Alert settings
'cert_expiry_threshold': 30, # Days before expiry to alert
'email_alerts_enabled': False,
}
for key, value in defaults.items():
# Only set if doesn't exist
if self.db.query(Setting).filter_by(key=key).first() is None:
self.set(key, value)
class PasswordManager:
"""
Manages password hashing and verification using bcrypt.
Used for the single-user authentication system.
"""
@staticmethod
def hash_password(password: str) -> str:
"""
Hash a password using bcrypt.
Args:
password: Plain text password
Returns:
Bcrypt hash string
"""
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
@staticmethod
def verify_password(password: str, hashed: str) -> bool:
"""
Verify a password against a bcrypt hash.
Args:
password: Plain text password to verify
hashed: Bcrypt hash to check against
Returns:
True if password matches, False otherwise
"""
try:
return bcrypt.checkpw(password.encode(), hashed.encode())
except Exception:
return False
@staticmethod
def set_app_password(settings_manager: SettingsManager, password: str) -> None:
"""
Set the application password (stored as bcrypt hash).
Args:
settings_manager: SettingsManager instance
password: New password to set
"""
hashed = PasswordManager.hash_password(password)
# Password hash stored as regular setting (not encrypted, as it's already a hash)
settings_manager.set('app_password', hashed, encrypt=False)
@staticmethod
def verify_app_password(settings_manager: SettingsManager, password: str) -> bool:
"""
Verify the application password.
Args:
settings_manager: SettingsManager instance
password: Password to verify
Returns:
True if password matches, False otherwise
"""
stored_hash = settings_manager.get('app_password', decrypt=False)
if not stored_hash:
# No password set - should prompt user to create one
return False
return PasswordManager.verify_password(password, stored_hash)

290
app/web/utils/validators.py Normal file
View File

@@ -0,0 +1,290 @@
"""
Input validation utilities for SneakyScanner web application.
Provides validation functions for API inputs, file paths, and data integrity.
"""
import os
from pathlib import Path
from typing import Optional
import yaml
def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
"""
Validate that a configuration file exists and is valid YAML.
Args:
file_path: Path to configuration file (absolute or relative filename)
Returns:
Tuple of (is_valid, error_message)
If valid, returns (True, None)
If invalid, returns (False, error_message)
Examples:
>>> validate_config_file('/app/configs/example.yaml')
(True, None)
>>> validate_config_file('example.yaml')
(True, None)
>>> validate_config_file('/nonexistent.yaml')
(False, 'File does not exist: /nonexistent.yaml')
"""
# Check if path is provided
if not file_path:
return False, 'Config file path is required'
# If file_path is just a filename (not absolute), prepend configs directory
if not file_path.startswith('/'):
file_path = f'/app/configs/{file_path}'
# Convert to Path object
path = Path(file_path)
# Check if file exists
if not path.exists():
return False, f'File does not exist: {file_path}'
# Check if it's a file (not directory)
if not path.is_file():
return False, f'Path is not a file: {file_path}'
# Check file extension
if path.suffix.lower() not in ['.yaml', '.yml']:
return False, f'File must be YAML (.yaml or .yml): {file_path}'
# Try to parse as YAML
try:
with open(path, 'r') as f:
config = yaml.safe_load(f)
# Check if it's a dictionary (basic structure validation)
if not isinstance(config, dict):
return False, 'Config file must contain a YAML dictionary'
# Check for required top-level keys
if 'title' not in config:
return False, 'Config file missing required "title" field'
if 'sites' not in config:
return False, 'Config file missing required "sites" field'
# Validate sites structure
if not isinstance(config['sites'], list):
return False, '"sites" must be a list'
if len(config['sites']) == 0:
return False, '"sites" list cannot be empty'
except yaml.YAMLError as e:
return False, f'Invalid YAML syntax: {str(e)}'
except Exception as e:
return False, f'Error reading config file: {str(e)}'
return True, None
def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
"""
Validate scan status value.
Args:
status: Status string to validate
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_scan_status('running')
(True, None)
>>> validate_scan_status('invalid')
(False, 'Invalid status: invalid. Must be one of: running, completed, failed')
"""
valid_statuses = ['running', 'completed', 'failed']
if status not in valid_statuses:
return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}'
return True, None
def validate_triggered_by(triggered_by: str) -> tuple[bool, Optional[str]]:
"""
Validate triggered_by value.
Args:
triggered_by: Source that triggered the scan
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_triggered_by('manual')
(True, None)
>>> validate_triggered_by('api')
(True, None)
"""
valid_sources = ['manual', 'scheduled', 'api']
if triggered_by not in valid_sources:
return False, f'Invalid triggered_by: {triggered_by}. Must be one of: {", ".join(valid_sources)}'
return True, None
def validate_scan_id(scan_id: any) -> tuple[bool, Optional[str]]:
"""
Validate scan ID is a positive integer.
Args:
scan_id: Scan ID to validate
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_scan_id(42)
(True, None)
>>> validate_scan_id('42')
(True, None)
>>> validate_scan_id(-1)
(False, 'Scan ID must be a positive integer')
"""
try:
scan_id_int = int(scan_id)
if scan_id_int <= 0:
return False, 'Scan ID must be a positive integer'
except (ValueError, TypeError):
return False, f'Invalid scan ID: {scan_id}'
return True, None
def validate_file_path(file_path: str, must_exist: bool = True) -> tuple[bool, Optional[str]]:
"""
Validate a file path.
Args:
file_path: Path to validate
must_exist: If True, file must exist. If False, only validate format.
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_file_path('/app/output/scan.json', must_exist=False)
(True, None)
>>> validate_file_path('', must_exist=False)
(False, 'File path is required')
"""
if not file_path:
return False, 'File path is required'
# Check for path traversal attempts
if '..' in file_path:
return False, 'Path traversal not allowed'
if must_exist:
path = Path(file_path)
if not path.exists():
return False, f'File does not exist: {file_path}'
if not path.is_file():
return False, f'Path is not a file: {file_path}'
return True, None
def sanitize_filename(filename: str) -> str:
"""
Sanitize a filename by removing/replacing unsafe characters.
Args:
filename: Original filename
Returns:
Sanitized filename safe for filesystem
Examples:
>>> sanitize_filename('my scan.json')
'my_scan.json'
>>> sanitize_filename('../../etc/passwd')
'etc_passwd'
"""
# Remove path components
filename = os.path.basename(filename)
# Replace unsafe characters with underscore
unsafe_chars = ['/', '\\', '..', ' ', ':', '*', '?', '"', '<', '>', '|']
for char in unsafe_chars:
filename = filename.replace(char, '_')
# Remove leading/trailing underscores and dots
filename = filename.strip('_.')
# Ensure filename is not empty
if not filename:
filename = 'unnamed'
return filename
def validate_port(port: any) -> tuple[bool, Optional[str]]:
"""
Validate port number.
Args:
port: Port number to validate
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_port(443)
(True, None)
>>> validate_port(70000)
(False, 'Port must be between 1 and 65535')
"""
try:
port_int = int(port)
if port_int < 1 or port_int > 65535:
return False, 'Port must be between 1 and 65535'
except (ValueError, TypeError):
return False, f'Invalid port: {port}'
return True, None
def validate_ip_address(ip: str) -> tuple[bool, Optional[str]]:
"""
Validate IPv4 address format (basic validation).
Args:
ip: IP address string
Returns:
Tuple of (is_valid, error_message)
Examples:
>>> validate_ip_address('192.168.1.1')
(True, None)
>>> validate_ip_address('256.1.1.1')
(False, 'Invalid IP address format')
"""
if not ip:
return False, 'IP address is required'
# Basic IPv4 validation
parts = ip.split('.')
if len(parts) != 4:
return False, 'Invalid IP address format'
try:
for part in parts:
num = int(part)
if num < 0 or num > 255:
return False, 'Invalid IP address format'
except (ValueError, TypeError):
return False, 'Invalid IP address format'
return True, None