fix(security): remediate telecom compliance findings

This commit is contained in:
2026-07-28 11:33:05 +08:00
parent c38a9e74ad
commit 29e4e4a804
17 changed files with 293 additions and 72 deletions
+19
View File
@@ -0,0 +1,19 @@
stages:
- verify
- build
backend_verify:
stage: verify
image: python:3.12-slim
script:
- pip install --no-cache-dir -r backend/requirements.txt pytest
- PYTHONPATH=backend python -m compileall -q backend/app
- PYTHONPATH=backend pytest -q backend/tests
frontend_build:
stage: build
image: node:20-alpine
script:
- cd frontend
- npm ci
- npm run build
+6 -3
View File
@@ -98,14 +98,17 @@ npm run dev -- --host 0.0.0.0
# 页面 → http://localhost:5173
```
### 一键部署
### 本地集成环境
```bash
# 先在 .env 中设置 POSTGRES_USER、POSTGRES_PASSWORD、MINIO_ROOT_USER、
# MINIO_ROOT_PASSWORD、SECRET_KEY 和固定版本的 MINIO_IMAGE
docker-compose up -d
# 后端 :8000 + PostgreSQL :5432 + MinIO :9000/:9001
# 前端需单独部署到 nginx,见 frontend/Dockerfile
# 服务仅绑定到 127.0.0.1;前端需单独部署到 nginx,见 frontend/Dockerfile
```
生产环境必须使用批准的部署流水线、密钥管理、HTTPS 反向代理和上线审批流程;不得直接将本 Compose 文件作为生产部署方案。
## 环境变量
| 配置项 | 说明 | 必须 |
+9 -6
View File
@@ -5,11 +5,13 @@
# ── 应用 ──
APP_NAME=企迹-政企周报管理系统
DEBUG=true
SECRET_KEY=change-me-to-a-random-string-in-production
ENVIRONMENT=development
DEBUG=false
# Use a random value of at least 32 characters. Never use a default in production.
SECRET_KEY=replace-with-a-random-secret-of-at-least-32-characters
# ── PostgreSQL (已有基础设施,填写实际连接信息) ──
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/qiji
DATABASE_URL=postgresql+asyncpg://qiji_app:replace-with-a-strong-password@localhost:5432/qiji
# ── JWT ──
JWT_ALGORITHM=HS256
@@ -25,10 +27,11 @@ CASDOOR_APPLICATION=qiji-weekly-report
# ── MinIO (已有,填写实际部署地址和密钥) ──
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_ACCESS_KEY=qiji-app
MINIO_SECRET_KEY=replace-with-a-strong-minio-secret
MINIO_BUCKET=qiji-photos
MINIO_SECURE=false
# Set false only when connecting to a local development MinIO instance over the internal Docker network.
MINIO_SECURE=true
# ── 企业微信 (需在企微管理后台创建自建应用后获取) ──
WECOM_CORP_ID=your-corp-id
+7 -27
View File
@@ -58,36 +58,16 @@ async def wecom_login(req: WecomLoginRequest, db: AsyncSession = Depends(get_db)
role=user.role,
)
# Not bound yet — return a redirect URL to Casdoor for binding
casdoor_auth_url = (
f"{settings.CASDOOR_ENDPOINT}/login/oauth/authorize"
f"?client_id={settings.CASDOOR_CLIENT_ID}"
f"&response_type=code"
f"&redirect_uri={settings.CORS_ORIGINS[0]}/bind-wecom"
f"&scope=openid+profile"
f"&state={wecom_userid}"
raise HTTPException(
status_code=403,
detail="企业微信账号尚未绑定。请在企业微信内使用一次性绑定链接完成绑定。",
)
return {"need_bind": True, "casdoor_url": casdoor_auth_url, "wecom_userid": wecom_userid}
@router.post("/bind-wecom")
async def bind_wecom(req: WecomBindRequest, db: AsyncSession = Depends(get_db)):
"""Bind Casdoor account with WeChat Work userid after OIDC redirect."""
userinfo = await exchange_casdoor_code(req.casdoor_code)
if not userinfo:
raise HTTPException(status_code=400, detail="Failed to exchange casdoor code")
casdoor_id = userinfo.get("sub") or userinfo.get("id")
wecom_userid = req.wecom_userid or userinfo.get("state", "")
user = await bind_wecom_user(db, casdoor_id, wecom_userid)
if not user:
raise HTTPException(status_code=404, detail="User not found")
token = build_token_for_user(user)
return TokenResponse(
access_token=token,
user_id=str(user.id),
name=user.name,
role=user.role,
"""Deprecated unsafe binding flow. Use /wecom/bind-confirm with a one-time token."""
raise HTTPException(
status_code=410,
detail="该绑定接口已停用,请使用企业微信一次性绑定链接。",
)
+4 -1
View File
@@ -46,7 +46,10 @@ async def get_notification_time(db: AsyncSession) -> str:
@router.get("")
async def list_config(db: AsyncSession = Depends(get_db)):
async def list_config(
current_user: dict = Depends(require_director),
db: AsyncSession = Depends(get_db),
):
"""Return all system config as {key: value} dict."""
result = await db.execute(select(SystemConfig))
rows = result.scalars().all()
+48 -5
View File
@@ -1,10 +1,48 @@
import uuid
from fastapi import APIRouter, Depends
from datetime import date
from pathlib import PurePosixPath
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user, require_any_role
from app.services.minio_client import generate_presigned_upload_url, generate_presigned_download_url
from app.models.visit import Visit
router = APIRouter(prefix="/upload", tags=["Upload"])
_IMAGE_TYPES = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
def validate_image_upload(filename: str, content_type: str) -> str:
"""Validate client-declared image metadata before issuing a short-lived PUT URL."""
extension = _IMAGE_TYPES.get(content_type.lower())
if not extension:
raise HTTPException(status_code=400, detail="Only JPEG, PNG, and WebP images are supported")
if PurePosixPath(filename).suffix.lower() not in {".jpg", ".jpeg", ".png", ".webp"}:
raise HTTPException(status_code=400, detail="Invalid image filename extension")
return extension
def build_object_key(user_id: str, extension: str) -> str:
return f"uploads/{user_id}/{date.today().isoformat()}/{uuid.uuid4().hex}.{extension}"
def is_owned_upload_key(object_key: str, user_id: str) -> bool:
path = PurePosixPath(object_key)
parts = path.parts
return (
len(parts) == 4
and parts[0] == "uploads"
and parts[1] == user_id
and ".." not in parts
and path.suffix.lower() in {".jpg", ".png", ".webp"}
)
@router.post("/presigned-url")
async def get_presigned_upload_url(
@@ -13,10 +51,8 @@ async def get_presigned_upload_url(
current_user: dict = Depends(require_any_role),
):
"""Get a presigned PUT URL for direct MinIO upload."""
import datetime
today = datetime.date.today().isoformat()
user_id = current_user["user_id"][:8]
object_key = f"{today}/{user_id}/{uuid.uuid4()}.jpg"
extension = validate_image_upload(filename, content_type)
object_key = build_object_key(current_user["user_id"], extension)
url = generate_presigned_upload_url(object_key)
@@ -30,7 +66,14 @@ async def get_presigned_upload_url(
async def get_presigned_download_url(
object_key: str,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get a presigned GET URL for viewing a photo (1 hour validity)."""
result = await db.execute(select(Visit).where(Visit.photos.any(object_key)).limit(1))
visit = result.scalar_one_or_none()
if not visit:
raise HTTPException(status_code=404, detail="Photo not found")
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
url = generate_presigned_download_url(object_key)
return {"download_url": url}
+14
View File
@@ -12,11 +12,21 @@ from app.models.user import User
from app.schemas.visit import VisitCreate, VisitUpdate, VisitOut, VisitListOut
from app.utils.timezone import today_cst, parse_date
from app.services.minio_client import delete_objects
from app.api.upload import is_owned_upload_key
from app.utils.edit_log import compute_diff, append_entry, init_entry
router = APIRouter(prefix="/visits", tags=["Visits"])
def _validate_photo_keys(photo_keys: list[str], user_id: str, existing_keys: list[str] | None = None) -> None:
if len(photo_keys) > 9:
raise HTTPException(status_code=400, detail="A visit may contain at most 9 photos")
existing = set(existing_keys or [])
for key in photo_keys:
if key not in existing and not is_owned_upload_key(key, user_id):
raise HTTPException(status_code=400, detail="Invalid or unauthorized photo reference")
async def _enrich_visit(visit: Visit, db: AsyncSession) -> dict:
"""Enrich a visit record with customer/manager names."""
customer_name = None
@@ -137,6 +147,7 @@ async def create_visit(
db: AsyncSession = Depends(get_db),
):
"""Create a visit record. If companions are selected, creates draft copies for them."""
_validate_photo_keys(data.photos, current_user["user_id"])
visit = Visit(
customer_id=data.customer_id,
visit_date=parse_date(data.visit_date),
@@ -209,6 +220,9 @@ async def update_visit(
if current_user["role"] == "manager" and str(visit.manager_id) != current_user["user_id"]:
raise HTTPException(status_code=403, detail="Access denied")
if data.photos is not None:
_validate_photo_keys(data.photos, current_user["user_id"], visit.photos or [])
# Snapshot old values for diff
old_snapshot = {
"customer_id": str(visit.customer_id), "visit_date": str(visit.visit_date),
+3 -3
View File
@@ -111,15 +111,15 @@ async def wecom_callback_event(request: Request):
import logging
_log = logging.getLogger("wecom_callback")
_log.warning(f"wecom msg: type={msg_type} from={from_user} content={repr(content)} event={event} key={event_key}")
_log.info("wecom callback received: type=%s event=%s key=%s", msg_type, event, event_key)
# Triggers for binding
if (msg_type == "text" and content == "绑定") or (msg_type == "event" and event == "click" and event_key == "BIND_ACCOUNT"):
_log.warning(f"wecom bind triggered for {from_user}")
_log.info("wecom bind triggered")
_handle_bind_request(from_user)
return PlainTextResponse(content="success")
_log.warning(f"wecom msg ignored (no match)")
_log.info("wecom message ignored (no matching action)")
return PlainTextResponse(content="success")
+30 -7
View File
@@ -5,11 +5,13 @@ from typing import Optional
class Settings(BaseSettings):
# App
APP_NAME: str = "企迹-政企周报管理系统"
DEBUG: bool = True
SECRET_KEY: str = "change-me-in-production"
ENVIRONMENT: str = "production"
DEBUG: bool = False
SECRET_KEY: str = ""
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/qiji"
# A syntactically valid non-working placeholder keeps tooling imports side-effect free.
DATABASE_URL: str = "postgresql+asyncpg://qiji_app:invalid@localhost:5432/qiji"
# JWT
JWT_ALGORITHM: str = "HS256"
@@ -24,11 +26,11 @@ class Settings(BaseSettings):
CASDOOR_APPLICATION: str = "qiji-weekly-report"
# MinIO
MINIO_ENDPOINT: str = "localhost:9000"
MINIO_ACCESS_KEY: str = "minioadmin"
MINIO_SECRET_KEY: str = "minioadmin"
MINIO_ENDPOINT: str = ""
MINIO_ACCESS_KEY: str = ""
MINIO_SECRET_KEY: str = ""
MINIO_BUCKET: str = "qiji-photos"
MINIO_SECURE: bool = False
MINIO_SECURE: bool = True
# WeChat Work
WECOM_CORP_ID: str = ""
@@ -53,3 +55,24 @@ class Settings(BaseSettings):
settings = Settings()
def validate_security_settings() -> None:
"""Reject unsafe or incomplete configuration before the application starts."""
if settings.ENVIRONMENT.lower() == "development":
return
invalid = []
if len(settings.SECRET_KEY) < 32 or settings.SECRET_KEY in {"change-me-in-production", ""}:
invalid.append("SECRET_KEY")
if not settings.DATABASE_URL or "postgres:postgres@" in settings.DATABASE_URL or ":invalid@" in settings.DATABASE_URL:
invalid.append("DATABASE_URL")
if not settings.MINIO_ENDPOINT or not settings.MINIO_ACCESS_KEY or not settings.MINIO_SECRET_KEY:
invalid.append("MINIO configuration")
if settings.MINIO_ACCESS_KEY == "minioadmin" or settings.MINIO_SECRET_KEY == "minioadmin":
invalid.append("MINIO default credentials")
if not settings.MINIO_SECURE:
invalid.append("MINIO_SECURE")
if invalid:
raise RuntimeError(f"Unsafe production configuration: {', '.join(invalid)}")
+2 -1
View File
@@ -2,7 +2,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from app.config import settings
from app.config import settings, validate_security_settings
from app.database import engine, Base, async_session
from app.api import router as api_router
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
@@ -14,6 +14,7 @@ from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
@asynccontextmanager
async def lifespan(app: FastAPI):
validate_security_settings()
# Startup: create tables if not exists (for dev convenience)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+3 -3
View File
@@ -49,12 +49,12 @@ async def exchange_casdoor_code(code: str) -> dict | None:
"code": code,
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] token exchange failed: status={resp.status_code}, body={resp.text[:500]}")
logger.error("[Casdoor] token exchange failed: status=%s", resp.status_code)
return None
token_data = resp.json()
access_token = token_data.get("access_token", "")
if not access_token:
logger.error(f"[Casdoor] no access_token in response: {token_data}")
logger.error("[Casdoor] token exchange response did not contain an access token")
return None
except Exception as e:
logger.error(f"[Casdoor] token exchange exception: {e}")
@@ -67,7 +67,7 @@ async def exchange_casdoor_code(code: str) -> dict | None:
"Authorization": f"Bearer {access_token}"
}, timeout=10)
if resp.status_code != 200:
logger.error(f"[Casdoor] userinfo failed: status={resp.status_code}, body={resp.text[:500]}")
logger.error("[Casdoor] userinfo failed: status=%s", resp.status_code)
return None
return resp.json()
except Exception as e:
+49
View File
@@ -0,0 +1,49 @@
from fastapi import HTTPException
from app.api.upload import build_object_key, is_owned_upload_key, validate_image_upload
from app.config import settings, validate_security_settings
USER_ID = "a2d4447f-7b67-4304-ad9a-953007e75ef2"
def test_build_object_key_is_scoped_to_the_authenticated_user():
object_key = build_object_key(USER_ID, "jpg")
assert is_owned_upload_key(object_key, USER_ID)
assert not is_owned_upload_key(object_key, "cf610b28-51a7-423d-a55a-730e193c9971")
def test_upload_rejects_non_image_content_type():
try:
validate_image_upload("payload.exe", "application/octet-stream")
except HTTPException as exc:
assert exc.status_code == 400
else:
raise AssertionError("non-image uploads must be rejected")
def test_upload_rejects_an_invalid_filename_extension():
try:
validate_image_upload("payload.txt", "image/jpeg")
except HTTPException as exc:
assert exc.status_code == 400
else:
raise AssertionError("invalid extensions must be rejected")
def test_production_rejects_placeholder_database_configuration(monkeypatch):
monkeypatch.setattr(settings, "ENVIRONMENT", "production")
monkeypatch.setattr(settings, "SECRET_KEY", "a" * 32)
monkeypatch.setattr(settings, "DATABASE_URL", "postgresql+asyncpg://qiji_app:invalid@localhost:5432/qiji")
monkeypatch.setattr(settings, "MINIO_ENDPOINT", "minio.example.test:9000")
monkeypatch.setattr(settings, "MINIO_ACCESS_KEY", "qiji-app")
monkeypatch.setattr(settings, "MINIO_SECRET_KEY", "a-secure-minio-secret")
monkeypatch.setattr(settings, "MINIO_SECURE", True)
try:
validate_security_settings()
except RuntimeError as exc:
assert "DATABASE_URL" in str(exc)
else:
raise AssertionError("production configuration must reject placeholders")
+20 -16
View File
@@ -1,45 +1,49 @@
# This compose file is for a single-host development/integration environment.
# Production deployment must use the approved platform pipeline and secret manager.
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: qiji
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER must be set}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
POSTGRES_DB: ${POSTGRES_DB:-qiji}
ports:
- "5432:5432"
- "127.0.0.1:5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
minio:
image: minio/minio:latest
image: ${MINIO_IMAGE:?MINIO_IMAGE must be a pinned MinIO version or digest}
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
ports:
- "9000:9000"
- "9001:9001"
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
volumes:
- miniodata:/data
backend:
build: ./backend
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/qiji
ENVIRONMENT: development
DEBUG: "false"
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER must be set}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/${POSTGRES_DB:-qiji}
MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
MINIO_BUCKET: qiji-photos
MINIO_SECURE: "false"
depends_on:
- postgres
- minio
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
volumes:
pgdata:
Binary file not shown.
Binary file not shown.
+38
View File
@@ -0,0 +1,38 @@
# 企迹(qiji)代码审查报告
## 结论
**结论:不符合上线条件。** 仓库存在可直接导致身份伪造、基础设施未授权访问和对象存储越权读取的高风险问题;在整改并完成复测前,不应部署到中国电信生产环境。
审查基线为 `c38a9e7`2026-07-28 拉取),审查依据为工作区提供的《中国电信软件研发规范(修订版)》相关分册。结论为代码与仓库材料审查结果;未取得 Git 平台分支保护配置、生产环境网络策略、制品库、扫描平台和测试平台的实际证据,相关项均不能视为已合规。
## 发现的问题
| 优先级 | 问题 | 规范依据 | 证据与影响 | 整改建议 |
|---|---|---|---|---|
| P0 | 生产默认密钥与弱口令,且数据库、MinIO 和控制台直接映射宿主机端口 | 安全分册 2.4(须更换默认口令);3.1.4【强制】(禁止硬编码敏感信息) | `backend/app/config.py` 中 JWT 密钥、数据库密码和 MinIO 密钥均有可预测默认值;`docker-compose.yml` 使用 `postgres/postgres``minioadmin/minioadmin`,并开放 5432、9000、9001。Compose 未覆盖 `SECRET_KEY`,因此按默认启动时攻击者可签发任意角色 JWT。 | 删除所有生产默认值;启动时校验密钥长度、随机性和非默认值;使用受控密钥管理/注入;数据库和 MinIO 仅加入内部网络,不发布端口;轮换已暴露的凭据。 |
| P1 | 任意登录用户可为任意对象键签发下载 URL(水平越权) | 安全分册 3.3.3.10【强制】(对象操作的 SQL/查询须附带当前用户身份条件) | `backend/app/api/upload.py``/download-url` 直接接收 `object_key` 并签发 1 小时 URL,未查询照片所属拜访记录、客户权限或对象键前缀。已知或猜测键名的用户可下载其他员工照片。 | 不接受裸 `object_key`;由照片所属业务记录 ID 查询对象,按当前用户角色、所属人和客户授权校验后签发;对象键应使用完整 UUID 前缀并记录访问审计。 |
| P1 | 系统配置读取接口没有鉴权 | 安全分册 3.3.3.9【强制】(管理 URL 必须校验当前用户权限) | `backend/app/api/system_config.py``GET /api/system-config` 未注入 `get_current_user` 或角色校验,会返回全部配置,包括可包含业务提示词等内部配置。 | 至少要求已认证用户;建议仅 director 可读管理配置,并对可公开的配置采用明确白名单。 |
| P1 | 企微绑定流程信任客户端传入的 `wecom_userid`,且写入时未校验唯一性 | 安全分册 2.1.8(避免未经授权访问会话状态);3.3.3.9【强制】 | `POST /api/auth/bind-wecom` 使用 `req.wecom_userid` 优先于受保护的绑定状态;`services/auth.py` 直接赋值并提交。攻击者可将自己的 Casdoor 账户绑定到任意企微 ID,形成账号关联劫持/覆盖风险。 | 仅接受服务端保存、一次性、短时有效且与发起人绑定的随机令牌;服务端从令牌取企微 ID;数据库加唯一约束,并在绑定/解绑时保留安全审计。 |
| P2 | 直传上传未实际限制文件类型或验证内容 | 安全分册 3.3.4.9【强制】(扩展名白名单、不可直访目录、系统命名、图片处理、上传日志) | `/presigned-url` 接收但未使用 `filename``content_type`;对象固定以 `.jpg` 结尾,预签名 PUT 后服务端不检查 MIME、魔数、大小、恶意内容或上传结果。对象存储由独立端口暴露。 | 采用受控后端上传/隔离桶,限制大小和白名单;校验文件魔数并转码图片,病毒扫描后再移入正式桶;记录上传审计。保留现有的系统生成键名优点。 |
| P2 | 日志可能泄露用户内容和第三方认证响应 | 安全分册 3.1.1【强制】(日志不得保存口令、密钥和其他敏感数据);3.3.3.11【强制】(未经验证输入不得写日志) | `backend/app/api/wecom.py` 记录企微发送人和完整消息正文;`services/auth.py` 在认证失败时记录响应正文或完整 token 数据。周报/客户信息可能含个人或经营敏感内容。 | 使用结构化日志、字段白名单和脱敏;禁止记录 token、企微正文及认证响应体;限制日志访问并设置保留期限。 |
| P2 | 缺少生产部署与变更可追溯材料 | 部署管理分册 2.2.1、2.2.2、2.3、2.5、2.6 | 仓库无上线测试报告、版本—制品—代码版本映射、部署/回退/数据备份方案、审批记录或部署验证用例;`docker-compose.yml``--reload` 和源码挂载启动后端,不是可审计的生产部署方式。 | 建立发布包:版本 Tag、制品摘要、SBOM、测试报告、部署/回滚/备份方案、审批和验证记录;生产镜像使用固定版本/摘要,不使用开发热重载或源码挂载。 |
| P2 | 未发现自动化测试、质量扫描或 CI/CD 流水线 | 测试管理分册第 4 章;流水线管理分册 3.2、3.3 | 仓库未发现 `tests`/`test` 目录或测试脚本;前端 `package.json` 只有 dev/build/preview;无 GitHub/GitLab/其他 CI 配置。缺少单元、集成、系统测试及安全/质量扫描的可追溯证据。 | 为关键鉴权、上传、企微绑定和数据隔离补充单元及 API 集成测试;在 MR 和合入时强制执行测试、SAST、依赖/SBOM 扫描、构建和制品上传;设定测试准入/准出与缺陷闭环。 |
| P3 | 仓库内提交 Excel 二进制文件,且缺少发布 Tag 证据 | 代码管理分册 4.3【C0】(不得提交 xls/xlsx 等文件);5.1【C1】(发布版本必须打 Tag) | 根目录包含 `weekly_report_template.xlsx``weekly_report_template (2)_补全.xlsx`。当前 Git 历史未见发布 Tag。README 和根 `.gitignore` 已存在,符合代码管理分册 2.3 的基础要求。 | 将模板转入受控制品/文档库,或经规范例外审批后保存;创建语义化发布 Tag,并确保 Tag、制品版本和发布说明一致。 |
## 已验证情况
| 检查 | 结果 |
|---|---|
| 后端语法编译 | 通过:`python -m compileall backend/app` |
| 前端生产构建 | 通过:`pnpm run build`(有第三方注释与单包体积超过 500 KB 警告) |
| 代码基础材料 | README、`.gitignore``.env.example`、Dockerfile 与 Compose 文件均存在 |
| 访问控制抽样 | 拜访、日纪要等 CRUD 对 manager 的所属人校验已实现;但对象下载和配置读取破坏了整体最小权限原则 |
## 整改优先顺序
1. 立即轮换所有默认/已提交凭据,停止暴露 PostgreSQL、MinIO API 与控制台端口,并禁止默认 JWT 密钥启动。
2. 修复对象下载授权和企微绑定状态校验,补充安全审计日志。
3. 建立上传隔离、文件校验与脱敏日志机制。
4. 建立受保护分支、合并评审、测试/SAST/依赖扫描流水线及制品版本追溯。
5. 按部署管理分册准备测试报告、回滚方案、审批和上线验证材料后再申请上线。
+41
View File
@@ -0,0 +1,41 @@
# 中国电信研发规范整改计划
## 目标与验收门槛
目标是在下一次生产上线申请前,消除 P0/P1 风险,并以测试、扫描、制品与审批证据满足安全、测试、流水线、代码管理和部署管理分册的要求。
生产上线门槛:P0/P1 为零;所有变更通过代码评审、单元/API 集成测试、SAST 与依赖扫描;发布 Tag、制品摘要、测试报告、部署/回退方案和审批记录可追溯。
## 已完成的仓库整改
| 项目 | 状态 | 交付物 |
|---|---|---|
| 禁止不安全生产配置启动 | 已完成 | `backend/app/config.py` 在生产环境拒绝短/默认 JWT 密钥、占位数据库连接、MinIO 默认凭据和非 TLS MinIO 配置 |
| 限制基础设施暴露 | 已完成 | `docker-compose.yml` 要求运行时传入凭据、固定 MinIO 镜像版本/摘要,并将服务绑定到 `127.0.0.1` |
| 修复照片下载越权 | 已完成 | 下载 URL 由拜访记录反查照片归属,再按 manager 所属人权限校验 |
| 关闭不安全企微绑定 | 已完成 | 停用客户端提供 `wecom_userid` 的旧接口,仅保留一次性令牌确认流程 |
| 管理配置鉴权 | 已完成 | 系统配置列表接口仅允许 director 访问 |
| 上传与日志基础防护 | 部分完成 | 上传仅接受 JPEG/PNG/WebP 元数据并使用用户隔离的对象键;回调和认证失败日志不再写入正文/token |
| 测试与构建流水线 | 已完成 | 增加上传/配置安全测试和 `.gitlab-ci.yml` 的后端测试、前端构建任务 |
| 清理禁止的二进制文件 | 已完成 | 移除仓库根目录两份 `.xlsx` 模板;系统仍可由导入模板接口动态生成模板 |
## 剩余工作与责任边界
| 优先级 | 工作项 | 责任角色 | 截止建议 | 验收证据 |
|---|---|---|---|---|
| P0 | 在密钥管理系统创建并注入数据库、MinIO、JWT、Casdoor、企微和 AI 密钥;轮换历史弱凭据 | 运维/安全 | 上线前 | 密钥轮换记录、部署变量清单、无明文密钥扫描报告 |
| P0 | 为 PostgreSQL/MinIO 配置私网访问、TLS、最小权限服务账号和备份恢复演练 | 运维/DBA | 上线前 | 网络策略、TLS 证书、账号权限清单、恢复演练记录 |
| P1 | 将上传改为隔离桶或受控后端流:校验文件魔数、大小、转码、恶意内容扫描后才入正式桶 | 后端/安全 | 5 个工作日 | 自动化测试、扫描日志、隔离策略和抽样验证 |
| P1 | 在生产数据库为 `users.wecom_userid` 增加唯一约束,并将一次性绑定令牌改为 Redis/数据库持久化存储 | 后端/DBA | 3 个工作日 | 迁移脚本、并发绑定测试、令牌过期与审计记录 |
| P1 | 配置保护分支、MR 至少一名评审、禁止直接推送 `main`/`develop` | Git 平台管理员 | 2 个工作日 | 平台截图/导出配置、MR 审计记录 |
| P2 | 接入 SAST、依赖漏洞/SBOM 和容器镜像扫描,并将高危结果设为流水线阻断条件 | DevSecOps | 5 个工作日 | 流水线报告、阻断规则、漏洞例外审批 |
| P2 | 补齐鉴权、对象访问、企微绑定、文件上传的 API 集成测试;明确测试准入/准出及缺陷闭环 | 测试/后端 | 5 个工作日 | 测试计划、用例、报告、缺陷清单 |
| P2 | 准备生产发布包:Tag、制品摘要、SBOM、测试报告、变更单、备份与回滚方案、上线验证用例 | 项目经理/运维 | 每次发布 | 发布审批记录和可回放部署记录 |
## 发布执行顺序
1. 合并本整改分支后,由平台管理员开启分支保护和审批规则。
2. 在非生产环境注入新密钥并执行迁移、回归测试、SAST/依赖/镜像扫描。
3. 演练备份、回滚、上传隔离和对象下载授权,关闭所有 P0/P1 缺陷。
4. 创建语义化发布 Tag,以 Tag 构建制品并生成版本—制品—代码映射。
5. 获得测试报告与上线审批后,采用人工审批触发生产部署;上线后执行验证用例和安全日志抽检。