Skip to content
Merged
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
129 changes: 127 additions & 2 deletions docs/SECURITY_CODEQL_TRIAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ gh api --paginate "repos/ReSerendipity/TTS_MultiModel/code-scanning/alerts?state
```

- 时点:2026-09-20。**open 110 | high 72 | medium 37 | critical 1**(另有 fixed 2)。
> **该快照已过期,2026-09-21 重测见 §6.1(open 76 | high 46 | medium 30 | critical 0)。**
> §0–§5 保留原样不原地改写:那是当天逐条读过代码的定性记录,台账的价值在于能看出变化。
- 检测窗口:`created_at` 从 2026-09-10 到 2026-09-19 —— 说明**边修边涨**,不是一次性历史包袱。
- 为什么能累积到 110 条还不阻塞:`main` 分支保护只要求 3 个检查
(`Lint (ruff)`、`Test (pytest) (3.12, ubuntu-latest)`、`Typecheck (mypy ratchet)`),
Expand Down Expand Up @@ -113,9 +115,132 @@ 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**。

---

## 6. 2026-09-21 刷新(现状以本节为准,§0–§5 是 09-20 的快照)

本表是从 PR #81 的分支里搬进来的。那条 PR 的**主体方向被实测推翻**("两个 lock 钉的 4.52.1
低于自家下界"其实是下界本身写错了方向,实测能跑的就是 4.52.1 —— 见
`docs/SECURITY_DEPENDABOT_TRIAGE.md` §1a 与 #103 的回退),所以 PR 关掉了;但这份逐条读过代码的
分诊台账是本仓唯一成体系的 CodeQL 记录,不该跟着一起丢。搬进来时 §0–§5 原文不动,
本节只叠加当天的测量 —— 台账要能看出变化,所以不改写历史。

### 6.1 总量与差额去向(可对上账)

| | 09-20(§0) | 09-21 实测 |
|---|---|---|
| open | 110 | **76** |
| high / medium / critical | 72 / 37 / 1 | **46 / 30 / 0** |
| dismissed | — | 32 |
| fixed | 2 | 4 |

- 差额 34 条的去向全部可解释:§5 已记的 25 条 dismiss + **§5 之后又 dismiss 的 7 条**
+ 重扫自动转 fixed 的 2 条。总数守恒:`110 open + 2 fixed = 76 + 32 + 4 = 112`,
也就是这个窗口内没有新增告警。
- 那 7 条是 `js/xss-through-dom` #95 #96 #97 #98 #99 #105 #106,09-20 16:07 一批 dismiss,
理由 `false positive`,平台 comment 里逐条写了插值来源(自家 Jinja 渲染的 i18n 文案 /
元素自身 `textContent` / 脚本内字面量,均不含外部输入)。**§5 没记这一批**(它冻结在 00:28),
属于台账滞后,不是处置缺理由。

### 6.2 现状分布(按规则 + 落点)

| rule | 级别 | 09-20 | 现在 | 落点(09-21 实测) |
|---|---|---|---|---|
| py/path-injection | high | 53 | 36 | `persona_manager.py` x18; `persona_metadata.py` x6; `routes/generate/voxcpm2/script.py` x3; `generation.py` x3; `routes/generate/voxcpm2/design.py` x3; `routes/generate/utils.py` x2; `routes/persona.py` x1 |
| py/stack-trace-exposure | medium | 29 | 29 | `routes/model.py` x11; `routes/training.py` x6; `routes/system/settings.py` x5; `routes/persona.py` x4; `routes/generate/utils.py` x2; `routes/generate/voxcpm2/streaming.py` x1 |
| js/xss-through-dom | high | 10 | 3 | 三个 tab 模板各 1:`indextts2_duration.html` / `indextts2_clone.html` / `indextts20_clone.html` |
| js/incomplete-sanitization | high | 3 | 3 | `lora_manager.html` x2; `history.html` x1 |
| py/reflective-xss | high | 2 | 2 | `routes/generate/step_audio_editx/edit.py`; `routes/generate/voicebox/convert.py` |
| py/bad-tag-filter | high | 3 | 1 | `engines/voxcpm2/design.py` x1 |
| py/url-redirection | medium | 1 | 1 | `routes/tabs.py` x1 |
| py/clear-text-storage-sensitive-data | high | 1 | 1 | `app_server.py` x1 —— 处置见 6.3 |
| py/overly-large-range | medium | 7 | **0** | 已全部 dismiss(§5 第一批) |
| py/unsafe-deserialization | **critical** | 1 | **0** | #53 `mitigated`。§2.1 声称的入口守卫**确在 main**,已按代码复核:`persona_manager.py:436-440` 的 realpath 前缀比对、`_PERSONA_NAME_RE.fullmatch`(不再是 `match`)、`tests/test_persona_embedding_load.py` 在库 |

### 6.3 §2.5 那条已落地,但形态与 §4 那行"决策(已做)"不一样

- §4 / #81 的原方案:默认 `raise`,只读部署靠 `TTS_ALLOW_EPHEMERAL_CSRF=1` 退回内存态密钥。
- **实际落地(PR #109)没有这个环境变量开关**:任何取不到密钥的情况一律 `RuntimeError` 拒绝启动,
`CSRFMiddleware.__init__` 另补一道"空 secret 直接 ValueError",防别的装配点绕过。
`.env.example` 也没有引入该变量 —— 读 §4 那行时要按这个口径理解,否则会去找一个不存在的开关。
- 追加的真加固:新密钥用 `os.open(..., 0o600)` 创建(不留"先 0644 建出来、再改权限"的窗口),
已存在的文件在读后/写前 chmod 收紧并复核;文件系统表达不了 POSIX 权限位时只 warning、
不拒绝启动(那种情况下拒绝启动不会更安全,只会让 app 起不来)。
3 条 POSIX 门控测试守着这件事,其中一条反空验证要求同目录 decoy 文件确实带 other 位。
- CodeQL 的"明文存储"半句用 sink 行上的 `codeql[py/clear-text-storage-sensitive-data] ignore`
带理由抑制,不是把规则整条静音。副作用:写入位置从 `:816` 挪到 `:745`,让既有告警 #1
在 PR 差异里被算成 "1 new alert"(这也是 #109 的 CodeQL 门禁当时红的原因)。

### 6.4 门禁口径没变(这正是它会漂走的原因)

`main` 的必需检查仍只有 3 项 —— `Lint (ruff)`、`Test (pytest) (3.12, ubuntu-latest)`、
`Typecheck (mypy ratchet)`,且 `strict=false`;**CodeQL 不在其中**。所以这张表不会因为谁没看而阻塞合并,
也正因为这样它的数字只能靠人定期重测。§4 最后那行"CodeQL 是否进 main 必需检查"仍未定,
判据不变:先降到 <30 条再纳入(现在 76)。
Loading