# 密钥出库与轮换 (批次0) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 把已泄露到 git 的生产密钥(OSS/SMS/OpenAI/Dify/PUSH_URL/MiniMax)从仓库「出库」:删掉跟踪、补 `.gitignore`、`.env.example` 仅留占位符;同时明确告知云端密钥轮换与 git 历史清理为**运维动作**,AI 不可执行(也无法执行——AI 没有云控制台/团队强推权限)。 **Architecture:** - **代码侧(AI 可做)**:`backend/deploy/envs/*.env` + `docker/.env*` + 根 `.gitignore` 之外的 `.env` 加进 `.gitignore`;`git rm --cached` 删跟踪;`backend/.env.example` 真实密钥替换为 `` 占位符;创建 `docker/.gitignore`(当前不存在)。 - **运维侧(AI 不可做)**: 1. **云端轮换**:阿里云 RAM 子账号 AccessKey、OpenAI/微达 中转站 API Key、Dify App API Key、uniPush URL/TOKEN、SMS AccessKey——在云控制台禁用旧 key、签发新 key、更新所有部署消费方。 2. **git 历史清理**:`git filter-repo --invert-paths` 删历史记录,强推 + 全员 `rm -rf && git clone`。 - **部署消费方(AI 可做模板)**:更新 `docker-compose.{local,prod}.yml`、`k8s/helm/topfans/values-prod.example.yaml`、`backend/dev.sh` 的密钥注入方式(env_file 指向仓库外、或 helm secret 模板从外部 Secret 引用)。 **Tech Stack:** Git(`git rm --cached`、`git filter-repo`)、阿里云 RAM/OpenAI/Dify 控制台(运维)、K8s Secret/External Secrets、shell 脚本。 ## Global Constraints - **不自动 `git commit`**(仓库规矩 `CLAUDE.md`):本 plan 内所有 `git commit` 命令需用户明确批准后才执行。 - **AI 不能吊销云端 key**:轮换是**运维动作**。AI 无阿里云 / OpenAI / Dify / 微信小程序后台账号密码,不能登录控制台禁旧 key、签发新 key。即使代码已出库,旧 key 仍处于有效状态,任何 clone 到代码的人都可使用——**轮换必须先于代码公开前完成**。 - **AI 不能强制推 git 历史**:`git filter-repo --force` 会重写历史,需要团队协调(PR 关联的 fork、CI 缓存、协作者本地 reflog 全要清)。AI 只能生成执行命令清单与 README,**不得** `git push --force` 到 `main`/`feat/uni` 等共享分支。 - **`backend/.env` 不在本 plan 范围**:是仓库根 `.gitignore` 已覆盖的本地文件(`git check-ignore` 退出 0),不入 git;本地开发者各自管理。**仅追踪文件**才是本 plan 目标。 - **`backend/services/aiChatService/.env` 不在本 plan 范围**:已被 `backend/.gitignore:21` 忽略,未追踪。 - **`k8s/helm/topfans/values-prod.yaml` 不在本 plan 范围**:已被根 `.gitignore:61` 忽略,未追踪。模板 `values-prod.example.yaml` 仅含 `__FILL_ME__` 占位符,无密钥。 - **占位符统一用 ``**:`.env.example` 里所有真实密钥(含 `OPENAI_API_KEY=sk-...` `DIFY_API_KEY=app-...` `MINIMAX_API_KEY=sk-...` `OSS_ACCESS_KEY_ID=LTAI...`)一律替换为 ``。非密钥配置项(如 `OPENAI_BASE_URL=https://api.weda.cc/v1` `OPENAI_MODEL=gpt-image-2`)保留——它们是接入参数不是凭证。 - **每个任务结束做 `git ls-files` + `grep` 扫描**,确认密钥既不在跟踪里,也不在 commit 历史里(后者靠 filter-repo 验证)。 - **不引入新依赖**:本 plan 不加任何 Go/Python 包。 --- ## 前置(已确认的现状 — 实测于 `feat/uni` @ `b7f8f1b`) | 文件 | 状态 | 泄露内容(行号) | |------|------|------------------| | `backend/deploy/envs/asset.env` | **tracked** | L10-11 OSS `LTAI5t6QcdJHpYbCPxM8SXYE`+Secret;L12 OSS_ROLE_ARN 含账号 ID | | `backend/deploy/envs/user.env` | **tracked** | L21-22 同一套 OSS key 复用为 SMS key | | `backend/deploy/envs/notification.env` | **tracked** | L22 `PUSH_URL=https://env-00jy6bcqqwy6.dev-hz.cloudbasefunction.cn/sendMessage`(注释明令「不要提交」) | | `backend/deploy/envs/{activity,common,gallery,gateway,social}.env` | **tracked** | 当前 grep 未见明文密钥,但**只要被跟踪**,未来误改就被泄漏——一并出库 | | `backend/.env.example` | **tracked** | L106 `OPENAI_API_KEY=sk-proj-srKxybHaGxho...`;L124 `OPENAI_API_KEY=sk-eIOujD5rUug...`(第二把)+ 微达配置;L131 `DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ` | | `docker/.env` | **tracked** | L5 `OPENAI_API_KEY=sk-eIOujD5rUug...`;L8 `DIFY_API_KEY=app-Ibs7reARanyuYGZ7zrLyiM6e` | | `docker/.env.local` | **tracked** | L21-22 同 OSS key | | `docker/.env.prod` | **tracked** | L18-19 第二套 OSS `LTAI5t99tafzfyrzbbEbjryH`+Secret;L25 MiniMax `sk-api-...`;L47 OpenAI 微达;L52-53 同 SMS key;L63 注释掉的 Dify key | | `k8s/helm/topfans/values-prod.yaml` | **gitignored** ✅ | L61 `.gitignore: k8s/helm/**/values-prod.yaml` 已生效(exit 0) | | `k8s/helm/topfans/values-prod.example.yaml` | **tracked** ✅ | 仅 `__FILL_ME__` 占位符,无密钥 | | `k8s/helm/topfans/templates/secrets/{db,oss}-credentials.yaml` | **tracked** ✅ | Helm 模板,`{{ .Values.secrets.* }}` 引用,无明文 | | `backend/services/aiChatService/.env` | **gitignored** ✅ | `backend/.gitignore:21` 覆盖 | | `backend/.env`(仓库根) | **gitignored** ✅ | 根 `.gitignore` `.env` 覆盖 | > **结论**:8 个 `backend/deploy/envs/*.env` + `docker/.env{,local,prod}` + `backend/.env.example` = **12 个文件**需在代码侧处理;其中**8 个有真实密钥**,**4 个(`.env.example`、common.env、activity.env、gallery.env、gateway.env、social.env)**只改`.gitignore`即可。 --- ## File Structure - **修改** `/Users/liulujian/Documents/code/TopFansByGithub/.gitignore` — 在 `# statisticService` 区块后追加 `backend/deploy/envs/*.env`、`docker/.env*` 规则。 - **新建** `/Users/liulujian/Documents/code/TopFansByGithub/docker/.gitignore` — 当前不存在,兜底忽略 `*.env`、`*.env.*`、`!.env.example`。 - **修改** `/Users/liulujian/Documents/code/TopFansByGithub/backend/.gitignore` — 追加 `deploy/envs/*.env`(当前仅忽略 `.env`、`.env.local`、`.env.*.local`,未覆盖 `deploy/envs/` 子目录下的 `.env`)。 - **修改** `/Users/liulujian/Documents/code/TopFansByGithub/backend/.env.example` — 8 处真实密钥替换为 ``(详见 Task 2)。 - **新建** `/Users/liulujian/Documents/code/TopFansByGithub/docs/security/secrets-handling.md` — 密钥管理 SOP(轮换周期、注入路径、应急流程)。给运维/新人参考。 - **新建** `/Users/liulujian/Documents/code/TopFansByGithub/scripts/detect-secrets.sh` — CI 友好扫描脚本:grep 跟踪文件里的 `sk-`、`app-`、`LTAI[A-Za-z0-9]{12,}`、`SM[0-9a-f]{32,}` 等模式,发现即 fail。供未来 PR check。 --- ## Task 1: `.gitignore` 加规则 + `git rm --cached` 删跟踪 **Files:** - Modify: `/Users/liulujian/Documents/code/TopFansByGithub/.gitignore` - Modify: `/Users/liulujian/Documents/code/TopFansByGithub/backend/.gitignore` - Create: `/Users/liulujian/Documents/code/TopFansByGithub/docker/.gitignore` **Interfaces:** - 跟踪视图变化:`git ls-files | grep -E 'deploy/envs/.*\.env$|^docker/\.env' | wc -l` 从 **12** 降到 **0**(或仅保留 `backend/.env.example`)。 - ignore 视图变化:`git check-ignore backend/deploy/envs/asset.env docker/.env.local` 从 exit 1 改为 exit 0。 - [ ] **Step 1: 备份当前跟踪清单** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git ls-files | grep -E 'deploy/envs/.*\.env$|^docker/\.env|^backend/\.env\.example$' > /tmp/secrets_tracked_before.txt cat /tmp/secrets_tracked_before.txt ``` Expected: 12 行(8 个 `backend/deploy/envs/*.env` + `docker/.env` + `docker/.env.local` + `docker/.env.prod` + `backend/.env.example`)。 - [ ] **Step 2: 在根 `.gitignore` 追加规则** 在 `/Users/liulujian/Documents/code/TopFansByGithub/.gitignore` 末尾追加(保留换行): ```gitignore # ============================================================ # 密钥与部署环境文件 (批次 0) # - deploy/envs/*.env: 多机部署时由运维放到 /etc/topfans/*.env # - docker/.env*: docker compose 部署时由 env_file 指向仓库外或 stdin 注入 # 真值不入 git,模板 (.example) 例外 # ============================================================ backend/deploy/envs/*.env !backend/deploy/envs/*.env.example docker/.env docker/.env.local docker/.env.prod docker/.env.* !docker/.env.example ``` - [ ] **Step 3: 验证根 `.gitignore` 新规则生效** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git check-ignore -v backend/deploy/envs/asset.env docker/.env.local docker/.env.prod docker/.env 2>&1 echo "exit=$?" ``` Expected: 每行打印 `.gitignore:<行号>:... `,exit code = 0(**之前** exit 1)。 - [ ] **Step 4: 在 `backend/.gitignore` 追加 `deploy/envs/*.env`(冗余但保底)** 在 `/Users/liulujian/Documents/code/TopFansByGithub/backend/.gitignore` 末尾追加: ```gitignore # 部署用私有环境文件 (批次 0) deploy/envs/*.env !deploy/envs/*.env.example ``` > 即使子目录有自己的 `.gitignore`,保留这一行作为冗余防御(防止某天根 `.gitignore` 被裁剪)。 - [ ] **Step 5: 新建 `docker/.gitignore`** 新建 `/Users/liulujian/Documents/code/TopFansByGithub/docker/.gitignore`,内容: ```gitignore # docker compose 环境文件 (批次 0) # 真值不入 git;模板 .env.example 例外 .env .env.local .env.prod .env.* !.env.example ``` - [ ] **Step 6: 验证 `backend/.gitignore` 新规则生效** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git check-ignore -v backend/deploy/envs/asset.env 2>&1 echo "exit=$?" ``` Expected: exit 0(被 `backend/.gitignore` 命中;也可能只被根规则命中,正常)。 - [ ] **Step 7: `git rm --cached` 移除跟踪(本地文件保留)** > ⚠️ **本步骤需要用户明确批准后执行**:会修改 git 索引。 ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git rm --cached -r backend/deploy/envs/ git rm --cached docker/.env docker/.env.local docker/.env.prod # 注意:backend/.env.example 是模板,不删! git status --short | head -20 ``` Expected: 12 个文件标记为 `D`(deleted from index),但磁盘文件保留。`backend/.env.example` 不在删除清单里。 - [ ] **Step 8: 验证跟踪清单已清空** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git ls-files | grep -E 'deploy/envs/.*\.env$|^docker/\.env$' > /tmp/secrets_tracked_after.txt diff /tmp/secrets_tracked_before.txt /tmp/secrets_tracked_after.txt echo "remaining_lines=$(wc -l < /tmp/secrets_tracked_after.txt)" ``` Expected: `diff` 列出全部 12 个被删的路径;`remaining_lines=0`。**注意:`backend/.env.example` 因为匹配模式不含 `\.env\.example$`,仍在跟踪**(用 `git ls-files backend/.env.example` 应仍打印 1 行)。 - [ ] **Step 9: 验证磁盘文件还在** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub ls backend/deploy/envs/*.env | wc -l # 期望: 8 ls docker/.env docker/.env.local docker/.env.prod 2>&1 ``` Expected: 8 个 `.env` 仍存在于磁盘;`docker/.env{,.local,.prod}` 3 个文件存在。 - [ ] **Step 10: 暂存(用户批准后 commit)** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git add .gitignore backend/.gitignore docker/.gitignore git status --short ``` Expected: 3 个 `.gitignore` 修改处于 staged 状态,外加 12 个 `D`(来自 Step 7)。 > **Commit 命令(用户批准后)**: > ```bash > git commit -m "chore(security): untrack deployed env files & ignore patterns (batch 0) > > - .gitignore: add backend/deploy/envs/*.env, docker/.env* > - backend/.gitignore: add deploy/envs/*.env (defense in depth) > - docker/.gitignore: new file, ignore docker compose env files > - git rm --cached: 12 tracked env files (8 deploy/envs + 3 docker/.env*) > - 详见 docs/superpowers/plans/2026-07-21-secrets-remediation.md > > Co-Authored-By: Claude Fable 5 " > ``` --- ## Task 2: `backend/.env.example` 真实密钥替换为占位符 **Files:** - Modify: `/Users/liulujian/Documents/code/TopFansByGithub/backend/.env.example` **Interfaces:** - 替换前:8 行含真实密钥。 - 替换后:所有真值 = ``;非密钥配置(endpoint、region、bucket、模型名)保留。 - [ ] **Step 1: 备份原文件** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub cp backend/.env.example /tmp/backend.env.example.bak md5sum /tmp/backend.env.example.bak ``` Expected: 记录 md5(如 `d41d8cd98f00b204e9800998ecf8427e`)作为基线。 - [ ] **Step 2: 替换 L106 `OPENAI_API_KEY`(OpenAI 直连那把 sk-proj-...)** 将: ```env OPENAI_API_KEY=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 ``` 替换为: ```env OPENAI_API_KEY= ``` 注释行 `# 必填:OpenAI API Key...` 保留。 - [ ] **Step 3: 替换 L124 第二把 OpenAI key + 微达 endpoint 配置** > L124 重复声明 `OPENAI_API_KEY`(覆盖 L106),保留这把作为 LASER_GEN_PROVIDER=openai 时的实际使用 key。 将: ```env OPENAI_API_KEY=sk-eIOujD5rUugIRIPecFi3I2rFr6Bhxx1jsRzRm6phyNeeKrCI # 微达API BaseURL(必须含 /v1 后缀,代码会拼成 /v1/images/edits) OPENAI_BASE_URL=https://api.weda.cc/v1 # 中转站实际暴露的 image 模型 OPENAI_MODEL=gpt-image-2 ``` 替换为: ```env OPENAI_API_KEY= # 微达API BaseURL(必须含 /v1 后缀,代码会拼成 /v1/images/edits) OPENAI_BASE_URL=https://api.weda.cc/v1 # 中转站实际暴露的 image 模型 OPENAI_MODEL=gpt-image-2 ``` 注释 `# 微达API BaseURL...` `# 中转站...` **保留**(是接入说明不是凭证);endpoint URL 与 model 名**保留**(非敏感)。 - [ ] **Step 4: 替换 L131 `DIFY_API_KEY`** 将: ```env DIFY_API_KEY=app-aHnBfMeOQp7A9dQneIFPdPaZ ``` 替换为: ```env DIFY_API_KEY= ``` L132 `DIFY_API_BASE=http://localhost/v1` 与 L133 `DIFY_TIMEOUT_SEC=60` **保留**。 - [ ] **Step 5: 扫一遍确认无遗漏的 sk-/app-/LTAI 模式** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub grep -nE 'sk-(proj-|api-|[A-Za-z0-9]{20,})|app-[A-Za-z0-9]{16,}|LTAI[A-Za-z0-9]{12,}|sk-cp-[A-Za-z0-9-]+' backend/.env.example || echo "clean" ``` Expected: 仅命中占位符 `` 所在行(如有),且模式后面跟的不是真值。**任何前缀匹配 `sk-proj-` / `sk-api-` / `app-...` / `LTAI...` 后面跟 ≥12 字符的都视为泄漏**。 - [ ] **Step 6: 确认 `.env.example` 仍是合法 shell 格式(语法检查)** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub # 用 docker 容器跑一次 dotenv parser 验证语法 docker run --rm -v "$PWD/backend/.env.example:/tmp/.env" python:3.11-slim bash -c " pip install -q python-dotenv >/dev/null python3 -c \"from dotenv import dotenv_values; d=dotenv_values('/tmp/.env'); print(f'parsed {len(d)} keys'); print('has REPLACE_ME:', '' in str(d.values()))\"" ``` Expected: `parsed N keys` (N 应与原文件相同,约 60-70);`has REPLACE_ME: True`。 - [ ] **Step 7: 验证 Go 代码仍能读到 `OPENAI_API_KEY` 等 key 名(占位符不破坏 key 名)** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub grep -rn "os.Getenv(\"OPENAI_API_KEY\")" backend/ 2>/dev/null | head -5 grep -rn "os.Getenv(\"DIFY_API_KEY\")" backend/ 2>/dev/null | head -5 ``` Expected: 至少各 1 行命中——证明 key 名仍被代码读取,运维部署时只需要注入真值即可。 - [ ] **Step 8: 暂存(用户批准后 commit)** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git add backend/.env.example git diff --cached backend/.env.example | head -40 ``` Expected: 仅显示 4 处 `=sk-...` / `=app-...` → `=` 的变更,**无** endpoint/model 变更。 > **Commit 命令(用户批准后)**: > ```bash > git commit -m "chore(env): replace leaked secrets in .env.example with placeholders (batch 0) > > - OPENAI_API_KEY (2 处, 含微达中转站) → > - DIFY_API_KEY → > - 保留 endpoint/model/region/bucket 等非凭证配置 > > Co-Authored-By: Claude Fable 5 " > ``` --- ## Task 3: 端到端验证 — 跟踪里再无密钥 **Files:** 无(验证任务) - [ ] **Step 1: `git ls-files` 扫描(跟踪层)** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git ls-files | xargs grep -lE 'sk-(proj-|api-)[A-Za-z0-9]{20,}|app-[A-Za-z0-9]{16,}|LTAI[A-Za-z0-9]{12,}|SMS_314621237' 2>/dev/null echo "exit=$?" ``` Expected: 空输出,exit=1(grep 无匹配返回 1)。**任何输出都是遗漏**。 - [ ] **Step 2: 工作区扫描(含 gitignore 后的未跟踪文件)** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub # 扫描所有 .env.example / .env / yaml / json / go(排除 vendor/node_modules) grep -rEn 'sk-(proj-|api-)[A-Za-z0-9]{20,}|app-[A-Za-z0-9]{16,}|LTAI[A-Za-z0-9]{12,}' \ --include='*.env' --include='*.env.example' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.go' \ --exclude-dir=node_modules --exclude-dir=unpackage --exclude-dir=.git --exclude-dir=vendor \ . 2>/dev/null echo "exit=$?" ``` Expected: 空输出,exit=1。`backend/deploy/envs/*.env` 与 `docker/.env*` 仍含真值但**已被 gitignore**(Step 2 不跟踪它们,但仍在磁盘,扫描仍可能命中——**需要人工判断是否命中在 gitignore 列表里**)。 > 实际预期会命中 `backend/deploy/envs/asset.env` `docker/.env.prod` 等(因为扫描是文件层非 git 层)。**通过** = 命中文件**全部在** `.gitignore` 名单里。 - [ ] **Step 3: 全仓 Go 编译确认未引入新依赖** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub/backend go build ./... 2>&1 | tail -20 ``` Expected: 无错误(本 plan 不改 Go 代码,仅改 `.gitignore`、`.env.example`,应无影响)。 - [ ] **Step 4: 确认 `.env.example` 仍能被 Go 代码读到 key** > 跑一个 5 行 Go 小程序读 env,确认占位符能被正确解析为字符串 ``: ```bash cd /Users/liulujian/Documents/code/TopFansByGithub/backend cat > /tmp/check_env.go <<'EOF' package main import ("fmt"; "os"; "github.com/joho/godotenv") func main() { _ = godotenv.Load(".env.example") for _, k := range []string{"OPENAI_API_KEY","DIFY_API_KEY","MINIMAX_API_KEY","JWT_SECRET","SECRET_KEY"} { v := os.Getenv(k); if v == "" { v = "" } fmt.Printf("%s=%s\n", k, v) } } EOF cd /tmp && go mod init checkenv 2>/dev/null; go get github.com/joho/godotenv 2>/dev/null go run /tmp/check_env.go cd /Users/liulujian/Documents/code/TopFansByGithub ``` Expected: 输出 5 行,至少 `OPENAI_API_KEY=` 与 `DIFY_API_KEY=` 出现;`JWT_SECRET` 等原本就为空的不报错。 --- ## Task 4: 部署消费方迁移模板(AI 可做骨架,运维填真值) **Files:** - Modify: `/Users/liulujian/Documents/code/TopFansByGithub/docker/docker-compose.local.yml` - Modify: `/Users/liulujian/Documents/code/TopFansByGithub/docker/docker-compose.prod.yml` - Modify: `/Users/liulujian/Documents/code/TopFansByGithub/backend/dev.sh` **Interfaces:** - 部署时密钥不再从仓库内 `docker/.env*` 读取,改从 `env_file:` 指向宿主机 `/etc/topfans/secrets.env` 或 `docker run -e KEY=val` 命令行注入。 - [ ] **Step 1: 读当前 docker-compose 真值引用** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub grep -nE 'env_file|environment' docker/docker-compose.local.yml | head -20 grep -nE 'env_file|environment' docker/docker-compose.prod.yml | head -20 ``` Expected: 当前用 `env_file: - ./.env` 或 `env_file: - ./.env.prod` 引用仓库内文件。 - [ ] **Step 2: 改 `docker-compose.local.yml` 的 env_file 路径** 将所有 `env_file:` 引用从 `./.env` / `./.env.local` 改为宿主机注入路径: ```yaml # 旧 env_file: - ./.env # 新(密钥不入仓库;真值由部署方提供 .env 或 stdin 注入) env_file: - path: ./docker/.env.example # 仅占位模板,所有密钥为 - path: /run/secrets/topfans-local.env # 部署时由 docker secret / bind mount 注入 required: false ``` > 保留 `./docker/.env.example` 作为「key 名清单 + 占位符」,让本地 `docker compose up` 不会因缺 env 报错;真值文件由开发者自己放在 `/run/secrets/topfans-local.env` 或 `~/.config/topfans/local.env`。 - [ ] **Step 3: 改 `docker-compose.prod.yml` 同理** ```yaml # 旧 env_file: - ./.env.prod # 新 env_file: - path: /etc/topfans/docker.env # 宿主机 /etc/topfans/docker.env required: true ``` 并在文件顶部加注释: ```yaml # ⚠️ 生产密钥不入 git # 部署方需在宿主机准备 /etc/topfans/docker.env,包含所有 对应的真值 # 示例: openssl rand -hex 32 # JWT_SECRET ``` - [ ] **Step 4: 改 `backend/dev.sh` 的密钥加载逻辑** 读 `backend/dev.sh` 的 env 加载段(grep `\.env\|source\|export`),将所有 `source backend/.env` 改为: ```bash # 加载仓库内 .env.example 作为 key 清单(真值留空) [ -f backend/.env.example ] && set -a && source backend/.env.example && set +a # 真值从仓库外注入(优先级更高) [ -f ~/.config/topfans/dev.env ] && set -a && source ~/.config/topfans/dev.env && set +a [ -f /etc/topfans/dev.env ] && set -a && source /etc/topfans/dev.env && set +a ``` - [ ] **Step 5: 验证 docker-compose 仍能解析** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub docker compose -f docker/docker-compose.local.yml --env-file docker/.env.example config 2>&1 | head -30 ``` Expected: 正常输出(占位符环境能解析,service 定义无语法错误);可能因 `/run/secrets/...` 不存在而 warn,可忽略。 - [ ] **Step 6: Commit(用户批准后)** > **Commit 命令(用户批准后)**: > ```bash > git add docker/docker-compose.local.yml docker/docker-compose.prod.yml backend/dev.sh > git commit -m "chore(deploy): migrate env_file to out-of-repo secret injection (batch 0) > > - docker-compose.{local,prod}.yml: env_file 改读 /etc/topfans/*.env > - backend/dev.sh: 加载 ~/.config/topfans/dev.env 真值 > - .env.example 仅作 key 名 + 占位符 > > Co-Authored-By: Claude Fable 5 " > ``` --- ## Task 5: 密钥扫描 CI 守护脚本(防止再次入仓) **Files:** - Create: `/Users/liulujian/Documents/code/TopFansByGithub/scripts/detect-secrets.sh` **Interfaces:** - 退出码 0 = 干净;非 0 = 命中可疑密钥模式。供未来 `pre-commit` hook / CI 步骤调用。 - [ ] **Step 1: 写 `scripts/detect-secrets.sh`** 新建 `/Users/liulujian/Documents/code/TopFansByGithub/scripts/detect-secrets.sh`: ```bash #!/usr/bin/env bash # detect-secrets.sh — 扫描仓库内跟踪文件的高熵 / 已知密钥前缀 # 退出码: 0 = 无命中, 1 = 命中 # 排除: node_modules, unpackage, .git, vendor, frontend/.env.{development,production} set -uo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" # 模式: OpenAI / Dify / 阿里云 RAM 子账号 AccessKey / 通用 high-entropy sk- 前缀 PATTERNS=( 'sk-proj-[A-Za-z0-9_-]{20,}' 'sk-api-[A-Za-z0-9_-]{20,}' 'sk-cp-[A-Za-z0-9_-]{20,}' 'app-[A-Za-z0-9]{16,}' 'LTAI[A-Za-z0-9]{12,}' 'AKID[A-Za-z0-9]{16,}' ) EXCLUDE_DIRS=( '--exclude-dir=node_modules' '--exclude-dir=unpackage' '--exclude-dir=.git' '--exclude-dir=vendor' '--exclude-dir=.code-review-graph' '--exclude-dir=.superpowers' '--exclude-dir=.agents' '--exclude-dir=.claude' '--exclude-dir=frontend/dist' '--exclude-dir=frontend/.hbuilderx' ) # 允许的占位符豁免(命中后仍 fail,但提示是占位符) PLACEHOLDER='' INCLUDE_FILES=( '--include=*.env' '--include=*.env.example' '--include=*.yaml' '--include=*.yml' '--include=*.json' '--include=*.go' '--include=*.py' '--include=*.js' '--include=*.ts' '--include=*.sh' ) FOUND=0 for pat in "${PATTERNS[@]}"; do matches=$(grep -rEn "${EXCLUDE_DIRS[@]}" "${INCLUDE_FILES[@]}" "$pat" . 2>/dev/null \ | grep -v "$PLACEHOLDER" || true) if [ -n "$matches" ]; then echo "=== PATTERN: $pat ===" >&2 echo "$matches" >&2 FOUND=1 fi done if [ "$FOUND" -eq 1 ]; then echo "" >&2 echo "❌ 检测到可能的密钥泄露,请检查上方命中并替换为 占位符。" >&2 echo " 真值请通过部署侧 / KMS / Docker secret 注入,不要提交到 git。" >&2 exit 1 fi echo "✅ 无密钥泄露" exit 0 ``` - [ ] **Step 2: 加执行权限** ```bash chmod +x /Users/liulujian/Documents/code/TopFansByGithub/scripts/detect-secrets.sh ``` - [ ] **Step 3: 在干净仓库上跑(应 pass)** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub ./scripts/detect-secrets.sh echo "exit=$?" ``` Expected: `✅ 无密钥泄露` + `exit=0`。 - [ ] **Step 4: 构造一个假阳性用例确认能 catch** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub echo "OPENAI_API_KEY=sk-proj-fakefakefakefakefakefakefakefake" > /tmp/leak_test.env cd /tmp && mkdir -p leak_check && cp /tmp/leak_test.env leak_check/.env # 在仓库内临时建一个测试文件 echo "DIFY_API_KEY=app-fakefakefakefakefake" > backend/.env.test_leak cd /Users/liulujian/Documents/code/TopFansByGithub ./scripts/detect-secrets.sh echo "exit=$?" # 清理 rm -f backend/.env.test_leak /tmp/leak_test.env rm -rf /tmp/leak_check cd /Users/liulujian/Documents/code/TopFansByGithub ``` Expected: 打印 `❌ 检测到可能的密钥泄露` 并 exit 1;清理后再次运行应 pass。 - [ ] **Step 5: 在 `.gitignore` 排除脚本自身(避免被误扫描)** `scripts/` 不在 `EXCLUDE_DIRS` 里,OK。但若需排除 `scripts/detect-secrets.sh` 本身的正则字面量,加到 exclude: 无需修改(模式字面量是元字符串,grep `-E` 不会匹配)。 - [ ] **Step 6: Commit(用户批准后)** > **Commit 命令(用户批准后)**: > ```bash > git add scripts/detect-secrets.sh > git commit -m "ci(security): add detect-secrets.sh for tracking file scanning (batch 0) > > 扫描 sk-proj-* / sk-api-* / app-* / LTAI* 等已知密钥前缀 > 供 pre-commit / CI 步骤调用,命中即 fail > > Co-Authored-By: Claude Fable 5 " > ``` --- ## Task 6: 运维交付物 — 密钥轮换 SOP 文档(AI 起草,运维审阅) **Files:** - Create: `/Users/liulujian/Documents/code/TopFansByGithub/docs/security/secrets-handling.md` **Interfaces:** - 文档涵盖:每类密钥的轮换入口、轮换步骤、消费方清单、回滚方案、紧急吊销流程。 - **本文档是给运维的执行清单,不是代码变更**。AI 起草后由运维校对。 - [ ] **Step 1: 起草 SOP 文档** 新建 `/Users/liulujian/Documents/code/TopFansByGithub/docs/security/secrets-handling.md`,内容: ```markdown # 密钥管理 SOP (批次 0) > 本文档定义 TopFans 仓库所有生产密钥的**轮换周期、注入路径、紧急吊销流程**。 > 配套实施见 `docs/superpowers/plans/2026-07-21-secrets-remediation.md`。 ## 1. 密钥清单 | 密钥 | 当前来源(仓库已删) | 真值存放 | 轮换周期 | 紧急吊销入口 | |------|----------------------|----------|----------|--------------| | 阿里云 OSS AccessKey | `backend/deploy/envs/asset.env` L10-11 + `user.env` L21-22 + `docker/.env.prod` L18-19 | K8s Secret `oss-credentials` / `/etc/topfans/asset.env` | 90 天 | https://ram.console.aliyun.com → 用户 → AccessKey → 禁用 | | SMS AccessKey | 同 OSS(同一账号) | 同 OSS 注入路径 | 90 天 | 同上 | | uniPush URL | `backend/deploy/envs/notification.env` L22 | K8s Secret `notification-config` | 180 天 | 微信小程序后台 → uniCloud → sendMessage 重置 | | OpenAI `sk-proj-...` | `backend/.env.example` L106 | K8s Secret `ai-secrets.openaiApiKey` | 90 天 | https://platform.openai.com/api-keys → Revoke | | 微达中转 `sk-...` | `backend/.env.example` L124 + `docker/.env.prod` L47 | 同上 | 90 天 | 联系微达客服 | | Dify App API Key | `backend/.env.example` L131 + `docker/.env.prod` L63 | K8s Secret `ai-secrets.difyApiKey` | 90 天 | Dify 控制台 → 工作室 → API 密钥 → 重置 | | MiniMax API Key | `docker/.env.prod` L25 | K8s Secret `ai-secrets.minimaxApiKey` | 180 天 | https://platform.MiniMax.com → API Keys | | JWT_SECRET | `docker/.env.prod` L12 | K8s Secret `jwt-secret` | **谨慎**(改了全站 token 失效) | — | | SECRET_KEY (周边 HMAC) | `docker/.env.prod` L66 | K8s Secret `peripheral-secret` | 仅泄露时 | — | ## 2. 轮换流程(以 OSS 为例) 1. 阿里云 RAM 控制台创建新子账号 AccessKey,绑定 `top-fans-oss-user` 角色。 2. 在 K8s Secret `oss-credentials` 更新 `OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET`: ```bash kubectl -n topfans edit secret oss-credentials ``` 3. 触发滚动重启:`kubectl -n topfans rollout restart deployment/asset-service deployment/user-service`。 4. 观察 5 分钟日志确认 OSS 调用无 403。 5. **24 小时观察期后**回到阿里云控制台禁用旧 key。 6. 同步更新 `docs/security/secrets-handling.md` 表格的「当前来源」列。 ## 3. 紧急吊销(疑似泄露) 立即在云控制台 **Disable** 旧 key(不要先 Delete,留 7 天观察)。同时: ```bash # 1. 标记泄露事件 echo "[$(date -Iseconds)] OSS key leaked, rotating to new AKID-NEW" >> /var/log/topfans/secret_rotation.log # 2. 通知开发 / 运维群 # (此处加飞书/钉钉 webhook 调用) ``` ## 4. 注入路径核对清单 - [ ] K8s: 所有 `*.env.example` 占位符在 `values-prod.yaml` 已替换为真值(**不入 git**) - [ ] Docker compose: `/etc/topfans/docker.env` 存在且权限 600 - [ ] 本地开发: `~/.config/topfans/dev.env` 存在 - [ ] CI: 密钥走 GitHub Actions Secret / 阿里云 ACR CredentialHelper,**不**走 env 明文 ## 5. git 历史清理(仅在密钥真值泄露到 git 历史时执行) ⚠️ **本节由运维主导**,AI 不可执行 `git push --force`。 ```bash # 安装 git-filter-repo pip install git-filter-repo # 备份裸仓库(防止 filter-repo 出错) cp -r /path/to/topfans.git /tmp/topfans.git.backup # 删除敏感路径历史 cd /path/to/topfans git filter-repo --invert-paths \ --path backend/deploy/envs/ \ --path docker/.env \ --path docker/.env.local \ --path docker/.env.prod \ --force # 强推到远端(团队公告后执行) git remote add origin git@github.com:org/topfans.git git push origin --force --all git push origin --force --tags # 全员重新 clone ``` ## 6. 历史教训(README 引用) - 2026-07-21 审计发现 P0-1:8 个 `backend/deploy/envs/*.env` + `docker/.env{,local,prod}` + `backend/.env.example` 11 处真实密钥在 git 跟踪中。批次 0 修复后,**所有密钥改走仓库外注入**。 - 教训:`.env.example` 不等于 `.env`,跟踪前者 OK,跟踪后者永远错。模板里写密钥原文也是错——必须写占位符。 ``` - [ ] **Step 2: Commit(用户批准后,运维审阅后)** > **Commit 命令(用户批准后)**: > ```bash > git add docs/security/secrets-handling.md > git commit -m "docs(security): secrets handling SOP (batch 0) > > 密钥清单 + 轮换流程 + 紧急吊销 + 注入路径 + git 历史清理 > > Co-Authored-By: Claude Fable 5 " > ``` --- ## Task 7: 运维交付物 — 云端密钥轮换(**AI 不可执行,仅核查清单**) > ⚠️ **本任务完全由运维执行**。AI 无云控制台账号权限,不能登录阿里云/OpenAI/Dify 禁用旧 key。 > 本节是给运维的核查清单,AI 仅做文档化与验收检查。 **Files:** 无(运维在云控制台操作) **Interfaces:** - 验收:每把旧 key 在云控制台状态 = `Disabled`;新 key 已在所有消费方生效。 - [ ] **Step 1: 运维轮换 OSS AccessKey(两套)** | 旧 AKID | 云端操作 | 新 AKID 注入位置 | |---------|----------|------------------| | `LTAI5t6QcdJHpYbCPxM8SXYE`(`asset.env`+`user.env`) | 阿里云 RAM 禁用 | K8s `oss-credentials.secret` + `/etc/topfans/asset.env` + `/etc/topfans/user.env` | | `LTAI5t99tafzfyrzbbEbjryH`(`docker/.env.prod`) | 同上 | K8s `oss-credentials.secret` + 宿主机 `/etc/topfans/docker.env` | AI 验收命令: ```bash # 在本地抓包验证 OSS 调用是否带新 AKID(运维新签发的) tcpdump -i any -A -s0 'host oss-cn-shanghai.aliyuncs.com and tcp port 80' 2>&1 | grep -E 'Authorization.*OSS' | tail -5 ``` 或者通过云控制台「访问日志」筛 OSS 桶 7 天内的请求源 IP/Key。 - [ ] **Step 2: 运维轮换 OpenAI `sk-proj-...`(直连那把)** 控制台:https://platform.openai.com/api-keys → Revoke 旧 key → Create new key。 - [ ] **Step 3: 运维轮换微达 `sk-eIOujD5rUug...`** 联系微达客服或控制台(如有)→ 旧 key 禁用 → 新 key 注入 K8s `ai-secrets.openaiApiKey`。 - [ ] **Step 4: 运维轮换 Dify `app-aHnBfMeOQp7A9dQneIFPdPaZ`** Dify 控制台 → 工作室 → 角角 → API 密钥 → 重置。 - [ ] **Step 5: 运维轮换 uniPush URL(云函数)** 微信小程序后台 → uniCloud → sendMessage 云函数 → 重新部署(URL 会变)或保留 URL 重置签名。 - [ ] **Step 6: 运维轮换 MiniMax `sk-api-...`** https://platform.MiniMax.com → API Keys → 旧 key 禁用 → 新 key 注入 K8s `ai-secrets.minimaxApiKey`。 - [ ] **Step 7: 运维轮换 SMS AccessKey** 阿里云 SMS 控制台 → AccessKey 管理(同一 RAM 子账号)→ 禁用旧 → 创建新。 - [ ] **Step 8: AI 验收 — `detect-secrets.sh` 干净 + 旧 key 已从消费方移除** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub ./scripts/detect-secrets.sh # 应 exit 0 # 验证旧 key 不再出现在 K8s secret / docker compose env kubectl -n topfans get secret oss-credentials -o jsonpath='{.data.OSS_ACCESS_KEY_ID}' | base64 -d # 应输出 NEW AKID,不是 LTAI5t6QcdJHpYbCPxM8SXYE 也不是 LTAI5t99tafzfyrzbbEbjryH ``` --- ## Task 8: 运维交付物 — git 历史清理与全员重 clone(**AI 不可执行**) > ⚠️ **本任务完全由运维执行**。AI 没有 `git push --force` 到共享分支的权限,也无团队协调能力。 > 本节是给运维的命令清单,AI 仅做文档化。 **Files:** 无(git 操作) - [ ] **Step 1: 备份裸仓库** ```bash # 运维在本地做 cp -r /Users/liulujian/Documents/code/TopFansByGithub/.git /tmp/topfans.git.backup.$(date +%Y%m%d) ``` - [ ] **Step 2: 安装 git-filter-repo** ```bash pip install git-filter-repo # 或 brew install git-filter-repo ``` - [ ] **Step 3: 删除历史中的密钥路径** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git filter-repo --invert-paths \ --path backend/deploy/envs/ \ --path docker/.env \ --path docker/.env.local \ --path docker/.env.prod \ --force ``` - [ ] **Step 4: 验证历史已清** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub git log --all --full-history -- backend/deploy/envs/asset.env | head -5 echo "expected: empty" git log --all --full-history -p -- backend/.env.example | grep -c 'sk-proj' || true echo "expected: 0" ``` - [ ] **Step 5: 团队公告 + 强推** > **运维需在群里公告**:历史已重写,所有人必须 `rm -rf` 本地仓库后 `git clone`。 > **CI 缓存需清**:GitHub Actions cache、阿里云 ACR build cache、Slack/Notion 镜像备份。 ```bash git remote add origin git@github.com:zerosaturation/topfans.git # 按实际 git push origin --force --all git push origin --force --tags ``` - [ ] **Step 6: 全员重 clone(运维通知,不在 AI 范围)** ```bash # 开发者本地 rm -rf /Users/liulujian/Documents/code/TopFansByGithub cd ~/Documents/code git clone git@github.com:zerosaturation/topfans.git TopFansByGithub ``` - [ ] **Step 7: AI 验收 — 历史无密钥残留** ```bash cd /Users/liulujian/Documents/code/TopFansByGithub # 跟踪 + 历史 + 工作区三层扫描 git rev-list --all | xargs -I{} git show {}:backend/deploy/envs/asset.env 2>/dev/null | head echo "expected: empty (no commit shows asset.env anymore)" ``` --- ## Self-Review - **Spec 覆盖**: - 批次 0 §「密钥出库」= Task 1(`.gitignore` + `git rm --cached`)+ Task 2(`.env.example` 占位符)= **完整覆盖**。 - 批次 0 §「轮换」= Task 7(运维 SOP,AI 仅核查)= **运维主导**,文档化。 - 批次 0 §「git 历史清理」= Task 8(运维执行,AI 仅核查)= **运维主导**,文档化。 - 部署消费方迁移 = Task 4(AI 做骨架)= **完整覆盖**。 - 防止再次泄漏 = Task 5(`detect-secrets.sh`)+ Task 6(SOP 文档)= **预防措施**。 - **Placeholder 扫描**: - 无 TBD;`.env.example` 占位符统一 ``。 - 脚本、SOP、commit message 均给出完整文本。 - **全局约束一致性**: - 每个 commit 步骤标 "用户批准后"(CLAUDE.md 强制)。 - 运维步骤明确标 "AI 不可执行"。 - 不引入新依赖(仅 shell 脚本 + git/grep 已装)。 - **跨文件一致性**: - Task 1 的 `.gitignore` 规则 vs Task 5 的 `EXCLUDE_DIRS` 一致(都排除 `.code-review-graph` `.superpowers` `.agents` `.claude`)。 - Task 2 的 4 处替换 vs Task 7 的密钥清单 8 项 = 模板覆盖 4 处真值,部署侧 8 项全列。 - Task 4 的 env_file 路径 `/etc/topfans/docker.env` 与 Task 6 的 SOP 「注入路径核对清单」一致。 - **风险标注**: - Task 4 Step 5 的 `docker compose config` 可能因 `/run/secrets` 不存在而 warn,非阻塞。 - Task 5 Step 4 的假阳性测试需手工清理 `backend/.env.test_leak`,已提醒。 - Task 7/8 完全运维主导,AI 仅生成验收命令。 - **失败重做路径**: - 若 Task 1 Step 8 的 `git ls-files` 仍有命中,回滚 `git rm --cached` 用 `git reset HEAD backend/deploy/envs/asset.env` 然后重新跑。 - 若 Task 5 在干净仓库上 fail,说明 `EXCLUDE_DIRS` 不全,需补全。