384 lines
13 KiB
Python
384 lines
13 KiB
Python
"""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}")
|