fix(security): complete security and delivery compliance remediation

This commit is contained in:
2026-07-28 17:56:28 +08:00
parent 5b07ec6df0
commit 81ab82e9ba
32 changed files with 798 additions and 94 deletions
+18 -3
View File
@@ -1,6 +1,7 @@
"""应用配置"""
import os
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings
# 项目根目录(config.py 位于 backend/app/core/parent.parent.parent 即 backend/
@@ -11,6 +12,7 @@ class Settings(BaseSettings):
APP_NAME: str = "H3C-ONU-MS"
DEBUG: bool = False
SECRET_KEY: str
CREDENTIAL_ENCRYPTION_KEY: str
DATABASE_URL: str
REDIS_URL: str
@@ -22,6 +24,7 @@ class Settings(BaseSettings):
CASDOOR_APP_NAME: str
CASDOOR_CERTIFICATE: str = "" # 支持文件路径或直接填 PEM 内容
CASDOOR_REDIRECT_URL: str = ""
CASDOOR_ISSUER: str = "" # 为空时使用 CASDOOR_ENDPOINT
SSH_TIMEOUT: int = 30
CHECK_INTERVAL: int = 1800
@@ -48,27 +51,39 @@ class Settings(BaseSettings):
WECHAT_PROXY_API_URL: str = ""
IMC_API_USERNAME: str = ""
IMC_API_PASSWORD: str = ""
IMC_API_VERIFY_SSL: bool = False
IMC_API_VERIFY_SSL: bool = True
IMC_CONNECT_TIMEOUT: float = 5.0
IMC_READ_TIMEOUT: float = 20.0
class Config:
env_file = str(PROJECT_ROOT / ".env")
@model_validator(mode="after")
def reject_insecure_imc_tls_in_production(self):
"""Prevent production deployments from silently disabling TLS verification."""
if self.IMC_API_URL and not self.DEBUG and not self.IMC_API_VERIFY_SSL:
raise ValueError("IMC_API_VERIFY_SSL must be true when DEBUG is false")
return self
@property
def casdoor_cert_content(self) -> str:
"""读取证书文件内容或直接返回证书字符串"""
cert = self.CASDOOR_CERTIFICATE
if not cert:
return ""
if "-----BEGIN" in cert:
return cert
cert_path = Path(cert)
if cert_path.is_absolute():
path = cert_path
else:
# 相对路径基于项目根目录解析
path = PROJECT_ROOT / cert
if path.is_file():
return path.read_text()
try:
if path.is_file():
return path.read_text()
except OSError:
pass
return cert # 直接是 PEM 内容
+51
View File
@@ -0,0 +1,51 @@
"""Encryption at rest for device credentials."""
from cryptography.fernet import Fernet, InvalidToken
from sqlalchemy.types import Text, TypeDecorator
from app.core.config import settings
CREDENTIAL_PREFIX = "enc:v1:"
def _fernet() -> Fernet:
key = settings.CREDENTIAL_ENCRYPTION_KEY.strip()
if not key:
raise RuntimeError("Credential encryption key is not configured")
try:
return Fernet(key.encode())
except (TypeError, ValueError) as error:
raise RuntimeError("Credential encryption key is invalid") from error
def encrypt_credential(value: str) -> str:
"""Encrypt a plaintext credential with the deployment-provided key."""
if value.startswith(CREDENTIAL_PREFIX):
return value
return CREDENTIAL_PREFIX + _fernet().encrypt(value.encode()).decode()
def decrypt_credential(value: str) -> str:
"""Decrypt an encrypted credential; retain legacy plaintext only for migration."""
if not value.startswith(CREDENTIAL_PREFIX):
return value
try:
return _fernet().decrypt(value[len(CREDENTIAL_PREFIX):].encode()).decode()
except InvalidToken as error:
raise RuntimeError("Credential decryption failed") from error
class EncryptedCredential(TypeDecorator):
"""SQLAlchemy column type that stores credentials encrypted and reads plaintext."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return encrypt_credential(value)
def process_result_value(self, value, dialect):
if value is None:
return None
return decrypt_credential(value)
+26
View File
@@ -0,0 +1,26 @@
"""Safe error responses for API trust boundaries."""
import logging
from uuid import uuid4
from fastapi import HTTPException
logger = logging.getLogger(__name__)
def internal_error(context: str, error: Exception) -> HTTPException:
"""Log only a non-sensitive error classification and return a safe response."""
error_id = uuid4().hex
logger.error(
"%s failed [error_id=%s, error_type=%s]",
context,
error_id,
type(error).__name__,
)
return HTTPException(
status_code=500,
detail={
"code": "INTERNAL_ERROR",
"message": "服务器内部错误,请联系管理员并提供错误编号",
"error_id": error_id,
},
)
+26
View File
@@ -0,0 +1,26 @@
"""Logging helpers that prevent sensitive values from reaching application logs."""
import logging
import re
_SENSITIVE_KEY = r"(?:password|passwd|secret|token|authorization|credential|client_secret|corpsecret|code)"
_KEY_VALUE_PATTERN = re.compile(
rf"(?i)([\"']?{_SENSITIVE_KEY}[\"']?\s*[:=]\s*)([\"']?)([^\s,;\]\}}\"']+)([\"']?)"
)
_BEARER_PATTERN = re.compile(r"(?i)(authorization\s*[:=]\s*bearer\s+)[^\s,;]+")
_QUERY_PATTERN = re.compile(rf"(?i)([?&]{_SENSITIVE_KEY}=)[^&\s]+")
def redact_log_message(message: str) -> str:
"""Mask common secret formats while keeping enough context for operations."""
masked = _BEARER_PATTERN.sub(r"\1***", message)
masked = _QUERY_PATTERN.sub(r"\1***", masked)
return _KEY_VALUE_PATTERN.sub(r"\1\2***\4", masked)
class SensitiveDataFilter(logging.Filter):
"""Redact sensitive values after interpolation and before formatter output."""
def filter(self, record: logging.LogRecord) -> bool:
record.msg = redact_log_message(record.getMessage())
record.args = ()
return True
+30 -3
View File
@@ -1,6 +1,8 @@
"""JWT 安全配置"""
from datetime import datetime, timedelta
from jose import JWTError, jwt
import jwt as pyjwt
from jose import JWTError, jwt as jose_jwt
from app.core.config import settings
ALGORITHM = "HS256"
@@ -11,12 +13,37 @@ def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return jose_jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
payload = jose_jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
def verify_casdoor_token(token: str) -> dict:
"""Verify a Casdoor-issued JWT before using any identity claims."""
certificate = settings.casdoor_cert_content.strip()
if not certificate:
raise ValueError("Casdoor certificate is not configured")
issuer = (settings.CASDOOR_ISSUER or settings.CASDOOR_ENDPOINT).rstrip("/")
if not issuer:
raise ValueError("Casdoor issuer is not configured")
header = pyjwt.get_unverified_header(token)
algorithm = header.get("alg")
if algorithm not in {"RS256", "RS384", "RS512"}:
raise ValueError("Unsupported Casdoor token algorithm")
return pyjwt.decode(
token,
certificate,
algorithms=[algorithm],
audience=settings.CASDOOR_CLIENT_ID,
issuer=issuer,
options={"require": ["exp", "iat", "sub"]},
)