Files
Cloud-Tour-to-Libo/app/security_baseline.py

384 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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}")