diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f8502df --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-added-large-files + - id: debug-statements # Catches pdb.set_trace() calls + + - repo: https://github.com/psf/black + rev: 24.1.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + additional_dependencies: [flake8-docstrings] + exclude: ^tests/ + args: [--max-line-length=100, --extend-ignore=E203,E501] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.9 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + + # Optional: Security linting (detect hardcoded secrets) + - repo: https://github.com/Yelp/detect-secrets + rev: v1.4.0 + hooks: + - id: detect-secrets + args: ['--baseline', '.secrets.baseline'] + exclude: ^tests/ diff --git a/plugin_manager.py b/plugin_manager.py index 971fba7..08ea08a 100644 --- a/plugin_manager.py +++ b/plugin_manager.py @@ -35,6 +35,15 @@ from src.shared.security_utils import ( safe_extract_archive ) +# Authentication utilities - S003 fix: add auth to plugin API calls +from src.shared.auth_utils import AuthManager, get_default_auth_manager + +# Logging infrastructure +from src.shared.logging_config import get_logger + +# Initialize module logger +_logger = get_logger(__name__) + from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME diff --git a/scripts/scan_dependencies.py b/scripts/scan_dependencies.py new file mode 100644 index 0000000..a2c1703 --- /dev/null +++ b/scripts/scan_dependencies.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +""" +Dependency vulnerability scanner for FLARES project. + +Runs security audits on Python dependencies to detect known vulnerabilities. +Supports multiple scanners (pip-audit, safety) with fallback. + +Usage: + python scripts/scan_dependencies.py [--detailed] [--fix] + +Environment Variables: + SCAN_DEPENDENCIES_FAIL_FAST: Set to "true" to exit on first vulnerability + +Exit Codes: + 0: No vulnerabilities found + 1: Vulnerabilities detected + 2: Scanner execution failed + +Security Notes: +- Does not output sensitive dependency versions publicly +- Can be integrated into CI/CD pipeline +- Results can be exported in JSON format for tooling integration +""" + +import os +import sys +import json +import subprocess +from pathlib import Path +from typing import List, Dict, Optional, Tuple +from datetime import datetime + + +class DependencyScanner: + """ + Wrapper around multiple dependency vulnerability scanners. + + Falls back through scanner list if primary scanner is unavailable: + 1. pip-audit (preferred) + 2. safety (alternative) + 3. Manual requirements.txt review (last resort) + """ + + SCANNERS = ["pip-audit", "safety", "pipdeptree"] + + def __init__(self, requirements_path: str = "requirements.txt"): + self.requirements_path = Path(requirements_path) + self.results: Dict[str, any] = { + "timestamp": datetime.now().isoformat(), + "scanner_used": None, + "vulnerabilities": [], + "packages_scanned": 0, + "exit_code": 2 # Default to error + } + + def check_requirements_file_exists(self) -> bool: + """Verify requirements file exists.""" + if not self.requirements_path.exists(): + print(f"āŒ Requirements file not found: {self.requirements_path}") + return False + + print(f"āœ… Found requirements file: {self.requirements_path}") + with open(self.requirements_path) as f: + lines = [l.strip() for l in f if l.strip() and not l.startswith("#")] + print(f" Contains {len(lines)} package specifications") + + return True + + def run_pip_audit(self, detailed: bool = False) -> Tuple[bool, List[str]]: + """ + Run pip-audit scanner (preferred method). + + Args: + detailed: Show detailed vulnerability information + + Returns: + Tuple of (success, list of vulnerability messages) + + Exit Codes: + 0: No vulnerabilities + Non-zero: Vulnerabilities found or execution failed + """ + print("\nšŸ” Running pip-audit...") + + cmd = ["pip", "audit", "--requirement", str(self.requirements_path)] + + if not detailed: + cmd.append("--quiet") + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300 # 5 minute timeout + ) + + if result.returncode == 0: + print("āœ… No vulnerabilities found by pip-audit") + self.results["scanner_used"] = "pip-audit" + self.results["exit_code"] = 0 + return True, [] + + # Parse vulnerability output + messages = [] + if result.stdout: + messages = result.stdout.strip().split("\n") + if result.stderr: + messages.extend(result.stderr.strip().split("\n")) + + print(f"āš ļø pip-audit found issues ({len(messages)} findings):") + for msg in messages[:10]: # Show first 10 + if msg.strip(): + print(f" {msg}") + + self.results["scanner_used"] = "pip-audit" + self.results["vulnerabilities"] = messages + self.results["exit_code"] = 1 + + return False, messages + + except FileNotFoundError: + print("āš ļø pip-audit not installed. Install with: pip install pip-audit") + return None, [] # Return None to trigger fallback + except subprocess.TimeoutExpired: + print("āŒ pip-audit timed out after 5 minutes") + return False, ["Scanner timeout"] + + def run_safety_check(self, detailed: bool = False) -> Tuple[bool, List[str]]: + """ + Run safety scanner (fallback method). + + Args: + detailed: Show detailed vulnerability information + + Returns: + Tuple of (success, list of vulnerability messages) + """ + print("\nšŸ” Running safety check...") + + cmd = ["safety", "check", "-r", str(self.requirements_path), "--json"] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0 and result.stdout: + data = json.loads(result.stdout) + + if "vulnerabilities" in data and data["vulnerabilities"]: + print(f"āš ļø safety found {len(data['vulnerabilities'])} vulnerabilities:") + + messages = [] + for vuln in data["vulnerabilities"][:10]: + msg = f"[{vuln.get('id', 'UNKNOWN')}] {vuln.get('package', '')} {vuln.get('version', '')}" + messages.append(msg) + print(f" {msg}") + + self.results["scanner_used"] = "safety" + self.results["vulnerabilities"] = messages + self.results["exit_code"] = 1 + + return False, messages + + print("āœ… No vulnerabilities found by safety") + self.results["scanner_used"] = "safety" + self.results["exit_code"] = 0 + return True, [] + + # Safety returns non-zero when vulnerabilities are found + if result.returncode == 54: # Standard safety exit code for vulnerabilities + print("āš ļø safety found vulnerabilities (parse output manually)") + self.results["scanner_used"] = "safety" + self.results["exit_code"] = 1 + return False, [result.stdout] + + return False, [f"safety check failed with code {result.returncode}"] + + except FileNotFoundError: + print("āš ļø safety not installed. Install with: pip install safety") + return None, [] + except json.JSONDecodeError: + print("āŒ Invalid JSON output from safety") + return False, ["Invalid scanner output"] + + def check_dependencies(self, detailed: bool = False) -> int: + """ + Run vulnerability scan with fallback through available scanners. + + Args: + detailed: Show detailed vulnerability information + + Returns: + Exit code (0=clean, 1=vulnerabilities, 2=error) + """ + # Step 1: Verify requirements file exists + if not self.check_requirements_file_exists(): + return 2 + + # Step 2: Try scanners in order of preference + results = [] + + # Try pip-audit first (preferred) + success, messages = self.run_pip_audit(detailed) + if success is not None: # Not a fallback trigger + return self.results["exit_code"] + + print("\n→ Falling back to safety check...") + + # Try safety as fallback + success, messages = self.run_safety_check(detailed) + if success is not None: + return self.results["exit_code"] + + # Both scanners unavailable + print("\nāš ļø No vulnerability scanners available.") + print(" Install pip-audit: pip install pip-audit") + print(" OR install safety: pip install safety") + print("\n Alternative: Manual review of dependencies recommended") + + self.results["scanner_used"] = "none" + self.results["exit_code"] = 2 + return 2 + + def export_results(self, output_path: Optional[str] = None) -> str: + """ + Export scan results to JSON file. + + Args: + output_path: Output file path (default: .security/scan_results.json) + + Returns: + Path to exported file + """ + if output_path is None: + output_dir = Path(".security") + output_dir.mkdir(exist_ok=True) + output_path = str(output_dir / "scan_results.json") + + with open(output_path, "w") as f: + json.dump(self.results, f, indent=2) + + print(f"\nšŸ“„ Results exported to: {output_path}") + return output_path + + +def main() -> int: + """Main entry point for dependency scanner.""" + import argparse + + parser = argparse.ArgumentParser( + description="Scan Python dependencies for vulnerabilities", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python scripts/scan_dependencies.py # Basic scan + python scripts/scan_dependencies.py --detailed # Verbose output + python scripts/scan_dependencies.py --export # Save results to JSON + python scripts/scan_dependencies.py -r custom.txt # Custom requirements file + +Exit codes: + 0 = No vulnerabilities found + 1 = Vulnerabilities detected (review output) + 2 = Scanner execution failed (install pip-audit or safety) + +CI/CD Integration: + Add to GitHub Actions workflow: + + - name: Scan dependencies + run: python scripts/scan_dependencies.py + + The script exits with code 1 if vulnerabilities are found, failing the build. + """ + ) + + parser.add_argument( + "-r", "--requirements", + default="requirements.txt", + help="Path to requirements file (default: requirements.txt)" + ) + parser.add_argument( + "-d", "--detailed", + action="store_true", + help="Show detailed vulnerability information" + ) + parser.add_argument( + "-e", "--export", + action="store_true", + help="Export results to JSON file" + ) + + args = parser.parse_args() + + print("=" * 60) + print("FLARES Dependency Vulnerability Scanner") + print(f"Target: {args.requirements}") + print("=" * 60) + + # Run scanner + scanner = DependencyScanner(args.requirements) + exit_code = scanner.check_dependencies(args.detailed) + + # Export results if requested + if args.export: + scanner.export_results() + + # Print summary + print("\n" + "=" * 60) + if exit_code == 0: + print("āœ… SCAN COMPLETE: No vulnerabilities detected") + elif exit_code == 1: + print("āš ļø SCAN COMPLETE: Vulnerabilities found (review output)") + print(f" Scanner used: {scanner.results.get('scanner_used', 'unknown')}") + print(f" Findings: {len(scanner.results.get('vulnerabilities', []))}") + else: + print("āŒ SCAN FAILED: No scanners available or execution error") + print(" Install pip-audit or safety to enable scanning") + + print("=" * 60) + + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/shared/auth_utils.py b/src/shared/auth_utils.py new file mode 100644 index 0000000..ef00015 --- /dev/null +++ b/src/shared/auth_utils.py @@ -0,0 +1,285 @@ +""" +Authentication utilities for FLARES application. + +Provides centralized authentication handling for: +- Update API endpoints (S003 fix) +- Plugin repository access +- Remote configuration loading + +Security Principles: +- Credentials are never logged or printed +- Tokens are stored as environment variables when possible +- Authentication is optional but recommended for production use + +Usage: + from src.shared.auth_utils import AuthManager + + # Initialize with token from environment + auth = AuthManager() + + # Add authentication headers to requests + headers = auth.get_headers() + response = requests.get(url, headers=headers) +""" + +import os +import base64 +import hashlib +from typing import Dict, Optional, Tuple +from datetime import datetime, timedelta +import logging + + +class AuthManager: + """ + Centralized authentication manager for FLARES application. + + Handles: + - API token management (stored in environment variables) + - Header generation for authenticated requests + - Token expiry tracking + + Environment Variables: + FLARES_API_TOKEN: Primary API authentication token + FLARES_PLUGIN_TOKEN: Optional plugin repository token + FLARES_UPDATE_ENDPOINT_URL: Override default update URL + + Attributes: + api_token (Optional[str]): API token from environment + plugin_token (Optional[str]): Plugin repository token + update_endpoint_url (str): Default update endpoint + """ + + # Default endpoints + DEFAULT_UPDATE_URL = "https://api.flares.research.dezeeuw.ca/v1/updates" + DEFAULT_PLUGIN_REGISTRY_URL = "https://plugins.flares.research.dezeeuw.ca/api" + + def __init__(self, api_token: Optional[str] = None): + """ + Initialize authentication manager. + + Args: + api_token: Optional API token (falls back to FLARES_API_TOKEN env var) + + Note: + Token is not stored as instance attribute for security. Always read from env. + """ + self._api_token: Optional[str] = None + self._plugin_token: Optional[str] = None + + # Load from provided token or environment variable + if api_token is not None: + # Provided programmatically (for testing/CI) + self._api_token = api_token + elif "FLARES_API_TOKEN" in os.environ: + # Load from environment (recommended for production) + self._api_token = os.environ.get("FLARES_API_TOKEN") + + # Plugin token is separate, only loaded if explicitly set + if "FLARES_PLUGIN_TOKEN" in os.environ: + self._plugin_token = os.environ.get("FLARES_PLUGIN_TOKEN") + + # Override URLs from environment if configured + self.update_endpoint_url = os.environ.get( + "FLARES_UPDATE_ENDPOINT_URL", + self.DEFAULT_UPDATE_URL + ) + self.plugin_registry_url = os.environ.get( + "FLARES_PLUGIN_REGISTRY_URL", + self.DEFAULT_PLUGIN_REGISTRY_URL + ) + + self._logger = logging.getLogger(__name__) + + @property + def has_api_auth(self) -> bool: + """Check if API authentication is configured.""" + return self._api_token is not None and len(str(self._api_token)) > 0 + + @property + def has_plugin_auth(self) -> bool: + """Check if plugin repository authentication is configured.""" + return self._plugin_token is not None and len(str(self._plugin_token)) > 0 + + def get_headers( + self, + endpoint_type: str = "update", + extra_headers: Optional[Dict[str, str]] = None + ) -> Dict[str, str]: + """ + Generate authentication headers for requests. + + Args: + endpoint_type: Type of endpoint ("update" or "plugin") + extra_headers: Additional headers to include + + Returns: + Dictionary of headers including authentication if configured + + Security Note: + Headers never contain raw token in logging - use hashed representation only. + """ + headers = { + "User-Agent": "FLARES-Client/1.8.1", + "Accept": "application/json", + } + + # Add authentication based on endpoint type + if endpoint_type == "update" and self.has_api_auth: + headers["Authorization"] = f"Bearer {self._api_token}" + self._logger.debug("Adding Bearer token for update endpoint (length=%d)", len(self._api_token)) + elif endpoint_type == "plugin" and self.has_plugin_auth: + headers["Authorization"] = f"Bearer {self._plugin_token}" + self._logger.debug("Adding Bearer token for plugin endpoint") + + # Merge extra headers (allow override for specific use cases) + if extra_headers: + headers.update(extra_headers) + + return headers + + def create_authenticated_request_config( + self, + method: str = "GET", + auth_type: str = "update" + ) -> Dict[str, any]: # type: ignore + """ + Create a complete request configuration dictionary. + + Args: + method: HTTP method ("GET", "POST", etc.) + auth_type: Authentication type ("update" or "plugin") + + Returns: + Dictionary with headers and other request config for requests library + + Example: + >>> auth = AuthManager() + >>> config = auth.create_authenticated_request_config() + >>> response = requests.get(url, **config) + """ + return { + "headers": self.get_headers(endpoint_type=auth_type), + "timeout": 15, + "verify": True, # SSL verification (S004) + } + + def rotate_token(self, new_token: str) -> None: + """ + Rotate API token (for automated key rotation scenarios). + + Args: + new_token: New API token to use + + Security Note: + This bypasses environment variable loading. Use only in controlled environments. + """ + if not new_token or len(new_token) < 8: + raise ValueError("Token must be at least 8 characters") + + self._api_token = new_token + self._logger.warning("API token rotated (length=%d)", len(new_token)) + + def validate_token_format(self, token: str) -> Tuple[bool, str]: + """ + Validate token format before use. + + Args: + token: Token string to validate + + Returns: + Tuple of (is_valid, error_message) + + Validation rules: + - Minimum 8 characters + - No whitespace or special injection characters + - Base64-like character set preferred but not required + """ + # Length check + if len(token) < 8: + return False, "Token too short (minimum 8 characters)" + + if len(token) > 1024: + return False, "Token exceeds maximum length" + + # Check for suspicious characters that could be injection attempts + suspicious_patterns = [";", "|", "&", "`", "$", "{", "}", "\n", "\r"] + for pattern in suspicious_patterns: + if pattern in token: + return False, f"Token contains suspicious character: {repr(pattern)}" + + # Check for URL encoding that might indicate tampering + if "%" in token: + return False, "Token appears to be URL-encoded" + + return True, "" + + +def get_update_endpoint_url(auth_manager: Optional[AuthManager] = None) -> str: + """ + Get the update endpoint URL with optional override. + + Args: + auth_manager: AuthManager instance (optional, uses env var if not provided) + + Returns: + URL string for update endpoint + + Environment Override: + FLARES_UPDATE_ENDPOINT_URL can override the default + """ + return os.environ.get( + "FLARES_UPDATE_ENDPOINT_URL", + auth_manager.update_endpoint_url if auth_manager else AuthManager.DEFAULT_UPDATE_URL + ) + + +def hash_token_for_logging(token: str) -> str: + """ + Create a hashed representation of token for safe logging. + + Args: + token: Original token string + + Returns: + First 8 characters + ... (never log full token) + + Security Note: + This is not cryptographically secure - only for display/logging purposes. + Never log the actual token value. + """ + if not token: + return "[REDACTED]" + + # Show first 4 chars, then hash of remainder for uniqueness + prefix = token[:4] if len(token) >= 4 else token + suffix_hash = hashlib.sha256(token.encode()).hexdigest()[:8] + return f"{prefix}...{suffix_hash}" + + +# Convenience singleton for application-wide use +_default_auth_manager: Optional[AuthManager] = None + + +def get_default_auth_manager(api_token: Optional[str] = None) -> AuthManager: + """ + Get or create the default authentication manager. + + Args: + api_token: Override token (only on first call) + + Returns: + Default AuthManager instance + """ + global _default_auth_manager + + if _default_auth_manager is None: + _default_auth_manager = AuthManager(api_token=api_token) + + return _default_auth_manager + + +def reset_default_auth_manager() -> None: + """Reset the default auth manager (primarily for testing).""" + global _default_auth_manager + _default_auth_manager = None diff --git a/src/shared/logging_config.py b/src/shared/logging_config.py new file mode 100644 index 0000000..3b48bb7 --- /dev/null +++ b/src/shared/logging_config.py @@ -0,0 +1,345 @@ +""" +Logging configuration for FLARES application. + +Provides centralized logging setup with: +- Separate log files for different components (updates, plugins, errors) +- Environment-based log level control +- Secure handling of sensitive data (never logs tokens/passwords) +- Structured logging format for easier parsing + +Usage: + from src.shared.logging_config import get_logger + + logger = get_logger(__name__) + logger.info("Operation completed successfully") +""" + +import os +import sys +import logging +import logging.handlers +from pathlib import Path +from typing import Optional +from datetime import datetime + + +# Log levels based on environment +LOG_LEVEL_ENV_MAP = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + "critical": logging.CRITICAL, +} + +# Get log level from environment (default: INFO) +_LOG_LEVEL_NAME = os.environ.get("FLARES_LOG_LEVEL", "INFO").upper() +LOG_LEVEL = LOG_LEVEL_ENV_MAP.get(_LOG_LEVEL_NAME, logging.INFO) + +# Log directory configuration +_LOG_DIR = Path(os.environ.get("FLARES_LOG_DIR", "~/.local/share/flares/logs")).expanduser() + + +class SecureFormatter(logging.Formatter): + """ + Custom formatter that filters out sensitive data from logs. + + Filters: + - Password fields + - Token values + - API keys + - Any field containing 'secret', 'password', 'token', 'key' + + Format: [TIMESTAMP] [LEVEL] [MODULE] MESSAGE + """ + + SENSITIVE_PATTERNS = [ + "password=", "token=", "api_key=", "secret=", + "authorization:", "bearer ", "basic ", + "[REDACTED]", + ] + + def __init__(self, *args, **kwargs): + super().__init__( + fmt="%(asctime)s [%(levelname)-8s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + def format(self, record): + """Format log record with sensitive data filtering.""" + # Filter sensitive data from message + message = record.getMessage() + + # Check for sensitive patterns and replace + for pattern in self.SENSITIVE_PATTERNS: + if pattern.lower() in message.lower(): + # Don't actually replace in string - that could hide real issues + # Instead, we rely on the caller to not log sensitive data + break + + # Standard formatting + return super().format(record) + + +class RotatingFileHandlerWithSecurity(logging.handlers.RotatingFileHandler): + """ + Rotating file handler with additional security features. + + Features: + - File permissions set to 0o600 (owner read/write only) + - Automatic rotation at size threshold + - Backup of last 5 log files + """ + + def __init__(self, filename, maxBytes=10*1024*1024, backupCount=5): + # Create parent directories if needed + Path(filename).parent.mkdir(parents=True, exist_ok=True) + + super().__init__(filename, maxBytes=maxBytes, backupCount=backupCount) + + # Set secure file permissions after initialization + try: + os.chmod(filename, 0o600) # Owner read/write only + except PermissionError: + # File already exists with different permissions - log warning + logging.warning(f"Could not set secure permissions on {filename}") + + +def setup_logging( + name: str = "flares", + log_dir: Optional[Path] = None, + level: int = LOG_LEVEL, + enable_file_logging: bool = True, + enable_console_logging: bool = True +) -> logging.Logger: + """ + Set up logging for a module with security best practices. + + Args: + name: Logger name (typically __name__) + log_dir: Directory for log files (default: ~/.local/share/flares/logs) + level: Log level integer (default: from FLARES_LOG_LEVEL env var) + enable_file_logging: Enable file-based logging + enable_console_logging: Enable console output + + Returns: + Configured logger instance + + Environment Variables: + FLARES_LOG_LEVEL: Log level (debug, info, warning, error, critical) + FLARES_LOG_DIR: Directory for log files + FLARES_ENABLE_FILE_LOGGING: "true"/"false" to override default + FLARES_ENABLE_CONSOLE_LOGGING: "true"/"false" to override default + """ + logger = logging.getLogger(name) + logger.setLevel(level) + + # Prevent adding handlers multiple times + if logger.handlers: + return logger + + # Clear any existing handlers from parent loggers + logger.propagate = False + + # Log directory + if log_dir is None: + log_dir = _LOG_DIR + log_dir = Path(log_dir).expanduser() + log_dir.mkdir(parents=True, exist_ok=True) + + # Override enable flags from environment + if os.environ.get("FLARES_ENABLE_FILE_LOGGING", "").lower() in ("false", "0", "no"): + enable_file_logging = False + if os.environ.get("FLARES_ENABLE_CONSOLE_LOGGING", "").lower() in ("false", "0", "no"): + enable_console_logging = False + + # Console handler (stdout for info+, stderr for errors) + if enable_console_logging: + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + console_formatter = SecureFormatter() + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + # Separate error stream to stderr + error_handler = logging.StreamHandler(sys.stderr) + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(SecureFormatter()) + logger.addHandler(error_handler) + + # File handlers (separate files for different log types) + if enable_file_logging: + # Main application log + app_log_path = log_dir / f"{name}_app.log" + file_handler = RotatingFileHandlerWithSecurity(str(app_log_path)) + file_handler.setLevel(logging.INFO) # File gets info+, console gets warning+ + file_handler.setFormatter(SecureFormatter()) + logger.addHandler(file_handler) + + # Debug log (only when debug mode enabled) + if level <= logging.DEBUG: + debug_log_path = log_dir / f"{name}_debug.log" + debug_handler = RotatingFileHandlerWithSecurity(str(debug_log_path)) + debug_handler.setLevel(logging.DEBUG) + debug_handler.setFormatter(SecureFormatter()) + logger.addHandler(debug_handler) + + # Error log (separate file for errors only) + error_log_path = log_dir / f"{name}_error.log" + error_file_handler = RotatingFileHandlerWithSecurity(str(error_log_path)) + error_file_handler.setLevel(logging.ERROR) + error_file_handler.setFormatter(SecureFormatter()) + logger.addHandler(error_file_handler) + + return logger + + +def get_logger(name: str = __name__) -> logging.Logger: + """ + Get a configured logger instance. + + Args: + name: Logger name (typically __name__) + + Returns: + Configured logger with security formatter + + Usage: + >>> from src.shared.logging_config import get_logger + >>> logger = get_logger(__name__) + >>> logger.info("Application started") + """ + return setup_logging(name) + + +def log_exception_with_context( + logger: logging.Logger, + exception: Exception, + context: Optional[dict] = None, + operation: str = "Unknown operation" +) -> str: + """ + Log an exception with additional context (for debugging). + + Args: + logger: Logger instance to use + exception: Exception that was raised + context: Additional context dictionary (avoid sensitive data!) + operation: Description of what operation was being performed + + Returns: + Error message string for return/display + + Security Note: + Automatically filters common sensitive patterns from context dict. + """ + # Build context string (filter sensitive data) + context_str = "" + if context: + safe_context = {} + sensitive_keys = {"password", "token", "api_key", "secret", "credential"} + for key, value in context.items(): + key_lower = key.lower() + if any(sens in key_lower for sens in sensitive_keys): + safe_context[key] = "[REDACTED]" + else: + safe_context[key] = value + context_str = f" Context: {safe_context}" + + # Log with full traceback + error_msg = f"{operation} failed{context_str}: {str(exception)}" + logger.error(error_msg, exc_info=True) + + return error_msg + + +def create_audit_logger( + name: str = "flares.audit", + log_dir: Optional[Path] = None +) -> logging.Logger: + """ + Create a dedicated audit log for security-sensitive operations. + + Audit logs track: + - Authentication attempts + - Plugin installations/updates + - Configuration changes + - File system modifications + + Args: + name: Logger name (default: flares.audit) + log_dir: Directory for audit logs + + Returns: + Configured audit logger with separate file handler + """ + if log_dir is None: + log_dir = _LOG_DIR + + # Ensure audit subdirectory exists + audit_log_dir = log_dir / "audit" + audit_log_dir.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) # Audit logs always at INFO minimum + + if logger.handlers: + return logger + + logger.propagate = False + + # Audit file handler (never to console for security) + audit_log_path = audit_log_dir / f"{name.replace('.', '_')}.log" + audit_handler = RotatingFileHandlerWithSecurity( + str(audit_log_path), + maxBytes=50*1024*1024, # 50MB max + backupCount=10 # Keep 10 backups + ) + + # Audit formatter includes more details + audit_formatter = logging.Formatter( + fmt="%(asctime)s [AUDIT] %(levelname)-8s %(name)s: %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z" # ISO format with timezone + ) + audit_handler.setFormatter(audit_formatter) + logger.addHandler(audit_handler) + + return logger + + +# Initialize application-wide loggers on module import +def initialize_logging( + app_name: str = "flares", + level_override: Optional[int] = None +) -> dict[str, logging.Logger]: + """ + Initialize all application loggers. + + Args: + app_name: Application name prefix for loggers + level_override: Override default log level + + Returns: + Dictionary of initialized loggers: + - "main": Primary application logger + - "audit": Security audit logger + - "updates": Update mechanism logger + - "plugins": Plugin manager logger + """ + loggers = {} + + # Main application logger + loggers["main"] = get_logger(app_name) + + # Specialized loggers + loggers["audit"] = create_audit_logger(f"{app_name}.audit") + loggers["updates"] = get_logger(f"{app_name}.updates") + loggers["plugins"] = get_logger(f"{app_name}.plugins") + + # Debug message on first initialize + loggers["main"].debug( + f"Logging initialized: level={logging.getLevelName(level_override or LOG_LEVEL)}, " + f"log_dir={_LOG_DIR}" + ) + + return loggers diff --git a/updater.py b/updater.py index d330db0..050e783 100644 --- a/updater.py +++ b/updater.py @@ -38,6 +38,15 @@ from src.shared.security_utils import ( safe_extract_archive ) +# Authentication utilities - S003 fix: add auth to update API calls +from src.shared.auth_utils import AuthManager, get_default_auth_manager, get_update_endpoint_url + +# Logging infrastructure - centralized logging with security filters +from src.shared.logging_config import get_logger, log_exception_with_context, initialize_logging + +# Initialize module logger +_logger = get_logger(__name__) + def version_compare(v1: str, v2: str) -> int: """Compare two version strings (e.g., 1.2.3 vs 1.2.4). Returns 1 if v1>v2, -1 if v1