依赖漏洞扫描卡在 PR 合并前,我们靠一道自动门把第三方库更新挡在了 main 外面
依赖漏洞扫描放进 CI 的团队不少,但真正把它做成「合并前强制门禁」的并不多。我们踩过的坑是:扫描跑了,结果没人看,坏依赖照样进 main,最后在线上被 SCA 工具告警时才追悔莫及。这篇文章记录我们如何把 osv-scanner 和许可证检查嵌进 PR 流程,用一道自动门把不合规的第三方库更新挡在 main 外面。
为什么扫描报告救不了 main 分支
很多团队的做法是:CI 里跑一次 npm audit 或 pip-audit,把结果打印在日志里,或者推送到某个安全平台。问题在于,这些报告是「事后」的——PR 已经合并了,代码已经进 main 了,安全团队第二天打开 dashboard 才发现昨天合进来一个 CVE-2024-1234。
我们团队在 2024 年初就遇到过:一个依赖了 golang.org/x/crypto 旧版本的 PR 正常合并,因为 CI 里的 govulncheck 只输出警告不阻断。三天后 Snyk 发邮件告警,我们才回滚并修复。那次之后我们定了一个明确目标:任何新增或变更的依赖,只要漏洞评分达到阈值或许可证不合规,PR 必须无法合并。
选型:osv-scanner + license-checker,两个轻量工具拼成门禁
我们不想引入一套完整的 SCA 平台(Snyk、Black Duck 这类太重,且需要额外预算审批),于是用了两个开源工具:
- osv-scanner:Google 开源的漏洞扫描器,数据源是 OSV 数据库,支持 Go、npm、PyPI、Maven、Ruby 等生态。单二进制、无外部服务依赖,适合直接塞进 CI。
- license-checker:npm 生态里比较老但稳定的许可证检查工具,能列出所有依赖的许可证类型,支持
--failOn参数直接阻断不合规许可证。
选这两个的原因很实际:都是命令行工具,退出码非零即失败,在 GitHub Actions 里可以天然当作门禁来用。不需要写复杂的解析脚本,也不需要维护额外的服务。
实现:一条 workflow 里的三个 job
我们的 PR 合并流程用 GitHub Actions,仓库是 monorepo,包含 Go 服务和几个前端包。门禁 workflow 拆成三个 job,分别对应 Go 依赖、npm 依赖、许可证检查。
name: dependency-gate
on:
pull_request:
branches: [main]
jobs:
go-vuln-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install osv-scanner
run: |
go install github.com/google/osv-scanner/cmd/osv-scanner@v1.7.1
- name: Scan Go dependencies
run: |
osv-scanner scan --lockfile=go.mod \
--format=json \
--output=osv-report.json \
--fail-on-severity=HIGH,CRITICAL
关键参数是 --fail-on-severity=HIGH,CRITICAL。我们刻意没有把 MEDIUM 加进去,因为早期试过所有级别都阻断,结果 PR 经常因为一些上游已经修复但 Go 生态还没跟上的中危漏洞被卡住,开发同事开始绕门禁。只拦 HIGH 和 CRITICAL 是当时团队讨论后定的平衡点——高危必须修,中危记录在案、后续统一处理。
npm 这边类似,但多了一步:先跑 npm audit 的 JSON 输出,再用 jq 判断是否有 high/critical 级别的漏洞:
npm-vuln-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Audit for high/critical vulnerabilities
run: |
npm audit --json > audit.json || true
HIGH_OR_CRITICAL=$(jq '[.vulnerabilities[] | select(.severity == "high" or .severity == "critical")] | length' audit.json)
if [ "$HIGH_OR_CRITICAL" -gt 0 ]; then
echo "Found $HIGH_OR_CRITICAL high/critical vulnerabilities"
exit 1
fi
这里有个细节:npm audit 默认退出码非零只要有任何漏洞就触发,但我们只需要 HIGH/CRITICAL 阻断,所以用 || true 吞掉退出码,再用 jq 自己判断。这个写法要感谢一位同事在 PR review 时指出来,否则一开始我们直接让 npm audit 裸跑,结果 LOW 级别的漏洞也把 PR 卡死了。
许可证检查单独一个 job,因为它的逻辑和漏洞扫描不同——漏洞是看「新引入的包」,许可证是看「所有依赖的许可证类型」:
license-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Check licenses
run: |
npx license-checker --failOn 'GPL;AGPL;LGPL;EUPL;CC-BY-NC' \
--excludePrivatePackages \
--summary
我们公司的法务政策不允许使用 GPL/AGPL/LGPL 系列的 copyleft 许可证(除非经过特殊审批)。--failOn 后面的分号分隔列表就是「黑名单」,只要依赖树里出现这些许可证,命令退出码非零,PR 直接红掉。
门禁怎么生效:branch protection 是关键
工具配置只是第一步。如果 CI 红了但开发者可以手动 merge,那门禁就是摆设。我们在 GitHub 仓库设置里开了 branch protection:
main分支要求dependency-gateworkflow 的所有 job 必须通过才能 merge- 勾选「Require status checks to pass before merging」
- 勾选「Do not allow bypassing the above settings」
这一步做了之后,才真正形成「自动门」。之前有同事试图在 PR 评论区说「这个漏洞是误报,我先合了」,现在他连 merge 按钮都点不了。
上线后遇到的两个实际问题
第一个问题是 osv-scanner 的误报。我们有一个内部 fork 的 Go 库,模块路径是 github.com/company/internal-lib,osv-scanner 在扫描时把它和上游某个同名公开仓库匹配上了,报了一个完全不相关的 CVE。解决办法是在 osv-scanner 的配置里加 ignore:
- name: Scan Go dependencies
run: |
osv-scanner scan --lockfile=go.mod \
--config=osv-scanner.toml \
--fail-on-severity=HIGH,CRITICAL
osv-scanner.toml 里指定忽略规则:
[[IgnoredVulns]]
id = "GHSA-xxxx-xxxx-xxxx"
reason = "False positive: internal fork not affected"
第二个问题是 依赖更新 PR 的「僵尸」现象。Dependabot 每周自动开的依赖更新 PR,经常因为某个上游包的漏洞被门禁卡住,然后没人管。我们后来加了一条规则:Dependabot 的 PR 如果因为漏洞被阻断,自动打上 security-blocked 标签,并触发一条 Slack 通知到安全频道。这样至少不会默默烂在那里。
效果:从「事后告警」到「事前拦截」
这套门禁上线后,main 分支的依赖漏洞告警从月均 3-4 次降到了零——因为坏依赖根本进不来。代价是开发同事偶尔要花时间处理被卡住的依赖更新,但相比事后回滚和紧急修复的成本,这个代价小得多。
有一个数据可以分享:上线后的第一个月,门禁拦截了 7 个 PR,其中 5 个是 Dependabot 的自动更新(因为上游新版本依赖了一个有 CVE 的传递依赖),2 个是开发者手动的依赖升级。如果没有门禁,这 7 个 PR 都会顺利合并,然后在一周后的例行扫描中被发现。
常见问题
为什么只拦 HIGH/CRITICAL,不拦 MEDIUM?
早期我们试过所有级别都阻断,结果 PR 频繁因为一些上游已修复但生态链还没跟上的中危漏洞被卡住,开发同事开始绕过门禁(比如在 PR 里手动改 lockfile 再合)。只拦 HIGH/CRITICAL 是团队讨论后的平衡:高危必须当下修,中危记录在案、由安全团队按周批量处理。
osv-scanner 和 npm audit 的数据源有什么不同?
osv-scanner 用的是 OSV 数据库,覆盖 Go、Rust、Python 等生态,数据源来自各个语言官方的安全公告。npm audit 用的是 npm 官方维护的 advisory 数据库。两者对同一个 npm 包可能会有不同的漏洞覆盖,所以我们的 npm 项目两个都跑——osv-scanner 扫 lockfile 拿一份,npm audit 再拿一份,互相补充。
如果开发者真的需要合并一个有漏洞的依赖怎么办?
我们有一个「临时豁免」流程:开发者在 PR 描述里写明豁免理由(比如「该 CVE 只影响 Windows 平台,我们的服务跑在 Linux 上」),由安全团队 review 后在 branch protection 里临时 bypass,同时记录到豁免清单里,一个月后自动复查。这个流程目前用得很少,平均每月不到一次。
许可证检查会不会漏掉传递依赖?
license-checker 默认遍历整个依赖树,包括传递依赖。我们用 --excludePrivatePackages 排除了公司内部私有包(这些包有自己的合规流程),其余所有 npm 包都在检查范围内。对于 Go 模块,我们用 go-licenses 单独跑一次,不过 Go 生态的许可证问题比 npm 少很多,目前还没出过问题。