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
+5 -1
View File
@@ -2,6 +2,8 @@
APP_NAME=H3C-ONU-MS
DEBUG=false
SECRET_KEY=your-secret-key-change-this
# 独立于 SECRET_KEY;使用 `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成
CREDENTIAL_ENCRYPTION_KEY=
# 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/h3c_onu_ms
@@ -17,6 +19,8 @@ CASDOOR_ORG_NAME=your_org
CASDOOR_APP_NAME=h3c-onu-ms
CASDOOR_CERTIFICATE=backend/token_jwt_key.pem
CASDOOR_REDIRECT_URL=
# 为空时使用 CASDOOR_ENDPOINTJWT 的 iss 必须与此值一致
CASDOOR_ISSUER=
# SSH配置
SSH_TIMEOUT=30
@@ -37,7 +41,7 @@ NTP_NEW_SERVER=172.16.1.252
IMC_API_URL=
IMC_API_USERNAME=
IMC_API_PASSWORD=
IMC_API_VERIFY_SSL=false
IMC_API_VERIFY_SSL=true
IMC_CONNECT_TIMEOUT=5
IMC_READ_TIMEOUT=20
+5 -6
View File
@@ -1,13 +1,12 @@
FROM python:3.11-slim
ARG PYTHON_BASE_IMAGE=python:3.11-slim
FROM ${PYTHON_BASE_IMAGE}
WORKDIR /app
# 使用清华镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && \
pip config set global.trusted-host https://pypi.tuna.tsinghua.edu.cn
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
ARG PIP_INDEX_URL
RUN if [ -n "$PIP_INDEX_URL" ]; then pip config set global.index-url "$PIP_INDEX_URL"; fi \
&& pip install --no-cache-dir -r requirements.txt
COPY . .
+42 -19
View File
@@ -1,33 +1,48 @@
"""认证 API"""
import base64
import json
from fastapi import APIRouter, Depends, HTTPException, Header
import hmac
import secrets
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from fastapi import APIRouter, Depends, HTTPException, Header, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.casdoor import casdoor_sdk
from app.core.security import create_access_token, verify_token
from app.core.errors import internal_error
from app.core.security import create_access_token, verify_casdoor_token, verify_token
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import Token, UserInfo
from datetime import datetime
router = APIRouter(prefix="/api/auth", tags=["认证"])
OAUTH_STATE_COOKIE = "h3c_oauth_state"
OAUTH_STATE_TTL_SECONDS = 300
def decode_jwt_payload(token: str) -> dict:
"""直接解码 JWT payload,不验签(Casdoor 已完成认证)"""
payload_b64 = token.split(".")[1]
rem = len(payload_b64) % 4
if rem:
payload_b64 += "=" * (4 - rem)
return json.loads(base64.urlsafe_b64decode(payload_b64))
def _with_oauth_state(url: str, state: str) -> str:
"""Replace the SDK-generated state with the browser-bound state value."""
parts = urlsplit(url)
query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key != "state"]
query.append(("state", state))
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
@router.get("/login")
def login():
def login(response: Response):
"""获取 Casdoor 登录 URL"""
return {"url": casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)}
state = secrets.token_urlsafe(32)
response.set_cookie(
key=OAUTH_STATE_COOKIE,
value=state,
max_age=OAUTH_STATE_TTL_SECONDS,
httponly=True,
secure=not settings.DEBUG,
samesite="lax",
path="/api/auth",
)
login_url = casdoor_sdk.get_auth_link(settings.CASDOOR_REDIRECT_URL)
return {"url": _with_oauth_state(login_url, state)}
class CallbackRequest(BaseModel):
@@ -36,18 +51,29 @@ class CallbackRequest(BaseModel):
@router.post("/callback", response_model=Token)
def callback(body: CallbackRequest, db: Session = Depends(get_db)):
def callback(
body: CallbackRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
"""Casdoor 登录回调"""
try:
expected_state = request.cookies.get(OAUTH_STATE_COOKIE)
response.delete_cookie(OAUTH_STATE_COOKIE, path="/api/auth")
if not expected_state or not hmac.compare_digest(body.state, expected_state):
raise HTTPException(status_code=400, detail="登录状态校验失败,请重新登录")
token_response = casdoor_sdk.get_oauth_token(code=body.code)
if isinstance(token_response, dict) and "error" in token_response:
raise HTTPException(status_code=400, detail=token_response.get("error_description", token_response["error"]))
access_token = token_response.get("access_token") if isinstance(token_response, dict) else token_response
identity_token = token_response.get("id_token") if isinstance(token_response, dict) else None
if not access_token:
raise HTTPException(status_code=400, detail="Casdoor 未返回 access_token")
casdoor_user = decode_jwt_payload(access_token)
casdoor_user = verify_casdoor_token(identity_token or access_token)
user = db.query(User).filter(User.casdoor_id == casdoor_user["sub"]).first()
if not user:
@@ -76,10 +102,7 @@ def callback(body: CallbackRequest, db: Session = Depends(get_db)):
except HTTPException:
raise
except Exception as e:
import traceback
import logging
logging.getLogger(__name__).error("callback error: %s\n%s", e, traceback.format_exc())
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Casdoor login callback", e)
@router.get("/permissions")
+4 -4
View File
@@ -11,6 +11,7 @@ from slowapi.util import get_remote_address
from app.tasks.check_tasks import check_all_devices
from app.core.celery_app import celery_app
from app.core.database import get_db
from app.core.errors import internal_error
from app.services.check_service import CheckService
from app.middleware.permission_middleware import require_permission
@@ -41,8 +42,7 @@ def trigger_check(request: Request, _: dict = Depends(require_permission('device
task = check_all_devices.delay()
return {"task_id": task.id, "status": "started"}
except Exception as e:
logger.error(f"触发状态检查失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"触发状态检查失败: {str(e)}")
raise internal_error("Trigger device status check", e)
@router.get("/status/{task_id}")
@@ -84,7 +84,7 @@ def scan_olt(
result = asyncio.run(service.scan_olt(olt_id))
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Scan OLT", e)
@router.post("/discover/{olt_id}")
@@ -99,4 +99,4 @@ def discover_olt(
result = service.scan_and_discover(olt_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Discover OLT devices", e)
+5 -4
View File
@@ -8,6 +8,7 @@ from sqlalchemy import asc, desc, distinct, or_
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.errors import internal_error
from app.middleware.permission_middleware import require_permission
from app.models.device import ONUDevice, DeviceStatusHistory, OLTDevice, DeviceReplacement
from app.schemas.device import DeviceListResponse, ONUDeviceResponse, RebootResponse, OpticalPowerResponse
@@ -439,7 +440,7 @@ def refresh_device_status(
result = service.check_single_device(device_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Refresh device status", e)
@@ -641,7 +642,7 @@ def reboot_device(
result = IMCService().reboot_onu(device.mac_address)
return RebootResponse(**result)
except Exception as e:
raise HTTPException(status_code=500, detail=f"重启失败: {str(e)}")
raise internal_error("Reboot ONU", e)
@router.get("/{device_id}/optical-power", response_model=OpticalPowerResponse)
@@ -694,7 +695,7 @@ def get_device_optical_power(
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取光功率失败: {str(e)}")
raise internal_error("Get optical power", e)
@router.get("/{device_id}/onu-events")
@@ -726,7 +727,7 @@ def get_onu_events(
"events": events,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}")
raise internal_error("Get ONU events", e)
@router.get("/{device_id}/optical-power-history")
+8 -6
View File
@@ -4,9 +4,11 @@ from sqlalchemy.orm import Session
from sqlalchemy import distinct
from pydantic import BaseModel
from app.core.database import get_db
from app.core.errors import internal_error
from app.core.config import settings
from app.middleware.permission_middleware import require_permission
from app.models.device import OLTDevice
from app.schemas.olt import serialize_olt
import pandas as pd
import io
@@ -64,7 +66,7 @@ def get_devices(
q = q.filter(OLTDevice.region.in_(areas))
else:
return []
return q.all()
return [serialize_olt(device) for device in q.all()]
@router.post("/devices")
@@ -148,8 +150,8 @@ async def import_devices(
df = pd.read_excel(io.BytesIO(content))
# 标准化列名
df.columns = [str(c).strip() for c in df.columns]
except Exception as e:
raise HTTPException(status_code=400, detail=f"文件解析失败: {str(e)}")
except Exception:
raise HTTPException(status_code=400, detail="文件解析失败,请确认文件格式")
required_cols = ['IP地址', '用户名', '密码']
missing = [c for c in required_cols if c not in df.columns]
@@ -267,7 +269,7 @@ def clear_onu_port(
with SSHService(olt.ip_address, olt.username, olt.password) as ssh:
ssh.clear_onu_port(body.port_id)
except Exception as e:
raise HTTPException(status_code=500, detail=f"清除失败: {str(e)}")
raise internal_error("Clear ONU port", e)
# 从 ports 列表移除已清除的端口
remaining = [p for p in record.ports if p["port_id"] != body.port_id]
@@ -571,7 +573,7 @@ def get_olt_ports(
ports = ssh.get_olt_ports()
return {"ports": ports}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Get OLT ports", e)
@router.post("/devices/{olt_id}/ports/toggle")
@@ -588,5 +590,5 @@ def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Se
ssh.toggle_olt_port(port_name, body.action)
return {"message": f"端口 {port_name}{'关闭' if body.action == 'shutdown' else '开启'}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise internal_error("Toggle OLT port", e)
+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"]},
)
+2
View File
@@ -9,12 +9,14 @@ from slowapi.errors import RateLimitExceeded
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from app.core.config import settings
from app.core.logging_utils import SensitiveDataFilter
from app.api.v1 import auth, devices, check, import_data, stats, olt, provision, users, roles, inventory, settings as settings_api, audit, wechat, ws, monitor
from app.middleware.audit_middleware import AuditMiddleware
# 结构化 JSON 日志
_handler = logging.StreamHandler()
_handler.setFormatter(jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s'))
_handler.addFilter(SensitiveDataFilter())
logging.getLogger().handlers = [_handler]
logging.getLogger().setLevel(logging.INFO)
logging.getLogger('uvicorn.access').handlers = [_handler]
+16 -5
View File
@@ -26,6 +26,21 @@ _LOG_GET_PATHS = {
"/api/auth/profile",
}
_SENSITIVE_PARAM_MARKERS = ("password", "passwd", "secret", "token", "authorization", "credential", "code")
def sanitize_audit_params(value):
"""Recursively redact sensitive request parameters before asynchronous logging."""
if isinstance(value, dict):
return {
key: "***" if any(marker in key.lower() for marker in _SENSITIVE_PARAM_MARKERS)
else sanitize_audit_params(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_audit_params(item) for item in value]
return value
def _should_log(method: str, path: str) -> bool:
for skip in _SKIP_PATHS:
@@ -62,11 +77,7 @@ class AuditMiddleware(BaseHTTPMiddleware):
if body_bytes:
try:
request_params = json.loads(body_bytes)
# 脱敏:移除密码字段
if isinstance(request_params, dict):
for k in ("password", "passwd", "secret"):
if k in request_params:
request_params[k] = "***"
request_params = sanitize_audit_params(request_params)
except Exception:
pass
except Exception:
@@ -67,7 +67,7 @@ def require_permission(permission: str):
token = authorization[7:]
payload = verify_token(token)
if not payload:
logger.warning("auth rejected: token 验证失败 (permission=%s): token前20字符=%.20s...", permission, token[:20])
logger.warning("auth rejected: token 验证失败 (permission=%s)", permission)
raise HTTPException(status_code=401, detail="令牌无效或已过期")
role = payload.get('role', 'user')
+2 -1
View File
@@ -3,6 +3,7 @@ from sqlalchemy import Column, BigInteger, String, Integer, Float, Text, TIMESTA
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
from app.core.credentials import EncryptedCredential
class OLTDevice(Base):
@@ -11,7 +12,7 @@ class OLTDevice(Base):
id = Column(BigInteger, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False)
username = Column(String(100), nullable=False)
password = Column(Text, nullable=False)
password = Column(EncryptedCredential(), nullable=False)
slot_command = Column(String(50), nullable=False)
region = Column(String(100), index=True)
location = Column(String(200))
+17
View File
@@ -0,0 +1,17 @@
"""OLT response serialization."""
from app.models.device import OLTDevice
def serialize_olt(device: OLTDevice) -> dict:
"""Return an OLT record without its device credential."""
return {
"id": device.id,
"ip_address": device.ip_address,
"username": device.username,
"slot_command": device.slot_command,
"region": device.region,
"location": device.location,
"description": device.description,
"created_at": device.created_at,
"updated_at": device.updated_at,
}
+1 -2
View File
@@ -1,5 +1,4 @@
"""告警相关 Celery 任务"""
import traceback
from datetime import datetime
from sqlalchemy import func, case
from app.core.celery_app import celery_app
@@ -77,6 +76,6 @@ def check_school_offline_alerts():
send_wechat_markdown(content)
return {"alerted": True, "schools": len(rows)}
except Exception as e:
return {"alerted": False, "error": str(e), "traceback": traceback.format_exc()}
return {"alerted": False, "error": "告警任务失败", "error_type": type(e).__name__}
finally:
db.close()
+1 -2
View File
@@ -1,5 +1,4 @@
"""审计日志 Celery 任务"""
import traceback
from app.core.celery_app import celery_app
from app.core.database import SessionLocal
@@ -57,6 +56,6 @@ def cleanup_audit_logs_task():
deleted = cleanup_old_logs(db)
return {'success': True, 'deleted': deleted}
except Exception as e:
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
return {'success': False, 'error': '审计日志任务失败', 'error_type': type(e).__name__}
finally:
db.close()
+7 -6
View File
@@ -1,6 +1,5 @@
"""状态检查任务"""
import time
import traceback
import redis as redis_lib
from datetime import datetime, timedelta
from sqlalchemy import func, case
@@ -86,13 +85,15 @@ def check_all_devices(self):
errors.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'error': str(e)
'error': 'OLT 状态检查失败',
'error_type': type(e).__name__,
})
results.append({
'olt_id': olt.id,
'olt_name': olt.location or olt.ip_address,
'success': False,
'error': str(e)
'error': 'OLT 状态检查失败',
'error_type': type(e).__name__,
})
self.update_state(state='PROGRESS', meta={'current': total, 'total': total, 'status': '检查完成'})
@@ -118,8 +119,8 @@ def check_all_devices(self):
except Exception as e:
return {
'success': False,
'error': str(e),
'traceback': traceback.format_exc()
'error': '设备状态检查任务失败',
'error_type': type(e).__name__,
}
finally:
# 任务完成后记录时间、清除运行标记
@@ -181,7 +182,7 @@ def aggregate_daily_snapshot():
return {'success': True, 'date': date_str, 'total': snapshot.total, 'online': snapshot.online}
except Exception as e:
db.rollback()
return {'success': False, 'error': str(e), 'traceback': traceback.format_exc()}
return {'success': False, 'error': '设备状态检查任务失败', 'error_type': type(e).__name__}
finally:
db.close()
+3 -3
View File
@@ -20,6 +20,6 @@ python-json-logger==2.0.7
pytest==8.3.4
pytest-asyncio==0.25.0
casdoor==1.18.0
aiohttp>=3.9.0
PyJWT>=2.8.0
requests>=2.31.0
aiohttp==3.9.5
PyJWT==2.8.0
requests==2.31.0
@@ -0,0 +1,32 @@
"""One-time migration that encrypts legacy plaintext OLT credentials."""
from sqlalchemy.orm.attributes import flag_modified
from app.core.database import SessionLocal
from app.models.device import OLTDevice
from app.core.credentials import CREDENTIAL_PREFIX
def migrate(batch_size: int = 100) -> int:
"""Encrypt every legacy credential and return the number of migrated records."""
db = SessionLocal()
migrated = 0
try:
devices = db.query(OLTDevice).yield_per(batch_size)
for device in devices:
if device.password.startswith(CREDENTIAL_PREFIX):
continue
# Reading a legacy row yields plaintext. Mark it dirty so the column type encrypts it on flush.
device.password = device.password
flag_modified(device, "password")
migrated += 1
db.commit()
return migrated
except Exception:
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
print(f"Migrated {migrate()} OLT credential(s).")
+176
View File
@@ -0,0 +1,176 @@
"""Security regression tests for P0 remediations."""
from datetime import datetime, timedelta, timezone
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from app.core.errors import internal_error
from app.core.credentials import CREDENTIAL_PREFIX, decrypt_credential, encrypt_credential
from app.core.logging_utils import redact_log_message
from app.core.security import verify_casdoor_token
from app.middleware.audit_middleware import sanitize_audit_params
def test_production_rejects_disabled_imc_tls_verification():
from app.core.config import Settings
with pytest.raises(ValidationError, match="IMC_API_VERIFY_SSL"):
Settings(
SECRET_KEY="test-secret",
CREDENTIAL_ENCRYPTION_KEY="Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU=",
DATABASE_URL="sqlite:///./test.db",
REDIS_URL="redis://localhost:6379/15",
CASDOOR_ENDPOINT="https://casdoor.example.test",
CASDOOR_CLIENT_ID="test-client",
CASDOOR_CLIENT_SECRET="test-client-secret",
CASDOOR_ORG_NAME="test-org",
CASDOOR_APP_NAME="test-app",
IMC_API_URL="https://imc.example.test",
IMC_API_VERIFY_SSL=False,
DEBUG=False,
)
def test_audit_params_redacts_nested_sensitive_values():
params = {
"corpsecret": "corp-secret",
"profile": {"access_token": "access-token", "name": "operator"},
"items": [{"password": "device-password"}],
"normal": "kept",
}
assert sanitize_audit_params(params) == {
"corpsecret": "***",
"profile": {"access_token": "***", "name": "operator"},
"items": [{"password": "***"}],
"normal": "kept",
}
def test_internal_error_does_not_expose_exception_text():
exception = internal_error("test operation", RuntimeError("database password=should-not-leak"))
assert isinstance(exception, HTTPException)
assert exception.status_code == 500
assert exception.detail["code"] == "INTERNAL_ERROR"
assert "should-not-leak" not in str(exception.detail)
assert exception.detail["error_id"]
def test_log_redaction_masks_common_credential_formats():
message = "Authorization: Bearer abc.def password=hunter2&access_token=token-value corpsecret: corp-secret"
redacted = redact_log_message(message)
assert "abc.def" not in redacted
assert "hunter2" not in redacted
assert "token-value" not in redacted
assert "corp-secret" not in redacted
def test_olt_credential_encryption_round_trip(monkeypatch):
from cryptography.fernet import Fernet
from app.core.config import settings
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
encrypted = encrypt_credential("olt-device-password")
assert encrypted.startswith(CREDENTIAL_PREFIX)
assert "olt-device-password" not in encrypted
assert decrypt_credential(encrypted) == "olt-device-password"
def test_olt_response_never_contains_device_password():
from app.schemas.olt import serialize_olt
from app.models.device import OLTDevice
device = OLTDevice(
id=1,
ip_address="10.0.0.1",
username="operator",
password="olt-device-password",
slot_command="display onu slot",
region="城区",
)
response = serialize_olt(device)
assert "password" not in response
assert "olt-device-password" not in str(response)
def test_olt_password_is_encrypted_in_database(monkeypatch):
from cryptography.fernet import Fernet
from app.core.config import settings
from app.core.database import Base
from app.models.device import OLTDevice
monkeypatch.setattr(settings, "CREDENTIAL_ENCRYPTION_KEY", Fernet.generate_key().decode())
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
session.add(
OLTDevice(
id=1,
ip_address="10.0.0.1",
username="operator",
password="olt-device-password",
slot_command="display onu slot",
)
)
session.commit()
stored_password = session.execute(text("SELECT password FROM olt_devices")).scalar_one()
assert stored_password.startswith(CREDENTIAL_PREFIX)
assert "olt-device-password" not in stored_password
session.expire_all()
assert session.query(OLTDevice).one().password == "olt-device-password"
def test_verify_casdoor_token_requires_expected_signature_and_claims(monkeypatch):
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_pem = private_key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
now = datetime.now(timezone.utc)
from app.core.config import settings
monkeypatch.setattr(settings, "CASDOOR_CERTIFICATE", public_pem)
monkeypatch.setattr(settings, "CASDOOR_ENDPOINT", "https://casdoor.example.test")
monkeypatch.setattr(settings, "CASDOOR_ISSUER", "")
monkeypatch.setattr(settings, "CASDOOR_CLIENT_ID", "h3c-client")
token = jwt.encode(
{
"sub": "user-1",
"iss": "https://casdoor.example.test",
"aud": "h3c-client",
"iat": now,
"exp": now + timedelta(minutes=5),
},
private_key,
algorithm="RS256",
)
assert verify_casdoor_token(token)["sub"] == "user-1"
invalid_token = jwt.encode(
{
"sub": "user-1",
"iss": "https://casdoor.example.test",
"aud": "other-client",
"iat": now,
"exp": now + timedelta(minutes=5),
},
private_key,
algorithm="RS256",
)
with pytest.raises(jwt.InvalidAudienceError):
verify_casdoor_token(invalid_token)