From 29e4e4a804dea553a2cca5df9e330f28fdf345ab Mon Sep 17 00:00:00 2001 From: v6ole Date: Tue, 28 Jul 2026 11:33:05 +0800 Subject: [PATCH] fix(security): remediate telecom compliance findings --- .gitlab-ci.yml | 19 +++++++++ README.md | 9 +++-- backend/.env.example | 15 +++++--- backend/app/api/auth.py | 34 ++++------------- backend/app/api/system_config.py | 5 ++- backend/app/api/upload.py | 53 +++++++++++++++++++++++--- backend/app/api/visits.py | 14 +++++++ backend/app/api/wecom.py | 6 +-- backend/app/config.py | 37 ++++++++++++++---- backend/app/main.py | 3 +- backend/app/services/auth.py | 6 +-- backend/tests/test_upload_security.py | 49 ++++++++++++++++++++++++ docker-compose.yml | 36 +++++++++-------- weekly_report_template (2)_补全.xlsx | Bin 22924 -> 0 bytes weekly_report_template.xlsx | Bin 18344 -> 0 bytes 审查报告-中国电信研发规范.md | 38 ++++++++++++++++++ 整改计划-中国电信研发规范.md | 41 ++++++++++++++++++++ 17 files changed, 293 insertions(+), 72 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 backend/tests/test_upload_security.py delete mode 100644 weekly_report_template (2)_补全.xlsx delete mode 100644 weekly_report_template.xlsx create mode 100644 审查报告-中国电信研发规范.md create mode 100644 整改计划-中国电信研发规范.md 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 11f7a0a34441a527b7fe80f6007a86ee0133f96f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22924 zcmZ^~W0+-2vo2b;ZQEv-ZQJa!ZQIpl+qThV+qP}qT5I2P&;HKcXZ)G-nHiZG^Nq|m zVnjyDO96vG0sQlX%I@+1^ZCCX$e%C9wnp*}wswy6@;_oIKR-bHE9R9X*OLzj0PqY1 z0D$n{#SHB1=-jNWvJ$2(Q5aE%uLHh?M!GVrn~26IP;S9>3q936<(O^PdZm#PI4(Ty ziRSPk1QCttwMtRojck)8FZmi)uX1)XaH|^XbvwHb-Y1{Ew}@w#YDXj>mn5nT+qX^? zfh13q%ezv$aeireiih887yVj>GUNghYf0#E&F@|Wt4!H}5T!t$_r8{iWh8&f+o=w$ z7bKl;-ocA?i9MxB1j;tMEeAMU6>WL<>zfIbB-CMU@o$v4Nc!>>QlhosZDGd11d+`b z8#++5i&Y|{FRWcVok%PZ0_1eBgbp0DfeVVaE@Ue(kiBwV;?6*{Vo?70JRrwnpmClC2rKAJrI;x4l9$SzoI@ z`~)$di5#r2>$c*MS0}>}&k0>0Tl{d-^^LZ}v^{*(q_FVsLKNgVuQd+o*m-25pODA)F>A)5ezJvi|22(h}_$cV2Kf}M% z8e)znGinU2dgLIbBT9~V@;#LI#Im6+xIgfPL#J1pHD@Ez1F#)*f5BZ~|0mW;D$|J~ zKUfF-V2%7ASUWp9*;@aDc2mN(Tp$C2-?q#LzW8=t5egV|E&nRgexT^FE-a;qB@k_% zNjt{3EBEooZTanGdWYAu&h^}&<2I)!uSK3Qm=`=?s8lf)VGE|4dd&s~$J#=D88@jr z7@T&Xh>v^xR*c0?0irfH8lf|o%Mgqs1nc^3_focuREGKD%JLBJFs-sRHAW3eh6f$& z0E9@K%v$LdF0cL0K*sf?ZG5z$7Pd&ctqq2LNbS0sz4H|H=N_h4Eh+%(S-MXG0p^#<$xhWO!>1e>?~k zVG|*hDi6YwZ(Q#1JeTUQ?N4A0J<@?LV~?%T%O-}4Jsmwsx!CDirRljC}O zL3`7!lkjQ+`3Akn%4<6G{r=R!!zvUPI;968~5$=YMAByX4AJktG;Z?HrMrg z+lFKjL7^wJ2CseiGsQ?RGv=H3*Ml5B`g(A~r-#RT8Ml|u*&;*YdEe>hY3Z-hZ(SaI zr$YMb?C0%}9NS{&`t~K})(2lamCSSBE%=$rd+}J9^x@{VH}ed!RXwxpm7}u~YdrGh zjK_`+Icv|yd1w3z^KQo*NsbAfPp7Ja=N8@7rLz)CT)mpDSR3p0iX5A(RXy_>Hj)Mh zi;8-P+1<*vxqEh@dRA;bHVPs(XuBYt&~{< zlXdwaKOH0z<19cRLl*ox4ihi}N%>2FBqP|Y;RfFKfqLz1H#4f69#DxIPMg-c~|4pMB1zodw#6^BUlT!Ax6gYP7UpP3A@ z5{ieafpHT0sHzMy8fW1bmZW9`!f8cdk)a6pHsX_DEK_pOXf>$C>S59;XMzc$#X)LC z0Lqh;gz;Li`9#!e>U}U&dE^P9MGWbwAikjKr@%PITLWPgU@YQ);2mftiBWW z1LH_!ZCJ&VA)u&J38^21lS_kUTgy}@6azuyO;}-f6>LyTGNT{hX>L7(a0mO7d9a=6L(R~WelAbRY_i7^pHU-1H)t*6USmr2r(9?HWQu3 zS2>ldKVtVsl_>nhgk>y)R!|#&xg;n@q9=Jyfy>*!kfWyjkDi3p5GP6|mbB?yi>g)T}#D%YXOH zG#vQ~u#3u1$u;iNa~jyvA(uoM{-!?*DU`AwjZk_mCnSe zEPz+C8~@(Wh$jLq`VPs&AT?vq-+aj|0*gKl&5VUL)2=}KS!pP%6Px0`to{ctAs{j~ ztjHr#$UHQ?i1F$x58t0J# z%A~i@Ad|&jFL(XJpiE?DKbyi}!ZSj=J83B%zg??d71d08WKmxrQ){mcFxql408}d) zPqh4QxsB(GJ12D;wwZ+9u_CS4EOSC1#lvuw)=d0JmBA3a5DXe9Pmj%ArWnSdziS~GzRoPg3M6<0qfYeh^VI1{Yl;cLBur7waKNytBm7M+V!6&LVOfqU za$<70#k|-ZB-czpW@;gV5oH?ehbnfL)QO*DIn8CG4>lb@N~=na0^)T1^m=--yj(x40LdfV6}2AHx) zE+*-}T}ZqX5gKk2HReMm42{D`lu;*)7jfVvw4sLKTw&4RZG;tS5r_S|KO#JQN2NJ0TY? zhq@fqS=?m)V62-T4I|56gfN}?QA?&;I&(y!BK3zK`AqB8SOdb2yUt;7qqDD=QwoCg zVuC8!^C4rd+i=5m7-MXHjl^qO%p&feq=!dYML0$eb43luN6Hati@8N$Bi<30h#GWF z4CF1KO4*YO`Yb3$8j%TEkRTqFA0BHH8_^#N`Ml8)TE~CH5Dw%Z67o5yqlF_*+$6RW zWlNrqzFX7(pwnkAru((}8TWiNn0z?Qnw=AuLw7>z(>W7+68i0KC_EBttn)(~ABEK; zrwW>K76rqx5{3>Rsss)Knt_A1qJ|xS@s>*ggO(uzSOC&u)trG@nX@ptsD%7TY{96~ zz$hL4yKsyO5k&R6Kj4P{fD8FXn+_&it?}a(Fk>h{I2@-X+!gR=>AjCtg9t{Ob$%Rz zco}s)Nf8U4hg1!}@fBy?*P%wa&nvWVFf)xLEEBwvAk>m5O8kcz=`4nw_cnqeFX|Pc zljUd?gn$wG-#<{)^0_(;7a+rw;hl;zr9I^k_9_HS$}JM@IwtnQF{xO_yedpnq9}~z zKuAs~A$4@;q~(Hb(`8tQilhQ+g`y0|-GXXTKqZaY0nsdk6$<_%RR*wVIh2sc|F7Ds z82mZ|QUJkwyo3#7O2mSG#%4cz0^mte z_uhyKz(j>qQI1jNbf=`{oeQywNMElv%e`Ny3AGfJ`XN-;5#GapxuEjq_Hl)Q7Y06E zqG8Ix(%_S!}GEd4T!lk^g{&@OE^fcB{gTM zH84xTqQMJGvgL>(%o69KvJ^Brxzo??ne_0Fhmo0K3h+xgOqg-TM;1~Q{WEl;-lXV_-(cKr)5Z_IwfXkist9-(n5!qQhI+=}^a2N_8jL>*`ED6QVk2;h z{V+rtx<6rc+PV~(HYEoeaeA*Q2qBvy`9)zhQF;-9Lt@p>hJu#m8w)G=Sr^eYsKNyV zDrG#6eO6>idyO!SL?9A#)jvKtPEl<@i84C$i&X%WNUQ@aa@a*gwH{G~WLZ;yT6uIi4N1|G0!r@JL}4Ibt1~5S_;LHpQh#ZxY$081(KU2Pxabvt);dJ@T^uS-gySHy7SwC(#S|{bYJkAZAx$xv{ zd-U2v^`7Z-7CSyeQjoHzK5aj{udSAEZ;pf>>jh2dW$EF;KLsV&X>?dNPsoKLJJzqX zzVC~cMKk+1FVDXmpJP8??{^O!oRw=tlyP;=)!wouBOxZgOV!BWYVzJ$V60z`6Fn$R zOTH*v-J-|#PYc~rzZGy(e=q7UeD-OvUw?uBXUZmT8?Cb$6ac^#=l}Y?&-Bmxeo~rN zYylgJ-!}ClAHl*hY^rergJiwtMU-Y)lBx>*G>ko&89`yMwCT5ZEa33&i!=~?N88Qo z&h#+b@4dz4*k6uYCK^S9_LueH*Iak0C>;+k^)BCE=cnUy&gERZS9_HlV@>Zus#nXC z(|lD^BCndbg_XErc9lz4dDU{U*=nTVX~TTux-7tiKIBJrJvcj_$yl4*mi4?SFkC6Z$AE3gqQ)cee>XJ6XR3XN)X zw<{B8df8+q3aIZM2`FvLE|bkLUA4z)Ui4j)7AgG{Zj5MaAoU#Hi}yRTM`?OpU{st~(IEJhf{!ZY-SzujfLxm*g8*j4YWKS%f6 zZ5aS0#)Me~Orm=ik4o}EWzfGz)$Rv*8(Jb;*kNz{0RllBOaK{zdHS8uQ1{cGocZ{UA3r06zlUo~qJSbGTu0*HMh}_jq)nFRPs@4z z%tu7n$-+G%p09u)j3R}U`krwAZ$II6QDH$-HM0^+a7+mn<$y3l2_Q#32S!ySEd5dN zKLKI-D@fPexW+iM{b<5}06i zaDvD@s36v;MqvJy93g6!Ary(I2tSJnkRJjRkPK=cWfTi}EMNd#R_Hs6TNh>80g#h8x z3jNdbnM6kWnB`^`XZz_)@o6;>EPLHZCH(DogbiL_veuXo1ldLA%v#F3W4?45F-6{(J%hmE&U+OA7U|Q8Mw!JLD$t=r% z>_yVh(@#1y-EozvLFLV&Ca4v_L1Abc#0Q(iKqlp@Dfc6O41CoJN0qXJVq8hBK-Y(e z{zfT3*rYQHbC}+~D>)>vs`i_+<;czxVg4sexrhELrrljQJT`iOXWGrD(c4I|Zj}5a zq?4I`AAX!4GGBOf2`KwOf+PQO2L9!(uK7HIhJ&VIFZ)#r7Dd4H&a3pM!nT4a&=4H4pSBZub^pYJuFH(PXjc%)Hg zc!1}Tt8Rv#OV3w*SL9h&x7`*7LS8A){T$0-g~{S*@UeXt0xA8@l!UVTM(^~Q)uPGZ zzR^0xG)=R1b=5Cf=!VD}%};3TQ!H|5Ze7aTXQtl z-7SB;$p$ibr#^l3HpV^TQDSMp0xqHlGj=rDiQxCi*j(3Lz`9kIY&@m=@-saO_Q6O}%CyT;B~{i%n~f>HpdfZ?qk_vEK)rW}1e<1#`al#a8hN zjZ2Q;{P>FMu{}S~GQoMJ>^7AlsQ;cba=e{Dzjwc+xBreVIhFzrZr5xrCaji8M*D#G z*~etLnP7VsHby}G!g8mwTw}R=DfsMkMZ`pLpM!FDG8q3*20oqz;KhekH3qIYFU3t} z=~!3Yv*BM9h;_ysXEo@t;a4D{_eoOYL9P#Zux#~Mr)^n+)h2=OUxqZT8J6Z}%wR-T zoH%AgR+t!1T|I{(g5&B_=c9u5qM($AzZ~F%27}}Jv|y7ks1M*LoakXXTH8nYjEP4s z#~4CDi9^5^gOV^~4I%Oa`^@d|BnsM(BE~@>RvVa^wrIvT1N6a|@E?=7zt;%XM|ctvG&2zeka_uE zLiy>Z;F|h;QF-3|!5gAeMnv;7J;9gCN##l={ADLbD> z(F_KZ$uq&2L(w%%NS_e~?JI`oDGU!Fp43*@lyG{eU4|2417rlJ9r7z?kyEBYUlb%U zl5r;rILSsSbPxD@pe=Xyv3gvzj10(V#av4Xg|Dw3rn4fSq<&t*@zbSe6ztHG1XCL%h|hO3A3i=deRpR{uw%Y+VuQu$TVmPy43$wwr-w%S z_gBLwN}KF_@gg0qx=6|u{aUM@YR`8wN5fkar~De%jy$W>^Gx%w*tBImFzvi)e@tTl zyQgZ3IkfLNdJX3i%cgViSU>T+`A(9#Zd?Cu-B%d+`t^O(5RE6b`65(eI+{83r7hfR z-Rb6*EB@X3;wk3aTFdW+0rgJIj}Pz)ZOV)>paKejas?z~h(h=WQ4JyDk?Or<`uo%Met)dmT^pcE=XqCw6Xf7GKUrgwlr5 z-^&KJ*70f&avu4hDC+GxBAC>@s6JUt$BdU;CU09h)BW-Maldg9G=its*-=tQf$Wk% zP0ax1loy;M6Ln}UTwDmwa)*vm?>Kk)MExlC;d|~%q|h~CCvA65%~^Ha$o1nT2G>;M zdS|Q{qHEpY()5)dpb1jjynDJ5Y7_fU0mrudi(}Q{?U}hQi zD3mDx@**>&prnO;r||Wzt=giexEscNjz< z#>=RMRnLn=S+$prq*_A~wFjXCY+$y__Ya(l4gJ!Qnsx3BLx!BJ^b51<6m-I+Hxvdk zn&L6YpGgG^M=@V4JIE8EH@g~uCYA0y%CAkvG7F8cqh=-$C1H8PCML?LM(DdPWeXfd zW2`?l%omS8KT;wR**^n9MZwZhcf2Ybca`zjdYlAlMoY(bUuGjZA_=pOo>Vm(%4$Mq zSsxaSG$SHrCKm+8mS>Gi8R?hwCfE4FH8S2zOLHnZ^4K5}C1FB~!$4fM3O?b|8W;(Q z3@0jZ{8zD+r9HGIZ&_EgJDDf3W&`=&Am|*|w=Z*A5lvtsqoy@+#GrumiTFC70dZ$G z9m+lIoB1{o>TWrGgwVMAV>CU&yP9J#-40g7 zqoE_>A!P`I(;|q9xHCh?$k&xoK$XeKjrEG5DEc7=tJ-T58eY{#nHpPb5AU*eK+Ibr zHe=yn{$wi9RdMn4ZkgKOxc$KXQ469+qC*lMHTF=Qjs3SKH@YM812MeB7E2qc6*l~$ zU6taZ$`Hn7igD3F-Iwc5z)XtRY1qI+@U`t<(S%y~LwgL8nQJ)n=x=J8JqIfLC{FMN zzb>cnE$kOaz8WteCbW;!n(X41Ht}{C68lG|{?sIR4y}E`p*Hg8^Z9TW-yj-$$=Ej!Xtcu_?|Xk*zHK>1GX- zTmW>U7xJ=Y8#B?*y8n&r*T(l0U(WPPeYfv8^SN*Zmx+j9?Ilqp8l%*lA%3nN-_HSX zoQk6&35gLX>%M|G>toaB-qj5Iv0ins!L@Ke*e zAZBPx*rv2T>+2$2`UtL5Gk8e{8A0tc6E%wWj_f@Y7D_h(^myPY&FHaKwudLolA754 z*KPkkro5&s>Ol^2OD%)X~!UQ6QHuYcu#!^MZeSoZ-n*v zu5VF&Yj|==<`=9%xTMN-0YX*Z{8^x&ki(z0k2tGh#-UoAdssG)kG+-(A0Iz5rM29p)NH5j*Ok@RzoF6pTm|$`{o_xWkdv8-waNcc|M>6#wQU0i z0KkL&Uo~9+pV+^`|JMFoYRK8Hv!V3BuecKYly#9eV}m8de*ihNwIQgb%nGrm|GNXiD18@+2QcOyKFs zl*cO|Br~@@7WRzMIV5=om=o?#CXn#`x6P8@c%v#>3k0ej1G{-t^uq}XBL3%$r zMOetMc9uxU*u`@7$Qy6EH-H)b5Y*`SbaLYW@31|(7|{U3NdQ|U?bJGXjy0j!&|>1vD{WLRtl zd5mpv0PxK58<$QJ)=uc2O5KekGSK13P;~%T-%EX{BhqNNsY?S$g&A6M_-Qka!|;k3 zhZ-j?m7?(Ya<(0zOigWB+fE2I737!b9w3G}}Au)LzTBM6Ph)-I|DNMDCRf8+K zGY9p&M<`CaxH!awWDq{mQR*#MHYH9{JkDMiDy$E3MXmHmpZ1H0wj9-s6F-Jn7h`)r~p(fP!KO?hFTwAzz#lghJF)8&%-7 zJ-g5Y_Y2@K^`>_U((q;pdH^?zov}RyuhwEUfLg^42*cR%3a|^7N zl7{3(Hbq~B-FIJqN8s;UUVnAHEw`=3O;_h-&-3%Ea&2DACLI*@(yn&*TJO5kgn}fQ zyC5buc}Gzj$$4_A6`CP$jD_S0#(B#z$xZ4E>3|7bB)tl!-AmM~daH^?y8&AoILbz5Ym)Hp~zE~-H zW2!AjjRn0_oLmfq*QdIRi~dZbAO_lb1&Lab!7;Szg;Jd`7bDq_ON-3V+a(oOSr>2Y zWs>RK0*&unaF|Mj%{x>mv5Xr-v%6=G>u*8r`P&rLV;@e{L`b|sD;8l4eKV^(_y8?~ zo*P{P;@dcYigM|#$Ok9Hy?Fyh2bSR?(Awcb&m$4@NjEPA@d+ z-H{#2dCxYTp_q}8W!%ohO1SP~1OGam8YW|=I9xKApD})#7NJ>H`KdqpweA@iaS|?) zp)y?$l#1FMpql)-0zQ`!Wz=oNhrB92Y$*|`>n9c{BAF3TPoxO z(1hC80wx2~50?LyVErqOF3!acBsvkdv9C}0W5{cf%ExW5;C<%=w{bL zXWmD>5!W;AIVTl`DghuC81{43#nqIW7`@M{o~`ef1HKZUZ1-n_x5tr}Wg}v_;^Ln7 zo6n9@z8<{SWxmh9YTGft($c)SKR?&;`K0B#@LsRW(&D$fUe|lvS(%yNK|D)->&AXC zaKF!w&(5Y@`=ok&yBwXv^U25<9d7e|e0*Kfd_5oY#ZG-Z+%K$O|Mp2uPxt=bKN(nA zIXHQ5xh@vV_Iln;IbB^QPE2e)N;!4?z8zn{e@Q9X-;U~ZXT86gtXO5wccUOyL6qXY zT&b8{Wv>xEb=6I*;H+6mN<&Ut$FFbJK(7j_OKy8+MzysTFs2?#=bATX8CY!>higL` zzZ%L?TVr+EzIe~WtwTW@Z0{b*d})j8%w}Iou-pl#eKK%!tp(m`uZy4XhRi4e+!rw=WL>mr-)P+Bw2pZ zZ}TNmZ3*-H%cEcwxmLD*;<1>}jakBV>G@1kNFTP(T-Qvwr-Qg|ug3dY0v5YJjarpR zKMbSU)xk8dw0)}b)HQ}!_+n`(Q*Ax=k}OU?s=ZI2$BFbMxMHokv3{f{81f;I-QsY* z08?ZjaJ4<={Ws*z>AeJ$0bnj`Ek!ae6r*6PltjZdV}mng-kpHPUPWf@s$bLE^W?u2ANfV1bt_wC zv29PWOzW*mo7NJgvE^gC27OP+x47E7nk@S@%hG{~u5kI5;Uu5ly`-b=mvG~JzQ8&B;^tm4g1q z`l#%Y3BY`k&sEvc$@U2`A8Qj%Qy~PO_VKF`HYEr3qR88m#=+!qz;k})(hV8Ug@2L+s4p?2NKEniZ z(4{yLbrDyazv5L?u1tp~gFgR?_@0C9iU?m_MwLB0^5wx+?Cv3497Yqhi<%p0{m>e&N^C`6YTMz%)h{#mgpX7<@K*XHl&*Xc)w5`54UGjqeNz%`4aE~9&P!+8FE9g9)BbwZ28K|tSc*LnTjw`UqX*Al zCCxE#i8Qz6LOL=IGTZv7Rh_Z&ic%%U`2p}Bw8W@kOa3&=R-mEfsQyf0n$g;YipQRt zM+xYxToUJ(HOz>H zLP2X(6F~E=z%kAlS@Xt2E=lH_J>@`c8;X7g2ZZJR9GQw#Bi%9o5qbz$rao&N-wePt z+egaG3L6-wD;Dh7jM0F)^B63%&cAtxeHwh*5Spkk-3?x>(*&2TvIZ={AmH~%!Y;}k zdAZ{i)J^A1gHuu{3E*0N0#Glwmph!=B=525*O4r{ql@v1>F$k8!rK!mgu;n_xNj>o zi2&xI+{?5a;qcu&6~7t^HJ;RWsL^X zhrFEl^T3mNJtzr73xq8WZ=uB>Q}6oQ#sXerksJ8S(2qWjm;M|b)aTi6eyl?2`V1v} zFkw}>u{hPRpJ)QmGdzraEV*G1bc9UZNqxCMyQ_C>W4W~0`fvft*eZXJ2H!Shg9rw3{BEhP{6jtZ|)$g1k0<9~N)MWHMZI1elEI8V{e2ZA!B!(aeQg7|s4ROsLUq@h$ zgUa`}$4}kLgjB?hx>1%#oAr=1pDm;yhd*_m3l?b*02Sv~=M|F$plAC|)=(J~VKE$` z;e+>wrJr)yFqwA?c2^1^aT;h-byMXbGi_0Uili(zA+gR~&S$IAG_$k`b;m2HNl_3h z*eu3Q1A0rP5Ms9r*6&`@O7_SGSOU~uB#JTMyQ|L{I)4JUFVsy1M%(LQkA%EXx-v~~ zn~F#5sspgkQ|B;wfQ+tJv%odyx<4YV1&v5e%|Yed`uC@S6YwJPzMLTuafx6E4peQS zyYBFVMA6rc{#>z5 ziTp-|faMW_S>#yPr2o|b_;P=4#+db1rY2ml{YbdbiR@2tz@DZ>}O|(8=DqP;+Fb7`+(yHT?g#{60kdwU=ilVlHeCCYb2d`D$ z$%K{5CTUP}9!RR*Y4#TA52n>L_+S7PaW0VfxIIkH=m9B!4q58O17Jm?BFX%EP-7~C zBfqTuAngVb^wVs>Z{O$Pz5%!hJZir4&pu{Hw!ge!!bATJNtl2WU zZ^4c0%G7gw`7DF2Cf^XG2WQ?gJvUK2hp^!UlYCn%<}qJ7v5=8=sS=WTj&59qEC&CI zsJs{-fSHn+b*()R{Klw64goJEISj8!04+eYn!0hSkKZph>v1)sj0 zKeON(7MdCLq&o@*h@xLlTIcFAcAo^L85s%K1HJ06%4jn#4Ty>(T2ZnYXE?x=U^{c) z_Cos^gz`m%-1uZ*%oI}rXbSqM0H-Zy-c&aZJp)tOS zDhhlp3#ynym2}2Z{D7>+S0u9MP9ir)Qj$q3E^hxY- zqsAjUjcyh~2-NE5D|abLs!n)Ho2q~X>a5@l$RQMRV!p0Bp(El0tZ{#vx5I!b5Le*R zEVbQ3FL$c8G>oXu=T+aDL8&1Bn$U0QC>d!Vp4Z10<8SUyG%CrsNuotPEyR*;!ieYm zOn&bvvloZ>B8{WJ2T& zdSm{uMln&xt|mmg&f{;Y{lYc#Pk$!(h2u)FhN?OVB%QChYVf5*l$$@rjf-r#-Y6&H zzo?zdTa^=g#p3J zDWjA%t6VTxu9jE4Yg5f9Xf`bC2H*K7b|(A)yO&NhkHg5GVa5)VuKhy5)~>Ei`D7F7Y!0t83gMK%zoriz8-tF3IWS(+1l2F z)g>A+7JvQ(B;CoLLM5X`mMq*^tmSV~X^A%LC00qLMmV%U~ zU%71{Aklr5gbjKe$eSR1h(%AQU`d^8I%m_W7UOZ*P!1)MS+W)%oqn}zNk&!pjWzuC zC>XzXwE!QLF)qw9PzDfKnpZ1KZ06!3Gr# zlF0{1Su!;fy^nxh5mxw4!ab!7N2@tO3%XnHd@$n3C|fUzb_rNE-#IS^-o>Y5=o>8= zI4v4N=DSwlLe4a<3qr*T*_6KYaL&KbOf_mOGtGf0tfMf^2DEB(PY*3Ffli=4h5nc?eMb@l7&E% zVeIEQBWI1fQxbQp0b9_)qgQ)2sAGXLHc9-{$OXIm9O1=la2Yc=dv84|5|hEuC@a@LDG$vw1b$9>j~4_6x4%-JgN+Ls;-G=r676 zr$~57zmt=fuZV(-u8d$t1kpO11Y4Kj;CJlcBD=G&LiU1eGbBDcH^fX}ln&pS%5%9E^XQ?@5^Es=?#&1;H7Y9b6J#cD0?*`|p3T>m$YTfOyrUmSTftlt33qOS zf{K40Z0{q*eltb`ge2x?(CeVa>c#%t!BErqB3CC3;AJC;kBgA*hk@YSw8PHKN z+K@V-1ao!2HXz>a&bZo=YJRSPB~MP0Ngw&a<)Q|8DsQDA?h>kc;8<wChGx)kA@C)^xFvWzr-1arN7-(#Ybw(XC*bfWa(Y7+=_j7a_Qf9 zcoV?fjF$idBN2>&fqD~v7Ta|9cCby059l@_X7iM4`4k? zm2{_l_x{Rfbzb(7T?|d;KRbqcq-!Hh5TSE;7?#l=K39Grs^@*Kv;UrUIcIHXq+(8O z#uc{_*L*ss@7^-KkF!!R4p_Miy!JfM=|gsO#f{}9OuFUo`-o0>nF#G_y7xWfd{4DX zCCicNfbAX+$kE6pJ?)R+Cxl^m%Yl;*(qLozWmursEBk|GunJ(Y0c5^RHG7QQ)AQ@5 z9D^5I)>`EKazODYcYEuWEs2&|_N?@@tB*zZ)j8gd+H$ida0^?rd{k>wNUQ$=t~J8f zy8k58)9+gp3s^y))*$`$ZpiVE1Bw%j5>-ftF$K*LMMEUe_Suyhv+^M%O@_sY^T(w1 zT&brSf(a@DO4<;jpBgQ)h$OX`>FKmr)(F*vlo%VRt>@jHIv0cbObzzftTJoKvRTZj zYyCTsa1z3ksNMqs^A<0qb*XpLWR&3og@p@@0NCtMAYL%Xb>3G|`VLa;dx8Pa*Ft>V z-&2dh)lSG-w~OZyVclcHN-I6D-d>(;PMs32%%-eQQD%##z*c_`w`+K8%;9M>p%vP} zDOj$WC9D^tcg;jL(vtwlSx}|Os1;wDc~uvxtlKw5oNUNozx&wO;ogEkVqeKi1Gb(3 znSR%3JWitNzM3qHg!w5(Fwcu@vDLghq6X@XU@lPKysB=NM|Gt+pr(he*oD%5!5nri zEw1&gcRg^O^KSUCE1?RuZlzsHPU+jg>+)Ipf(Aw{;8%C$8}T`0hl{N2y=5eV$2l-G`NAs0sV$FHq1*CQPz{ z86Vs;xOs2jc9y4<(z>NfdyQ{*IHq;0)VC;bOuP3p^|xJrEI5lb zO6_97-74r!+akMH-LHPF?OU=0uEbApyt$#v=Ea$8RAlB{V+_v+IscUc(1(G`TX7Ce z)T;Fp+av8KL7Io#lclj1fCUhx$^V4EHqs3IhkBd=W9=2N+36s}s_v&JSN2M^Q+ z#y(kSTTY&rAOEZpz@Omb5f}C@ar}jIWC`-xh1RnX9*=fj!VB zIFP-95Tfg8GaK7DKNoxdX@3_DXOVh)h>;FIzul*VN^zq4Ms|LU^^iRUeCuO{7QVZJ z8eAc5F^brH0K7>0E7V@9Pi;VbZNzsfn^RvyjRER+DB8$#W5y!rs3{Y%RNfMHJyoo9 ze4-PVRc1oU8`9YidM`f5Rm7w$a*P{$)2sFCZxG*2r+qzr4* zP^{z=B)-g;7}u<@MvQnl($U!#0%nR3vfEG(M;)GGsbsMb1xTKrjB?F-9u`OdmtmY& z;3dK8ban0R%;Kuq5_CbGSID9?_NIL~(x;dOEYtO-k{O-T_RXXtNhTyq?j?AGIheXh zs&)9Yh=e`u-CVGVf}K1wFwITa=()h?}G4l;86rqKLeFu+tP#DT%B} zHj3?VI$p}Pj{%O6uL-KHJ(x(nqq`Zms}1w_jkp!Ck_N?~Y^)8W+hcq<0hLtE;SDof zY-6l=_xqQWga>a+v`VUEn_bphJ)v0%^{{tiO@4RJDIR9G@(I-o|8Aa&ZT(j<;UdPJ z7`K$>!y%)0olHcANz|EzwmdVjoQ1^J$+p1koTeefofj~mMTn>>!L4U8uzJzj z!y9QbC#qZ+LJF6TpMq_%%}(YxxRQ?nx(c?c)>F&Fy-SmOX{-AR_eW~jR1XRAj8tc2 z*s(~=*%cptkBvFl59+@qP|)f_z8cs~+KUtzN!qmE>P66e$uO6deR%Xqnx=Ydq=(hJ zk^3R|%TC6_1{q&*&=s)=YzrZRmy*1$n_q|H%v+_8rYCofT0^0C?1hMJHu|#Y)C-tX zO#_fWfRFj(uaP++1=0hsyEWHu*wkXmf(_=kIt{KX4T_t7QNV97A_&eNk}h4b(j*tZ z9|#onVErZ*g(L19NcwSAoTFkao$NE`+ULcb-k>K}$<;{=G3IOtl!j!V)l@`2$|Fke zImj@}xEZBnsMVHz`PQAo(UukF3=O7ZkFD9`ouFY>qXZgZaUI!3iYoUT*N0Fqbyrqy z4(0D3Qxxa$iro(GFY1k>89j0=x#q?#bg&@Pm|=sZaR60t^yma<*9Il2C|Ji zarsGcHjsK`62>=NX=OT~XzFNeIdVnF}pfaWtb!cJj>Md8HG=Y=-~^ zBs6oVb5QC%EvSX=S-+60WsFIunn-a{?VnFcL9;JOZf1xt3`Re3rUc1UXL5F8~IAMu6b(obsa79!?fd4>>kFCyPDf&^EyvZXIhm5c&{8$ z&PtRzwTL~=*lRBkL={>hbAnn_98Too>R-HVSxP9Tt%ZZ{J3lPR_t@LpuLbLuc?1w9 zUvqYxmW7R%v{2Q4D6aV_Uf*$8n(GwJ=jB<E) zoyT&{Jsz)gHvGH{ryt)gZF!634RYzgZd%>4`VkzoBoqIEpB-lEr#m7XCe0mZH@X4> z%4JB4DHllIv}OzXtHlZE)VHP5oxudulzIJ$=ZWv>o+zaGz}dNYZD{sJa*pD#2=4+2 zmiE9ghAJ}1FXw$L>8>&kj^r@(fo%V}r&?Gk+!jEj3<*ini}<(*X?5jGMnJTfBRDb# z59G$izI3$1Mont^Z%YMpWUrmD3nRJ~61-)_%x^=t041KMJCr}$@>dSwpUgSr#5jP9 z;(_~)DmJ4(-^iSI`+dWHX5E(fe)~>AQsw=Smus#pg(DDtb6CQyNbljB)a8RWV6Cz;fh_Ox$J4|L zEtDSndpxZ7?bNECQPPx-+29YQ6dmZSjt7apcZm4gP(2$m3?F6R$C( ziZtKMfH(>V?*>W4BP0QWov(?rqP$=$}u-5liYV&i6d z8toW--tW|LYPaWyB(QpYt4tDosz7Dte(ItfEQl`)S@?Z#!jRVWE;$a;%Wr_;nz>!+ zzC)1|c~cpQ%A#0d4Wrx+*8$Xl2`Y;|sa4T)>VCtlI|{ryv_#w{7*zr5TiMbO!?q{I zV)=aApJZL4goAV~toZI3)6p5=Yu7ssz+)(ca6Z}9et#+YA!x6%T-dPu2R54!z;8#U z0HIESd%0@awV~1~Ps7k;LDx%oQ3PJcwHXluw0R$7dlI9SVgJIjg~7 zs#6bj-cHF!k=lRzB6Owb9_tiyu9Ok@H|FdK`!8jukMMarRd(8bBSro9B++S6PdlIM zyZ$@u7fCAmXwgYHFVOp_`}`lJe^YXz!_j#qFW}aws)Y;qKP;2~HR)4|#PfDK=}DA} zLPZk)3-E6%De0U6{&su+wi3GIJJoY;C5PW(|KkSHCybuub|I%*;5;sbp7Vw-0X>#< zAt74{Ewc3gpF0Nk8T9Y8@qf=9J#qX(q{14;hzekSFd3lOGUo}ottoc`*`4{%=<17mvefa218W&QiQN@UVkaEVW zfi4BTM*Bj_B~(KGAEcaBY@{qmU; Qqr#xVU`E-`0CbA`KZozvmH+?% diff --git a/weekly_report_template.xlsx b/weekly_report_template.xlsx deleted file mode 100644 index ba8b0bce012de96d2469a01b0092e6694a7e7bcc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18344 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 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. 获得测试报告与上线审批后,采用人工审批触发生产部署;上线后执行验证用例和安全日志抽检。