diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..40828e0 --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/README.md b/README.md index 6cfb82c..a405e5f 100644 --- a/README.md +++ b/README.md @@ -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 文件作为生产部署方案。 + ## 环境变量 | 配置项 | 说明 | 必须 | diff --git a/backend/.env.example b/backend/.env.example index f67f3ac..7b00f47 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 208636d..10f94bd 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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="该绑定接口已停用,请使用企业微信一次性绑定链接。", ) diff --git a/backend/app/api/system_config.py b/backend/app/api/system_config.py index 9307453..72e73de 100644 --- a/backend/app/api/system_config.py +++ b/backend/app/api/system_config.py @@ -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() diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index c979403..bfb4e89 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -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} diff --git a/backend/app/api/visits.py b/backend/app/api/visits.py index d38ad12..5158046 100644 --- a/backend/app/api/visits.py +++ b/backend/app/api/visits.py @@ -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), diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index 3ba3277..4bebd2e 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -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") diff --git a/backend/app/config.py b/backend/app/config.py index 14a9d28..3b60f75 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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)}") diff --git a/backend/app/main.py b/backend/app/main.py index 7d11e4b..2cbfa84 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index f0372e1..581534c 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -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: diff --git a/backend/tests/test_upload_security.py b/backend/tests/test_upload_security.py new file mode 100644 index 0000000..6e438c4 --- /dev/null +++ b/backend/tests/test_upload_security.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index 29f89dc..a3ec8df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/weekly_report_template (2)_补全.xlsx b/weekly_report_template (2)_补全.xlsx deleted file mode 100644 index 11f7a0a..0000000 Binary files a/weekly_report_template (2)_补全.xlsx and /dev/null differ diff --git a/weekly_report_template.xlsx b/weekly_report_template.xlsx deleted file mode 100644 index ba8b0bc..0000000 Binary files a/weekly_report_template.xlsx and /dev/null differ diff --git a/审查报告-中国电信研发规范.md b/审查报告-中国电信研发规范.md new file mode 100644 index 0000000..18aa904 --- /dev/null +++ b/审查报告-中国电信研发规范.md @@ -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. 按部署管理分册准备测试报告、回滚方案、审批和上线验证材料后再申请上线。 diff --git a/整改计划-中国电信研发规范.md b/整改计划-中国电信研发规范.md new file mode 100644 index 0000000..0d1b6ae --- /dev/null +++ b/整改计划-中国电信研发规范.md @@ -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. 获得测试报告与上线审批后,采用人工审批触发生产部署;上线后执行验证用例和安全日志抽检。