眼见为真有时候会杀掉你一天的时间
“地图不是疆域。” —— Alfred Korzybski
单纯就是让红色错误消失,看起来简单直接,但是有可能是最危险的操作
结合最近笔者在工作中遇到的一个git相关的错误,一开始看起来简单直接,但是前前后后花了多半天时间,才搞清楚事件的来龙去脉,平时你看到的一个错误小红点,可能只是冰山一角,需要升维思考或者换个角度才可以认识问题的全貌。 下面一起开始这个总是的算盘之旅吧。
从“让 commit 跑起来”到“搞清楚它为什么跑不起来”——这中间隔着一个差点推上公开仓库的 OAuth 密钥。
我在一次 git commit 卡在了这一行,光标不动了:
Check for added large files..............................................Passed
Detect hardcoded secrets.................................................
没有报错,没有退出,就停在 gitleaks 这一步。第一直觉是“钩子挂了,想办法让它过去”。
这篇文章想说的,恰恰是这个直觉错在哪里。那一行 Detect hardcoded secrets... 是一张地图;真正的疆域是:一个脱缰的进程把机器压到了 load 40,以及一个 40MB 的暂存区里藏着 14 个真实的 Google OAuth 密钥。如果我顺着“让红色消失”这个目标函数走下去,两件事都会出错——先误诊,再泄密。
如果你也在 CI 或 pre-commit 前卡过、而第一反应是“怎么把它跳过去”,下面这三节就是为你准备的:
- “卡住(stuck)”和“失败(failed)”是两种病,用同一种药会要命——诊断前先分清是哪种。
- 安全扫描在大 diff 上吐出一百多条,不等于它在狼来了;报警密度高,恰恰是你最该逐条看的信号。
- allowlist 是给噪音的,不是给密钥的;把真 token 静音掉换 CI 变绿,等于亲手把它提交进历史。
下面按“最容易踩的坑”排序,不是按我当时踩的顺序——真实顺序里我先解决的是卡死,但对你更有用的,是先建立“别急着让红色消失”这个判断。
一、“卡住”不是“失败”:先分清你在看哪种红
光标停住的那一刻,有两条岔路:
- 路 A:钩子在拒绝我(exit 非零)。那是“失败”,要去看它为什么拒绝。
- 路 B:钩子根本还没出结果,进程在空转。那是“卡住”,问题可能根本不在钩子。
我差点直接走路 A——去改 gitleaks 配置,想让它“别卡了”。但一条命令拦住了我:
ps aux | grep gitleaks
# 35958 ... gitleaks git --config .gitleaks.toml --redact -v # 跑了 91 分钟 CPU
uptime
# load averages: 22.80 39.96 33.70
Load 40。这台机器被一个 gitleaks git(注意:没有 --staged)的进程压死了——它在扫整个 git 历史,40MB+,而 pre-commit 真正该跑的是 gitleaks git --pre-commit --staged,只扫暂存区,几十毫秒的事。commit 不是被钩子拒绝,是被 CPU 饥饿饿死了。那个脱缰进程还是我自己前面手滑起的后台任务。
| 卡住(stuck) | 失败(failed) | |
|---|---|---|
| 信号 | 无输出、光标不动、风扇狂转 | 明确的 exit code、报错文本 |
| 该查的地方 | ps / uptime / top——系统资源 |
钩子的输出、配置、被扫的内容 |
| 典型误操作 | 去改钩子配置“让它别卡” | 去加资源“让它别慢” |
| 正解 | 找到并杀掉真正占资源的进程 | 读报错,针对报错修 |
红色的形状告诉你它是哪一种病。无输出的红和带 exit code 的红,是两套诊断路径;走错一条,你会在正确的工具上做错误的手术。
(进程表、.git/index.lock 这类机制我在上一篇《git 锁与进程表的陷阱》里写过,这里不重复;本文的重点在红色之后的判断。)
二、129 条报警,不是一团噪音——是三种东西混在一起
杀掉脱缰进程、负载回落后,真正的 --staged 扫描才跑起来。结果:
scanned ~40.49 MB (40.49 MB) in 1m1.7s
WRN leaks found: 129
129 条。这里有个极强的直觉陷阱:“这么多,肯定是误报,整体 allowlist 掉得了。” 这台机器上但凡装过扫描器的人都动过这个念头。
但我把它们按文件分了类,画面完全变了:
| 类别 | 条数 | 命中的东西 | 判断 |
|---|---|---|---|
| 噪音 | ~100 | 抓来的第三方网页 raw_html/*.html、笔记、2023 年日志样本里的 UUID |
不是我的密钥,安全豁免 |
| 浏览器会话 JWT | 15 | *_auth_state.json、*_session.json(Jira / Azure DevOps / GitHub 登录态) |
真 token |
| 真实凭证 | 14 | client_secret_*.json、token.json、credentials*.json——Google OAuth 的 client secret 和 token |
真密钥 |
扫描器这次是对的。它在一百条网页噪音里,精确地抓到了 14 条我自己的 OAuth 密钥——而且这些文件是 M(已跟踪的修改),意味着它们早就在仓库里了,我只是在给它们续命。
这就是 129 这个数字的险恶之处:它大到让你想一键静音,而静音的代价,是把混在噪音里的真密钥一起放行。报警密度高不是“降低逐条审查价值”的理由,恰恰相反——diff 越大、报警越密,逐条分类的收益越高,因为那正是真密钥最容易藏身、又最容易被“反正都是误报”的心态冲掉的地方。
把扫描器的输出当成一个布尔(“过/不过”)来优化,你就丢掉了它真正的价值:它是一个分类器,不是一个闸门。
三、allowlist 给噪音,停跟踪给密钥
一旦承认这三类是不同的东西,处理方式就必须分叉——用一种手段处理全部,要么泄密,要么把该留的源码也误删:
# .gitleaks.toml —— 只豁免"已核实"的噪音路径
'''(^|/)raw_html/.*\.html$''' # 抓来的第三方网页
'''(^|/)web_scrawl_scratch/.*\.html$'''
'''Meta System Design Knowledge.*\.txt$'''
'''(^|/)ai/unifyxp/support/fixlog_.*\.py$''' # 内嵌 2023 日志样本的脚本
# .gitignore —— 真凭证:从此不再跟踪
*_auth_state.json
*_session.json
client_secret*.json
credentials*.json
token.json
token-*.json
# 真凭证:停止跟踪,但保留磁盘上的文件
git rm --cached <那 17 个凭证/会话文件>
关键是那条绝不能走的近路:在 .gitleaks.toml 里把这些 client_secret_*.json 的路径也一起豁免掉。那样 CI 立刻变绿,commit 立刻通过——代价是把活的 OAuth 密钥,一次又一次地提交进一个公开仓库的历史。绿色会骗你说“没事了”,而疆域里,密钥正在往外走。
| 偷懒解 | 正解 | |
|---|---|---|
| 噪音(网页/笔记) | allowlist 路径 ✅ | allowlist 路径 ✅ |
| 真凭证(OAuth/token) | allowlist 路径 ❌(提交进历史) | git rm --cached + .gitignore |
| 衡量标准 | CI 变绿了吗? | 真密钥还会不会进下一个 commit? |
重扫,no leaks found。这一次的绿色,是真的。
四、git rm --cached ≠ 清历史:绿色之后还有一层
必须说清楚一个容易让人松懈的点:git rm --cached + .gitignore 只解决未来——从下一个 commit 起不再跟踪。但那 14 个 OAuth 密钥的旧版本,仍然躺在历史提交里,git log 能翻出来,克隆过这个仓库的人也都有。
所以真正的收尾是两步,和让 commit 通过毫无关系:
- Rotate——把这些 Google OAuth client secret / token 作废重发。它们一旦进过公开历史,就当已泄露。
- 清历史——
git filter-repo把这些文件从所有历史提交里抹掉(会改写所有 commit hash,需 force-push,属高风险操作,要先备份)。
这就是“代码考古学”的反面:今天图省事只让绿灯亮,三个月后没人记得历史里还埋着密钥。
rm --cached让新密钥不再进门;它不会把已经进了门的请出去。
彩蛋:第四道红,还是同一个毛病
commit 真正提交时,又被另一个钩子 check-yaml 拦了——和 gitleaks 毫无关系。两个原因:一批 k8s 多文档 YAML(--- 分隔,合法,但 check-yaml 默认不认),外加一个真语法错:
# swagger 文件里
title:XYX Pricing Service # title: 后面少了个空格,整个文档解析崩了
这里“让红色消失”的偷懒解是 git commit --no-verify——一键跳过所有钩子。它能过,但会把那个坏掉的 YAML 一起带进去。正解是两个真修复:给 check-yaml 加 --allow-multiple-documents(多文档本就合法),把 title:XYX 补上空格。同一个毛病,第四次出现:绿色不是目标,它只是目标的影子。
五、rebase 冲突:最像“我是不是敲错命令了”的一道红
commit 终于过了,接着 git pull --rebase 同步远端,它停下来吐出 CONFLICT,4 个文件。那一刻最自然的念头是:“我是不是命令用错了?”
没有。命令是对的。rebase 冲突根本不是报错——它是 git 在诚实地告诉你:你这个提交,和远端那几个更新的提交,改到了同一段内容,它不敢替你选,所以停下来问你。
| 普通人的看法 | 资深工程师的洞察 |
|---|---|
| “报 CONFLICT 了,我 rebase 搞砸了” | rebase 本就会在重叠改动处停下——这是设计,不是故障 |
慌了,git rebase --abort 一把退回 |
先看冲突长什么样,再决定 abort 还是逐个解 |
真正的根因,在更早一步:一次 git add -A 把工作区 3487 个文件全塞进了一个提交,其中很多是文件的旧版本,而远端那几个新提交刚好动过同一批文件。旧版撞新版,rebase 必须二选一。那 4 个冲突,没有一个来自真正要做的安全修复——全是被大批量顺带捎上的。
这里“让红色消失”的偷懒解有三种,每种都更糟:git rebase --abort(放弃同步)、--skip(悄悄丢掉自己的提交)、或 push --force(拿旧版把别人的新工作碾掉)。正解是读懂冲突的两侧——rebase 里 ours 是你正站上去的那个新基(远端),theirs 是你正在重放的提交(你的旧版)——按文件取不丢工作的那一侧,把真有用的改动留出来,再 --continue。
一把梭的
git add -A不是省事,是把“我没想清楚要提交什么”这件事,延后到 rebase 冲突时连本带利还给你。
四道红色,四条岔路
| 红色 | 偷懒解(让它消失) | 正解(搞清为什么) | 代价差 |
|---|---|---|---|
| commit 卡死 | 改 gitleaks 配置“让它别卡” | ps/uptime 找到脱缰进程杀掉 |
在错的工具上做手术 |
| 129 条密钥报警 | 整体 allowlist | 按类分:噪音豁免、密钥停跟踪 | 把 14 个 OAuth 密钥推上公开仓库 |
| check-yaml 失败 | --no-verify 跳过 |
加 --allow-multiple-documents + 修 typo |
把坏 YAML 带进主干 |
| rebase 冲突 | --abort / --skip / --force |
读懂 ours/theirs,按文件取安全侧再 --continue |
丢掉自己或别人的工作 |
四行红色,四次同样的诱惑:把目标函数设成“让它变绿”。而每一次,变绿的最快路径,都正好是最坏的工程决策。
立刻可以做的事
- 在你自己的仓库跑一次
gitleaks git --pre-commit --staged -v,把结果按文件分类,别只看总数——总数是用来吓人的,分类才是用来决策的。 - 搜一遍有没有被跟踪的凭证产物:
git ls-files | grep -iE 'client_secret|credentials|token.*\.json|_auth_state|_session\.json'。有就git rm --cached+ 加进.gitignore。 - 若上一步命中了东西,它们大概率已在历史里:把对应的密钥全部 rotate,再决定要不要
git filter-repo清历史。 - 给 pre-commit 的
check-yaml加args: ['--allow-multiple-documents'],如果你的仓库里有 k8s 清单——省得将来有人用--no-verify绕过整条安全链。 - 把“这条红是 stuck 还是 failed”写进你 debug 的第一步,而不是凭手感改配置。
- 戒掉
git add -A/git add .的一把梭:按主题分批add,提交前先git pull --rebase站到最新点上——大多数 rebase 冲突都是“旧版撞新版”,从源头就能避开。
CI 变绿从来不是你的目标,它只是你真正目标投下的影子。盯着影子调参,你会在某个深夜,把一把本该作废的密钥,亲手推成全世界可见的历史。