阶段一 · CI 流水线与制品

质量门禁与安全扫描

一句话总结

质量门禁是流水线里一道道"不达标就拦下"的自动关卡;
这一篇把它们装齐--从测试覆盖率,静态扫描(SAST),到依赖与镜像漏洞扫描(Trivy),SBOM 清单,镜像签名,层层守住"合进来的代码够好,推出去的镜像可信".

前置回顾

第 01 篇定义了质量门禁:阶段间的卡点,是"敢于自动化"的前提.第 02 篇我们用 GitHub Actions 让 npm test 当了第一道门.第 03 篇产出了可追溯的镜像.本篇要回答:除了"测试通过",还该卡哪些条件,才能让无人工审查的自动交付真正放心?这是阶段一的收尾,也是阶段二 GitOps 自动部署的安全底座.

为什么"测试通过"远远不够

很多人以为 CI 的门禁就是"跑单测".但测试只能证明功能符合预期,管不了下面这些同样能搞垮生产的问题:

测试全绿,但是:

  • 新写的代码根本没被测试覆盖到 -- 测试"绿"得没意义
  • 代码里有 SQL 拼接,存在注入漏洞 -- 功能正常,安全是个洞
  • 引入的某个第三方库有已知高危 CVE -- 你的锅,但不是你写的
  • 基础镜像 alpine 自带的某个包有漏洞 -- 镜像层面的风险
  • 镜像在推送途中被人掉包 -- 部署的不是你构建的那个

这些问题有个共同点:功能测试发现不了,但都会变成生产事故或安全事件.质量门禁的设计,就是为每一类风险配一道专门的自动关卡.我们从"代码本身"由内向外,一层层往镜像和供应链推.

[代码] → [门禁1<br/>测试+覆盖率] → [门禁2<br/>SAST 静态扫描] → [门禁3<br/>依赖扫描] → [构建镜像<br/>(第03篇)] → [门禁4<br/>镜像漏洞扫描 Trivy] → [门禁5<br/>SBOM + 签名] → [可信镜像<br/>进仓库]

门禁一:测试覆盖率

覆盖率(Coverage)衡量"测试到底跑过了多少比例的代码".它堵的是"测试绿得没意义"这个洞--如果新增的 200 行代码一行都没被测到,那测试通过只能说明没被测的部分恰好没崩.

- run: npm test -- --coverage      # 跑测试并统计覆盖率
- run: |
COVERAGE=$(...) # 取出覆盖率数字
if [ "$COVERAGE" -lt 80 ]; then
echo "覆盖率 $COVERAGE% 低于 80%,门禁拦截"
exit 1 # ← 非 0 退出码 = Step 失败 = 流水线红
fi

实践中更常用现成工具(如 SonarQube,Codecov)来设阈值,但原理就是这个 exit 1:不达标就让 Step 失败,流水线随之中止.这就是第 02 篇"失败即变红,阻止合并"机制的延续.

别盲目追求 100% 覆盖率

覆盖率是必要不充分的指标:100% 覆盖也可能断言写得毫无意义.而且强行追求高覆盖会逼出大量"为覆盖而覆盖"的无效测试.更实用的门禁是增量覆盖率--只要求"本次新增/改动的代码"覆盖率达标,而非整个项目,这样既守住新代码质量,又不会被历史欠债拖垮.

门禁二:SAST 静态应用安全测试

SAST(Static Application Security Testing,静态应用安全测试):不运行代码,直接扫描源码,找出危险的写法和已知的漏洞模式.它堵的是"功能正常但有安全洞"这一类.

典型能揪出的问题:SQL 注入(字符串拼接 SQL),命令注入,硬编码的密码/密钥,不安全的加密算法,路径穿越等.

SAST 像拼写检查器:它不理解你文章写得好不好(那是功能测试的事),但能机械地标出所有"拼错的词"--那些已知有风险的代码模式.

常用工具:CodeQL(GitHub 原生,在 Actions 里几行配置就能开),Semgrep,SonarQube.在 GitHub Actions 里启用 CodeQL:

- uses: github/codeql-action/init@v3
with:
languages: javascript
- uses: github/codeql-action/analyze@v3
# 扫描结果直接出现在 PR 的 "Security" 标签里,有高危问题可配置为阻止合并

SAST 之外还有 DAST

与 SAST(静态,看源码)相对的是 DAST(Dynamic,动态):把应用真正跑起来,像黑客一样发请求攻击它,从外部找漏洞.SAST 早(提交时就能扫),覆盖全(看得到所有代码),但有误报;DAST 准(真实攻击),但晚(要先部署),且只能测到跑起来的路径.两者互补,CI 阶段以 SAST 为主.

门禁三:依赖扫描

现代项目里,你自己写的代码可能只占 10%,其余 90% 是第三方依赖.这些依赖里的已知漏洞,就是你应用的漏洞--著名的 Log4j 漏洞就是这么炸全网的.

依赖扫描(也叫 SCA,Software Composition Analysis,软件成分分析)比对你的依赖清单和公开漏洞库(CVE),揪出"你用的某个库的某个版本有已知高危漏洞".

你的 package.json
└─ 用了 some-lib@1.2.0
└─ 它又依赖 vulnerable-pkg@0.5.0 ← CVE-2023-XXXX 高危!
(你可能根本不知道有这层间接依赖)

工具:GitHub 自带的 Dependabot(免费,自动提修复 PR),Snyk,以及下面要重点讲的 Trivy(它依赖扫描和镜像扫描都能干).

门禁四:用 Trivy 扫描镜像

前三道门守的是源码和依赖.但第 03 篇构建出的镜像,还包含基础镜像(如 alpine)自带的操作系统包--这些你没写,没直接引入,但同样可能有漏洞.需要直接扫"成品镜像".

Trivy(Aqua Security 出品)是云原生领域最流行的扫描器,一个工具扫多种目标:镜像里的 OS 包漏洞,应用依赖漏洞,配置错误,密钥泄露.在流水线里,它接在第 03 篇构建之后:

- uses: docker/build-push-action@v6   # 第 03 篇:构建镜像
with:
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
push: false # 先别推,扫过再说

- uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
severity: 'HIGH,CRITICAL' # 只关心高危和严重
exit-code: '1' # ← 发现就让 Step 失败 = 门禁拦截

关键又是 exit-code: 1:扫到高危漏洞就让流水线变红,这个镜像根本不会被推到仓库,更不会上线.门禁的精髓始终是这一下"拦截".

扫描左移:越早扫越省事

注意上面是"先构建,扫过再推".理想情况下扫描还要更"左"(更靠近开发):本地提交前扫,PR 时扫,构建时扫,层层前置.这叫安全左移(Shift Left)--问题发现得越早,修复成本越低.等镜像都推到生产了才扫出高危漏洞,代价是指数级的.

门禁五:供应链安全 -- SBOM 与镜像签名

前面四道门解决"东西本身有没有问题".最后这道门解决一个更隐蔽的问题:我部署的这个镜像,真的是我那条可信流水线构建出来的吗?中途有没有被掉包?里面到底装了什么?这就是软件供应链安全(Software Supply Chain Security).

SBOM:软件物料清单

SBOM(Software Bill of Materials,软件物料清单) 是一份机器可读的清单,精确列出"这个镜像里到底装了哪些包,哪些版本".

SBOM 就像食品包装上的成分配料表.平时你可能不看,但一旦某种成分被曝出有问题(某个库爆出新漏洞),你能立刻翻配料表,精确查出"我哪些镜像含这个成分,受不受影响",而不用挨个把镜像拆开重新化验.

构建时用 Trivy 或 Syft 生成 SBOM,随镜像一起存档.等于给每个制品留了一份"出厂成分存根",出事时能秒级排查影响面.

镜像签名:证明"出身清白"

镜像从仓库拉取,在网络上传输,理论上可能被篡改或冒名顶替.镜像签名用密码学手段给镜像盖一个"防伪章":流水线在推送前用私钥签名,集群在部署前用公钥验签--验不过就拒绝运行.

云原生标准工具是 cosign(属于 Sigstore 项目):

# 流水线里:构建推送后签名
$ cosign sign ghcr.io/org/myapp:a1b2c3d

# 集群侧:部署前验签(可由准入策略强制)
$ cosign verify ghcr.io/org/myapp:a1b2c3d
# 验证通过 → 确认这镜像确实由我们的流水线签发,未被篡改

这和第 02 篇的 OIDC 是一条线索

第 02 篇推荐用 OIDC 免长期密钥,本篇推荐签名免被篡改--它们共同指向一个现代安全理念:不靠"藏好一个长期秘密",而靠"每次都用短期,可验证的凭证证明身份".cosign 甚至支持"keyless"无密钥签名,直接用 OIDC 身份签发.整条供应链的信任,就是这样一环扣一环建立起来的.SLSA 框架(Supply-chain Levels for Software Artifacts)正是对这套"可追溯,可验证"实践的分级标准.

收尾:阶段一的完整 CI 流水线长什么样

把第 01-04 篇所有要点拼起来,一条成熟的 CI 流水线的骨架就清晰了:

[提交/PR]                                       ← 01 篇:事件触发

├─ test + lint (并行,快速失败) ← 02 篇:Job 并行 / needs
│ └─ 覆盖率门禁 ← 04 篇:门禁一
├─ SAST 静态扫描 ← 04 篇:门禁二
├─ 依赖扫描 ← 04 篇:门禁三

▼ (前面全绿才继续)
[构建镜像] 多阶段 + 缓存 + commit-SHA tag ← 03 篇

├─ Trivy 镜像漏洞扫描门禁 ← 04 篇:门禁四
├─ 生成 SBOM + cosign 签名 ← 04 篇:门禁五


[推送可信镜像到仓库]

▼ (交给阶段二)
GitOps 自动部署到集群 ← 第 05 篇起

到这里,流水线已经能自动,可信地把一次提交变成一个经过层层把关的镜像.正因为有这一整套门禁兜底,阶段二才敢让 GitOps 无人工介入地把镜像部署上线--门禁的严密程度,决定了你敢把自动化推进到哪一步.这正好呼应第 01 篇:门禁是敢于自动化的前提.

快速回顾

  • 测试通过 ≠ 安全可上线:覆盖率,安全洞,依赖漏洞,镜像漏洞,被篡改,功能测试都管不了
  • 门禁一 覆盖率:堵"测试绿得没意义",优先用增量覆盖率
  • 门禁二 SAST:不运行代码,扫源码找危险写法(CodeQL),像拼写检查器
  • 门禁三 依赖扫描(SCA):比对 CVE 库揪出第三方库的已知漏洞(Dependabot)
  • 门禁四 Trivy:扫成品镜像里的 OS 包/依赖漏洞,exit-code:1 拦截,讲究安全左移
  • 门禁五 供应链:SBOM 是"成分清单"便于排查影响面,cosign 签名证明"未被篡改,出身可信"
  • 主线:门禁越严,越敢把自动化(直到无人工的 GitOps 部署)推得越远

这么多扫描,流水线会不会变得很慢?

会增加时间,但有应对:1. 并行跑(第 02 篇)--SAST,依赖扫描和单测互不依赖,可同时进行;2. 分级--PR 时跑快速扫描,合并到主干或发版前再跑全量深度扫描;3. 缓存扫描数据库.安全和速度的平衡,本身就是门禁设计的一部分,不必所有检查都卡在每一次提交上.

扫出几十个漏洞但短期修不完,难道流水线就一直红着?

现实中确实如此,所以工具都支持分级和豁免:只对 HIGH,CRITICAL 设硬门禁(第 04 篇示例),中低危先记录不拦截;对确认无法利用或已有缓解措施的特定 CVE,可以加到忽略清单(如 .trivyignore)并写明理由和期限.关键是有意识地决策每个漏洞"修,缓,忍",而不是直接关掉门禁假装看不见.

动手练习

  1. 覆盖率门禁验证:给第 02 篇的流水线加一个覆盖率统计,设一个阈值,故意让覆盖率不达标,确认门禁拦截.
  2. CodeQL SAST 扫描:在仓库开启 CodeQL,故意写一段拼接 SQL 的代码,看 SAST 能否在 PR 的 Security 标签里报出注入风险.
  3. Trivy 镜像扫描:本地用 trivy image <你第03篇推的镜像> 扫一次,看看一个"普通"镜像里到底有多少 OS 层漏洞.
  4. 基础镜像更新对比:把基础镜像从某个旧版本换成最新版,重新扫描对比漏洞数量变化--直观感受"及时更新基础镜像"为何重要.
  5. (进阶)SBOM 生成:用 trivy image --format spdx-json 给镜像生成一份 SBOM,打开看看里面列了多少你"从没听说过"的包.