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
+126
View File
@@ -0,0 +1,126 @@
stages:
- verify
- test
- security
- build
workflow:
rules:
- if: $CI_COMMIT_TAG
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
default:
interruptible: true
variables:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_NO_INPUT: "1"
PYTHONDONTWRITEBYTECODE: "1"
dependency-source-policy:
stage: verify
image: "$INTERNAL_CONTAINER_PROXY/alpine:3.20"
script:
- test -n "$INTERNAL_CONTAINER_PROXY" || (echo "INTERNAL_CONTAINER_PROXY must point to the approved container-image proxy" && exit 1)
- test -n "$INTERNAL_PYPI_URL" || (echo "INTERNAL_PYPI_URL must point to the approved PyPI proxy" && exit 1)
- test -n "$INTERNAL_NPM_REGISTRY" || (echo "INTERNAL_NPM_REGISTRY must point to the approved npm proxy" && exit 1)
rules:
- if: $CI_COMMIT_BRANCH || $CI_MERGE_REQUEST_ID || $CI_COMMIT_TAG
backend-tests:
stage: test
image: "$INTERNAL_CONTAINER_PROXY/python:3.11-slim"
needs: ["dependency-source-policy"]
cache:
key:
files:
- backend/requirements.txt
paths:
- .cache/pip
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
SECRET_KEY: "ci-test-secret-not-for-deployment"
CREDENTIAL_ENCRYPTION_KEY: "Q6NgOwoM3sna4ti4UeEo3Lo2cIzxc8wJMvMZ2cmw7lU="
DATABASE_URL: "sqlite:///./ci-test.db"
REDIS_URL: "redis://localhost:6379/15"
CASDOOR_ENDPOINT: "https://casdoor.example.test"
CASDOOR_CLIENT_ID: "ci-client"
CASDOOR_CLIENT_SECRET: "ci-client-secret"
CASDOOR_ORG_NAME: "ci-org"
CASDOOR_APP_NAME: "ci-app"
before_script:
- python -m pip install --index-url "$INTERNAL_PYPI_URL" --upgrade pip
- python -m pip install --index-url "$INTERNAL_PYPI_URL" -r backend/requirements.txt
script:
- cd backend
- python -m compileall -q app
- pytest -q --junitxml=../reports/backend-junit.xml
artifacts:
when: always
reports:
junit: reports/backend-junit.xml
paths:
- reports/backend-junit.xml
expire_in: 30 days
frontend-build:
stage: test
image: "$INTERNAL_CONTAINER_PROXY/node:20-alpine"
needs: ["dependency-source-policy"]
cache:
key:
files:
- frontend/package-lock.json
paths:
- frontend/.npm/
before_script:
- npm config set registry "$INTERNAL_NPM_REGISTRY"
script:
- cd frontend
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts:
paths:
- frontend/dist/
expire_in: 7 days
python-dependency-audit:
stage: security
image: "$INTERNAL_CONTAINER_PROXY/python:3.11-slim"
needs: ["dependency-source-policy"]
cache:
key:
files:
- backend/requirements.txt
- ci/requirements-audit.txt
paths:
- .cache/pip
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
before_script:
- python -m pip install --index-url "$INTERNAL_PYPI_URL" --upgrade pip
- python -m pip install --index-url "$INTERNAL_PYPI_URL" -r ci/requirements-audit.txt
script:
- mkdir -p reports
- pip-audit --index-url "$INTERNAL_PYPI_URL" -r backend/requirements.txt --format json --output reports/pip-audit.json
artifacts:
when: always
paths:
- reports/pip-audit.json
expire_in: 30 days
container-build:
stage: build
image: "$INTERNAL_CONTAINER_PROXY/docker:27-cli"
needs: ["backend-tests", "frontend-build", "python-dependency-audit"]
services:
- name: "$INTERNAL_CONTAINER_PROXY/docker:27-dind"
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- docker build --pull --build-arg "PYTHON_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/python:3.11-slim" --build-arg "PIP_INDEX_URL=$INTERNAL_PYPI_URL" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --tag "h3c-onu-ms-backend:$CI_COMMIT_SHA" backend
- docker build --pull --build-arg "NODE_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/node:20-alpine" --build-arg "NGINX_BASE_IMAGE=$INTERNAL_CONTAINER_PROXY/nginx:alpine" --build-arg "NPM_CONFIG_REGISTRY=$INTERNAL_NPM_REGISTRY" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --tag "h3c-onu-ms-frontend:$CI_COMMIT_SHA" frontend
rules:
- if: $CI_COMMIT_BRANCH || $CI_MERGE_REQUEST_ID || $CI_COMMIT_TAG
+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)
+2
View File
@@ -0,0 +1,2 @@
# CI-only security tooling. Keep this file pinned so the scan itself is reproducible.
pip-audit==2.7.3
+11 -2
View File
@@ -13,10 +13,17 @@ DEBUG=false
# 应用密钥 (使用 openssl rand -hex 32 生成)
SECRET_KEY=your-secret-key-change-in-production
# OLT SSH 密码加密密钥;请通过受控密钥服务或受限环境变量注入,切勿提交至仓库
# 生成示例:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CREDENTIAL_ENCRYPTION_KEY=
# 时区设置
TZ=Asia/Shanghai
# 宿主机发布端口。并行验证时可改为未占用端口,确认后再切换为正式端口。
BACKEND_PORT=8001
FRONTEND_PORT=18062
# ============================================
# 数据库配置 (PostgreSQL)
# ============================================
@@ -67,6 +74,8 @@ CASDOOR_APP_NAME=h3c-onu-ms
# Casdoor 回调地址(部署后改为实际域名)
CASDOOR_REDIRECT_URL=
# 为空时使用 CASDOOR_ENDPOINTJWT 的 iss 必须与此值一致
CASDOOR_ISSUER=
# ============================================
# SSH连接配置
@@ -127,7 +136,7 @@ IMC_API_URL=
IMC_API_USERNAME=
IMC_API_PASSWORD=
# 本地认证不需要 SSL 验证
IMC_API_VERIFY_SSL=false
IMC_API_VERIFY_SSL=true
IMC_CONNECT_TIMEOUT=5
IMC_READ_TIMEOUT=20
@@ -206,4 +215,4 @@ BACKUP_SCHEDULE="0 2 * * *"
# DEBUG=true
# DATABASE_URL=postgresql://h3c_user:password@localhost:5432/h3c_onu_ms_dev
# REDIS_URL=redis://localhost:6379/0
# VITE_API_BASE_URL=http://localhost:8000
# VITE_API_BASE_URL=http://localhost:8000
+9 -3
View File
@@ -6,7 +6,7 @@ services:
dockerfile: Dockerfile
container_name: h3c-onu-ms-backend
ports:
- "8001:8000"
- "${BACKEND_PORT:-8001}:8000"
environment:
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
@@ -17,7 +17,9 @@ services:
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- CASDOOR_REDIRECT_URL=${CASDOOR_REDIRECT_URL:-}
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
- SECRET_KEY=${SECRET_KEY}
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
- DEBUG=${DEBUG:-false}
- CORS_ORIGINS=${CORS_ORIGINS:-}
- FRONTEND_URL=${FRONTEND_URL:-https://onu.dhdx.fun}
@@ -60,7 +62,9 @@ services:
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
- SECRET_KEY=${SECRET_KEY}
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
- WECHAT_CORPID=${WECHAT_CORPID:-}
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
@@ -90,7 +94,9 @@ services:
- CASDOOR_CERTIFICATE=${CASDOOR_CERTIFICATE}
- CASDOOR_ORG_NAME=${CASDOOR_ORG_NAME}
- CASDOOR_APP_NAME=${CASDOOR_APP_NAME}
- CASDOOR_ISSUER=${CASDOOR_ISSUER:-}
- SECRET_KEY=${SECRET_KEY}
- CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY}
- WECHAT_CORPID=${WECHAT_CORPID:-}
- WECHAT_CORPSECRET=${WECHAT_CORPSECRET:-}
- WECHAT_AGENTID=${WECHAT_AGENTID:-}
@@ -111,12 +117,12 @@ services:
dockerfile: Dockerfile
container_name: h3c-onu-ms-frontend
ports:
- "18062:80"
- "${FRONTEND_PORT:-18062}:80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:80/health"]
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:80/health"]
interval: 30s
timeout: 10s
retries: 3
+31
View File
@@ -0,0 +1,31 @@
# CI 实施说明
本仓库提供 `.gitlab-ci.yml`,用于满足 C1 项目最小的代码更新、构建、测试和依赖安全检查能力。流水线在合并请求、分支更新和标签创建时运行;受保护分支应在 GitLab 中配置为仅允许通过流水线和评审合入。
## 发布前配置
在 GitLab 项目的 CI/CD 变量中配置以下**受保护且掩码**变量:
| 变量 | 用途 | 要求 |
| --- | --- | --- |
| `INTERNAL_PYPI_URL` | 企业统一 PyPI 代理地址 | 仅允许访问经批准的代理仓库;不得配置为个人镜像。 |
| `INTERNAL_NPM_REGISTRY` | 企业统一 npm 代理地址 | 仅允许访问经批准的代理仓库;不得提交认证令牌到仓库。 |
| `INTERNAL_CONTAINER_PROXY` | 企业统一容器镜像代理地址(不含末尾 `/`) | Runner、构建基础镜像和 Docker-in-Docker 均从该代理拉取镜像。 |
流水线会在变量缺失时失败,避免从非受控公共源下载依赖或基础镜像。若代理需要认证,应使用 GitLab 受保护变量或 Runner 的受控凭据注入机制,不得将账号、口令或 token 写入 `.gitlab-ci.yml``requirements.txt``package-lock.json`、Dockerfile 或日志。
## 流水线内容与门禁
1. `dependency-source-policy`:验证统一依赖源已配置。
2. `backend-tests`:安装锁定的 Python 依赖,执行语法检查和 pytest,归档 JUnit 报告。
3. `frontend-build`:通过 `npm ci` 使用锁文件构建前端,归档静态构建产物。
4. `python-dependency-audit`:执行 Python 第三方组件漏洞扫描,归档 JSON 报告;发现可识别漏洞时失败。
5. `container-build`:仅构建带 commit SHA 标签的后端与前端镜像,验证 Dockerfile 可构建;Python、npm 与基础镜像均从企业代理获得,本 job 不推送镜像。
容器构建依赖 Docker-in-Docker runner。若当前 Runner 未授权特权容器,应由平台管理员提供隔离的构建 Runner;不得为了通过流水线而取消镜像构建或将 Docker Socket 暴露给不受信任的 job。
## 仍需由平台完成的事项
- 在 GitLab 中开启合并请求评审与成功流水线门禁,并保护 `main`/发布分支。
- 增加平台 SAST、前端依赖/SCA、许可证扫描和镜像扫描 job,并把报告关联到统一制品库中的制品元数据。
- 将镜像推送至统一制品库,采用不可变版本(发布 tag + commit SHA)并记录制品、测试、扫描和部署关联;该动作需要制品库地址与发布权限,未在本次整改中写入。
+83
View File
@@ -0,0 +1,83 @@
# H3C ONU 设备管理系统研发规范整改计划
## 1. 审查基线
- 审查日期:2026-07-28
- 审查版本:`5b07ec6df0448bd43966260b1b7b70532c1d552b``main`
- 审查范围:Vue/Vite 前端、FastAPI 后端、PostgreSQL/Redis/Celery、Docker Compose 与仓库交付物。
- 目标等级:**C1(待项目负责人确认)**。未分类项目按 C1 基线审查,依据《总体规范》2.1。
- 说明:本计划只将原文标明 C1/C2/C3/C4 或“【强制】”的内容标为规范要求;其余安全加固按工程风险处置。
## 2. 整改优先级与工作包
### P0:上线前阻断项(安全)
| 编号 | 整改项 | 主要证据 | 验收标准 | 依据 |
| --- | --- | --- | --- | --- |
| SEC-01 | 删除日志中的令牌片段,并建立统一日志脱敏器。脱敏范围至少覆盖 `Authorization`、token、password、secret、corpsecret、客户端凭据及请求体嵌套字段。 | `backend/app/middleware/permission_middleware.py:70` 写入 token 前 20 位;审计中间件仅脱敏三个精确字段名。 | 自动化测试证明日志中不出现任一敏感值或其可复用片段。 | 《安全分册》3.1.1【强制】 |
| SEC-02 | 停用 iMC 调用中的 MD5 Digest 实现,优先接入 iMC 支持的 Digest SHA-256、OAuth 或受控网关认证;如设备仅支持 MD5,须形成例外审批、隔离边界和替代改造计划。 | `backend/app/services/imc_service.py:82-87` 使用 `hashlib.md5`。 | 代码与扫描结果不再出现 MD5;或已具备批准的临时例外和退役日期。 | 《安全分册》3.1.2【强制】 |
| SEC-03 | 修复“关于”页存储型 XSS:Markdown 渲染后使用白名单 HTML 清洗,限制 URL 协议;为所有 `v-html` 建立受信来源约束。 | `frontend/src/views/About.vue:14,30` 将管理员可写 Markdown 直接插入 DOM。 | 恶意 `script`、事件属性和 `javascript:` URL 均不能执行;新增前端测试。 | 《安全分册》3.2.1.4【强制】 |
| SEC-04 | 认证回调验证 Casdoor 返回 JWT 的签名、发行者、受众、有效期和 nonce/state;失败默认拒绝。 | `backend/app/api/v1/auth.py:18-24,50` 明确“不验签”后使用 `sub` 建立本地会话。 | 伪造、过期、错误 issuer/audience 的令牌均被拒绝;通过真实 OIDC 回调回归。 | 《安全分册》2.1.10、2.2.2;《总体规范》4.5.1C1 |
| SEC-05 | 生产环境强制 TLS 校验,禁止 `IMC_API_VERIFY_SSL=false` 默认值;为内部 CA 配置证书链。 | `backend/app/core/config.py:51` 与两份 `.env.example` 默认关闭校验。 | 非开发环境启动时拒绝关闭 TLS 校验;HTTPS 证书错误请求失败。 | 《安全分册》2.2.4、2.3.1 |
| SEC-06 | 统一异常处理:对外返回错误码与通用信息,内部以脱敏日志记录关联 ID;禁止透出异常文本、堆栈和上游响应正文。 | 多处 `detail=str(e)`,如 `devices.py:442``check.py:87,102``auth.py:82`;任务结果含 traceback。 | 500 响应不含路径、凭据、堆栈或上游报文;异常日志可按关联 ID 检索。 | 《安全分册》3.2.2.1【强制】 |
| SEC-07 | 对需要复用的 OLT SSH 密码实施加密(密钥由受控密钥服务或部署密钥注入),完成历史数据迁移、轮换和审计。 | `backend/app/models/device.py:14` 原以明文 `Text` 存储设备密码。 | 数据库备份、查询和审计日志均无明文密码;迁移可回滚、轮换可执行。 | 工程安全整改;密钥方案待确认(规范未指定具体 KMS 实现)。 |
### P1C1 研发交付链整改
| 编号 | 整改项 | 验收标准 | 依据 |
| --- | --- | --- | --- |
| ENG-01 | 在内部研发平台配置 CI:后端依赖安装、pytest、前端 `npm ci && npm run build`、SAST、依赖/SCA 扫描、镜像构建。为失败设置合入门禁并归档报告。 | 每次代码更新均有可追溯构建、单测和安全扫描结果;阻断级问题不可合入或发布。 | 《总体规范》4.5.1、4.5.3C1);《流水线分册》2.3.2、3.2 |
| ENG-02 | 补齐测试策略、用例、测试报告与缺陷闭环;优先增加认证验签、权限隔离、审计脱敏、文件上传、XSS、iMC TLS、OLT 密码加密迁移的自动化测试。 | 测试计划、评审记录、执行报告、缺陷清单齐全;安全整改均有回归用例。 | 《总体规范》4.5.1、4.6、5.5.7C1);《测试管理分册》3.2-3.5、6 |
| ENG-03 | 将第三方依赖改为可复现、可审核来源:固定后端所有直接依赖版本,使用内部统一制品库代理,生成依赖清单与许可证/漏洞扫描报告。 | `requirements` 无无上限版本;构建只从批准镜像/依赖源获取依赖,扫描报告可追溯。 | 《制品管理分册》4.1(C1);《总体规范》4.5.5C1 |
| ENG-04 | 建立版本、制品与部署追溯:镜像标签包含应用版本与 commit ID;测试/生产只从统一制品库拉取已扫描制品,保留版本、测试、部署与回退记录。 | 任一生产版本能反查 commit、制品、扫描/测试结果和部署记录;禁止可变标签发布。 | 《制品管理分册》2.5、2.6、3.1(C1);《部署管理分册》2.2-2.3(C1) |
| ENG-05 | 补齐 C1 最小交付物:需求编号及变更记录、概要设计、安全设计/威胁分析、测试计划/报告、部署实施与回退方案;建立需求—用例—代码/制品—版本追踪表。 | 文档受版本控制,且每次发布可对应需求、测试与制品版本。 | 《总体规范》4.1、4.2、4.3、4.4(C1);《代码管理分册》2.3(C1) |
| ENG-06 | 在远端仓库核验并固化治理配置:仓库管理员不超过 3 人、最小权限、离职回收、主干保护、发布 tag;补全仓库描述中的项目编号、项目名称和子项目名称。 | 导出远端权限、保护分支和 tag 证据;README 与根 `.gitignore` 保持合规。 | 《代码管理分册》2.2-3.1(C1 |
### P2:运行与可维护性改进
| 编号 | 整改项 | 验收标准 | 属性 |
| --- | --- | --- | --- |
| OPS-01 | 统一部署脚本与实际 Compose 服务、端口和健康检查;补充发布前检查、回退演练、备份恢复验证。 | 部署、备份和恢复在预发环境演练成功,并记录证据。 | 规范要求(部署材料,C1) |
| OPS-02 | Docker 构建使用固定基础镜像摘要、非 root 用户、最小镜像和健康检查;为容器设置资源与网络边界。 | 镜像安全扫描通过,运行身份和暴露端口可审计。 | 工程建议 |
| OPS-03 | 清理 Pydantic/SQLAlchemy 弃用用法,增加 lint/format/type-check;建立代码所有者与评审清单。 | CI 中无新增高等级质量问题,弃用告警清零。 | 工程建议 |
## 3. 实施顺序与里程碑
1. **M0:范围确认(0.5 天)**:确认项目等级、数据分级、iMC 支持的认证方式、内部制品库/CI/密钥服务和生产发布窗口。
2. **M1:安全止血(3-5 天)**:完成 SEC-01 至 SEC-06,补充回归测试;SEC-07 先输出密钥与迁移设计,禁止新增明文凭据。
3. **M2:凭据迁移与测试(3-5 天)**:实施 SEC-07,完成历史数据加密、密钥轮换演练和核心接口测试。
4. **M3:研发交付链(3-5 天)**:实施 ENG-01 至 ENG-04,接入内部流水线、制品库、SAST/SCA 与可追溯发布。
5. **M4:文档与上线验收(2-3 天)**:实施 ENG-05、ENG-06、OPS-01,完成预发演练、测试报告、部署审批材料和复审。
## 4. 当前证据缺口与需确认事项
- 未获得远端仓库的成员、权限、保护分支、合并评审和流水线运行记录,不能据此断言其合规性。
- 未获得研发云、制品库、SAST/SCA、测试管理、缺陷管理和生产部署平台的证据。
- 本地测试尝试因当前运行环境未安装 `paramiko` 而在收集阶段中断;前端构建因工作环境没有 npm 未执行。应由 CI 使用锁定工具链和依赖后重跑。
- 需项目负责人确认:项目 C 级、数据分级、iMC 接口可支持的认证算法、密钥托管产品和发布窗口。
## 4.1 SEC-07 发布前操作(待发布授权)
1. 在受控密钥服务中生成并保管 `CREDENTIAL_ENCRYPTION_KEY`;不得写入仓库、镜像、部署脚本或审计日志。
2. 备份数据库并完成恢复演练;记录备份版本和操作人。
3. 将密钥仅注入 backend、celery-worker、celery-beat 运行环境,部署新版代码后执行 `python scripts/migrate_olt_credentials.py`
4. 以数据库管理员账户核验 `olt_devices.password` 全部为 `enc:v1:` 前缀;通过应用的 OLT 扫描、重启和端口管理回归测试确认可解密使用。
5. 如迁移异常,先停止后续发布,使用已验证的数据库备份回退;密钥泄露时按应急流程轮换密钥并重新加密所有凭据。
## 4.2 SEC-02 iMC 认证能力核验(2026-07-28
- 已在部署服务器上对 iMC REST 接口发起未认证的只读请求。服务端返回 `401``Digest realm="iMC RESTful Web Services", qop="auth"`,未声明 `algorithm` 参数;现网项目的 iMC 客户端亦按 MD5 Digest 计算认证摘要。
- 受控浏览器因 iMC 使用不受信任的 TLS 证书而拒绝建立连接,未绕过证书校验;因此不能把“浏览器能访问”作为验收证据。
- 目前未发现 Digest SHA-256、OAuth/OIDC 或令牌认证的可用证据。SEC-02 不能以修改客户端代码的方式单独关闭:须向 iMC 厂商/平台管理员取得当前版本 REST API 的认证能力说明并确认升级路径。
- 若确认该版本仅支持 MD5 Digest,则上线前应提交安全例外审批,至少限定 iMC 为受控内网目标、使用专用最小权限账户、启用有效 TLS 证书与证书校验、禁止记录 `Authorization`/nonce/响应敏感数据,并明确 iMC 升级或网关替代方案的责任人和退役日期。
## 4.3 ENG-01、ENG-03 当前落实情况(2026-07-28
- 已新增 GitLab CI 基线:统一依赖源检查、后端单元测试与 JUnit 报告、前端 `npm ci` 构建、Python 组件漏洞扫描和容器构建校验。详见 `docs/CI实施说明.md`
- 已锁定后端直接依赖中原本使用下限约束的 `aiohttp``PyJWT``requests`;前端已存在 `package-lock.json`,流水线与 Docker 构建均使用 `npm ci`
- Dockerfile 不再固定第三方镜像站;CI 通过 `INTERNAL_PYPI_URL``INTERNAL_NPM_REGISTRY``INTERNAL_CONTAINER_PROXY` 三个受保护变量接入企业代理。变量、受保护 Runner、合并门禁和统一制品库推送尚需由平台管理员配置后才能验收。
- 本地环境没有可用的 Docker、pytest 及项目运行依赖;已通过 Python 语法编译与 `git diff --check`,完整测试、镜像构建、依赖扫描和 GitLab CI Lint 均待在配置完成的内部流水线执行。
## 5. 下一项可执行动作
由 GitLab/制品库管理员先配置 `INTERNAL_PYPI_URL``INTERNAL_NPM_REGISTRY``INTERNAL_CONTAINER_PROXY` 与隔离的 Docker Runner;随后将本整改分支推送到 GitLab,确认 VerifyCI 全绿并将 `main` 设置为“合并请求评审 + 成功流水线”门禁。SEC-02 保持例外审批依赖,SEC-07 仍待密钥服务与发布窗口确认。
+7 -3
View File
@@ -1,10 +1,14 @@
# Stage 1: Build
FROM node:18-alpine AS build
ARG NODE_BASE_IMAGE=node:20-alpine
ARG NGINX_BASE_IMAGE=nginx:alpine
FROM ${NODE_BASE_IMAGE} AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
ARG NPM_CONFIG_REGISTRY
RUN if [ -n "$NPM_CONFIG_REGISTRY" ]; then npm config set registry "$NPM_CONFIG_REGISTRY"; fi \
&& npm ci
COPY . .
@@ -12,7 +16,7 @@ COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine AS serve
FROM ${NGINX_BASE_IMAGE} AS serve
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
+29
View File
@@ -20,6 +20,35 @@ server {
access_log off;
}
# Keep the browser and API on the same origin in production. This must be
# evaluated before the SPA fallback, otherwise /api/* returns index.html.
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 15s;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location /docs {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /openapi.json {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# SPA fallback - all routes serve index.html
location / {
try_files $uri $uri/ /index.html;
+21 -2
View File
@@ -19,15 +19,34 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { marked } from 'marked'
import { marked, Renderer } from 'marked'
import request from '../utils/request'
const content = ref('')
const loading = ref(true)
const safeLink = (href) => {
try {
const url = new URL(href, window.location.origin)
return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.href : ''
} catch {
return ''
}
}
const safeRenderer = new Renderer()
safeRenderer.html = () => ''
safeRenderer.image = ({ text }) => text
safeRenderer.link = function ({ href, tokens }) {
const text = this.parser.parseInline(tokens)
const safeHref = safeLink(href)
if (!safeHref) return text
return `<a href="${safeHref}" rel="noopener noreferrer" target="_blank">${text}</a>`
}
const renderedContent = computed(() => {
if (!content.value) return ''
return marked.parse(content.value)
return marked.parse(content.value, { renderer: safeRenderer })
})
onMounted(async () => {
+1 -18
View File
@@ -8,8 +8,6 @@
<div v-else-if="error">
<p>登录失败: {{ error }}</p>
<el-button @click="$router.push('/login')">返回登录</el-button>
<el-button type="info" @click="showDetails = !showDetails">详细信息</el-button>
<pre v-if="showDetails" class="error-details">{{ errorDetails }}</pre>
</div>
</el-card>
</div>
@@ -26,8 +24,6 @@ const router = useRouter()
const authStore = useAuthStore()
const loading = ref(true)
const error = ref('')
const errorDetails = ref('')
const showDetails = ref(false)
onMounted(async () => {
const code = new URLSearchParams(window.location.search).get('code')
@@ -44,8 +40,7 @@ onMounted(async () => {
authStore.setToken(data.access_token)
router.push('/dashboard')
} catch (err) {
error.value = err.message || '登录失败'
errorDetails.value = err.response?.data?.detail || JSON.stringify(err, null, 2)
error.value = err.response?.data?.detail || '登录失败,请稍后重试'
loading.value = false
}
})
@@ -58,16 +53,4 @@ onMounted(async () => {
align-items: center;
height: 100vh;
}
.error-details {
margin-top: 12px;
padding: 12px;
background: #f5f5f5;
border-radius: 4px;
font-size: 12px;
text-align: left;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
</style>