""" Secret management configuration for thrillwiki project. This module provides patterns for secure secret handling including: - Secret validation - Secret rotation support - Integration points for secret management services - Secure fallback to environment variables For production, consider integrating with: - AWS Secrets Manager - HashiCorp Vault - Google Secret Manager - Azure Key Vault Why python-decouple? - Already used across the project for consistency - Provides secure environment variable handling - Supports .env files and environment variables """ import logging import warnings from datetime import datetime, timedelta from typing import Optional from decouple import config, UndefinedValueError logger = logging.getLogger("security") # ============================================================================= # Secret Configuration # ============================================================================= # Enable secret rotation checking (set to True in production) SECRET_ROTATION_ENABLED = config( "SECRET_ROTATION_ENABLED", default=False, cast=bool ) # Secret version for tracking rotations SECRET_KEY_VERSION = config("SECRET_KEY_VERSION", default="1") # Secret expiry warning threshold (days before expiry to start warning) SECRET_EXPIRY_WARNING_DAYS = config( "SECRET_EXPIRY_WARNING_DAYS", default=30, cast=int ) # ============================================================================= # Required Secrets Registry # ============================================================================= # List of required secrets with validation rules REQUIRED_SECRETS = { "SECRET_KEY": { "min_length": 50, "description": "Django secret key for cryptographic signing", "rotation_period_days": 90, }, "DATABASE_URL": { "min_length": 10, "description": "Database connection URL", "contains_password": True, }, } # Optional secrets that should be validated if present OPTIONAL_SECRETS = { "SENTRY_DSN": { "min_length": 10, "description": "Sentry error tracking DSN", }, "CLOUDFLARE_IMAGES_API_TOKEN": { "min_length": 20, "description": "Cloudflare Images API token", }, "FORWARD_EMAIL_API_KEY": { "min_length": 10, "description": "ForwardEmail API key", }, "TURNSTILE_SECRET_KEY": { "min_length": 10, "description": "Cloudflare Turnstile secret key", }, } # ============================================================================= # Secret Validation Functions # ============================================================================= def validate_secret_strength(name: str, value: str, min_length: int = 10) -> bool: """ Validate that a secret meets minimum strength requirements. Args: name: Name of the secret (for logging) value: The secret value to validate min_length: Minimum required length Returns: bool: True if valid, False otherwise """ if not value: logger.error(f"Secret '{name}' is empty or not set") return False if len(value) < min_length: logger.error( f"Secret '{name}' is too short ({len(value)} chars, " f"minimum {min_length})" ) return False # Check for placeholder values placeholder_patterns = [ "your-secret-key", "change-me", "placeholder", "example", "xxx", "todo", ] value_lower = value.lower() for pattern in placeholder_patterns: if pattern in value_lower: logger.warning( f"Secret '{name}' appears to contain a placeholder value" ) return False return True def validate_secret_key(secret_key: str) -> bool: """ Validate Django SECRET_KEY meets security requirements. Requirements: - At least 50 characters - Contains mixed case letters - Contains numbers - Contains special characters Args: secret_key: The SECRET_KEY value Returns: bool: True if valid, False otherwise """ if len(secret_key) < 50: logger.error( f"SECRET_KEY is too short ({len(secret_key)} chars, minimum 50)" ) return False has_upper = any(c.isupper() for c in secret_key) has_lower = any(c.islower() for c in secret_key) has_digit = any(c.isdigit() for c in secret_key) has_special = any(not c.isalnum() for c in secret_key) if not all([has_upper, has_lower, has_digit, has_special]): logger.warning( "SECRET_KEY should contain uppercase, lowercase, digits, " "and special characters" ) # Don't fail, just warn - some generated keys may not have all return True def get_secret( name: str, default: Optional[str] = None, required: bool = True, min_length: int = 0, ) -> Optional[str]: """ Safely retrieve a secret with validation. Args: name: Environment variable name default: Default value if not set required: Whether the secret is required min_length: Minimum required length Returns: The secret value or None if not found and not required Raises: ValueError: If required secret is missing or invalid """ try: value = config(name, default=default) except UndefinedValueError: if required: raise ValueError(f"Required secret '{name}' is not set") return default if value and min_length > 0: if not validate_secret_strength(name, value, min_length): if required: raise ValueError(f"Secret '{name}' does not meet requirements") return default return value def validate_required_secrets(raise_on_error: bool = False) -> list[str]: """ Validate all required secrets are set and meet requirements. Args: raise_on_error: If True, raise ValueError on first error Returns: List of error messages (empty if all valid) """ errors = [] for name, rules in REQUIRED_SECRETS.items(): try: value = config(name) min_length = rules.get("min_length", 0) if not validate_secret_strength(name, value, min_length): msg = f"Secret '{name}' validation failed" errors.append(msg) if raise_on_error: raise ValueError(msg) except UndefinedValueError: msg = f"Required secret '{name}' is not set: {rules['description']}" errors.append(msg) if raise_on_error: raise ValueError(msg) return errors def check_secret_expiry() -> list[str]: """ Check if any secrets are approaching expiry. This is a placeholder for integration with secret management services that track secret expiry dates. Returns: List of warning messages for secrets approaching expiry """ warnings_list = [] # Placeholder: In production, integrate with your secret manager # to check actual expiry dates # Example check based on version if SECRET_ROTATION_ENABLED: try: version = int(SECRET_KEY_VERSION) # If version is very old, suggest rotation if version < 2: warnings_list.append( "SECRET_KEY version is old. Consider rotating secrets." ) except ValueError: pass return warnings_list # ============================================================================= # Secret Provider Integration Points # ============================================================================= class SecretProvider: """ Base class for secret provider integrations. Subclass this to integrate with secret management services: - AWS Secrets Manager - HashiCorp Vault - Google Secret Manager - Azure Key Vault """ def get_secret(self, name: str) -> Optional[str]: """Retrieve a secret by name.""" raise NotImplementedError def set_secret(self, name: str, value: str) -> bool: """Set a secret value.""" raise NotImplementedError def rotate_secret(self, name: str) -> str: """Rotate a secret and return the new value.""" raise NotImplementedError def list_secrets(self) -> list[str]: """List all available secrets.""" raise NotImplementedError class EnvironmentSecretProvider(SecretProvider): """ Default secret provider using environment variables. This is the fallback provider for development and simple deployments. """ def get_secret(self, name: str) -> Optional[str]: """Retrieve a secret from environment variables.""" try: return config(name) except UndefinedValueError: return None def set_secret(self, name: str, value: str) -> bool: """Environment variables are read-only at runtime.""" logger.warning( f"Cannot set secret '{name}' in environment provider. " "Update your .env file or environment variables." ) return False def rotate_secret(self, name: str) -> str: """Cannot rotate secrets in environment provider.""" raise NotImplementedError( "Secret rotation is not supported for environment variables. " "Use a proper secret management service in production." ) def list_secrets(self) -> list[str]: """List all known secret names.""" return list(REQUIRED_SECRETS.keys()) + list(OPTIONAL_SECRETS.keys()) # Default provider instance _secret_provider: SecretProvider = EnvironmentSecretProvider() def get_secret_provider() -> SecretProvider: """Get the current secret provider instance.""" return _secret_provider def set_secret_provider(provider: SecretProvider) -> None: """Set a custom secret provider.""" global _secret_provider _secret_provider = provider # ============================================================================= # Startup Validation # ============================================================================= def run_startup_validation() -> None: """ Run secret validation on application startup. This function should be called during Django initialization to catch configuration errors early. """ debug_mode = config("DEBUG", default=True, cast=bool) # Validate required secrets errors = validate_required_secrets(raise_on_error=not debug_mode) if errors: for error in errors: if debug_mode: warnings.warn(f"Secret validation warning: {error}") else: logger.error(f"Secret validation error: {error}") # Check for expiring secrets if SECRET_ROTATION_ENABLED: expiry_warnings = check_secret_expiry() for warning in expiry_warnings: logger.warning(f"Secret expiry: {warning}") # Validate SECRET_KEY specifically try: secret_key = config("SECRET_KEY") if not validate_secret_key(secret_key): if not debug_mode: raise ValueError("SECRET_KEY does not meet security requirements") except UndefinedValueError: if not debug_mode: raise ValueError("SECRET_KEY is required in production")