预提交安全:Gitleaks 深度拆解
“测试只能证明缺陷存在,不能证明缺陷不存在。” — Edsger W. Dijkstra
“信任,但要验证。” — 罗纳德·里根(常用于强调安全控制的验证原则)
预提交安全:Gitleaks 深度拆解
别让一条看上去很严厉的 CI 命令,实际只检查了暂存区。
你的 CI 里写着 pre-commit run --all-files gitleaks。它真的把仓库扫了一遍吗?先别相信参数名;先确认这个 hook 会不会接收 pre-commit 传给它的文件列表。
一个很常见的真实场景是:某团队在一次安全整改后,把下面这条命令加入 PR CI,并在审计记录中写下“每次合并请求均扫描全仓库密钥”。数月后,一名工程师排查历史泄露时发现,半年前某个已删除配置文件中的云访问密钥仍可从旧提交取回。CI 虽然每次都是绿的,但团队使用的 Gitleaks hook 配置了 pass_filenames: false,并以 --staged 运行;CI 中的 --all-files 并没有让 Gitleaks 扫描工作树,更没有扫描 Git 历史。这个故事的重点不在于某个命令“失效”,而在于三层工具对“全部”的理解不同:pre-commit 准备了文件列表,hook 没有接收它,Gitleaks 则转而读取暂存区。
如果需要检查已获取的全部 refs 所能到达的 Git 历史,先确认 gitleaks --help 列出了 git 子命令;再在已获取目标 refs 的受控环境中运行:
gitleaks git --redact --exit-code 1 --log-opts="--all"
这里的 --all 交给 Git 的日志参数处理,要求它从本地已有的所有 refs 出发遍历历史。没有先获取需要评估的远端分支、标签或其他 refs,这条命令也扫不到它们。若不传 --log-opts="--all",不要把结果表述成“全部可达历史”:Git 日志扫描通常围绕当前选定的修订范围展开。
命中后不要把输出贴进工单或聊天记录。先按泄露事件处理:吊销或轮换凭据,再评估历史清理。读完本文,你能分清 pre-commit run --all-files gitleaks 实际扫描的范围,并为本地、CI 和服务端各放一道合适的关卡。
--all-files 承诺的是文件列表,不是扫描范围
把 pre-commit 想成机场安检的调度员:日常提交时,它通常只把暂存区对应的行李交给安检机;加上 --all-files,它会把仓库中的受跟踪文件都交出去。问题在于:安检机也可以拒收这份名单,自己跑去看另一条传送带。
pre-commit 是用 YAML 配置 Git hook 的框架。你的 .pre-commit-config.yaml 以固定的 rev 引用上游 hook;首次执行时,它会取得相应源码,并按 hook 声明的语言建立隔离运行环境。对 language: golang 的 Gitleaks hook,这意味着由框架准备可执行程序,而不是要求每位开发者手工安装同一套依赖。
关键不在这个安装过程,而在 hook 的定义。原始配置中记录的 Gitleaks hook 形状如下;具体字段仍应以你锁定的 rev 中的 .pre-commit-hooks.yaml 为准:
entry: gitleaks git --pre-commit --redact --staged --verbose
language: golang
pass_filenames: false
pass_filenames: false 是分水岭。它表示 pre-commit 不会把待检查文件名传给这个 hook;而 --staged 又要求 Gitleaks 自己读取 Git 暂存区。于是,pre-commit run --all-files gitleaks 和 pre-commit run gitleaks 对这种 hook 的有效扫描对象基本相同:都是当前暂存内容。什么也没暂存,就没有它预期要检查的那批内容。
参数看起来扩大了范围,前提是被调用的程序真的接受这个范围。
| 你想证明什么 | 容易误用的命令 | 更接近目标的做法 |
|---|---|---|
| 本次提交没有带入新密钥 | pre-commit run gitleaks |
保留 staged 模式的本地 hook |
| 仓库当前文件没有明文密钥 | 指望 --all-files 改变任何 hook |
确认 hook 是否接收文件名;必要时用 gitleaks dir 扫目录 |
| 已获取 refs 的可达历史中没有遗留密钥 | pre-commit run --all-files gitleaks |
获取需评估的 refs 后,用 gitleaks git --log-opts="--all" |
| 每次 PR 都不重复付出全历史成本 | 每次都全量扫历史 | 增量范围配合已审基线,另设定期全量扫描 |
这张表也是一个通用排查法:不要从命令的修饰词推断能力,要沿着“调度器传了什么—被调程序接了什么—程序最终读了什么数据”走一遍。
Gitleaks 查的是模式和异常,不是“看起来像密码”
Gitleaks 用 Go 实现,规则通常来自默认规则集以及项目可选的 .gitleaks.toml。它的两类信号很实用:
- 正则表达式识别已知凭据形状,例如具有固定前缀或结构的访问令牌;
- Shannon 熵规则识别随机性异常高的候选字符串,用来补足没有固定格式的 token。
熵不是“密钥鉴定器”。它只是在问:这串字符的分布是否像随机生成物。测试夹具、压缩片段、校验值都可能高熵;短密码或格式特殊的凭据也可能逃过它。所以规则通常还会配合路径、关键字、捕获组和 allowlist。
熵阈值不是跨规则、跨字符集通用的安全线;它必须跟着具体规则配置看。扫描开销也不能简单承诺为某个固定的大 O:它取决于候选文本、规则数和正则实现。对大仓库做全历史扫描,先测自己的仓库,别把别人的时间预算写进流水线。
历史里的密钥,不能靠删文件假装没发生
本地 hook 的价值是快:开发者保存、暂存、提交时就能收到反馈。但它可以被 --no-verify 跳过,而且它通常只看这一次提交。后加的 hook 更有一个盲区:它出现之前进入仓库的文件,从未经过它。
一旦凭据进入 Git 提交历史,风险判断应当变成“它已经泄露过”,而不是“现在工作区还有没有这个文件”。git rm 只影响新提交;旧对象、旧提交和可能存在的克隆仍保留那段内容。
处理顺序应是:
- 先轮换或吊销凭据,让旧值失效;
- 用
gitleaks git确认暴露范围; - 用
git filter-repo等工具重写历史,并协调受影响的远端、克隆和缓存; - 记录原因,避免同一类密钥再次进入仓库。
这也是我不同意“CI 每次都全历史扫描才算安全”的原因。它很容易把昂贵的回溯检查伪装成日常门禁。更合适的组合是:建立一次全量基线;PR 做增量扫描;对已知、审过的误报使用 baseline;定期重跑全历史。若你的版本支持 --log-opts,还可以限制 Git 提交范围。
反方观点同样成立:小仓库、低频流水线,或正处在泄露清查期时,每次全历史扫描更简单,也更不容易因范围计算失误漏掉提交。代价就是等待时间和重复工作;这里没有免费午餐。
Fail-closed 必须留一扇受控的门
密钥扫描倾向 fail-closed:宁愿把可疑字符串拦下来,也不要静默放过真实凭据。但没有逃生通道的 fail-closed,常见结局是全员学会 --no-verify,安全控制从此只剩装饰。
Gitleaks 可通过 allowlist 缩小范围,例如按正则、路径、提交标识或 stopwords 排除;也可对确认为可接受的既有命中使用 --baseline-path。行内 # gitleaks:allow 之类的豁免,应只用于规则允许且经过审查的情形。豁免本身应当可追踪:为什么是误报、谁确认过、何时复查。否则 allowlist 很快会变成另一个没人敢碰的秘密仓库。
客户端是提醒,服务端才有资格执行政策
一套可操作的纵深防御,职责应当分开:
- 本地 pre-commit:快速反馈,适合阻止手滑;但客户端可以绕过。
- CI 中的 Gitleaks 或 TruffleHog:PR 门禁,适合统一执行团队规则。
- 服务端 push protection:由托管平台或 Git 服务端在接收 push 时执行;只要该策略由服务端强制,客户端的
--no-verify对它无效。 - 运行期检测与自动吊销:把已暴露凭据的有效期压到最短。部分密钥提供方与代码托管平台有联动通知或撤销机制,但覆盖范围取决于提供方和配置,不能假设所有 token 都会自动失效。
Gitleaks 是兜网,不是主控制:主控制应当让真密钥没有机会进入仓库。
因此,敏感环境更该把密钥放入专用密钥管理系统,例如 Vault、云端 Secrets Manager 或受控的运行期注入机制。op:// 这类引用可以留在仓库,真值在运行时才解析。短时令牌进一步缩短了意外暴露后的可用窗口。canary token 则是故意部署的假凭据:一旦有人尝试使用它,就能触发告警;它监测的是访问行为,不替代扫描。
金融或关键系统往往还会采用签名提交、不可变审计日志和自动轮换。这里要说准确:SOX 或 PCI DSS 不会因为你写了“有 Gitleaks”就自动满足合规;具体义务取决于系统范围和审计要求。它们增加的是证据、访问控制和响应流程的压力。
工具会变,检查边界的办法不会变
Gitleaks 的较新命令组织以 git、dir、stdin 为入口;较老的 detect、protect 在一些版本中仍以兼容别名存在。写 CI 前先运行目标版本的 gitleaks --help,不要把网上旧示例当作接口契约。若你的版本没有 git 子命令,应按该版本帮助文档使用对应的历史扫描入口,而不是直接复制本文开头的命令。
TruffleHog 的一项差异化能力是:对部分已发现的凭据尝试验证其是否仍有效。这能减少某些类别的误报,但也意味着网络请求、覆盖范围、延迟和隐私边界需要单独评估。LLM 或分类器用于区分测试随机串和真实密钥也有人探索,不过本地 pre-commit 场景仍要面对成本与响应时间。
这些工具正在被放进更大的供应链安全体系:SLSA、sigstore、SBOM 处理的是构建来源、签名与组件可追溯性;秘密扫描处理的是另一条风险链。Rust 实现的 hook 工具如 prek 也在出现,主打单二进制和更少的运行时依赖。无论换成哪一个,先问它读取什么输入、在哪里执行、失败由谁强制,答案不会变。
把它记成免疫系统的比喻也行:本地 hook 像第一层屏障,正则像识别已知特征,熵规则像发现异常;服务端拦截守住入口。只是这个比喻的边界很清楚:安全系统不是生物体,规则、权限和例外都需要人明确配置。密钥写入历史后,轮换让旧值失效,重写历史才处理遗留记录;两件事缺一不可。
下次看到 --all-files,别先问“它扫得够不够狠”。打开被调用 hook 的定义,问一句更扎实的话:它最后到底读了哪一份数据?