把 Codex Security CLI 挂进提交前钩子,目标只有一个:提交动作发生的瞬间,先用命令扫一遍这次改动,可疑就返回非零退出码,让 git commit 停下来。判断点在三处:钩子挂在 pre-commit 还是 pre-push、扫描范围是本次暂存文件还是全量、扫描结果怎么翻译成拦截或放行。先把这三处定死,再谈忽略清单和 CI 兜底,否则钩子很容易被 `--no-verify` 绕过去然后被关掉。
提交前钩子适合「改动小、提交频」的仓库:pre-commit 只扫 git diff `--cached` 出的文件清单,用退出码决定拦或放,扫描器自身异常时不要静默放行也不要当成有问题。灰度期先只告警不拦截,忽略清单逐条写原因并集中入库。钩子可以被跳过,所以 CI 里要用同一条命令再跑一次,并对照两边的文件清单是否一致。
确定钩子挂在哪一步:提交前还是推送前
两种挂法的差别在于触发频率和可见范围。pre-commit 每次提交都触发,反馈最快,一次提交涉及的改动通常很少,适合做增量快扫;代价是提交次数多,钩子必须足够快,否则开发者会开始用 `--no-verify` 跳过。pre-push 一次推送里可能包含多个提交,扫描范围要算成「远端分支到 HEAD」的区间,逻辑更复杂,但触发次数少,可以跑更重的检查。比较稳妥的搭配是:pre-commit 做增量拦截,pre-push 或 CI 用同样命令做一次兜底,两边共用同一个脚本,避免规则分叉。
钩子文件放在 .git/hooks/pre-commit,赋可执行权限后生效。如果要把钩子随仓库分发,可以把脚本放进仓库目录,再用 git config core.hooksPath scripts/hooks 指过去,这样团队成员拉代码后只需执行一次配置命令。下面是可用的最小骨架,子命令和参数名必须换成 codex-security `--help` 里实际存在的写法。
#!/usr/bin/env bash
# scripts/hooks/pre-commit
set -uo pipefail
LIST=$(mktemp)
git diff `--cached` `--name-only` `--diff-filter`=ACMR > "$LIST"
if [ ! -s "$LIST" ]; then
rm -f "$LIST"
exit 0 # 没有可扫的改动,直接放行
fi
# 参数占位:子命令与参数名以 codex-security `--help` 的实际输出为准
# 常见形态有两种:直接传入文件清单,或让工具自己读 diff 范围
CS_ARGS=() # 这里填「只扫清单内文件」对应的开关
codex-security scan "${CS_ARGS[@]}" < "$LIST"
STATUS=$?
rm -f "$LIST"
exit "$STATUS"
骨架里 exit "$STATUS" 只是把工具退出码原样透传,真正的拦截判定放在下文的 case 分支里做。
用改动文件清单限定扫描范围
全量扫描在提交前不现实:耗时随仓库增长,每次提交都等一遍,钩子很快会被当成噪音。合理的做法是只把本次暂存的文件交给扫描器。git diff `--cached` `--name-only` 得到的是「即将进入这次提交」的文件清单,配合 `--diff-filter`=ACMR 只保留新增、复制、修改、重命名,删除的文件没有内容可扫,直接排除能省一部分时间。
# 暂存区的改动文件清单
git diff `--cached` `--name-only` `--diff-filter`=ACMR
# 文件名可能含空格或中文,用 -z 与 xargs -0 更稳
git diff `--cached` `--name-only` -z `--diff-filter`=ACMR | xargs -0 -r codex-security scan
上面第二行是通用写法示意,xargs 后面的参数同样要按 `--help` 输出替换,不要照抄。还有一个容易被忽略的点:pre-commit 阶段工作区里可能同时存在未暂存的修改,如果扫描器按路径去读工作区文件,它读到的内容未必等于即将提交的内容。需要结合环境确认工具是否支持指定内容来源;不支持时,可以先在脚本里提示「存在未暂存改动,扫描结果可能与提交内容不一致」,或者临时用 git stash `--keep-index` 隔离,但这会改变开发者本地状态,不适合无提示地自动执行。二进制文件和生成产物建议在传入清单前按扩展名过滤掉,减少无意义输出。
把退出码翻译成拦截或放行
钩子要能替代人眼判断,靠的是退出码,不是靠人读输出。先确认工具退出码的含义(通常在 `--help` 或命令自身的说明里),下面按「0 表示通过、1 表示发现可疑、其他表示工具自身异常」这一常见约定写分支,实际以工具文档为准。
case "$STATUS" in
0)
exit 0
;;
1)
echo "已拦下:本次改动存在可疑项,先处理再提交" >&2
echo "如需查看详情,单独执行同一条扫描命令" >&2
exit 1
;;
*)
echo "扫描器异常退出,退出码 $STATUS,本次未做判定" >&2
# 取舍点:异常时拦住会挡住急事,放行会掩盖问题
# 建议放行但在输出里明确留痕,再由 CI 侧做硬性检查
exit 0
;;
esac
工具自身崩溃(配置写错、依赖缺失、超时)不要和「发现漏洞」混为一谈,也不建议静默放行——至少要在终端和日志里打出一行明确的异常说明,否则钩子坏了很久都没人发现。
验证方式是造一个可控的反例:新建一个文件,写入一段明显是假凭据的测试字符串(注明是示例值,不要用真实密钥),git add 后执行提交,应该看到拦截信息,并且 git log -1 显示没有产生新提交;把该文件从暂存区移除后重试,应能正常通过。再用 git commit `--no-verify` 跑一次,确认钩子确实可以被跳过,这一点直接决定了下文 CI 兜底的必要性。
误报多的时候怎么降噪
钩子被关掉的原因通常不是规则太严,而是输出里混着大量与本次改动无关的告警。降噪分两层:一是工具自带的忽略配置(是否存在、文件路径与格式按 `--help` 确认),二是钩子脚本里对文件清单的过滤,比如跳过文档目录、测试夹具和压缩产物。两层都应该集中放在仓库里的配置文件中,纳入代码评审,而不是让每个人在本地改。
忽略条目的维护建议带三件事:忽略的是什么路径、为什么可以忽略、由谁复核。每条忽略加一行注释说明理由,定期清理已经失效的条目,避免清单只增不减。灰度策略上,先给脚本加一个开关,例如 CS_ENFORCE=0 时照常扫描和打印结果但始终 exit 0,观察一段时间、把高频误报收敛进忽略清单后,再在团队约定下把开关切到拦截。这样做的好处是切换前后行为可对照,出问题能立刻退回告警模式,而不是直接把钩子删掉。
在 CI 日志里验证钩子真的生效
本地钩子不会自动在 CI 里执行,CI 环境是干净检出加你自己的步骤,所以要在流水线里显式调用同一个脚本,否则「钩子生效」只是本地幻觉。CI 里尤其要注意 diff 基准:流水线常做浅克隆,git diff `--cached` 在 CI 阶段通常为空,需要把范围改成「目标分支到 HEAD」,基准分支通过 CI 变量传入。
# scripts/security-scan.sh 中的范围计算(CI 与本地共用)
BASE=${BASE:-origin/main}
git fetch `--no-tags` origin "$BASE" || true
git diff `--name-only` `--diff-filter`=ACMR "$BASE"...HEAD > "$LIST"
CI 日志里应该能找到这几段,缺哪段就说明哪一环没跑:脚本自身的启动行(便于确认它被执行过而不是被条件跳过)、传给扫描器的文件清单条数或前几个路径、扫描命令与工具版本、以及最终的判定行和退出码。对照方法是本地用相同的基准分支跑一遍同一脚本,比较两边文件条数与判定是否一致;数量对不上,先查 diff 范围和 fetch 是否成功,再查忽略配置是否只在本地生效。如果 CI 里判定为拦截而本地通过,优先怀疑范围不一致或配置未入库,而不是怀疑工具本身。