把生产密码写进了 Git:一次提交前拦截与完整修复
把生产密码写进了 Git:一次提交前拦截与完整修复
事情是这样的:我在整合一个项目的时候,顺手写了个同步生产库的脚本。为了让脚本能跑,我直接把数据库密码写在了里面:
DB_PASSWORD = "Pr0d-Db-P@ssw0rd-2026x****" # 生产库密码,就这么硬编码了
然后我在工作日志里记录当天做了什么,也很自然地写上了"库名 / 用户名 / 密码"。
接着 git add -A,git commit。
提交完的那一刻我意识到不对劲。 立刻查了一下——密码已经进了版本库。
万幸的是:我还没 push。所以这件事最终只是一次虚惊,而不是一次事故。但复盘下来,里面的教训很值得写下来。
一、为什么"还没 push"是决定性的
很多人有个误解:提交(Commit)就等于泄露了。其实不是。
| 阶段 | 影响范围 | 能否挽回 |
|---|---|---|
| 工作区改了 | 仅本机 | 能(改回来就行) |
| 已 commit | 仅本地仓库 | 能(--amend / 重写历史) |
| 已 push | 所有人可见(含历史) | 基本不能——改历史也没用,别人可能已经拉走了,必须轮换凭据 |
所以这次能全身而退,唯一原因就是提交后、推送前的那次自查。
真正该做的不是"指望自己记得检查",而是把检查变成流程的一部分(见第四节)。
二、三个泄露点(我全踩了)
复盘发现,泄露不止一处:
| # | 泄露点 | 为什么容易中招 |
|---|---|---|
| 1 | 脚本里硬编码密码 | 写脚本时只想"让它先跑起来",随手就把密码填进去了 |
| 2 | 工作日志里明文记录 | 记录时追求"信息完整",把连接信息整段贴了上去 |
| 3 | 文档/报告里附带 | 之前写的对齐报告里也带了完整连接串 |
共同特点:都不是故意的,都是"顺手"。
三、四步修复(commit 之后、push 之前)
步骤 1:定位——先搞清楚泄露了哪些文件
不要凭印象,直接扫:
# 扫已跟踪文件
git ls-files | xargs grep -l "Pr0d-Db-P@ssw0rd-2026x" 2>/dev/null
# 顺手把其它敏感特征也扫一遍
git ls-files | xargs grep -lE "BEGIN OPENSSH|\.pem|AKIA[0-9A-Z]{16}" 2>/dev/null
我的结果:命中 2 个文件(一个脚本、一份日志)。
步骤 2:治本——脚本不再持有密码
把"读密码"改成按优先级取值,并给出清晰的失败提示:
def _get_password():
# 1) 环境变量
pw = os.environ.get("PROD_DB_PASSWORD", "").strip()
if pw:
return pw
# 2) 已被 .gitignore 排除的本地文件
secret_file = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "secrets", "db_password")
if os.path.exists(secret_file):
with open(secret_file, encoding="utf-8") as f:
pw = f.read().strip()
if pw:
return pw
return ""
DB_PASSWORD = _get_password()
拿不到就明确报错,而不是悄悄用一个空密码去连。
步骤 3:脱敏——文档里的明文替换成"指针"
不要直接删掉(那样以后自己也不知道去哪找),替换成指向真正存放位置的说明:
sed -i 's/Pr0d-Db-P@ssw0rd-2026x[A-Za-z0-9]*/<生产DB密码:见 <部署配置文件> \/ ops\/secrets\/db_password(不入库)>/g' 相关文件.md
步骤 4:改历史 + 补 .gitignore
因为还没 push,可以安全地重写提交:
# 把含敏感内容的目录从索引移除(文件仍在磁盘)
git rm -r --cached .workbuddy/memory -q
git add -A
git commit --amend -m "chore: ...(修订说明中写明已移除凭据)"
同时在 .gitignore 里补上该排除的东西:
# 凭据
*.pem
*.key
ops/secrets/
.env
# 含生产数据的导出
*.sql
# 会话/记忆日志(极易误记凭据,建议直接排除)
.workbuddy/memory/
最后复查一遍:
git ls-files | xargs grep -lE "Pr0d-Db-P@ssw0rd-2026x|BEGIN OPENSSH" 2>/dev/null
# 无输出 = 干净
四、顺带发现:.gitignore 漏掉的东西比想象中多
这次自查还顺带扫出三个"体积炸弹",都是原本 .gitignore 没挡住的:
| 漏网之鱼 | 体积 | 说明 |
|---|---|---|
WSL2 安装介质(.AppxBundle / .msi / rootfs.tar.gz) |
2.1 GB | 我特意保留的离线安装包 |
| 某个 demo 用的完整 Python 虚拟环境 | 约 14 MB(含大量 .exe) |
里面连 python.exe 都有 |
| 数据库导出文件 | 不仅大,还含生产真实数据 | 双重风险 |
补上规则后,整个仓库从约 2.2 GB 降到 4.32 MB。
经验:
.gitignore要按"类别"排除,不要按"文件名"排除。
写*.msi、*.exe、*.sql、*.tar.gz,比写具体文件名可靠得多。
五、把它变成流程:提交前自检脚本
靠"记得检查"一定会漏。把这个挂进 pre-commit(或者至少养成本地跑一下的习惯):
#!/usr/bin/env bash
# pre-commit-credential-check
set -euo pipefail
PATTERNS='BEGIN OPENSSH|BEGIN RSA PRIVATE|BEGIN OPENSSH PRIVATE KEY|AKIA[0-9A-Z]{16}|wx[0-9a-f]{16}|db_password *[:=] *[^ <]'
found=0
while IFS= read -r f; do
if grep -qE "$PATTERNS" "$f" 2>/dev/null; then
echo "🔴 疑似凭据: $f"
found=1
fi
done < <(git diff --cached --name-only --diff-filter=ACM)
# 体积检查:单个暂存文件 > 5MB 就提醒
while IFS= read -r f; do
size=$(stat -c%s "$f" 2>/dev/null || echo 0)
if [ "$size" -gt 5242880 ]; then
echo "🟡 大文件(>5MB): $f ($(( size / 1024 / 1024 ))MB)"
fi
done < <(git diff --cached --name-only --diff-filter=ACM)
if [ "$found" -eq 1 ]; then
echo "❌ 提交中止:请先移除凭据"
exit 1
fi
echo "✅ 凭据自检通过"
提示:在项目自己的"业务密码"这类非通用模式上,上面的正则拦不住。
更稳妥的做法是维护一份本项目专属的敏感串清单,一起扫。
六、如果已经 push 了怎么办
这次没走到这一步,但结论很明确:
- 立刻轮换(Rotate)泄露的凭据——改数据库密码、吊销并重新签发 API Token/密钥。这是唯一真正有效的动作。
- 重写历史(
git filter-repo/ BFG)只是补充,不能替代轮换; - 检查该凭据是否还出现在别处(CI 配置、部署脚本、备份、聊天记录);
- 事后补上自检流程,避免重演。
顺序不能反:先轮换,再清理历史。很多人第一反应是去改 Git 历史,那期间凭据仍然有效,风险窗口反而被拉长了。
小结
| 认知 | 说明 |
|---|---|
| commit ≠ 泄露,push 才是分水岭 | 提交后仍有挽回余地,--amend 即可 |
| 泄露往往是"顺手"造成的 | 脚本、日志、报告是三个高发区 |
| 排查要扫全,不能凭印象 | git ls-files \| xargs grep 一次搞定 |
.gitignore 按类别排除 |
别按文件名,否则必漏 |
| 检查要固化成流程 | 靠记忆一定会漏,挂进 pre-commit |
| 真 push 了 → 先轮换 | 改历史只是补充,不能替代轮换 |
整套修复花了不到二十分钟。但它提醒我一件事:安全习惯的价值不在于"小心",而在于把"小心"变成不需要思考的自动动作。
本文环境:Windows 11 + Git / WSL2 Ubuntu-22.04 / Python 3.13
文中案例为真实事件复盘,凭据均已脱敏;涉及密钥管理的官方规范请以所用平台文档为准。