Merge pull request #532 from fastapiadmin/dev

security(deploy): 生产凭据轮换 + 镜像不再携带口令
This commit is contained in:
fastapiadmin
2026-10-04 06:40:53 +08:00
committed by GitHub
3 changed files with 49 additions and 6 deletions
+4 -3
View File
@@ -190,7 +190,8 @@
| Web i18n 与通知枚举 | 85/101 页面未接入 i18n;通知状态枚举与后端语义冲突 |
| 测试覆盖与夹具 | 夹具掩盖缺陷、迁移链路 0 覆盖(CI 已建立,但用例仍需补) |
| 移动端其它缺陷 | `alova.ts:115-119` 请求头写成 `ContentType`(缺连字符),修正需回归上传/表单/JSON 三类请求 |
| 密钥残留 | 旧数据库口令与 OPENAI_API_KEY 仍存在于 git 历史,建议按已泄露处理并重新签发 |
| 凭据轮换与镜像卫生 | ① 生产口令整体轮换:MySQL 应用用户 / MySQL root / Redis(旧值实测被拒:`ERROR 1045`、`WRONGPASS`);② 镜像不再携带任何口令:`deploy-artifacts.sh` 构建时剔除 `DATABASE_PASSWORD / REDIS_PASSWORD / SECRET_KEY / DATA_ENCRYPTION_KEY` 等键(审计 B3 根治),重建后镜像内口令行命中 0 条,应用改由 compose 注入口令且 `db_status/redis_status=1`;③ 本地 `backend/env/.env.prod` 中泄露值清空并改 600 权限 | 旧口令拒绝测试、新口令容器内连接测试、镜像内容扫描、四端 200、容器全 healthy |
| 公开仓库历史暴露(需用户侧吊销) | 仓库为 **public**,历史中真实出现过:**OpenAI API Key(35 字符 `sk-…`)**、旧 MySQL/Redis/root 口令、旧 `SECRET_KEY`、旧自签名 TLS 私钥;其余 `PAYMENT_* / EMAIL_PASSWORD / DINGDING_SECRET / MONGO_DB_PASSWORD / POSTGRESQL_PASSWORD` 等仅出现在 `.example` 占位文件 | 已消除:DB/Redis/root 口令与 `SECRET_KEY`/`DATA_ENCRYPTION_KEY` 本轮轮换(历史值即失效);线上证书与历史私钥**不同**(指纹比对)。**待用户处理:到 OpenAI 控制台吊销该 Key**(第三方无法代办);是否用 `git-filter-repo` 清洗历史见 §9.2 |
| 对外口径(异常统一后的取舍) | 存储「测试连接」密钥环损坏、上传/下载失败、OAuth 渠道未配置、Redis 同步失败等已由具体原因变为通用 5xx 文案(细节只在日志)。若希望管理员在界面看到可操作原因,正确做法是为这些场景定义**业务码**,而不是回到「包装意外异常」 |
| 仍可能外泄的出口 | `ValueError` 全局处理器返回 `msg=str(exc)`;建议改为通用文案 + 日志,或让我们的校验统一抛 `CustomException` |
| 静默吞异常(既有) | 48 处「记录日志但不 raise」的 best-effort 处理器(redis_crud、ap_scheduler、middlewares、discover 等),属审计「静默吞异常」主线,建议单独排一轮 |
@@ -231,7 +232,7 @@
**收尾后仍需人工处理(不阻塞上线)**
1. 轮换 `OPENAI_API_KEY` 与旧数据库口令(已进历史/镜像层,按已泄露处理)。
2. 审计报告内含旧密钥的少量特征(前 4 字符与长度)用于佐证;若仓库将转为公开,建议先按上述轮换再考虑是否脱敏。
1. **吊销旧 OpenAI API Key**:仓库是 public,`sk-…`(35 字符)确实出现在 `backend/env/.env.prod` 的历史提交中。线上环境从未使用它(容器 env 与该键均为空、新镜像已不含任何密钥),所以风险只在于该 Key 在 OpenAI 侧可能仍有效。请到 <https://platform.openai.com/api-keys> 吊销;如需 AI 功能再签发新 Key 并只写入服务器 `.env`。
2. **是否清洗 git 历史**:`git-filter-repo` 可把历史中的 `.env*` 与旧证书整体抹除,但会重写所有 SHA(需 force-push `dev` 与 `master`,影响既有克隆与 PR 引用,且 gitee 镜像需同步)。由于相关口令已轮换、Key 将吊销,**建议以「吊销 + 轮换」为准,清洗作为可选项**;若仓库要转为对外开源,则建议执行清洗。
3. §9.2 中的产品/架构决策项(会话失效、数据权限注入、多租户规格、i18n、移动端 `ContentType`)。
+34 -1
View File
@@ -123,7 +123,40 @@ ss -tlnp | grep docker-proxy # 确认对外只有 80/443
`pydantic_settings.exceptions.SettingsError: error parsing value for field "XXX" from source "EnvSettingsSource"`,
就是该字段的取值格式与类型不匹配。
## 7. 已知遗留问题
## 7. 凭据轮换(生产)
轮换对象:MySQL 应用用户 / MySQL root / Redis。三者都通过 `docker/.env` 注入,且
**镜像内不得包含任何口令**(`deploy-artifacts.sh` 在构建上下文里剔除
`DATABASE_PASSWORD / REDIS_PASSWORD / SECRET_KEY / DATA_ENCRYPTION_KEY` 等键,
仅保留主机名、端口等非敏感项——审计 B3 的根治)。
标准步骤(约 1 分钟中断窗口,顺序不可颠倒):
```bash
cd /home/FastapiAdmin/docker
cp -a .env ".env.bak-rotate-$(date +%Y%m%d-%H%M%S)" # 先备份,便于回滚
# 1) 改数据库口令(用 stdin 传 SQL,避免口令出现在命令行历史/ps)
NEW=$(openssl rand -base64 36 | tr -d '/+=' | cut -c1-30)
docker exec -i -e MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql mysql -uroot <<SQL
ALTER USER 'fastapiadmin'@'%' IDENTIFIED BY '$NEW';
FLUSH PRIVILEGES;
SQL
# 2) 同步写入 .env(键名:MYSQL_PASSWORD)
# 3) 重建引用该值的容器:mysql → redis → backend
# 注意:mysql/redis 的健康检查命令内插了 .env 值,必须一并重建,否则健康检查会一直失败
docker compose up -d --force-recreate mysql && sleep 20
docker compose up -d --force-recreate redis
docker compose up -d --force-recreate backend
# 4) 验证:旧口令必须被拒(1045 / WRONGPASS),新口令可用,健康检查 db_status/redis_status = 1
```
排查提示:`docker exec -i` 会让容器内进程读走脚本自身的 stdin(若脚本经 `bash -s` 传入,
后续步骤会被吞掉)——务必加 `</dev/null` 或改用 `-e` 直传。
## 8. 已知遗留问题
安全与质量债的完整清单见 `AUDIT-REPORT.md` 与各分报告(`backend/audit-*.md`、`frontend/audit-*.md`、`docker/audit-deploy.md`)。当前仍待处理的高优先级项:
+11 -2
View File
@@ -146,8 +146,9 @@ deploy_backend() {
rm -rf /tmp/fa-ctx/requirements && mkdir -p /tmp/fa-ctx/requirements
cp -R backend/requirements/. /tmp/fa-ctx/requirements/
# 镜像内的 .env.prod 只是兜底:数据库/Redis 真实地址由 compose 运行时注入。
# 这里抹掉本地私网地址与所有密钥,避免把开发凭据烤进镜像。
# 镜像内的 .env.prod 只是兜底:数据库/Redis 真实地址与口令由 compose 运行时注入。
# 这里抹掉本地私网地址与所有密钥/口令,避免把任何凭据烤进镜像层
# (审计 B3:旧镜像里曾烘进真实 DATABASE_PASSWORD / REDIS_PASSWORD)。
python3 - "$db" <<'PY'
import re, pathlib, sys
db = sys.argv[1]
@@ -156,6 +157,12 @@ force = {"DATABASE_HOST": "localhost", "REDIS_HOST": "localhost"}
if db == "postgres":
force.update({"DATABASE_TYPE": "postgres", "DATABASE_PORT": "5432", "DATABASE_USER": "postgres"})
drop_prefixes = ("OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENAI_MODEL")
# 口令类:运行时全部由 compose 注入,镜像内保留会成为泄露面
drop_keys = {
"DATABASE_PASSWORD", "REDIS_PASSWORD",
"SECRET_KEY", "DATA_ENCRYPTION_KEY", "DATA_ENCRYPTION_OLD_KEYS",
"MYSQL_PASSWORD", "MYSQL_ROOT_PASSWORD",
}
out = []
for line in p.read_text().splitlines():
m = re.match(r"^(\s*)([A-Z_]+)(\s*=\s*)(.*)$", line)
@@ -163,6 +170,8 @@ for line in p.read_text().splitlines():
out.append(f"{m.group(1)}{m.group(2)}{m.group(3)}{force[m.group(2)]}")
elif m and m.group(2).startswith(drop_prefixes):
continue
elif m and m.group(2) in drop_keys:
continue
else:
out.append(line)
p.write_text("\n".join(out) + "\n")