diff --git a/docs/SECURITY_CODEQL_TRIAGE.md b/docs/SECURITY_CODEQL_TRIAGE.md index 1369d0e..f8f5c4c 100644 --- a/docs/SECURITY_CODEQL_TRIAGE.md +++ b/docs/SECURITY_CODEQL_TRIAGE.md @@ -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)`), @@ -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)。