build(security): add migration and CI security gates
This commit is contained in:
+4
-1
@@ -6,9 +6,12 @@ backend_verify:
|
|||||||
stage: verify
|
stage: verify
|
||||||
image: python:3.12-slim
|
image: python:3.12-slim
|
||||||
script:
|
script:
|
||||||
- pip install --no-cache-dir -r backend/requirements.txt pytest
|
- pip install --no-cache-dir -r backend/requirements.txt pytest ruff bandit pip-audit
|
||||||
- PYTHONPATH=backend python -m compileall -q backend/app
|
- PYTHONPATH=backend python -m compileall -q backend/app
|
||||||
|
- ruff check backend/app backend/tests
|
||||||
- PYTHONPATH=backend pytest -q backend/tests
|
- PYTHONPATH=backend pytest -q backend/tests
|
||||||
|
- bandit -q -r backend/app -lll
|
||||||
|
- pip-audit -r backend/requirements.txt
|
||||||
|
|
||||||
frontend_build:
|
frontend_build:
|
||||||
stage: build
|
stage: build
|
||||||
|
|||||||
+1
-1
@@ -9,4 +9,4 @@ COPY . .
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["sh", "-c", "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"]
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Establish the tracked baseline schema and replace application-startup DDL.
|
||||||
|
|
||||||
|
Revision ID: 20260728_01
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-07-28
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
import app.models # noqa: F401 # Register every model with Base.metadata.
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260728_01"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: Sequence[str] | None = None
|
||||||
|
depends_on: Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create the current schema and upgrade tables created by older app versions."""
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind, checkfirst=True)
|
||||||
|
|
||||||
|
# These columns were historically added in application startup code. Keep this
|
||||||
|
# compatibility path so existing deployments can adopt Alembic safely.
|
||||||
|
statements = (
|
||||||
|
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''",
|
||||||
|
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''",
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE",
|
||||||
|
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE",
|
||||||
|
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID",
|
||||||
|
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'",
|
||||||
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)",
|
||||||
|
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||||
|
"ALTER TABLE daily_notes ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||||
|
"ALTER TABLE work_plans ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||||
|
"ALTER TABLE mini_business ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||||
|
"ALTER TABLE key_visits ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'",
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_wecom_userid "
|
||||||
|
"ON users (wecom_userid) WHERE wecom_userid IS NOT NULL",
|
||||||
|
)
|
||||||
|
for statement in statements:
|
||||||
|
op.execute(statement)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove only objects introduced by the tracked security hardening revision.
|
||||||
|
|
||||||
|
The historical base tables pre-date Alembic and may contain production data, so
|
||||||
|
they are deliberately not dropped by a downgrade operation.
|
||||||
|
"""
|
||||||
|
op.execute("DROP INDEX IF EXISTS uq_users_wecom_userid")
|
||||||
|
op.execute("DROP TABLE IF EXISTS wecom_bind_tokens")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Alembic migration revisions for the Qiji backend."""
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import struct
|
import struct
|
||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from defusedxml import ElementTree as ET
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -38,17 +40,20 @@ class BindConfirmRequest(BaseModel):
|
|||||||
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
|
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
|
||||||
items = sorted([token, timestamp, nonce, encrypted])
|
items = sorted([token, timestamp, nonce, encrypted])
|
||||||
raw = "".join(items)
|
raw = "".join(items)
|
||||||
return hashlib.sha1(raw.encode()).hexdigest() == signature
|
# The WeCom callback protocol mandates SHA-1; compare_digest avoids timing leaks.
|
||||||
|
expected = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()
|
||||||
|
return hmac.compare_digest(expected, signature)
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_msg(encrypted: str) -> str:
|
def _decrypt_msg(encrypted: str) -> str:
|
||||||
"""Decrypt WeChat Work callback message. Returns plaintext XML."""
|
"""Decrypt WeChat Work callback message. Returns plaintext XML."""
|
||||||
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
|
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
|
||||||
ciphertext = base64.b64decode(encrypted)
|
ciphertext = base64.b64decode(encrypted)
|
||||||
from Crypto.Cipher import AES
|
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
|
||||||
cipher = AES.new(key, AES.MODE_CBC, iv=key[:16])
|
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
||||||
plaintext = cipher.decrypt(ciphertext)
|
|
||||||
pad_len = plaintext[-1]
|
pad_len = plaintext[-1]
|
||||||
|
if pad_len < 1 or pad_len > 32 or plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
|
||||||
|
raise ValueError("Invalid callback padding")
|
||||||
plaintext = plaintext[:-pad_len]
|
plaintext = plaintext[:-pad_len]
|
||||||
msg_len = struct.unpack(">I", plaintext[16:20])[0]
|
msg_len = struct.unpack(">I", plaintext[16:20])[0]
|
||||||
return plaintext[20:20 + msg_len].decode("utf-8")
|
return plaintext[20:20 + msg_len].decode("utf-8")
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
# App
|
# App
|
||||||
APP_NAME: str = "企迹-政企周报管理系统"
|
APP_NAME: str = "企迹-政企周报管理系统"
|
||||||
ENVIRONMENT: str = "production"
|
ENVIRONMENT: str = "production"
|
||||||
@@ -49,11 +50,6 @@ class Settings(BaseSettings):
|
|||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
env_file_encoding = "utf-8"
|
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-66
@@ -3,7 +3,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.config import settings, validate_security_settings
|
from app.config import settings, validate_security_settings
|
||||||
from app.database import engine, Base, async_session
|
from app.database import engine, async_session
|
||||||
from app.api import router as api_router
|
from app.api import router as api_router
|
||||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
||||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
|
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
|
||||||
@@ -15,71 +15,6 @@ from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
validate_security_settings()
|
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)
|
|
||||||
# Add columns that may be missing from older tables
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
|
|
||||||
))
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
|
|
||||||
))
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
|
|
||||||
))
|
|
||||||
# edit_log columns for change tracking
|
|
||||||
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
|
|
||||||
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
|
|
||||||
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
|
|
||||||
))
|
|
||||||
# New columns for v0.3
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE"
|
|
||||||
))
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE"
|
|
||||||
))
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
|
|
||||||
))
|
|
||||||
# New columns for v0.4
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'"
|
|
||||||
))
|
|
||||||
# v0.6 — manager color tag
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)"
|
|
||||||
))
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_wecom_userid "
|
|
||||||
"ON users (wecom_userid) WHERE wecom_userid IS NOT NULL"
|
|
||||||
))
|
|
||||||
# system_config table for v0.5
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"CREATE TABLE IF NOT EXISTS system_config (key VARCHAR(64) PRIMARY KEY, value TEXT DEFAULT '')"
|
|
||||||
))
|
|
||||||
# leaves table for v0.7
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"CREATE TABLE IF NOT EXISTS leaves ("
|
|
||||||
" id UUID PRIMARY KEY DEFAULT gen_random_uuid(),"
|
|
||||||
" manager_id UUID NOT NULL REFERENCES users(id),"
|
|
||||||
" leave_type VARCHAR(20) NOT NULL DEFAULT '事假',"
|
|
||||||
" start_date DATE NOT NULL,"
|
|
||||||
" end_date DATE NOT NULL,"
|
|
||||||
" reason VARCHAR(500) DEFAULT '',"
|
|
||||||
" submitted_by UUID NOT NULL REFERENCES users(id),"
|
|
||||||
" created_at TIMESTAMPTZ DEFAULT now(),"
|
|
||||||
" updated_at TIMESTAMPTZ DEFAULT now()"
|
|
||||||
")"
|
|
||||||
))
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_leaves_manager_id ON leaves(manager_id)"
|
|
||||||
))
|
|
||||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)"
|
|
||||||
))
|
|
||||||
|
|
||||||
# Read notification_time from DB (or use default 17:30)
|
# Read notification_time from DB (or use default 17:30)
|
||||||
notification_hour, notification_minute = 17, 30
|
notification_hour, notification_minute = 17, 30
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -91,14 +92,14 @@ class WecomClient:
|
|||||||
|
|
||||||
if errcode == 45009:
|
if errcode == 45009:
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
time.sleep(1 * (attempt + 1))
|
await asyncio.sleep(1 * (attempt + 1))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
last_error = data
|
last_error = data
|
||||||
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
||||||
last_error = {"errcode": -1, "errmsg": str(e)}
|
last_error = {"errcode": -1, "errmsg": str(e)}
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
time.sleep(0.5 * (attempt + 1))
|
await asyncio.sleep(0.5 * (attempt + 1))
|
||||||
|
|
||||||
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
|
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from jose import jwt, JWTError
|
import jwt
|
||||||
|
from jwt import InvalidTokenError
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -15,5 +16,5 @@ def decode_token(token: str) -> Optional[dict]:
|
|||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||||
return payload
|
return payload
|
||||||
except JWTError:
|
except InvalidTokenError:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# Fail CI for parse errors and undefined names. Broader style cleanup is tracked
|
||||||
|
# separately because the existing codebase has a substantial legacy backlog.
|
||||||
|
select = ["E9", "F63", "F7", "F82"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# SQLAlchemy resolves these relationship targets from strings after model import.
|
||||||
|
"app/models/*.py" = ["F821"]
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.140.7
|
||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
sqlalchemy[asyncio]==2.0.36
|
sqlalchemy[asyncio]==2.0.36
|
||||||
asyncpg==0.30.0
|
asyncpg==0.30.0
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
pydantic==2.10.3
|
pydantic==2.10.3
|
||||||
pydantic-settings==2.7.0
|
pydantic-settings==2.7.0
|
||||||
python-jose[cryptography]==3.3.0
|
PyJWT==2.13.0
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
python-multipart==0.0.18
|
python-multipart==0.0.32
|
||||||
minio==7.2.10
|
minio==7.2.10
|
||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
apscheduler==3.11.0
|
apscheduler==3.11.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.2.2
|
||||||
pycryptodome
|
cryptography==49.0.0
|
||||||
Pillow==11.1.0
|
defusedxml==0.7.1
|
||||||
|
Pillow==12.3.0
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.api import system_config, upload
|
||||||
|
|
||||||
|
|
||||||
|
def _png_bytes() -> bytes:
|
||||||
|
buffer = BytesIO()
|
||||||
|
Image.new("RGB", (8, 8), color=(10, 20, 30)).save(buffer, format="PNG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_client(authenticated: bool) -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(upload.router)
|
||||||
|
if authenticated:
|
||||||
|
app.dependency_overrides[upload.require_any_role] = lambda: {
|
||||||
|
"user_id": "a2d4447f-7b67-4304-ad9a-953007e75ef2",
|
||||||
|
"role": "manager",
|
||||||
|
}
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_endpoint_requires_bearer_authentication():
|
||||||
|
response = _upload_client(authenticated=False).post(
|
||||||
|
"/upload/image",
|
||||||
|
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_endpoint_validates_and_normalizes_before_storage():
|
||||||
|
with patch("app.api.upload.put_validated_image") as put_image:
|
||||||
|
response = _upload_client(authenticated=True).post(
|
||||||
|
"/upload/image",
|
||||||
|
files={"file": ("photo.png", _png_bytes(), "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
object_key = response.json()["object_key"]
|
||||||
|
assert object_key.startswith("uploads/a2d4447f-7b67-4304-ad9a-953007e75ef2/")
|
||||||
|
assert object_key.endswith(".jpg")
|
||||||
|
stored_bytes = put_image.call_args.args[1]
|
||||||
|
with Image.open(BytesIO(stored_bytes)) as image:
|
||||||
|
assert image.format == "JPEG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_configuration_cannot_be_read_without_director_authentication():
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(system_config.router)
|
||||||
|
|
||||||
|
response = TestClient(app).get("/system-config")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from app.config import settings
|
||||||
|
from app.utils.security import create_access_token, decode_token
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_round_trip_and_tamper_rejection(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "SECRET_KEY", "x" * 32)
|
||||||
|
token = create_access_token({"user_id": "user-1", "role": "manager"})
|
||||||
|
|
||||||
|
assert decode_token(token)["user_id"] == "user-1"
|
||||||
|
assert decode_token(f"{token}tampered") is None
|
||||||
+10
-3
@@ -13,6 +13,11 @@ services:
|
|||||||
- "127.0.0.1:5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
image: ${MINIO_IMAGE:?MINIO_IMAGE must be a pinned MinIO version or digest}
|
image: ${MINIO_IMAGE:?MINIO_IMAGE must be a pinned MinIO version or digest}
|
||||||
@@ -41,9 +46,11 @@ services:
|
|||||||
MINIO_BUCKET: qiji-photos
|
MINIO_BUCKET: qiji-photos
|
||||||
MINIO_SECURE: "false"
|
MINIO_SECURE: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
postgres:
|
||||||
- minio
|
condition: service_healthy
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
minio:
|
||||||
|
condition: service_started
|
||||||
|
command: sh -c "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
+7
-5
@@ -16,8 +16,10 @@
|
|||||||
| 关闭不安全企微绑定 | 已完成 | 停用客户端提供 `wecom_userid` 的旧接口,仅保留一次性令牌确认流程 |
|
| 关闭不安全企微绑定 | 已完成 | 停用客户端提供 `wecom_userid` 的旧接口,仅保留一次性令牌确认流程 |
|
||||||
| 管理配置鉴权 | 已完成 | 系统配置列表接口仅允许 director 访问 |
|
| 管理配置鉴权 | 已完成 | 系统配置列表接口仅允许 director 访问 |
|
||||||
| 上传与日志基础防护 | 已完成(仓库侧) | 上传经后端校验尺寸与图片内容、重编码为 JPEG 后才写入私有对象存储;回调和认证失败日志不再写入正文/token |
|
| 上传与日志基础防护 | 已完成(仓库侧) | 上传经后端校验尺寸与图片内容、重编码为 JPEG 后才写入私有对象存储;回调和认证失败日志不再写入正文/token |
|
||||||
| 企微绑定令牌持久化 | 已完成(仓库侧) | 一次性令牌存入数据库并原子消费;启动时创建企微 ID 的部分唯一索引,防止重复绑定 |
|
| 企微绑定令牌持久化 | 已完成(仓库侧) | 一次性令牌存入数据库并原子消费;通过 Alembic 迁移创建企微 ID 的部分唯一索引,防止重复绑定 |
|
||||||
| 测试与构建流水线 | 已完成 | 增加上传/配置安全测试和 `.gitlab-ci.yml` 的后端测试、前端构建任务 |
|
| 测试与构建流水线 | 已完成(仓库侧) | 增加上传、配置和 JWT 安全 API 测试;CI 执行编译、Ruff 致命错误检查、pytest、高危 Bandit 扫描、依赖漏洞审计和前端构建 |
|
||||||
|
| 可追踪数据库变更 | 已完成(仓库侧) | 新增 Alembic 基线迁移;容器启动先执行 `alembic upgrade head`,不再由应用启动时直接改表 |
|
||||||
|
| 企微回调解析与 JWT 依赖加固 | 已完成(仓库侧) | 使用 defusedxml 解析回调、cryptography 解密、常量时间签名比较;移除存在无修复传递漏洞的 `python-jose`/`ecdsa` 依赖 |
|
||||||
| 清理禁止的二进制文件 | 已完成 | 移除仓库根目录两份 `.xlsx` 模板;系统仍可由导入模板接口动态生成模板 |
|
| 清理禁止的二进制文件 | 已完成 | 移除仓库根目录两份 `.xlsx` 模板;系统仍可由导入模板接口动态生成模板 |
|
||||||
|
|
||||||
## 剩余工作与责任边界
|
## 剩余工作与责任边界
|
||||||
@@ -27,10 +29,10 @@
|
|||||||
| P0 | 在密钥管理系统创建并注入数据库、MinIO、JWT、Casdoor、企微和 AI 密钥;轮换历史弱凭据 | 运维/安全 | 上线前 | 密钥轮换记录、部署变量清单、无明文密钥扫描报告 |
|
| P0 | 在密钥管理系统创建并注入数据库、MinIO、JWT、Casdoor、企微和 AI 密钥;轮换历史弱凭据 | 运维/安全 | 上线前 | 密钥轮换记录、部署变量清单、无明文密钥扫描报告 |
|
||||||
| P0 | 为 PostgreSQL/MinIO 配置私网访问、TLS、最小权限服务账号和备份恢复演练 | 运维/DBA | 上线前 | 网络策略、TLS 证书、账号权限清单、恢复演练记录 |
|
| P0 | 为 PostgreSQL/MinIO 配置私网访问、TLS、最小权限服务账号和备份恢复演练 | 运维/DBA | 上线前 | 网络策略、TLS 证书、账号权限清单、恢复演练记录 |
|
||||||
| P1 | 在生产环境接入恶意内容扫描,并确认对象存储桶策略不允许匿名读取 | 后端/安全 | 上线前 | 扫描日志、桶策略和抽样验证 |
|
| P1 | 在生产环境接入恶意内容扫描,并确认对象存储桶策略不允许匿名读取 | 后端/安全 | 上线前 | 扫描日志、桶策略和抽样验证 |
|
||||||
| P1 | 在生产数据库验证 `users.wecom_userid` 唯一索引创建成功,并完成并发绑定演练 | 后端/DBA | 上线前 | 数据库迁移日志、并发绑定测试、令牌过期与审计记录 |
|
| P1 | 在生产数据库执行 Alembic 迁移,验证 `users.wecom_userid` 唯一索引创建成功,并完成并发绑定演练 | 后端/DBA | 上线前 | 数据库迁移日志、并发绑定测试、令牌过期与审计记录 |
|
||||||
| P1 | 配置保护分支、MR 至少一名评审、禁止直接推送 `main`/`develop` | Git 平台管理员 | 2 个工作日 | 平台截图/导出配置、MR 审计记录 |
|
| P1 | 配置保护分支、MR 至少一名评审、禁止直接推送 `main`/`develop` | Git 平台管理员 | 2 个工作日 | 平台截图/导出配置、MR 审计记录 |
|
||||||
| P2 | 接入 SAST、依赖漏洞/SBOM 和容器镜像扫描,并将高危结果设为流水线阻断条件 | DevSecOps | 5 个工作日 | 流水线报告、阻断规则、漏洞例外审批 |
|
| P2 | 补充 SBOM 和容器镜像扫描,并将高危结果设为流水线阻断条件 | DevSecOps | 5 个工作日 | 流水线报告、阻断规则、漏洞例外审批 |
|
||||||
| P2 | 补齐鉴权、对象访问、企微绑定、文件上传的 API 集成测试;明确测试准入/准出及缺陷闭环 | 测试/后端 | 5 个工作日 | 测试计划、用例、报告、缺陷清单 |
|
| P2 | 扩展鉴权、对象访问、企微绑定、文件上传的 API 集成测试覆盖,并明确测试准入/准出及缺陷闭环 | 测试/后端 | 5 个工作日 | 测试计划、用例、报告、缺陷清单 |
|
||||||
| P2 | 准备生产发布包:Tag、制品摘要、SBOM、测试报告、变更单、备份与回滚方案、上线验证用例 | 项目经理/运维 | 每次发布 | 发布审批记录和可回放部署记录 |
|
| P2 | 准备生产发布包:Tag、制品摘要、SBOM、测试报告、变更单、备份与回滚方案、上线验证用例 | 项目经理/运维 | 每次发布 | 发布审批记录和可回放部署记录 |
|
||||||
|
|
||||||
## 发布执行顺序
|
## 发布执行顺序
|
||||||
|
|||||||
Reference in New Issue
Block a user