Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion app/integrated_app/app_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion app/integrated_app/security/integrity_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file not shown.
21 changes: 21 additions & 0 deletions docs/DOD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 安全 & 隐私

Expand Down
64 changes: 62 additions & 2 deletions docs/SECURITY_CODEQL_TRIAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,69 @@ 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 行守卫),这本身是修复已进主干的旁证。

## 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**。
36 changes: 35 additions & 1 deletion docs/release-governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,44 @@
- 发布由 `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`,
直推会被 `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`

Expand Down
Loading
Loading