From bb1dd97874d4f9e8334fd8a74011b30694d77bd3 Mon Sep 17 00:00:00 2001
From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com>
Date: Fri, 7 Aug 2026 01:32:43 +0000
Subject: [PATCH] =?UTF-8?q?docs:=20=E5=AE=89=E5=85=A8=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=88security-fix-plan=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/security-fix-plan.md | 172 ++++++++++++++++++++++++++++++++++++++
1 file changed, 172 insertions(+)
create mode 100644 docs/security-fix-plan.md
diff --git a/docs/security-fix-plan.md b/docs/security-fix-plan.md
new file mode 100644
index 0000000..c19e4d4
--- /dev/null
+++ b/docs/security-fix-plan.md
@@ -0,0 +1,172 @@
+# 安全修复计划 — Codex Security Finding 修复
+
+> 基于 `codex-security-findings-2026-08-04T22-22-52.609Z.csv` 报告核实结果制定
+> 日期:2026-08-05
+> 修订:v2 — 2026-08-05(纳入首轮独立审核反馈)
+
+## 一、背景
+
+Codex 安全扫描报告了一条 **high** 级别 finding,涉及 commit `5e25be23` (`ci: Release 自动构建 + 签名配置`),相关文件为 `.github/workflows/release.yml` 和 `app/build.gradle.kts`。
+
+核实结论:报告**半真半假**——action mutable reference 和硬编码 fallback 密码是真实风险,但"workflow 未导出 KEYSTORE_PASSWORD 等环境变量"是事实错误(代码第24-27行已明确导出)。
+
+## 二、真实风险点(需要修复)
+
+| # | 风险 | 严重度 | 涉及文件 |
+|---|---|---|---|
+| R1 | 所有 workflow 使用 mutable action tag 引用(`@v3`、`@v2`、`@v4`)而非 immutable commit SHA pin | medium | `release.yml`、`ci.yml` |
+| R2 | `build.gradle.kts` 第35-37行硬编码了签名 fallback 密码(`"RainyToken2026!"`、`"rainy"`),公开可见 | medium | `app/build.gradle.kts` |
+| R3 | `release.jks` 解码后留在 workspace,后续 step(含第三方 action)可读取 | low-medium | `release.yml` |
+| R4 | `ci.yml` 缺少显式 `permissions` 声明,违反最小权限原则 | medium | `ci.yml` |
+
+## 三、报告中的错误(不需要修复的部分)
+
+| 描述 | 事实 |
+|---|---|
+| "workflow does not export KEYSTORE_PASSWORD, KEYSTORE_ALIAS, or KEY_PASSWORD" | **错误** — `release.yml` 第24-27行已通过 `env:` 导出全部三个变量 |
+
+## 四、修复方案
+
+### 修复 1:所有 action 引用改为 commit SHA pin
+
+**原因**:Immutable SHA pin 可防止 action 被恶意 retag 后的供应链攻击。
+
+**涉及文件**:`.github/workflows/release.yml`、`.github/workflows/ci.yml`
+
+**SHA 映射**(通过 GitHub API 获取,2026-08-05 查询):
+
+| Action | Tag | Commit SHA | 备注 |
+|---|---|---|---|
+| `actions/checkout` | v4 | `11d5960a326750d5838078e36cf38b85af677262` | |
+| `actions/setup-java` | v4 | `cf277c60eb25467037889841efdb72551f06f6c3` | v4 已弃用,后续可升级 v5(不阻塞本次修复) |
+| `android-actions/setup-android` | v3 | `9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407` | |
+| `softprops/action-gh-release` | v2 | `3bb12739c298aeb8a4eeaf626c5b8d85266b0e65` | |
+| `actions/upload-artifact` | v4 | `ea165f8d65b6e75b540449e92b4886f43607fa02` | |
+
+**修改形式**(以 `android-actions/setup-android` 为例):
+```yaml
+# Before
+- uses: android-actions/setup-android@v3
+
+# After
+- uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
+```
+
+**影响范围**:
+- `release.yml`:4 处 action 引用(checkout、setup-java、setup-android、action-gh-release)
+- `ci.yml`:4 种 action 引用(checkout、setup-java、setup-android、upload-artifact),共出现 13 次
+ - test job:5 次(checkout、setup-java、setup-android、upload-artifact ×2)
+ - build-debug job:4 次(checkout、setup-java、setup-android、upload-artifact)
+ - build-release job:4 次(checkout、setup-java、setup-android、upload-artifact)
+
+> **修订说明**:v1 计划中误写为 11 次,经逐行核实实际为 13 次,已更正。
+
+### 修复 2:移除 build.gradle.kts 中的硬编码签名密码
+
+**原因**:硬编码的明文密码公开暴露了签名凭据。应改为:env 变量缺失或为空时直接报错,不提供隐式 fallback。
+
+**涉及文件**:`app/build.gradle.kts` 第33-37行
+
+**关键设计点 — 空字符串防御**:
+
+> **首轮审核发现的阻断项**:GitHub Actions 中 `${{ secrets.X }}` 在 secret 未设置时会被替换为**空字符串**(不是 null),经 `env:` 传递后 `System.getenv("X")` 返回空字符串 `""`。Kotlin 的 `?:` (Elvis operator) 只检查 `null`,不检查空字符串——空字符串会绕过 `?: throw`,被直接传给签名工具。
+
+因此方案使用 `?.takeIf { it.isNotBlank() }` 同时防御 null 和空白字符串:
+
+```kotlin
+// Before (第33-37行)
+if (keystoreFile.exists()) {
+ storeFile = keystoreFile
+ storePassword = System.getenv("KEYSTORE_PASSWORD") ?: "RainyToken2026!"
+ keyAlias = System.getenv("KEYSTORE_ALIAS") ?: "rainy"
+ keyPassword = System.getenv("KEY_PASSWORD") ?: "RainyToken2026!"
+
+// After — 使用 takeIf 防御 null + 空字符串
+if (keystoreFile.exists()) {
+ storeFile = keystoreFile
+ storePassword = System.getenv("KEYSTORE_PASSWORD")
+ ?.takeIf { it.isNotBlank() }
+ ?: throw GradleException("KEYSTORE_PASSWORD env var not set or empty — cannot sign release")
+ keyAlias = System.getenv("KEYSTORE_ALIAS")
+ ?.takeIf { it.isNotBlank() }
+ ?: throw GradleException("KEYSTORE_ALIAS env var not set or empty — cannot sign release")
+ keyPassword = System.getenv("KEY_PASSWORD")
+ ?.takeIf { it.isNotBlank() }
+ ?: throw GradleException("KEY_PASSWORD env var not set or empty — cannot sign release")
+```
+
+**兼容性分析**:
+- Release workflow(`release.yml`)已通过 `env:` 导出这三个变量(secrets 已正确配置)→ ✅ 不受影响
+- CI workflow(`ci.yml`)不涉及 `release.jks`,走 debug keystore fallback → ✅ 不受影响
+- 本地构建:若开发者放置了 `release.jks` 但未设置 env 变量 → 会报错(这是正确行为,避免静默使用错误密码)
+
+### 修复 3:构建完成后清理 release.jks
+
+**原因**:减少 keystore 在 workspace 中的暴露窗口。虽然后续 step 不多,但作为深度防御措施值得添加。
+
+**涉及文件**:`release.yml`
+
+**修改方案**:在 "Build Release APK" step 之后、"Rename & Release" step 之前添加清理 step:
+```yaml
+ - name: Cleanup keystore (always run for security)
+ run: rm -f release.jks
+ if: always()
+```
+
+> **修订说明**:step name 加注 `(always run for security)` 说明意图。`if: always()` 确保即使构建失败也清理 keystore——这是正确的安全行为。后续的 `softprops/action-gh-release` 依赖已生成的 APK 文件(通过 `files:` 参数),不依赖 `release.jks`,清理不影响其运行。
+
+### 修复 4:ci.yml 补充最小权限声明
+
+**原因**:ci.yml 完全没有 `permissions` 块。CI job 只做编译、测试、上传 artifact,不需要 write 权限。显式声明 `contents: read` 遵循最小权限原则,尤其 `pull_request` 触发时(可能来自 fork)更应限制。
+
+**涉及文件**:`.github/workflows/ci.yml`
+
+**修改方案**:在 workflow 顶层添加 `permissions: contents: read`(覆盖所有 job):
+```yaml
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ ...
+```
+
+**兼容性分析**:
+- ci.yml 中的所有 job 只做 checkout、build、test、upload-artifact → 仅需 `contents: read`
+- `actions/upload-artifact` 不需要 `contents: write`,artifact 上传使用独立权限 → ✅ 不受影响
+- `pull_request` 触发时来自 fork 的 PR,GitHub 自动将 token 限制为 read-only → 显式声明与此一致,无冲突
+
+## 五、不修改的部分
+
+| 项 | 理由 |
+|---|---|
+| `release.yml` 的 `permissions: contents: write` | Release workflow 需要此权限创建 GitHub Release,无法移除 |
+| `KEYSTORE_BASE64` secret 解码方式 | 标准做法,无更安全替代 |
+| Debug keystore fallback 逻辑(CI 环境) | CI 中使用 debug keystore 只为验证编译/资源完整性,不用于正式发布 |
+| `setup-java@v4` 升级到 v5 | v4 已被标记弃用,但 SHA pin 到 v4 当前 commit 仍是安全的。升级 v5 属后续改进项,不阻塞本次安全修复 |
+
+## 六、修改顺序
+
+1. **`app/build.gradle.kts`** — 移除硬编码密码,使用 `takeIf` 防御空字符串(修复 2)
+2. **`.github/workflows/release.yml`** — SHA pin + 清理 keystore(修复 1 + 修复 3)
+3. **`.github/workflows/ci.yml`** — SHA pin + 补充 `permissions: contents: read`(修复 1 + 修复 4)
+
+## 七、验证方法
+
+- 检查 `release.yml` 和 `ci.yml` 中所有 `uses:` 行均为 `@<40位SHA> # vN` 格式,无 `@vN` 裸 tag
+- 检查 `build.gradle.kts` 中 grep `"RainyToken2026!"` 和 `"rainy"` 返回空
+- 检查 `build.gradle.kts` 中存在 `takeIf { it.isNotBlank() }` 模式
+- 检查 `release.yml` 中存在 `rm -f release.jks` 清理 step,且 step name 含 `always run for security`
+- 检查 `ci.yml` 中存在 `permissions: contents: read` 声明
+- 确认 CI workflow 的 build-release job 不受 build.gradle.kts 修改影响(走 debug keystore fallback 路径)
+
+## 八、修订日志
+
+| 版本 | 日期 | 修订内容 |
+|---|---|---|
+| v1 | 2026-08-05 | 初版计划 |
+| v2 | 2026-08-05 | 纳入首轮独立审核反馈:
1. 修复 2:`?: throw` → `?.takeIf { it.isNotBlank() } ?: throw`,防御空字符串绕过(审核问题 #1,高严重度)
2. 新增修复 4:ci.yml 补充 `permissions: contents: read`(审核问题 #3,中严重度)
3. 修复 1:ci.yml action 计数从 11 更正为 13(审核问题 #4,低严重度)
4. 修复 3:step name 加注 `(always run for security)` 说明意图(审核问题 #5,建议性)
5. 新增"不修改的部分"中 `setup-java@v4` 弃用说明(审核问题 #6,信息性)
6. 新增 R4 风险点(ci.yml 缺少 permissions)
7. 新增本修订日志 |
\ No newline at end of file