密钥进了仓库,删文件没有用:任何拉过仓库副本的人手里都还有,历史里也一直查得到。
在临时分支提交一个假密钥,提交前与合并前这两处都会在几秒内变红吗?
只装了本地钩子、门禁里没有这一项,等于没有兜底:钩子可以被一次绕过,助手也不经过它。两处都要有,缺一处就有一个窗口。
扫描扫的是「像密钥的字符串」,所以先要知道自己这边的密钥长什么样、从哪些位置出去。下面后三处是扫描的盲区——它们的共同点是密钥在运行时才成形。
| 出口 | 扫得到吗 | 靠什么兜 |
|---|---|---|
| 代码里的常量 | 扫得到,模式明确 | 移进环境配置,按环境配置的两份文件写法 |
| 误提交的环境文件 | 扫得到 | 真实那份进忽略名单,示例那份只放占位符 |
| 提交信息里的密钥 | 要扫提交历史才看得到 | 本地钩子默认只看暂存区,这一项只能靠门禁的全量扫描兜 |
| 拼接出来的密钥(两段字符串相加、从环境读出后写入文件) | 扫不到 | 靠评审:差异里出现按片段拼接的长字符串,要求说明用途 |
| 日志里打印的凭据头 | 扫不到,运行时才出现 | 日志脱敏,见日志 |
| 持续集成里回显敏感变量 | 扫不到 | 工作流里出现回显敏感变量一律退回 |
这一节的边界:扫描只看得见静态文本。所以「前端不得持有服务端密钥」「日志不得打印凭据」这两条是约定,不是工具能查的事。
一条密钥告警是「位置 + 规则号 + 命中形态」,且不回显原文——终端与持续集成日志都会留档。
[FAIL] backend/app/conf/ds.py:12 规则 generic-api-key 命中 AKIA**************** (不回显原文) 范围 全量工作树 + 提交历史
要认的是这个形状:命中靠模式,所以误报天然偏高。处理只有「按路径或指纹放行」一种;把规则本身放宽,会让后来真的密钥一起漏过。
[allowlist]
patterns = ["AKIA.*"] 能匹配真密钥,等于整条规则放行
或 DSN = os.getenv("DSN") # 原值 sk-live-xxxx 写进注释
认它的办法:看放行模式有多宽。能匹配真密钥的放行等于把这条规则关掉;把原值留在注释或文档里,扫描不报但人看得见——而日志与历史都会留档。允许清单只按假密钥的固定前缀放行,不放宽规则本身。
场景:为了验证门禁是不是真的在拦,在临时分支提交一个假密钥,开一次合并请求。
本地钩子管「别提交」,门禁管「别合并」,两者缺一段就有一个窗口。顺序按「快 → 全」:钩子几秒,门禁几十秒。
| 拦截点 | 覆盖范围 | 漏掉什么 |
|---|---|---|
| 提交前钩子 | 暂存区,几秒返回 | 被绕过;提交信息;已有历史 |
| 门禁 | 全量工作树与提交历史,几十秒 | 运行时才成形的密钥 |
| 合并请求模板的勾选项 | 人看过一次 | 靠人记得勾 |
误报率天然偏高,因为「像密钥」靠模式匹配,测试夹具和示例文档里全是长得像的东西。处理只有两种:按路径或指纹放行,或者放宽规则本身——只能选前一种。放宽规则会让后来真的密钥一起漏过,而且没人记得关过。允许清单里的匹配要写得窄,只匹配假密钥的固定前缀,不放宽规则本身。
顺序不能换,而且第一步不是清理,是轮换。清历史不是安全措施——任何已经拉过仓库的人手里还有那份副本,密钥在他那里照样能用。
每一步都要有可确认的判据:签发完成、旧密钥调用返回 401、全量扫描在该规则上不再命中。第 5 步不补,下次同一条路还会进来。
随机数是这一层最靠前的一环:生成方式错了,后面怎么管都补不回来。math/rand 这类随机数用来生成测试数据和洗牌,不用来生成密钥。用当前时间做种子时熵不够,生成的会话密钥可以被人猜到。
openssl rand -base64 32 生成;在语言里用 os.urandom() 或 secrets.token_urlsafe();生成后写进 .env,并当场确认它在 .gitignore 里。
math/rand 或 random 生成会话密钥;拿时间戳、进程号或用户 id 当种子;把生成出来的密钥贴进代码或提交信息——贴进提交信息后,它在仓库历史里一直可查,删掉文件也没用。