1 minute read

“测试只能证明缺陷存在,不能证明缺陷不存在。” — 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 只影响新提交;旧对象、旧提交和可能存在的克隆仍保留那段内容。

处理顺序应是:

  1. 先轮换或吊销凭据,让旧值失效;
  2. 用 gitleaks git 确认暴露范围;
  3. 用 git filter-repo 等工具重写历史,并协调受影响的远端、克隆和缓存;
  4. 记录原因,避免同一类密钥再次进入仓库。

这也是我不同意“CI 每次都全历史扫描才算安全”的原因。它很容易把昂贵的回溯检查伪装成日常门禁。更合适的组合是:建立一次全量基线;PR 做增量扫描;对已知、审过的误报使用 baseline;定期重跑全历史。若你的版本支持 --log-opts,还可以限制 Git 提交范围。

反方观点同样成立:小仓库、低频流水线,或正处在泄露清查期时,每次全历史扫描更简单,也更不容易因范围计算失误漏掉提交。代价就是等待时间和重复工作;这里没有免费午餐。

Fail-closed 必须留一扇受控的门

密钥扫描倾向 fail-closed:宁愿把可疑字符串拦下来,也不要静默放过真实凭据。但没有逃生通道的 fail-closed,常见结局是全员学会 --no-verify,安全控制从此只剩装饰。

Gitleaks 可通过 allowlist 缩小范围,例如按正则、路径、提交标识或 stopwords 排除;也可对确认为可接受的既有命中使用 --baseline-path。行内 # gitleaks:allow 之类的豁免,应只用于规则允许且经过审查的情形。豁免本身应当可追踪:为什么是误报、谁确认过、何时复查。否则 allowlist 很快会变成另一个没人敢碰的秘密仓库。

客户端是提醒,服务端才有资格执行政策

一套可操作的纵深防御,职责应当分开:

  1. 本地 pre-commit:快速反馈,适合阻止手滑;但客户端可以绕过。
  2. CI 中的 Gitleaks 或 TruffleHog:PR 门禁,适合统一执行团队规则。
  3. 服务端 push protection:由托管平台或 Git 服务端在接收 push 时执行;只要该策略由服务端强制,客户端的 --no-verify 对它无效。
  4. 运行期检测与自动吊销:把已暴露凭据的有效期压到最短。部分密钥提供方与代码托管平台有联动通知或撤销机制,但覆盖范围取决于提供方和配置,不能假设所有 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 的定义,问一句更扎实的话:它最后到底读了哪一份数据?

Updated: