Rainytoken/docs/archive/security-fix-plan.md
WaterRain eecf54986f
Some checks are pending
CI / Unit Tests & Lint (push) Waiting to run
CI / Debug APK (push) Blocked by required conditions
CI / Release APK (debug-signed) (push) Blocked by required conditions
docs: README 补充 Token活动热力图说明与截图;归档历史计划文档至 docs/archive
2026-09-10 07:58:14 +00:00

172 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 安全修复计划 — 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 | 纳入首轮独立审核反馈:<br>1. 修复 2:`?: throw` → `?.takeIf { it.isNotBlank() } ?: throw`,防御空字符串绕过(审核问题 #1,高严重度)<br>2. 新增修复 4:ci.yml 补充 `permissions: contents: read`(审核问题 #3,中严重度)<br>3. 修复 1:ci.yml action 计数从 11 更正为 13(审核问题 #4,低严重度)<br>4. 修复 3:step name 加注 `(always run for security)` 说明意图(审核问题 #5,建议性)<br>5. 新增"不修改的部分"中 `setup-java@v4` 弃用说明(审核问题 #6,信息性)<br>6. 新增 R4 风险点(ci.yml 缺少 permissions)<br>7. 新增本修订日志 |