fix(security): complete security and delivery compliance remediation
This commit is contained in:
@@ -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 内容
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"]},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user