按下 Merge 按钮之前,你一般会看什么?跑完的 CI 绿钩、还算清晰的 diff、提交者写的一段说明,然后手指一滑,蓝色按钮点下去。这个动作在开源社区一天要发生成千上万次,稳定、流畅、几乎不需要思考。但就在这样的一次点击里,恶意 PR 混进了信任的轨道——提交者不是陌生人,而是被项目方刚刚解雇的开发者。他比任何人都熟悉这个仓库的结构,知道怎么让测试通过,也最清楚哪个角落能埋下隐蔽的暗桩。作为软件测试从业者,我复盘这类"开源复仇事件"时,最关心的不是某个具体漏洞,而是整个社区习以为常的一条默认逻辑——"测试通过等于可以合并"——为什么会在这类攻击面前失效。
1. 事件复盘:一次利用"信任"的恶意提交
1.1 三个骗过审查者的伪装环节
恶意 PR 不会以"我是来投毒的"这种姿态出现,它通常穿着最日常的三件外套。
第一件是"修复 Bug"。提交信息写得很正式,比如 "fix: 修复在特定时区下日期偏移计算错误",改动集中在几行业务逻辑上,看起来人畜无害。但真正的恶意逻辑可能藏在同一个文件里一处不起眼的条件判断中,通过环境变量或特定时间触发,只在生产环境生效。审查者看到"修复 Bug"这个动机,天然会降低防备,因为这是开源协作里最正当、最常见的理由。
第二件是"依赖升级"。"chore: 升级某个库到 2.4.1"——依赖升级的 PR 通常 diff 很大、内容枯燥,很少有人愿意逐行看完。攻击者要做的只是修改一处版本号,再把 lockfile 里的下载地址替换成自己控制的一个同名仓库,或者干脆在看似正常的版本更新里夹带一份被篡改的子依赖。依赖升级 PR 是恶意代码最舒服的掩体,没有之一。
第三件是"代码重构"。"refactor: 提取公共工具类,降低重复逻辑",这类改动涉及文件多、增删行数大,审查者很难每一行都核对新旧行为是否完全等价。恶意代码可以趁着重构的混乱,把一段正常的字符串处理改成偷偷外发数据的逻辑。重构本身就是"大量改动"的代名词,而大量改动意味着注意力会被稀释。
这三类伪装为什么有效?因为它们精确命中了一个审查者每天都要面对的工作流信号:提交信息规整、改动动机合理、CI 一片绿。在这种信号轰炸之下,绝大多数人会默认提交者是善意且努力的,于是真正的 diff 反而成了最容易被跳过的一环。
1.2 为什么"被解雇者"比陌生人更危险
安全威胁模型里通常区分两类角色:外部攻击者和内部威胁。被解雇的开发者介于两者之间——权限可能在名义上回收了,但对代码库的认知、对团队流程的熟悉,都完整保留在脑子里。这类人发起攻击时,掌握的信息量远超一个普通外部攻击者。
他清楚项目的 CI 是怎么跑的,知道哪些模块几乎没有测试覆盖;他知道 CODEOWNERS 里谁会审查什么类型的改动,于是专挑那些"看起来很安全"的文件下手;他甚至知道项目发版时要跑哪些脚本、有没有对依赖的签名校验、release 流程里哪一步是可以利用的缝隙。
最讽刺的是,传统安全认知里"陌生人的提交要被反复审查"这条规则,反而成了他的优势。一个被标记为已删除的协作者、一个刚发了离职通知的开发者,他的 PR 在团队眼里还是"自己人"的贡献,审查强度远低于对一个新面孔的代码。越是熟悉的面孔,越不会有人花十分钟逐行读 diff。攻击者真正利用的,不是代码漏洞,而是人际关系里残存的信任惯性。
1.3 四个环节之间的空隙:提交、审查、合并、发版
复盘这类事件最容易忽略的地方,是人手不足的小型开源项目里,"提交、审查、合并、发版"四个环节常常是同几个兼职维护者在轮流完成。提交者可以自己审查自己的 PR,或者两个长期熟识的维护者互相给 Approve,名义上的职责分离实际并不存在。
更麻烦的是权限回收延迟。很多开源项目对协作者权限的管理依赖维护者手动操作,没有定时审计。一个员工离职三个月,GitHub 上的 Write 权限可能还挂着。当这个人后面提交了一个看起来合理的修复 PR,流程上完全说得通——他本来就有权限,为什么不能提交?但正是这种"流程上的正常",让恶意代码一路绿灯进了主干分支。等到发版阶段,Release Manager 通常只核对版本号和 changelog,很少重新审视合并进主干的每个 commit,于是恶意代码随着新版本一起被分发到下游。四个环节只要有一个没有被严肃对待,整个信任链条就断了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 测试视角:恶意 PR 为什么能躲过"全绿"的测试流水线
2.1 "功能正确"和"行为健康"是两回事
这是我这些年测试工作里最大的体会。绝大多数测试团队追求的是"功能正确"——输入合法数据,走正常逻辑,输出符合预期。但恶意代码的特征恰好是:在正常输入下它完全正确,在非预期的环境或输入下,它会偷偷做出不该做的事。
举个例子,一段支付金额校验逻辑,原来写的是 if (amount > 0) { process(amount); }。恶意 PR 把它改成 if (amount > 0 && !isInternalRequest()) { process(amount); }。其中 isInternalRequest() 检查一个只有内网环境才存在的环境变量。开发环境、CI 环境都没有这个变量,所以所有测试跑出来全是绿的:正常金额能支付,零金额和负数金额会被拒绝,单测断言全过。但一旦部署到生产环境,内部请求路径就绕过支付校验,损失在事后才被发现。
测试验证的是预期行为,恶意代码走的是非预期路径。你写再多的正常用例,都测不出这种问题,因为恶意逻辑在正常条件下根本不会触发。这不是测试用例写得不够多,而是测试哲学出了问题——只关心"代码在预期条件下是否按预期工作",没关心"代码在非预期条件下是否会有非预期行为"。
2.2 恶意代码通常藏在覆盖率的盲区
搞测试的人爱谈覆盖率,行覆盖、分支覆盖、语句覆盖。但恶意代码作者恰恰懂得挑覆盖率的低谷下手。常见藏身之处有这么几类。
异常处理和日志回调是第一个重灾区。catch 分支里的代码,尤其是那条静默吞掉异常后额外干点别的事的路径,几乎不会被单测直接断言。序列化和反序列化逻辑是第二个重灾区。测试数据都是老老实实的干净 JSON,不会带攻击者精心构造的额外字段,反序列化后多执行一条逻辑也就不会被发现。定时任务和后台任务排第三。测试套件通常不会真的去触发一个每天凌晨三点才执行的定时器,于是恶意逻辑可以藏在定时任务的回调里,测试期间它永远不跑。最后还有外部配置解析。测试环境用的配置文件干净、参数少,而攻击者可能让代码在读取配置时多执行一段逻辑,CI 里根本不会覆盖到。
所以即使你做到了 100% 的行覆盖率,依然卡不住恶意 PR。覆盖率只说明代码被执行过,不说明执行的结果被人验证过。被覆盖但没被断言的行为,和没被覆盖的行为一样危险。
2.3 把"敌意测试"写进测试用例
既然恶意代码专门走非预期路径,那就把非预期路径主动变成测试用例。我在团队里把这组用例叫"敌意测试",设计思路是站在攻击者立场问自己:如果我要让这段代码在某个隐蔽条件下出问题,我会怎么改?
具体可以包括这几类:故意乱设环境变量,看程序会不会因某个开关被打开而改变行为;让外部 API 返回超时或恶意负载,确认系统不会把非法响应当正常数据处理;在输入中塞异常编码、超长字符串、负数字段叠加状态,检查校验边界;把本地文件替换成特殊权限或符号链接,看处理逻辑是否会出格。敌意用例不用多,但必须覆盖到关键业务路径上每一个可能被恶意利用的分支。
更实际的防线是给 CI 加一个污点检查 Job。用 SAST 工具扫描本次 diff 中新增的代码,检查有没有调用外部网络地址、有没有硬编码 token、有没有 eval 或 exec 或 shell 调用。GitHub 免费自带的 CodeQL 很多项目都集成了,但往往只是挂在流程里当作摆设。我的建议是:把 CodeQL 的 security 和 quality 级别的告警结果设置为合并前置门槛,至少在核心模块上强制拦截。SAST 拦不住所有恶意逻辑,但至少能把"代码里突然多了一个外发请求""突然出现一段 base64 字符串"这类明显异常拦在门外。
3. diff 审查实战:区分"正常改动"与"恶意改动"
3.1 审查顺序错了,等于没审查
很多人的 PR 审查流程是这样的:先让 CI 跑起来,看到绿色勾,心里先踏实一半,然后快速扫一遍 diff,没什么大问题就点 Approve。这个流程的问题在于,CI 的绿色给了大脑一种"应该没问题"的暗示,人在这种暗示下看 diff,会不自觉地帮改动找合理性,而不是挑毛病。
我建议把顺序彻底反过来。第一步,先读提交信息,再对照 diff 看它们说的是不是同一件事。如果提交信息说"修复登录跳转逻辑",但 diff 里改的是密码加密函数,这就是第一个红旗。第二步,逐行读 diff,对每一行改动问三个问题:为什么要改?改成这样会有什么副作用?这行改动是完成这个目标所必需的吗?第三步,等这些问题都有了答案,再去看 CI 结果。测试在这里的作用是验证你对改动的理解是否正确,而不是替代审查。
我现在基本把 CI 当作一个"衰减器",它只负责缩小怀疑范围,不负责签发信任。如果 CI 全绿但 diff 让我看不懂,我宁可挂起 PR 请提交者解释,也不会为了绿勾放行。
3.2 一份值得收藏的可疑信号清单
长期做审查之后,我整理了一份自己的"可疑信号清单"。每一条单独出现都不一定有问题,但如果一条 PR 里同时出现两项以上,就必须认真对待。
| 信号 | 为什么危险 | 应对动作 |
|---|---|---|
| 提交信息描述的改动范围和实际 diff 范围不一致 | 可能是借"正当理由"掩盖真实改动 | 打回 PR,要求提交者逐条解释差异 |
| 流程中的固定常量(阈值、超时时长、重试次数)被修改,但没有对应需求说明 | 悄悄改变校验边界,让非法输入溜过去 | 要求补充需求来源和测试用例 |
| 新增对 IP、域名、外部端口的引用 | 可能是数据外发或后门回调 | 核查引用的服务归属和必要性 |
| 代码里出现随机数、时间戳、环境变量作为分支条件 | 这是典型的恶意逻辑触发器套路 | 重点审查该分支的用途,确认只在预期场景触发 |
| 新增 try-catch 且异常被静默吞掉 | 后门逻辑最爱藏在静默异常路径里 | 要求说明吞掉异常的理由,并补充异常路径断言 |
| 出现拼接出的 base64、十六进制字符串 | 可能是混淆过的命令或载荷 | 解码验证内容,确认业务上确实需要 |
这套清单不需要背,关键是养成一种直觉:看到改动时先问"它要做什么",再问"它有没有可能额外做了什么"。恶意代码和正常代码在视觉上往往高度相似,差别只在你是否愿意对每一行保持怀疑。
3.3 "依赖升级 PR"是投毒的重灾区
开源供应链攻击里,依赖升级 PR 是最容易得手的方式,因为几乎没人会去 diff 一个新版本库的完整源码。攻击者只需要把 package.json 里的版本号改成一个看似合理的数字,同时把 lockfile 里的解析地址指向自己控制的仓库,或者指向一个名字只差一两个字母的同名包。审查者看了一眼版本号,觉得"这不就是常规升级嘛",然后合并。
这里有个很实用的判断方法:优先信任自动化工具发起的依赖升级 PR。Dependabot、Renovate 这类工具生成的升级 PR 来源可追溯、通常会附上 changelog 链接,提交者身份是可信的。而人工发起的"依赖升级"PR 需要额外提高警惕,尤其是那些顺手把 lockfile 更新了一大片、却只字不提为什么需要这个新版本的操作。
另一种危险是依赖源被篡改。一个库可能本身没问题,但解析地址从官方 registry 被换成了某个人仓库的镜像,或者插件的下载源被加了参数。遇到这类变更,最有效的做法是去官方发布页确认这个版本的真实发布时间和校验值。如果"最新版"的发布时间异常,或者维护者列表里出现了陌生成员,先不要合并,发到讨论区让更多人确认。依赖一旦进了主干,影响会通过 release 被复制到所有下游项目,修复成本远高于拒绝一个可疑 PR 的成本。
4. 开源项目如何建立针对恶意 PR 的防御体系
4.1 权限模型:默认最小化,敏感目录指定专人
很多开源项目的权限设置非常随意:只要贡献过一两个 PR,就顺手给了 Write 权限。这给恶意 PR 提供了太多方便。合理的做法是把权限尽可能收敛,主要内容包括几块。
强制走 PR 流程,主干分支禁止直接 push。任何代码改动必须经过 PR,这对"被解雇者还留着协作者权限"的情况是有效的兜底。启用分支保护规则,PR 必须通过 CI 和 Code Owner 审批才能合并。引入 CODEOWNERS 机制,把核心目录、敏感目录(鉴权模块、支付模块、部署配置、CI 工作流、依赖清单、Dockerfile)指定给固定的核心维护者审批。我在自己的项目里把 .github/workflows/ 和 package.json 都纳入了 CODEOWNERS,任何改动都要先过我这一关——因为攻击者最想动的就是 CI 脚本和依赖声明,改了这两个文件等于拿到了后续所有攻击的钥匙。
权限回收同样要落到流程里。关键问题是,很多项目只记得给贡献者加权限,从不记得在离职或闹僵之后收回。我给团队定的规矩是两件事:离职账号当天回收所有协作者权限,发布相关权限(npm 包发版、Docker Hub、域名 DNS)同步清查;每个季度做一次访问权限审计,把依然有写权限的账号列出来,逐个确认是否还活跃。这套流程说起来简单,真正执行起来能挡住一大半安全事故。
还有一个容易被忽视的细节:回收权限之后要验证回收是否真的生效。用专门的测试账号或者请已离职的同事配合,尝试用旧凭据访问受保护入口,确认所有入口真的拒绝了。这个操作听起来像运维的事,其实非常适合测试团队负责——本质上就是一次针对"失效账户"的安全回归测试,和测一个登录接口没有任何区别。
4.2 自动化工具的定位:第一道筛子,不是免死金牌
防御体系离不开自动化,但自动化工具的能力边界必须清楚。Dependabot 和 Renovate 负责依赖升级的自动化,减少人工依赖升级带来的不确定性;CodeQL、Semgrep、SonarQube 负责静态扫描,能拦截硬编码密钥、SQL 注入、命令注入这类非常具体的问题;SCA 工具负责依赖成分分析,可以发现依赖中已知的 CVE,但对"版本号正常但源码被篡改"的情况同样无能为力。
工具能带来的红利是覆盖面:它们可以全天候扫描每个 PR 的每个 diff,不疲劳、不偷懒。但恶意 PR 中最难对付的"逻辑后门"——表面上是正常读取,实际上多了一次外发;表面上在排序,实际上对特定输入提前 return——机器很难识别,因为从语法和模式上看,这些代码和正常代码没有区别。真正的语义判断必须由人来做。
所以自动化工具的正确姿势是:让它们做第一道筛子,把所有明显的风险项挡在外面,减少人需要关注的噪音;同时绝不能因为工具没报警就放松审查。我见过太多团队引入了 CodeQL 就觉得自己安全了,实际上工具告警大概率被忽略,真正的审查也因为没有压力而变得敷衍。工具是必要的,但把它当成免死金牌,反而会让整个防御体系更脆弱。
4.3 直接能抄的防御清单
把前面这些散落的建议整理成一张可以直接执行的操作清单,适合中小型开源项目对照落地。
| 环节 | 具体措施 | 防御目标 |
|---|---|---|
| PR 入口 | 强制 PR 模板,要求填写变更原因、测试结果、影响范围 | 提高异常提交的暴露概率 |
| 分支保护 | main 分支禁止直接 push,要求至少 2 个 Approve 和 CI 通过 | 提高恶意代码进入主干的成本 |
| 代码审查 | CODEOWNERS 覆盖鉴权、支付、CI、依赖等重点路径 | 确保敏感改动永远有专人把关 |
| 自动化测试 | CI 集成 SAST、SCA、lockfile 一致性校验 | 拦截硬编码密钥、篡改依赖等问题 |
| 权限管理 | 离职账号当日回收,季度权限审计,定期验证撤销效果 | 阻断前内部人的持续访问 |
| 发版控制 | 发版人核对 changelog 和实际提交范围,异常提交须追溯说明 | 防止恶意代码顺流进入 release |
这套清单不需要一步到位。哪怕你的项目现在只有一个人维护,先把分支保护里"禁止直接 push main"打开,再把 CODEOWNERS 加上,就能挡住一批最简单的投毒尝试。剩下的措施可以按项目活跃度和团队规模逐渐补齐,但底线是:不要假设贡献者都是善意的,也不要假设自己永远不会成为攻击目标。
5. 软件测试从业者的专业反思
5.1 测试的边界要延伸到"信任链"上
这些年做测试,我越来越意识到一个事实:我们测的不只是代码,更是整个开发和发布过程中建立的信任链条。一段代码能进入主干,意味着提交者被信任、审查者认可了他的改动、CI 认为它没有破坏任何已知功能、发布流程认为它值得出现在新版本里。恶意 PR 事件中真正被击穿的,正是这条信任链上最薄弱的环节——身份被信任,但没有被验证。
所以我在现在的团队里,会把权限变更、敏感操作这类的"信任边界"也纳入测试范围。具体做法是:每季度给一系列"失效账户测试"用例,模拟已离职员工的 token 去访问内部系统和远端仓库,确认鉴权真正生效;再验证一下 CI 的 secrets 是否只对指定分支可见,确认外部 PR 拿不到敏感变量。这些测试不验证业务功能,但和业务功能同等重要——它们验证的是"这个系统还能不能守住边界"。
5.2 用"红队 PR 模拟"检验自己的防御
我强烈建议开源项目的核心维护者尝试一次这个操作:自己在本地 fork 里制造一个包含恶意逻辑的分支,代码里故意塞一个隐蔽的后门,比如某个条件触发的外部请求、一处静默吞异常后额外执行的动作,然后凭着普通贡献者的身份向主仓库发起 PR,观察整个审查流程到底能不能拦下来。
这个做法叫"红队 PR 模拟",一年做一次就够。它会非常直观地暴露你的防御体系哪里是盲区:也许是 SAST 没有覆盖到某个路径,也许是 CODEOWNERS 没把关键目录划进去,也许是审查者习惯性看提交信息就放行。发现盲区之后马上补,比哪个项目出事了再后悔要划算得多。我自己做过一次之后,立刻调整了 CI 里的 CodeQL 配置,把几个原本只是警告级别的规则提升到阻断级别,效果立竿见影。
5.3 我现在看 PR 的习惯:先看人,再看 diff,最后看 CI
从这些事件里沉淀下来的,其实是一套非常简单但有效的习惯:收到一个 PR 时,先看提交者的身份和背景。这是一个陌生账号的首个贡献,还是一位长期协作者,抑或是一个刚从团队里离开的人?身份不同,我投入的审查精力完全不同。然后看 diff,逐行过,对照提交信息,寻找任何"名不副实"的改动。等这些都看完了,我才会去理会 CI 的绿勾。
这个顺序把"对代码的信任"建立在了"对改动的验证"之上,而不是建立在"对提交者的信任"上。作为测试从业者,我一直觉得我们这行的底色是怀疑——不是恶意地怀疑每个人,而是清楚地认识到:所有没被验证过的事,都不能假设它安全。被解雇者提交恶意 PR 这类事件提醒我的正是这个道理:不要因为一个人曾经可信,就停止验证他的代码。测试的边界从来不是代码的边界,而是信任的边界。守住这条线,开源协作才能继续保有其最宝贵的开放性。
