"""Fail-closed production security checks without exposing secret values.""" from __future__ import annotations from pathlib import PurePosixPath import re from typing import Any from urllib.parse import unquote, urlsplit from app.config import Settings, settings _PLACEHOLDER_MARKERS = ( "change-me", "change-this", "password", "dev-key", "example", ) _LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} _STORAGE_ID_RE = re.compile(r"^[A-Za-z0-9._-]{16,128}$") _UNSAFE_STORAGE_ROOTS = ( PurePosixPath("/app"), PurePosixPath("/tmp"), PurePosixPath("/var/tmp"), PurePosixPath("/var/lib/docker"), ) def _finding(code: str, severity: str, message: str) -> dict[str, str]: return {"code": code, "severity": severity, "message": message} def _looks_insecure_secret(value: str, *, minimum: int = 32) -> bool: normalized = value.strip().lower() return ( len(value.strip()) < minimum or any(marker in normalized for marker in _PLACEHOLDER_MARKERS) ) def _url_password(value: str) -> str: try: parsed = urlsplit(value) return unquote(parsed.password or "") except ValueError: return "" def _url_username(value: str) -> str: try: parsed = urlsplit(value) return unquote(parsed.username or "") except ValueError: return "" def _csv_values(value: str) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] def _safe_external_storage_path(value: str) -> bool: candidate = value.strip() if not candidate.startswith("/") or candidate == "/": return False try: path = PurePosixPath(candidate) except ValueError: return False if ".." in path.parts: return False return not any(path == root or root in path.parents for root in _UNSAFE_STORAGE_ROOTS) def security_readiness(config: Settings = settings) -> dict[str, Any]: """Return a sanitized security report suitable for an admin endpoint.""" production = config.app_environment.strip().lower() in { "production", "prod", "server", } findings: list[dict[str, str]] = [] if _looks_insecure_secret(config.auth_secret): findings.append( _finding("AUTH_SECRET", "critical", "JWT 签名密钥未替换或强度不足") ) if _looks_insecure_secret(config.interface_api_secret): findings.append( _finding( "INTERFACE_API_SECRET", "critical", "接口密钥散列密钥未独立配置或强度不足", ) ) if ( config.interface_api_secret.strip() and config.interface_api_secret == config.auth_secret ): findings.append( _finding( "SECRET_REUSE", "critical", "JWT 与接口密钥不能共用同一签名密钥", ) ) if _looks_insecure_secret(config.auth_default_password, minimum=12): findings.append( _finding( "DEFAULT_ADMIN_PASSWORD", "critical", "默认管理员密码未替换或少于 12 位", ) ) for code, url in ( ("POSTGRES_PASSWORD", config.database_url), ("MYSQL_PASSWORD", config.data_mysql_url), ): if _looks_insecure_secret(_url_password(url), minimum=16): findings.append( _finding(code, "critical", f"{code} 未替换或强度不足") ) if (config.mysql_host_bind.strip() or "127.0.0.1") not in _LOOPBACK_HOSTS: findings.append( _finding("MYSQL_PUBLIC_BIND", "critical", "MySQL 不能绑定公网地址") ) storage_backend = config.data_mysql_storage_backend.strip().lower() if production and storage_backend not in {"dedicated-block", "managed-mysql"}: findings.append( _finding( "MYSQL_STORAGE_BACKEND", "critical", "生产MySQL必须使用独立持久化块存储或托管MySQL", ) ) if storage_backend == "dedicated-block": if not _STORAGE_ID_RE.fullmatch(config.data_mysql_storage_id.strip()): findings.append( _finding( "MYSQL_STORAGE_ID", "critical" if production else "warning", "独立MySQL数据盘必须配置不可混淆的存储标识", ) ) if not _safe_external_storage_path(config.data_mysql_storage_mount): findings.append( _finding( "MYSQL_STORAGE_MOUNT", "critical" if production else "warning", "独立MySQL数据盘必须配置项目外的绝对挂载点", ) ) if not _safe_external_storage_path(config.data_mysql_data_dir): findings.append( _finding( "MYSQL_DATA_DIR", "critical" if production else "warning", "MySQL数据目录必须是项目外的绝对持久化路径", ) ) storage_mount = PurePosixPath( config.data_mysql_storage_mount.strip() or "/" ) data_dir = PurePosixPath(config.data_mysql_data_dir.strip() or "/") if ( storage_mount != PurePosixPath("/") and data_dir != PurePosixPath("/") and storage_mount not in data_dir.parents ): findings.append( _finding( "MYSQL_DATA_OUTSIDE_MOUNT", "critical" if production else "warning", "MySQL数据目录必须位于已声明的独立数据盘挂载点内", ) ) if config.data_mysql_direct_access_enabled and not config.data_mysql_ssh_tunnel_required: findings.append( _finding( "DBEAVER_WITHOUT_TUNNEL", "critical", "启用 DBeaver 管理接入时必须强制 SSH 隧道", ) ) if ( production and config.data_mysql_direct_access_enabled and not config.data_mysql_ssh_host.strip() ): findings.append( _finding( "DBEAVER_SSH_HOST", "critical", "启用DBeaver管理接入时必须配置服务器SSH域名或IP", ) ) if config.data_mysql_managed_access_enabled: if not config.data_mysql_direct_access_enabled: findings.append( _finding( "MANAGED_ACCESS_WITHOUT_DB_ACCESS", "critical" if production else "warning", "启用账号签发前必须启用DBeaver管理接入", ) ) if not config.data_mysql_ssh_authorized_keys_file.strip(): findings.append( _finding( "DBEAVER_AUTHORIZED_KEYS", "critical" if production else "warning", "账号签发必须配置专用SSH公钥文件", ) ) if not config.data_mysql_ssh_host_public_key_file.strip(): findings.append( _finding( "DBEAVER_HOST_KEY", "critical" if production else "warning", "账号签发必须配置可核验的SSH主机公钥文件", ) ) provisioner_user = config.data_mysql_provisioner_user.strip() if ( not provisioner_user or provisioner_user.lower() == "root" or provisioner_user == _url_username(config.data_mysql_url) ): findings.append( _finding( "DBEAVER_PROVISIONER_USER", "critical" if production else "warning", "账号签发必须使用独立的非root MySQL签发用户", ) ) if _looks_insecure_secret( config.data_mysql_provisioner_password, minimum=24, ): findings.append( _finding( "DBEAVER_PROVISIONER_PASSWORD", "critical" if production else "warning", "MySQL账号签发密码未替换或强度不足", ) ) origins = _csv_values(config.cors_allowed_origins) if not origins or "*" in origins: findings.append( _finding( "CORS_WILDCARD", "critical" if production else "warning", "生产环境必须明确配置允许访问后台的 HTTPS 来源", ) ) if production and any( not origin.startswith("https://") and not origin.startswith("http://localhost") and not origin.startswith("http://127.0.0.1") for origin in origins ): findings.append( _finding( "CORS_WITHOUT_HTTPS", "critical", "生产后台来源必须使用 HTTPS", ) ) trusted_hosts = _csv_values(config.trusted_hosts) if not trusted_hosts or "*" in trusted_hosts: findings.append( _finding( "TRUSTED_HOSTS_WILDCARD", "critical" if production else "warning", "生产环境必须明确配置可信域名", ) ) if config.auth_algorithm not in {"HS256", "HS384", "HS512"}: findings.append( _finding("JWT_ALGORITHM", "critical", "JWT 算法不在允许列表中") ) if not 5 <= config.auth_token_expire_minutes <= 120: findings.append( _finding( "JWT_LIFETIME", "critical" if production else "warning", "后台登录令牌有效期必须为 5–120 分钟", ) ) if any( _looks_insecure_secret(item, minimum=24) for item in _csv_values(config.ingest_api_keys) ): findings.append( _finding( "INGEST_API_KEYS", "critical" if production else "warning", "外部问答接口仍包含开发密钥或弱密钥", ) ) if not config.data_backup_enabled: findings.append( _finding( "BACKUP_DISABLED", "critical" if production else "warning", "生产环境必须启用自动加密备份", ) ) else: if not config.data_backup_encryption_required: findings.append( _finding( "BACKUP_ENCRYPTION_DISABLED", "critical" if production else "warning", "MySQL备份必须加密保存", ) ) if not 7 <= config.data_backup_retention_days <= 3650: findings.append( _finding( "BACKUP_RETENTION", "critical" if production else "warning", "MySQL备份保留天数必须在7–3650天之间", ) ) if not _safe_external_storage_path(config.data_backup_root): findings.append( _finding( "BACKUP_ROOT", "critical" if production else "warning", "MySQL备份目录必须是项目外的绝对路径", ) ) data_dir = PurePosixPath(config.data_mysql_data_dir.strip() or "/") backup_root = PurePosixPath(config.data_backup_root.strip() or "/") if ( data_dir != PurePosixPath("/") and backup_root != PurePosixPath("/") and ( data_dir == backup_root or data_dir in backup_root.parents or backup_root in data_dir.parents ) ): findings.append( _finding( "BACKUP_DATA_OVERLAP", "critical" if production else "warning", "MySQL运行数据与备份目录不能相互包含", ) ) if not config.data_mysql_audit_enabled: findings.append( _finding("DB_AUDIT_DISABLED", "warning", "DBeaver 数据库审计尚未启用") ) critical_count = sum(item["severity"] == "critical" for item in findings) warning_count = sum(item["severity"] == "warning" for item in findings) return { "environment": config.app_environment, "strict_mode": config.security_strict_mode, "status": "blocked" if critical_count else "ready", "critical_count": critical_count, "warning_count": warning_count, "findings": findings, } def enforce_security_baseline(config: Settings = settings) -> None: """Abort a strict deployment before it starts with unsafe settings.""" if not config.security_strict_mode: return report = security_readiness(config) critical = [item for item in report["findings"] if item["severity"] == "critical"] if critical: codes = ", ".join(item["code"] for item in critical) raise RuntimeError(f"生产安全基线检查失败:{codes}")