From 7ce431b09acf1cff7680151625f036edae31436f Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Sun, 20 Sep 2026 08:21:45 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(ci):=20=E9=92=89=E7=89=88=E4=B8=8D?= =?UTF-8?q?=E5=BE=97=E4=BD=8E=E4=BA=8E=E5=A3=B0=E6=98=8E=E4=B8=8B=E7=95=8C?= =?UTF-8?q?=E7=9A=84=E6=A3=98=E8=BD=AE=E9=97=A8=E7=A6=81=EF=BC=88scripts/c?= =?UTF-8?q?heck=5Fpin=5Ffloors.py=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 方向查证后确认:4.57.0 是本项目自己声明的下界,且写了两处权威位置 (pyproject.toml:59 带理由「VoxCPM2 / IndexTTS2 的 tokenizer 与 modeling 需要 较新 transformers API」+ requirements.txt),check_engine_compat.py 也按它检测。 所以违规的是两个 lock 钉的 4.52.1 —— 随便携包分发的就是低于自家下界的 transformers。 脚本实测:25 个声明下界 / 93 个钉版 / 1 处违规(transformers)。 不猜该改哪边,只把冲突摊开;CI 侧用 --allow-debt transformers 棘轮化 (名单内只报不拦,名单外新增即红),避免为了存量债务给每个 PR 挂常红灯。 放在 Security Scan 的 pip-audit job 里 —— 依赖问题的车道,且非必需检查。 同时把 docs/release-governance.md §2 的两处失真改掉: - 「git push 触发 release-please.yml」→ main 实际不可直推(GH006 + enforce_admins), 只有 PR 合入那一刻才触发; - 补上发布前置断言:本脚本 0 违规,且白名单应逐次清空。 自测中发现并修掉本脚本自己的一个 bug:_parse_ver 把 +cu132 的数字当成第四段, 会让 2.13.0+cu132 压过 1.2.3.4;已加本地版本段与预发布尾标的剥离及三条回归测试。 14 个用例全过,退出码语义实测:strict=1 / 命中白名单=0 / 白名单不匹配=1。 Signed-off-by: ReSerendipity --- .github/workflows/security.yml | 8 ++ docs/release-governance.md | 9 +- scripts/check_pin_floors.py | 185 +++++++++++++++++++++++++++++++++ tests/test_check_pin_floors.py | 99 ++++++++++++++++++ 4 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 scripts/check_pin_floors.py create mode 100644 tests/test_check_pin_floors.py diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c659f517..c0c4b941 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -34,6 +34,14 @@ jobs: pip-audit -r requirements.txt --desc # 2026-08-16 本地评估 0 已知漏洞,解锁为门禁;依赖升级由 dependabot 自动跟进 + - name: Pin-vs-floor consistency (ratchet) + run: | + # 便携分卷的钉版集不得低于 pyproject/requirements 声明的下界。 + # --allow-debt 是棘轮白名单:transformers 4.52.1 属已登记存量债务 + # (修它要重做便携包解析验证,见 docs/release-governance.md §1), + # 出现名单外的新违规即红。修好后请把 transformers 从白名单删掉。 + python scripts/check_pin_floors.py --allow-debt transformers + bandit: name: Code Security Scan (bandit) runs-on: ubuntu-latest diff --git a/docs/release-governance.md b/docs/release-governance.md index 1ef5aad2..d9e2be9d 100644 --- a/docs/release-governance.md +++ b/docs/release-governance.md @@ -17,8 +17,15 @@ ## 2. 发布流程 +0. **main 不可直推**:分支保护要求 3 项状态检查且 `enforce_admins=true`, + 直推会被 `GH006: 3 of 3 required status checks are expected` 拒绝(admin 也一样)。 + 一切变更走 PR;下面第 2 步的「push 触发」实际发生在 PR 合入那一刻。 +0.5 发布前置断言:`python scripts/check_pin_floors.py --allow-debt transformers` 必须 0 违规, + 且**白名单应逐次清空**。当前存量债务是便携钉版 `transformers==4.52.1` 低于 + `pyproject.toml:59` 声明的 `>=4.57.0`(理由:VoxCPM2/IndexTTS2 的 tokenizer 与 modeling + 需要较新 transformers API)——修它要重做便携包依赖解析并在真机验证,属发布级动作。 1. 确认 CHANGELOG `[Unreleased]` 条目完整;release-please 提交后自动收敛版本 -2. `git push` 触发 `release-please.yml` +2. PR 合入 main 触发 `release-please.yml` 3. 同步 `config.yaml` 顶层 `version`(release-please 不自动同步,需人工) 4. CI 盯到终态;容器镜像钉 digest 发布,禁止 `:latest` diff --git a/scripts/check_pin_floors.py b/scripts/check_pin_floors.py new file mode 100644 index 00000000..86b44695 --- /dev/null +++ b/scripts/check_pin_floors.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""断言:lock/钉版文件里每个版本都不低于 pyproject + requirements.txt 声明的下界。 + +背景(2026-09-20):便携分卷的钉版集(`launcher/requirements-small.txt`、 +`requirements-lock.txt`)里 `transformers==4.52.1` 低于 `pyproject.toml` 与 +`requirements.txt` 同时声明的 `>=4.57.0`(该下界的理由写在 pyproject: +VoxCPM2 / IndexTTS2 的 tokenizer 与 modeling 需要较新 transformers API)。 +钉版是为了便携包在真机上装得起来,但「装得起来」不等于「满足功能下界」—— +一旦降级到线以下,随包分发的就是一个自称支持 VoxCPM2 却带着旧 transformers 的产物。 + +本脚本不猜该改哪一边,只把冲突摊开:任一 pin 低于任一声明下界即报违规。 +`--allow-debt a,b` 是棘轮模式:名单内的存量违规只报不拦,名单外的新违规才 exit 1 +(与 mypy 棘轮同一口径,避免为了存量债务给每个 PR 挂一条常红灯)。 + +用法: + python scripts/check_pin_floors.py # 全部违规都拦 + python scripts/check_pin_floors.py --allow-debt transformers # 只拦新增 +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent + +# 声明下界的来源(PEP 508 依赖行) +FLOOR_SOURCES = ("requirements.txt", "pyproject.toml") +# 钉版集:== 才算钉,>= 只是下界不参与比对 +PINNED_SOURCES = ("requirements-lock.txt", "launcher/requirements-small.txt") + +# 比对时忽略的包:与引擎无关的构建/工具链层,lock 里由解析器决定 +IGNORE = {"pip", "setuptools", "wheel"} + +_NAME_RE = re.compile(r"^\s*(?P[A-Za-z0-9._-]+)\s*(?P>=|==|~=|>|<=)\s*(?P[0-9][0-9a-zA-Z.+-]*)") + + +def _parse_ver(v: str) -> tuple: + """版本串转成定长 4 元组,便于比较。 + + 先去掉本地版本段(``+cu132``)与预发布尾标(``rc0``/``a1``/``dev``), + 否则 ``2.13.0+cu132`` 里的 132 会被当成第四段,把 ``1.2.3.4`` 这类 + 真四段版本压下去。预发布与正式版按同值处理(PEP 440 下 rc < 正式版), + 本脚本只判「是否低于下界」,该近似不影响结论。 + """ + core = re.split(r"[+]", v, maxsplit=1)[0] + core = re.split(r"(?<=[\d.])(?:a|b|rc|dev|pre|post)\d*$", core)[0] + nums = [int(n) for n in re.findall(r"\d+", core)[:4]] + return tuple(nums + [0] * (4 - len(nums))) + + +def _iter_requirement_lines(text: str) -> list[str]: + out = [] + for raw in text.splitlines(): + line = raw.split("#", 1)[0].strip() + if not line or line.startswith("-") or line.startswith("http"): + continue + out.append(line) + return out + + +def collect_floors() -> dict[str, tuple[str, str]]: + """返回 {包名小写: (下界版本, 来源)},取多处声明中的最高下界。""" + floors: dict[str, tuple[str, str]] = {} + + req = _ROOT / "requirements.txt" + if req.exists(): + for line in _iter_requirement_lines(req.read_text(encoding="utf-8", errors="replace")): + m = _NAME_RE.match(line) + if m and m.group("spec") in (">=", "~=", ">"): + _keep_higher(floors, m.group("name"), m.group("ver"), "requirements.txt") + + py = _ROOT / "pyproject.toml" + if py.exists(): + in_deps = False + for raw in py.read_text(encoding="utf-8", errors="replace").splitlines(): + stripped = raw.strip() + if stripped.startswith("dependencies"): + in_deps = True + continue + if in_deps and stripped.startswith("]"): + in_deps = False + continue + if not in_deps: + continue + m = _NAME_RE.match(stripped.strip("\"'")) + if m and m.group("spec") in (">=", "~=", ">"): + _keep_higher(floors, m.group("name"), m.group("ver"), "pyproject.toml") + + return floors + + +def _keep_higher(floors: dict[str, tuple[str, str]], name: str, ver: str, src: str) -> None: + key = name.lower().replace("_", "-") + if key in IGNORE: + return + cur = floors.get(key) + if cur is None or _parse_ver(ver) > _parse_ver(cur[0]): + floors[key] = (ver, src) + + +def collect_pins() -> dict[str, tuple[str, str]]: + """返回 {包名: (钉定版本, 出现该钉版的所有文件)}。 + + 同一个包可以在多个钉版集里各钉一次(lock 与便携 small 就是两套), + 必须全列出来——只报第一个会低估影响面:真正随包分发的是 small 那份。 + """ + found: dict[str, list[tuple[str, str]]] = {} + for rel in PINNED_SOURCES: + path = _ROOT / rel + if not path.exists(): + continue + for line in _iter_requirement_lines(path.read_text(encoding="utf-8", errors="replace")): + m = _NAME_RE.match(line) + if m and m.group("spec") == "==": + key = m.group("name").lower().replace("_", "-") + found.setdefault(key, []).append((m.group("ver"), rel)) + pins: dict[str, tuple[str, str]] = {} + for key, hits in found.items(): + vers = {v for v, _ in hits} + if len(vers) > 1: + # 两套钉版集自己就不一致:取最低值参与下界比对(最保守) + low = min(vers, key=_parse_ver) + pins[key] = (low, "、".join(f"{v}@{r}" for v, r in hits)) + else: + pins[key] = (hits[0][0], "、".join(r for _, r in hits)) + return pins + + +def find_violations(floors: dict, pins: dict) -> list[str]: + out = [] + for name, (pinned, src) in sorted(pins.items()): + floor = floors.get(name) + if not floor: + continue + floor_ver, floor_src = floor + if _parse_ver(pinned) < _parse_ver(floor_ver): + out.append(f"{name}: {src} 钉 {pinned} < {floor_src} 声明的下界 {floor_ver}") + return out + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="检查钉版是否低于声明下界") + ap.add_argument("--quiet", action="store_true", help="只输出违规项") + ap.add_argument( + "--allow-debt", + default="", + help="逗号分隔的存量违规包名:这些只报不拦,出现名单外的新违规才 exit 1", + ) + args = ap.parse_args(argv) + + floors = collect_floors() + pins = collect_pins() + violations = find_violations(floors, pins) + allowed = {n.strip().lower() for n in args.allow_debt.split(",") if n.strip()} + + if not args.quiet: + print(f"[pin-floors] 声明下界 {len(floors)} 项,钉版 {len(pins)} 项") + blocking = [] + for v in violations: + name = v.split(":", 1)[0].strip() + if name in allowed: + print(f" [已知存量] {v}") + else: + blocking.append(v) + print(f" [FAIL] {v}") + + if blocking: + print( + f"\n[pin-floors] {len(blocking)} 处违规:要么把钉版升到下界以上并重新做便携包解析," + "要么在 pyproject/requirements 里显式降低下界并说明理由。", + file=sys.stderr, + ) + return 1 + if violations: + print(f"[pin-floors] 仅存量违规({sorted(allowed)}),修好后请同步删掉 --allow-debt") + else: + print("[pin-floors] PASS 无钉版低于声明下界") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_pin_floors.py b/tests/test_check_pin_floors.py new file mode 100644 index 00000000..3431d39d --- /dev/null +++ b/tests/test_check_pin_floors.py @@ -0,0 +1,99 @@ +"""`scripts/check_pin_floors.py` 的行为测试。 + +注意:本仓库当前**存在一处已知违规**(transformers 钉 4.52.1 < 声明下界 4.57.0), +所以这里不断言「全仓库无违规」——那会是一条假的绿。 +改为棘轮:只允许已知违规存在,新出现的违规直接红(与 mypy 棘轮同一口径)。 +""" + +import sys +from pathlib import Path + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_PROJECT_ROOT / "scripts")) + +import check_pin_floors as cpf # noqa: E402 + +# 当前唯一被接受的存量违规;修好后请把这个集合清空 +KNOWN_DEBT = {"transformers"} + + +class TestParseVer: + def test_numeric_ordering(self): + assert cpf._parse_ver("4.57.0") > cpf._parse_ver("4.52.1") + assert cpf._parse_ver("4.57.0") < cpf._parse_ver("4.100.0") + + def test_local_version_suffix_uses_numeric_prefix(self): + assert cpf._parse_ver("2.13.0+cu132") == cpf._parse_ver("2.13.0") + assert cpf._parse_ver("2.13.0+cu132") > cpf._parse_ver("2.5.1") + + def test_suffix_digits_do_not_outrank_a_real_fourth_segment(self): + """回归:早期实现把 +cu132 的 132 当第四段,会压过 1.2.3.4。""" + assert cpf._parse_ver("1.2.3+cu999") == cpf._parse_ver("1.2.3") + assert cpf._parse_ver("1.2.3.4") > cpf._parse_ver("1.2.3+cu999") + + def test_prerelease_marker_treated_as_base_version(self): + assert cpf._parse_ver("4.57.0rc0") == cpf._parse_ver("4.57.0") + + +class TestFindViolations: + def test_pin_below_floor_is_reported(self): + floors = {"widget": ("1.4.0", "requirements.txt")} + pins = {"widget": ("1.3.9", "requirements-lock.txt")} + out = cpf.find_violations(floors, pins) + assert len(out) == 1 and "widget" in out[0] + + def test_pin_equal_to_floor_passes(self): + floors = {"widget": ("1.4.0", "requirements.txt")} + pins = {"widget": ("1.4.0", "requirements-lock.txt")} + assert cpf.find_violations(floors, pins) == [] + + def test_pin_above_floor_passes(self): + floors = {"widget": ("1.4.0", "pyproject.toml")} + pins = {"widget": ("2.0.0", "requirements-lock.txt")} + assert cpf.find_violations(floors, pins) == [] + + def test_package_without_declared_floor_is_ignored(self): + pins = {"mystery": ("0.1", "requirements-lock.txt")} + assert cpf.find_violations({}, pins) == [] + + +class TestFloorCollection: + def test_highest_declared_floor_wins(self): + floors: dict = {} + cpf._keep_higher(floors, "transformers", "4.52.1", "requirements.txt") + cpf._keep_higher(floors, "transformers", "4.57.0", "pyproject.toml") + assert floors["transformers"] == ("4.57.0", "pyproject.toml") + cpf._keep_higher(floors, "transformers", "4.53.0", "requirements.txt") + assert floors["transformers"] == ("4.57.0", "pyproject.toml") + + def test_name_normalization_unifies_underscore_and_dash(self): + floors: dict = {} + cpf._keep_higher(floors, "some_pkg", "1.0", "requirements.txt") + assert "some-pkg" in floors + + def test_ignore_list_excludes_build_tools(self): + floors: dict = {} + cpf._keep_higher(floors, "wheel", "0.45", "requirements.txt") + assert floors == {} + + +class TestRepoState: + """棘轮:仓库真实状态里不允许出现新的违规。""" + + def test_no_new_violations_beyond_known_debt(self): + names = {v.split(":", 1)[0].strip() for v in cpf.find_violations(cpf.collect_floors(), cpf.collect_pins())} + assert names <= KNOWN_DEBT, f"新增钉版低于下界:{sorted(names - KNOWN_DEBT)}" + + def test_declared_floor_for_transformers_is_actually_parsed(self): + """存量违规必须仍被检测到,否则说明解析器坏了、棘轮会变成空过。""" + floors = cpf.collect_floors() + assert "transformers" in floors + names = {v.split(":", 1)[0].strip() for v in cpf.find_violations(floors, cpf.collect_pins())} + assert "transformers" in names or not KNOWN_DEBT, "解析器可能失效:已知违规不见了" + + def test_both_pinned_files_are_attributed(self): + """便携包真正随包分发的是 launcher 那份,漏报会低估影响面。""" + pins = cpf.collect_pins() + src = pins["transformers"][1] + assert "requirements-lock.txt" in src + assert "launcher/requirements-small.txt" in src From 466543ddcc926c994e3a463e3c37ae69e10e410e Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Sun, 20 Sep 2026 08:22:01 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(security):=20CSRF=20=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E5=A4=B1=E8=B4=A5=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E5=90=AF=E5=8A=A8=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E6=97=A0=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_app() 此前在 data/.csrf_secret 写不出来时只 logger.warning,然后拿 **空密钥**继续 add_middleware(CSRFMiddleware) —— 一次磁盘满/权限故障就让 CSRF 防护自我关闭,且与「警告后照旧继续」的既定纪律冲突。 - 默认 raise RuntimeError,消息自带出路(让 data/ 可写;compose 已是可写挂载); - 只读部署需显式 TTS_ALLOW_EPHEMERAL_CSRF=1,此时用内存态强随机密钥 (比空密钥强:进程内仍有签名),并 warning 说明重启后 token 全失效; - .env.example 记录该开关。 - app_server.py 属核心模块,哈希入册,已同步重算并重签清单。 验证(真跑,非 mock):把 _PROJECT_ROOT 指到不存在的盘符制造真实 OSError, - 默认路径:create_app 抛 RuntimeError,断言含 "CSRF" 且含可操作关键词; - 开关路径:警告先落、流程越过 CSRF 门后才在别处撞盘符,断言逃逸的不是 CSRF 硬失败。 两个用例 + 既有 csrf/route-uniqueness/security_expanded/voice_clone_consent 共 45 个用例全过。 Signed-off-by: ReSerendipity --- .env.example | 8 +++ app/integrated_app/app_server.py | 21 +++++++- .../security/integrity_manifest.json | 2 +- .../integrity_manifest.json.sig.ed25519 | Bin 64 -> 64 bytes tests/test_csrf_secret_hardfail.py | 48 ++++++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/test_csrf_secret_hardfail.py diff --git a/.env.example b/.env.example index 04d2a51a..7f3b4a81 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,11 @@ TTS_MODEL_MODE=portable # Windows 示例:D:/AIModels/TTS # macOS/Linux 示例:/opt/ai-models/tts 或 ~/AIModels/TTS TTS_SHARED_MODELS_ROOT= + +# ============================================================================= +# CSRF 密钥(安全相关,默认不需要设置) +# ============================================================================= +# CSRF HMAC 密钥持久化在 data/.csrf_secret。写不出来时应用默认**拒绝启动**, +# 不再静默降级成无签名模式。只有确实无法提供可写 data/ 的只读部署才需要打开: +# 设为 1 表示改用内存态密钥,代价是每次重启后所有已下发的 CSRF token 失效。 +# TTS_ALLOW_EPHEMERAL_CSRF=1 diff --git a/app/integrated_app/app_server.py b/app/integrated_app/app_server.py index 8dcd1f42..1e7f781a 100644 --- a/app/integrated_app/app_server.py +++ b/app/integrated_app/app_server.py @@ -805,6 +805,7 @@ def create_app() -> FastAPI: csrf_secret_path = os.path.join(_PROJECT_ROOT, "data", ".csrf_secret") csrf_secret = "" # nosec B105 - 占位初始化,随后立即被 secrets.token_urlsafe(48) 覆盖为强随机值 + csrf_store_err: OSError | None = None try: os.makedirs(os.path.dirname(csrf_secret_path), exist_ok=True) if os.path.exists(csrf_secret_path): @@ -816,7 +817,25 @@ def create_app() -> FastAPI: f.write(csrf_secret) logger.info("[create_app] 已生成新的 CSRF HMAC 密钥: %s", csrf_secret_path) except OSError as csrf_err: - logger.warning("[create_app] CSRF 密钥初始化失败,回退到无签名模式: %s", csrf_err) + csrf_store_err = csrf_err + + if not csrf_secret: + # 此前这里只 logger.warning 然后拿空密钥挂中间件 —— CSRF 防护会因为一次磁盘或 + # 权限故障静默自我关闭。改为默认硬失败;只读部署需显式声明才接受内存态密钥。 + if os.environ.get("TTS_ALLOW_EPHEMERAL_CSRF") == "1": + csrf_secret = _secrets.token_urlsafe(48) + logger.warning( + "[create_app] 按 TTS_ALLOW_EPHEMERAL_CSRF=1 使用内存态 CSRF 密钥," + "重启后所有已下发 token 失效;持久化失败原因: %s", + csrf_store_err, + ) + else: + raise RuntimeError( + f"CSRF 密钥不可用(持久化失败: {csrf_store_err}),拒绝以无签名模式启动。" + f"请让 {os.path.dirname(csrf_secret_path)}/ 可写后重启" + "(compose 已把 ./data 挂为可写);确需只读部署请显式设 " + "TTS_ALLOW_EPHEMERAL_CSRF=1,代价是重启后 token 全部失效。" + ) from csrf_store_err app.add_middleware(CSRFMiddleware, secret_key=csrf_secret) diff --git a/app/integrated_app/security/integrity_manifest.json b/app/integrated_app/security/integrity_manifest.json index 9d38aaac..d985d1d1 100644 --- a/app/integrated_app/security/integrity_manifest.json +++ b/app/integrated_app/security/integrity_manifest.json @@ -2,7 +2,7 @@ "description": "TTS_MultiModel 核心模块完整性清单 (自动生成)", "algorithm": "sha256", "files": { - "app_server.py": "80c2d6b4fc43dbc2533aecc0c8d3a9f900e6227b89f652d24f46229cd96594e1", + "app_server.py": "cb18822194090b1c405845fc1f336f4e21c43904bee2198532e0d18d26e6da6c", "config.py": "7ca9e3bbe7e178aecb9e0a31f2e3255d7bc071a95fd5ffc8b5f070ec0f3b7fdc", "config_models.py": "0416b85efe41611b0d267ddd715ee006703566e51a6f50b21dc75d42f87bd1f8", "engine_interface.py": "8976ee71d43b3407ac0e06d91a956e900bab86b667448254b52c02329a5cdb05", diff --git a/app/integrated_app/security/integrity_manifest.json.sig.ed25519 b/app/integrated_app/security/integrity_manifest.json.sig.ed25519 index f5ce3aae2828475a74a548e9f1ac23a5672260fd..4440989713f1647739caa5aacd56b17effb36364 100644 GIT binary patch literal 64 zcmV-G0Kfmn_CCoW*|-3EHK=$fip_gd#aTo?~BDInSa literal 64 zcmV-G0Kfn6GnPDTEfG`X1OhYtc&$PzsH)I?neSxSh7bu%0LHa(B{)u~g+ls`Nwlgl W$uq(x38dZtlAmAh#n$wMpwkGfksizd diff --git a/tests/test_csrf_secret_hardfail.py b/tests/test_csrf_secret_hardfail.py new file mode 100644 index 00000000..8ac3101c --- /dev/null +++ b/tests/test_csrf_secret_hardfail.py @@ -0,0 +1,48 @@ +"""CSRF 密钥持久化失败时的启动契约。 + +背景:`app_server.create_app()` 过去在 `data/.csrf_secret` 写不出来时只 +`logger.warning` 后用**空密钥**继续挂 `CSRFMiddleware`(注释自称「回退到无签名 +模式」)—— 一次磁盘或权限故障就让 CSRF 防护静默自我关闭。现改为默认硬失败, +只读部署需显式设 `TTS_ALLOW_EPHEMERAL_CSRF=1` 才接受内存态密钥。 + +这里用不存在的盘符制造一次真实的 OS 级失败,而不是 mock 到假。 +""" + +import pytest +from app.integrated_app import app_server + + +@pytest.fixture +def bad_project_root(monkeypatch, tmp_path): + """把工程根指向不存在的盘符:makedirs/open 必然抛 OSError。""" + missing = "Z:/tts_no_such_drive" + monkeypatch.setattr(app_server, "_PROJECT_ROOT", missing) + return missing + + +class TestCsrfSecretHardFail: + def test_create_app_refuses_to_start_unsigned(self, bad_project_root): + with pytest.raises(RuntimeError) as exc: + app_server.create_app() + msg = str(exc.value) + assert "CSRF" in msg, f"抛错来源不是 CSRF 密钥: {msg[:120]}" + # 报错必须自带可操作出路,而不是只丢一个栈 + assert "可写" in msg and "TTS_ALLOW_EPHEMERAL_CSRF" in msg + + def test_ephemeral_opt_in_clears_the_hard_fail(self, bad_project_root, monkeypatch): + """显式声明后,CSRF 这条不再拦启动。 + + create_app 在 CSRF 之后还会继续访问工程根,所以拿到的可能是别的 + Z: 盘错误 —— 那恰好证明已经走过了 CSRF 这道门。断言只针对 + 「逃逸出来的不是 CSRF 硬失败」,不假装后续都成立。 + """ + monkeypatch.setenv("TTS_ALLOW_EPHEMERAL_CSRF", "1") + escaped: BaseException | None = None + try: + app_server.create_app() + except BaseException as exc: # noqa: BLE001 - 后续失败与 CSRF 无关,见 docstring + escaped = exc + + assert not (isinstance(escaped, RuntimeError) and "CSRF" in str(escaped)), ( + f"白名单开关没生效:{str(escaped)[:120]}" + ) From 1ca956336be96b06c7d975767f84c37dec37f99c Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Sun, 20 Sep 2026 08:29:32 +0800 Subject: [PATCH 3/6] =?UTF-8?q?docs(security):=20=E8=AE=B0=E5=BD=95=2010?= =?UTF-8?q?=20=E6=9D=A1=20CodeQL=20=E5=91=8A=E8=AD=A6=E7=9A=84=20dismiss?= =?UTF-8?q?=20=E5=A4=84=E7=BD=AE=E4=B8=8E=E6=93=8D=E4=BD=9C=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dismiss 前先确认重扫:最近一次 CodeQL 分析 2026-09-19T18:05:48Z @ 552b0c6 (当前 main),results=97;逐条 GET 校验 state 与路径未变才 PATCH。 Signed-off-by: ReSerendipity #54-#60(emoji 码位区间)false positive、#111 #112(tests/ 静态断言)used in tests、 #53(critical)mitigated。open 110 → 99,critical 1 → 0。 §4 里两行划掉:CSRF 静默降级已在同 PR 改硬失败。 沉淀两条 API 口径,避免下次重踩:dismissed_comment 上限 280 字符(长判据只能放本表, 注释里带 §号引用);dismissed_reason 是人读枚举 "false positive"/"used in tests"/ "mitigated",写 false_positive 会 422。另记一条旁证:#53 汇点从 :460 推移到 :468, 正是我加在函数入口的 8 行守卫把它挤下去的。 --- docs/SECURITY_CODEQL_TRIAGE.md | 39 ++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/SECURITY_CODEQL_TRIAGE.md b/docs/SECURITY_CODEQL_TRIAGE.md index 1369d0ed..be170f4a 100644 --- a/docs/SECURITY_CODEQL_TRIAGE.md +++ b/docs/SECURITY_CODEQL_TRIAGE.md @@ -113,9 +113,44 @@ gh api --paginate "repos/ReSerendipity/TTS_MultiModel/code-scanning/alerts?state | 批次 | 内容 | 量 | 判据 | |---|---|---|---| | ~~P0~~(已做) | #53 可达路径封死 + 3 条回归测试 + `fullmatch` | 1 + 18 收敛 | 撤守卫必红、加回必绿 | -| P0 剩余 | `py/overly-large-range` 7 + `bad-tag-filter` 中 tests 2 条 → 平台上 dismiss 并写明理由 | 9 | 不动代码,先降噪 | +| ~~P0 剩余~~(已做,见 §5) | `py/overly-large-range` 7 + `bad-tag-filter` 中 tests 2 条 → 平台上 dismiss 并写明理由 | 9 | 不动代码,先降噪 | | P1 | `stack-trace-exposure` 统一「生产模式不回显异常细节」开关(含 `server.host != 127.0.0.1` 时强制) | 29 | 一处中间件,不逐点改 | | P1 | `path-injection` training/openai_api/audio 三处逐条定性 | 17 | 每条要么 dismiss 理由要么进追踪表 | | P2 | 前端 `xss-through-dom` + `incomplete-sanitization` | 13 | 逐条看数据源是否用户可控 | -| 决策 | CSRF 密钥写失败是否改硬失败 | 1 | 需先确认部署形态 | +| ~~决策~~(已做) | CSRF 密钥写失败改硬失败:默认 `raise`,只读部署需显式 `TTS_ALLOW_EPHEMERAL_CSRF=1` 走内存态密钥 | 1 | 已核实 `docker-compose.yml:39` 的 `./data` 是可写挂载 | | 决策 | CodeQL 是否进 `main` 必需检查 | — | 现在加会立刻卡死所有 PR;建议先降到 <30 条再纳入 | + +## 5. 处置进展(2026-09-20) + +dismiss **前先确认重扫过**:最近一次 CodeQL 分析 `2026-09-19T18:05:48Z`, +commit `552b0c6`(= 当前 main),results=97。逐条 GET 校验 state 与路径未变才 PATCH。 + +| 告警 | 规则 | 处置 | 理由类别 | +|---|---|---|---| +| #54–#60 | `py/overly-large-range` | dismissed | `false positive`(emoji 码位区间,非可放大循环) | +| #111 #112 | `py/bad-tag-filter` @ tests/ | dismissed | `used in tests`(静态断言测试自家 HTML,非安全边界) | +| #53 | `py/unsafe-deserialization` (critical) | dismissed | `mitigated`(`weights_only=True` + #79 的 realpath 入口守卫) | + +处置后:**open 110 → 99,critical 1 → 0**。剩余分布: + +| rule | open | +|---|---| +| py/path-injection | 53 | +| py/stack-trace-exposure | 29 | +| js/xss-through-dom | 10 | +| js/incomplete-sanitization | 3 | +| py/bad-tag-filter | 1(`engines/voxcpm2/design.py:82`,§2.4 待定夺) | +| py/reflective-xss | 2 | +| py/url-redirection | 1 | +| py/unsafe-deserialization | 0 | +| py/clear-text-storage-sensitive-data | 1(§2.5,记录不修) | +| py/overly-large-range | 0 | + +两条操作口径(踩过): + +- `dismissed_comment` **上限 280 字符**,长理由写不进 API;本表的 §2.x 才是判据的持久出处, + 告警注释里必须带 §号引用。 +- `dismissed_reason` 取人读枚举 `"false positive"` / `"used in tests"` / `"mitigated"` / + `"won't fix"`,不是 `false_positive` 这种下划线形式(422)。 +- 代码改动会**推移告警行号**:#53 的汇点从 `:460` 变成 `:468`(就是我加在函数入口的 + 8 行守卫),这本身是修复已进主干的旁证。 From cbca05da35c6d68bf8195446ef6ccead982b73cd Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Sun, 20 Sep 2026 09:00:51 +0800 Subject: [PATCH 4/6] =?UTF-8?q?docs(release):=20=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E4=BE=BF=E6=90=BA=E9=92=89=E7=89=88=E5=80=BA=E5=8A=A1=E5=8D=A1?= =?UTF-8?q?=EF=BC=88transformers<4.57=20=E8=BF=9E=E5=B8=A6=20tokenizers=20?= =?UTF-8?q?=E4=B8=8A=E9=99=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 PyPI 元数据实测,transformers 4.57.0 要求 tokenizers>=0.22.0,<=0.23.0, 而我们钉的是 0.21.0 —— 所以清这笔债不是单包 bump,必须连带 tokenizers 一起动; 其余关键包不受阻(huggingface-hub 0.36.2 满足 >=0.34,<1.0;numpy/safetensors/ pydantic 均满足),requirements.txt:6 对 tokenizers 只声明 >=0.19.0, vendor 侧也没有钉死 0.21.0。 给出可复现的三步(先 --dry-run 解析、再 real 模式重建便携包过门禁、最后删白名单), 不代做:解析与真机构建都需要 ≥60GB 的 self-hosted runner,仓库当前 runner 数为 0。 Signed-off-by: ReSerendipity --- docs/release-governance.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/release-governance.md b/docs/release-governance.md index d9e2be9d..436ec595 100644 --- a/docs/release-governance.md +++ b/docs/release-governance.md @@ -15,6 +15,33 @@ - 发布由 `release-please` 自动生成 GitHub Release 并打 semver tag;该作业若失败**不会**再被吞掉 (v2.2.2 曾在工作流全绿的情况下既没 tag 迁移也没 Release,原因见 `CHANGELOG.md` 该条标注)。 +### 1.1 存量依赖债务卡:便携钉版 `transformers` 低于自家下界 + +| 项 | 值 | +|---|---| +| 违规 | `requirements-lock.txt` 与 `launcher/requirements-small.txt` 钉 `transformers==4.52.1` | +| 下界 | `pyproject.toml:59` + `requirements.txt:5` 均声明 `>=4.57.0`,理由写在 pyproject:VoxCPM2 / IndexTTS2 的 tokenizer 与 modeling 需要较新 transformers API | +| 连带 | `transformers 4.57.0` 的元数据要求 **`tokenizers>=0.22.0,<=0.23.0`**(PyPI 实测),而我们钉 `tokenizers==0.21.0` → **不是单包 bump,必然连带 tokenizers** | +| 未受阻项 | `huggingface-hub==0.36.2`(需 `>=0.34,<1.0` ✔)、`numpy==2.5.2` ✔、`safetensors==0.8.0` ✔、`pydantic==2.13.4` ✔;`requirements.txt:6` 只声明 `tokenizers>=0.19.0`,无上游 vendor 钉死 0.21.0 | +| 为什么当初降到 4.52.1 | 见 `CHANGELOG.md`「便携钉装自洽修复」:全新 WinPython 3.12.10.1 上 `pip install -r requirements-small.txt` 报 `ResolutionImpossible`,当时按 `.venv` 实测值对齐了 9 项 | + +修它的正确顺序(属发布级动作,需真机;不要只改两行就发): + +```bash +# 1) 全量解析验证(先只解析不装):把两文件里的 transformers/tokenizers 改为 +# transformers==4.57.* 与 tokenizers==0.23.* 后 +python -m pip install --dry-run --ignore-installed --report /tmp/res.json \ + -r launcher/requirements-small.txt +# 2) 解析通过再重建便携包并过门禁(需要 ≥60GB 磁盘的 self-hosted runner) +pwsh scripts/build_portable_bundle.ps1 ... ; pwsh scripts/release_gate.ps1 -Mode real +# 3) 门禁绿了以后,把 CI 白名单收紧 —— 见 security.yml 的 --allow-debt +python scripts/check_pin_floors.py # 不带 --allow-debt,应为 0 违规 +``` + +当前 `python scripts/check_pin_floors.py` 实测:25 个声明下界 / 93 个钉版 / **1 处违规**。 +CI 侧(`Security Scan` 的 pip-audit job)用 `--allow-debt transformers` 棘轮化: +存量只报不拦,名单外新增即红;**这条债务修好后必须把 `transformers` 从白名单删掉**。 + ## 2. 发布流程 0. **main 不可直推**:分支保护要求 3 项状态检查且 `enforce_admins=true`, From 153e841e952c746e9f17197eb2933e60cbbecfa3 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Sun, 20 Sep 2026 09:25:24 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(dod):=20=E8=AE=B0=E5=BD=95=202026-09-2?= =?UTF-8?q?0=20=E7=9C=9F=E6=8E=A8=E7=90=86=E9=AA=8C=E6=94=B6=EF=BC=88RTX?= =?UTF-8?q?=205070=20Ti=EF=BC=8C=E6=A1=8C=E9=9D=A2=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E5=85=A8=E9=93=BE=E8=B7=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不是 pytest 绿当测过:真起 start_portable.py,加载 voxcpm2(冷 35s), 合成 15 字中文得 2.80s / 48kHz / 16bit 音频,RMS 5395、peak 30003 证明确实出声, nvidia-smi 独立佐证显存 11204/12227 MiB、利用率 54%,RTF 1.46, unload 与 /api/system/shutdown 均 200 干净退出。 如实写明边界:证据由两次运行拼成(第一次 RMS 校验代码有 bug、 第二次模型已在显存里所以「未加载 503」那格由第一轮供证), 人耳听感仍归人工;release-gate real 模式未跑,缺 WinPython 与 torch 轮子两个输入(补齐属大批量下载,不擅自执行),磁盘 141GB 不是瓶颈。 Signed-off-by: ReSerendipity --- docs/DOD.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/DOD.md b/docs/DOD.md index 3250a7c0..2bf8b4f9 100644 --- a/docs/DOD.md +++ b/docs/DOD.md @@ -95,6 +95,27 @@ 点重试 XHR 计数 +1 且错误块重新渲染(此前该按钮调的是从未定义的函数,纯死键,#133), 第 9 项 6 轮切换实测空闲显存回到切换前水平(spread 416MB,无单调递增)。 **仍需人工:第 7 项真开一次屏幕阅读器、第 8 项断网首屏。** +- 最近一次**真推理验收**:2026-09-20,RTX 5070 Ti Laptop(12227 MiB)+ v2.2.2 工作树, + 用桌面版真实入口 `python start_portable.py --host 127.0.0.1 --port 7869` 起服务, + 脚本化驱动全链路(驱动脚本是一次性产物,未入库): + + | 判据 | 实测 | + |---|---| + | 存活 | `/api/health/ping` 200,5ms | + | 就绪语义 | 未加载 `/readyz` **503** → 加载后 **200** | + | 引擎加载 | voxcpm2,冷启动 **35s**(热 26s) | + | 显存 | 应用侧 `vram_used_mb=6177`;`nvidia-smi` 独立佐证 **11204/12227 MiB、利用率 54%** | + | 合成 | 15 字中文 + "温柔亲切的年轻女声" 指令 → **2.80s** 音频,耗时 4.1s,**RTF 1.46** | + | 真出声(非静音) | 48000Hz / 1ch / 16bit,**RMS 5395、peak 30003**(满量程 32767) | + | wire 契约 | HTMX 片段带出 `/api/audio/voxcpm_design_*.wav`,回取 200 + `RIFF` 268,644 B | + | 收尾 | `/api/model/unload` 200、`/api/system/shutdown` 200(进程干净退出) | + + 诚实边界:证据由**两次运行**拼成——冷启动那次覆盖到「503→200 + 加载 + 音频 URL」, + 但 RMS 校验代码有 bug 未出声校验;第二次修好后跑通音频量化,然而模型已在显存中, + 那一轮的「未加载 503」不成立(不是回归)。人耳听感仍归人工。 + `release-gate -Mode real` 仍未跑:本机 `model/VoxCPM2`(4.6 GB)在,但缺 + `-RuntimeDir`(WinPython)与 `-TorchWheelDir`(torch cu132 轮子,约 3-4 GB)两个输入, + 补齐属大批量下载,未擅自执行;C: 空闲 141 GB,磁盘不是瓶颈。 ## 6. 安全 & 隐私 From 3374ac7013d225503720b8817820bb0e870ebf21 Mon Sep 17 00:00:00 2001 From: ReSerendipity Date: Sun, 20 Sep 2026 09:48:08 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(security):=20=E8=AE=B0=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E6=89=B9=20path-injection=2017=20=E6=9D=A1=E5=A4=8D?= =?UTF-8?q?=E6=A0=B8=E4=B8=8E=2015=20=E6=9D=A1=20dismiss?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audio 4 条误报(_safe_file_path 三层 + glob 复核)、training 9 条已缓解 (三处 makedirs 前无条件 _validate_path,且 startswith(base + os.sep) 写法无 同名兄弟目录漏洞)、openai 输出路径 2 条误报;openai voice 2 条是真问题, 留给 #83 修 + 重扫,不在这里提前收口。累计 dismiss 25 条,open 110 → 85。 Signed-off-by: ReSerendipity --- docs/SECURITY_CODEQL_TRIAGE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/SECURITY_CODEQL_TRIAGE.md b/docs/SECURITY_CODEQL_TRIAGE.md index be170f4a..fc920bb7 100644 --- a/docs/SECURITY_CODEQL_TRIAGE.md +++ b/docs/SECURITY_CODEQL_TRIAGE.md @@ -154,3 +154,28 @@ commit `552b0c6`(= 当前 main),results=97。逐条 GET 校验 state 与 `"won't fix"`,不是 `false_positive` 这种下划线形式(422)。 - 代码改动会**推移告警行号**:#53 的汇点从 `:460` 变成 `:468`(就是我加在函数入口的 8 行守卫),这本身是修复已进主干的旁证。 + +## 6. 第二批:17 条 `py/path-injection` 复核(2026-09-20,随 PR #83) + +逐条读码后分三类;已 dismiss 15 条,留 2 条给修复重扫。 + +| 组 | 告警 | 条数 | 复核结论 | 处置 | +|---|---|---|---|---| +| `routes/audio.py` | #2 #3 #4 #5 | 4 | 误报。全部文件访问过 `_safe_file_path`:字符白名单→强制拼接 `root_dir`→`resolve()+relative_to`(注释自述防 symlink);`:441` 的 glob 命中后再复核一次归属 | dismissed `false positive` | +| `routes/training.py` | #42–#50 | 9 | 已缓解。`pretrained_path`/`train_manifest`/`save_path` 三处在 `os.makedirs` 之前**无条件**过 `_validate_path`(`realpath` + `startswith(base + os.sep)`,带分隔符故无同名兄弟目录漏洞) | dismissed `mitigated` | +| `openai_api.py` 输出路径 | #12 #13 | 2 | 误报。sink 读的是应用自生成的输出路径(`final_path` / `_stream_file` 入参),非请求可控 | dismissed `false positive` | +| `openai_api.py` voice | #109 #110 | 2 | **真问题**:`os.path.exists(os.path.join(PERSONA_DIR, f"{body.voice}.wav"))` 把请求体原样拼进路径;命中继续合成、不命中才 400,响应差异即**存在性预言机**(受 `.wav` 后缀约束,可读面有限但仍可探测) | #83 改为 `_persona_wav_exists`(realpath 归属 + `isfile`)。**等合并重扫自然消解;若不消解再以 `mitigated` 收口** | + +新旧对照实测(同一夹具): + +``` +相对越界 …/secret_target 旧 -> True 新 -> False +绝对路径名(join 会丢弃 dir) 旧 -> True 新 -> False +同名兄弟目录 personas_evil/trap 新 -> False +合法音色 alice 新 -> True +``` + +刻意**不用** `_validate_persona_name` 的字符白名单来挡这件事:那会误伤早期登记、名字里带 +空格或全角字符的音色;遍历由 containment 挡掉即可。 + +累计:dismiss **25 条**(第一批 9 + #53;第二批 15),open **110 → 85**,critical **0**。