From 81ab82e9ba98e72f1f394eed7427a09216cc8874 Mon Sep 17 00:00:00 2001 From: v6ole Date: Tue, 28 Jul 2026 17:56:28 +0800 Subject: [PATCH] fix(security): complete security and delivery compliance remediation --- .gitlab-ci.yml | 126 +++++++++++++ backend/.env.example | 6 +- backend/Dockerfile | 11 +- backend/app/api/v1/auth.py | 61 ++++-- backend/app/api/v1/check.py | 8 +- backend/app/api/v1/devices.py | 9 +- backend/app/api/v1/olt.py | 14 +- backend/app/core/config.py | 21 ++- backend/app/core/credentials.py | 51 +++++ backend/app/core/errors.py | 26 +++ backend/app/core/logging_utils.py | 26 +++ backend/app/core/security.py | 33 +++- backend/app/main.py | 2 + backend/app/middleware/audit_middleware.py | 21 ++- .../app/middleware/permission_middleware.py | 2 +- backend/app/models/device.py | 3 +- backend/app/schemas/olt.py | 17 ++ backend/app/tasks/alert_tasks.py | 3 +- backend/app/tasks/audit_tasks.py | 3 +- backend/app/tasks/check_tasks.py | 13 +- backend/requirements.txt | 6 +- backend/scripts/migrate_olt_credentials.py | 32 ++++ backend/tests/test_security.py | 176 ++++++++++++++++++ ci/requirements-audit.txt | 2 + deploy/.env.example | 13 +- deploy/docker-compose.yml | 12 +- docs/CI实施说明.md | 31 +++ docs/整改计划.md | 83 +++++++++ frontend/Dockerfile | 10 +- frontend/nginx.conf | 29 +++ frontend/src/views/About.vue | 23 ++- frontend/src/views/Callback.vue | 19 +- 32 files changed, 798 insertions(+), 94 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 backend/app/core/credentials.py create mode 100644 backend/app/core/errors.py create mode 100644 backend/app/core/logging_utils.py create mode 100644 backend/app/schemas/olt.py create mode 100644 backend/scripts/migrate_olt_credentials.py create mode 100644 backend/tests/test_security.py create mode 100644 ci/requirements-audit.txt create mode 100644 docs/CI实施说明.md create mode 100644 docs/整改计划.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..ec323a1 --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 6bf7265..554811c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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_ENDPOINT;JWT 的 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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 25bb4c7..4b7f55f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 . . diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 7ba59e3..47c2122 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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") diff --git a/backend/app/api/v1/check.py b/backend/app/api/v1/check.py index 39b7cab..abcec3f 100644 --- a/backend/app/api/v1/check.py +++ b/backend/app/api/v1/check.py @@ -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) diff --git a/backend/app/api/v1/devices.py b/backend/app/api/v1/devices.py index 5bb9ff3..cbc4e3e 100644 --- a/backend/app/api/v1/devices.py +++ b/backend/app/api/v1/devices.py @@ -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") diff --git a/backend/app/api/v1/olt.py b/backend/app/api/v1/olt.py index 21956b0..02dedec 100644 --- a/backend/app/api/v1/olt.py +++ b/backend/app/api/v1/olt.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b363402..c99c699 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 内容 diff --git a/backend/app/core/credentials.py b/backend/app/core/credentials.py new file mode 100644 index 0000000..7943171 --- /dev/null +++ b/backend/app/core/credentials.py @@ -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) diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py new file mode 100644 index 0000000..ab595ff --- /dev/null +++ b/backend/app/core/errors.py @@ -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, + }, + ) diff --git a/backend/app/core/logging_utils.py b/backend/app/core/logging_utils.py new file mode 100644 index 0000000..96624ae --- /dev/null +++ b/backend/app/core/logging_utils.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 3f1e58d..75b70e4 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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"]}, + ) diff --git a/backend/app/main.py b/backend/app/main.py index db524b0..7893ce8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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] diff --git a/backend/app/middleware/audit_middleware.py b/backend/app/middleware/audit_middleware.py index 9ab69da..62cb2f8 100644 --- a/backend/app/middleware/audit_middleware.py +++ b/backend/app/middleware/audit_middleware.py @@ -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: diff --git a/backend/app/middleware/permission_middleware.py b/backend/app/middleware/permission_middleware.py index 6ea9f95..8069066 100644 --- a/backend/app/middleware/permission_middleware.py +++ b/backend/app/middleware/permission_middleware.py @@ -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') diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 80c20b8..4842047 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -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)) diff --git a/backend/app/schemas/olt.py b/backend/app/schemas/olt.py new file mode 100644 index 0000000..4e33a8c --- /dev/null +++ b/backend/app/schemas/olt.py @@ -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, + } diff --git a/backend/app/tasks/alert_tasks.py b/backend/app/tasks/alert_tasks.py index b54cf55..421c0e6 100644 --- a/backend/app/tasks/alert_tasks.py +++ b/backend/app/tasks/alert_tasks.py @@ -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() diff --git a/backend/app/tasks/audit_tasks.py b/backend/app/tasks/audit_tasks.py index c73c08f..1ebea00 100644 --- a/backend/app/tasks/audit_tasks.py +++ b/backend/app/tasks/audit_tasks.py @@ -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() diff --git a/backend/app/tasks/check_tasks.py b/backend/app/tasks/check_tasks.py index e78180d..893021f 100644 --- a/backend/app/tasks/check_tasks.py +++ b/backend/app/tasks/check_tasks.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt index ce57de6..d365242 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scripts/migrate_olt_credentials.py b/backend/scripts/migrate_olt_credentials.py new file mode 100644 index 0000000..8d4fa4c --- /dev/null +++ b/backend/scripts/migrate_olt_credentials.py @@ -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).") diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..0925def --- /dev/null +++ b/backend/tests/test_security.py @@ -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) diff --git a/ci/requirements-audit.txt b/ci/requirements-audit.txt new file mode 100644 index 0000000..7861997 --- /dev/null +++ b/ci/requirements-audit.txt @@ -0,0 +1,2 @@ +# CI-only security tooling. Keep this file pinned so the scan itself is reproducible. +pip-audit==2.7.3 diff --git a/deploy/.env.example b/deploy/.env.example index 8460523..dba619d 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -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_ENDPOINT;JWT 的 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 \ No newline at end of file +# VITE_API_BASE_URL=http://localhost:8000 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index d63fd0a..6d62c72 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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 diff --git a/docs/CI实施说明.md b/docs/CI实施说明.md new file mode 100644 index 0000000..0b36fe1 --- /dev/null +++ b/docs/CI实施说明.md @@ -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)并记录制品、测试、扫描和部署关联;该动作需要制品库地址与发布权限,未在本次整改中写入。 diff --git a/docs/整改计划.md b/docs/整改计划.md new file mode 100644 index 0000000..950fdb9 --- /dev/null +++ b/docs/整改计划.md @@ -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.1(C1) | +| 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 实现)。 | + +### P1:C1 研发交付链整改 + +| 编号 | 整改项 | 验收标准 | 依据 | +| --- | --- | --- | --- | +| ENG-01 | 在内部研发平台配置 CI:后端依赖安装、pytest、前端 `npm ci && npm run build`、SAST、依赖/SCA 扫描、镜像构建。为失败设置合入门禁并归档报告。 | 每次代码更新均有可追溯构建、单测和安全扫描结果;阻断级问题不可合入或发布。 | 《总体规范》4.5.1、4.5.3(C1);《流水线分册》2.3.2、3.2 | +| ENG-02 | 补齐测试策略、用例、测试报告与缺陷闭环;优先增加认证验签、权限隔离、审计脱敏、文件上传、XSS、iMC TLS、OLT 密码加密迁移的自动化测试。 | 测试计划、评审记录、执行报告、缺陷清单齐全;安全整改均有回归用例。 | 《总体规范》4.5.1、4.6、5.5.7(C1);《测试管理分册》3.2-3.5、6 | +| ENG-03 | 将第三方依赖改为可复现、可审核来源:固定后端所有直接依赖版本,使用内部统一制品库代理,生成依赖清单与许可证/漏洞扫描报告。 | `requirements` 无无上限版本;构建只从批准镜像/依赖源获取依赖,扫描报告可追溯。 | 《制品管理分册》4.1(C1);《总体规范》4.5.5(C1) | +| 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 仍待密钥服务与发布窗口确认。 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 74cc71f..115cbb8 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 08aefde..1dab688 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/views/About.vue b/frontend/src/views/About.vue index 4ba10dc..5159ddc 100644 --- a/frontend/src/views/About.vue +++ b/frontend/src/views/About.vue @@ -19,15 +19,34 @@