一次合并请求触发流水线后,最容易被忽略的安全假设是:扫描器越多、位置越靠前,门禁就越可靠。按这个假设,很容易把 SAST、依赖扫描和 DAST 全塞进同一个 security 阶段串行执行,任何一项有发现就 fail。结果是一个只改了三行样式的 MR 也要等 DAST 把整个预发布环境爬完,构建时间翻几倍,开发者很快学会绕过:把安全检查标成 allow_failure,或者在提交信息里申请跳过。

问题不在工具,而在编排:三类扫描器观察的对象根本不同,SAST 看源码,依赖扫描看清单和锁文件,DAST 看已经跑起来的实例。把它们放进同一阶段,等于要求源码、清单和运行实例在同一时刻同时就绪,而合并请求阶段几乎不可能成立。合理做法是按这三层对象拆开,让每层在它该出现的时刻运行。
按变更层次拆阶段,而不是按工具拆作业
一个可维护的流水线骨架大致如下,阶段顺序比工具选型更重要:
# 仅示意阶段划分,具体镜像与参数随团队环境调整
stages: [prepare, sast, build, sca, deploy-staging, dast, promote]
sast-diff:
stage: sast
script: ["./run-sast --diff-base $CI_MERGE_REQUEST_DIFF_BASE_SHA"]
dependency-scan:
stage: sca
needs: [build] # 锁文件/依赖清单已就绪
script: ["./sca --lockfile ./lock.json --sbom sbom.json"]
dast-smoke:
stage: dast
needs: [deploy-staging]
script: ["./dast --target $STAGING_URL --auth-profile ci-user"]
SAST:构建前跑,盯变更而非全量
SAST 的价值在“代码还没变成产物之前”就给出反馈,所以它应该排在 build 之前。但合并请求阶段不该做全量扫描:把整个仓库所有规则跑一遍,噪音和时间都会失控。增量模式需要工具本身支持基线比对,不同扫描器的参数差异很大,不能想当然地以为“只扫改动文件”是默认行为。更稳妥的配置是:MR 阶段做差异扫描并阻断,主干合并后或夜间做一次全量扫描,用于发现跨文件的数据流问题。
有一个容易被忽略的假设是“扫描规则集是稳定的”。一旦规则集在两次扫描之间升级,同一个提交的结论可能完全不同,所以规则集版本必须随扫描结果一起记录,否则门禁结果无法复盘。
依赖扫描:等清单和锁文件生成后再扫
依赖扫描的输入是依赖清单和锁文件,不是源码目录里随手写的 require 语句。它必须排在依赖解析之后、镜像构建推送之前,这样一旦发现已知漏洞,坏产物根本不会被推入仓库。要点有三:锁文件必须提交并且被流水线真正使用;要区分直接依赖与传递依赖,传递依赖的漏洞往往需要升级上游包才能解决;输出应包含 SBOM,便于后续追溯某个镜像到底带了哪个版本的组件。
DAST:只对预发布实例发起请求
DAST 是黑盒测试,必须有可访问的运行实例,因此只能放在部署到 staging 或预发布环境之后。它也最容易拖慢流水线,所以要控制范围:MR 阶段只跑关键路径的冒烟扫描(限制爬取深度与请求数),全量扫描放到夜间或发版前。另一个常见失败是没配认证——扫描器停在登录页,报告里一片“未发现漏洞”,看起来通过,实际什么都没测。认证配置要使用专用的 CI 测试账号,权限最小化,凭据走密钥管理而不是写在流水线变量里。关于 DAST 应对预发布环境运行、并配置认证以覆盖受保护区域的实践,可参考 Snyk 的 DAST 集成说明;流水线整体安全控制的基线可对照 OWASP CI/CD Security Cheat Sheet。
阈值:先 9.0 阻断,存量清完再收紧到 7.0
三类扫描器共用一个 if severity >= HIGH then fail 是最省事也最容易失效的配置:存量漏洞多的仓库第一天就红成一片,团队只能整体关掉门禁。更可落地的做法是分级推进,把阻断阈值当成一个随时间调整的参数,而不是一次性写死的常量。
| 严重级别 | CVSS 区间(示例) | 初始策略 | 存量修复后 |
|---|---|---|---|
| Critical | 9.0–10.0 | 阻断合并,必须修复或走例外流程 | 保持阻断 |
| High | 7.0–8.9 | 记录并开单,限期修复,不阻断 | 阻断合并 |
| Medium | 4.0–6.9 | 记录,按迭代排期 | 记录并开单 |
| Low | 0.1–3.9 | 仅记录 | 仅记录 |
初始以 CVSS 9.0 作为阻断线,是为了让门禁先立起来而不是先吵起来;等 Critical 和大部分 High 存量清完,再把阻断线下调到 7.0。有两个细节必须注意:阈值要和扫描器实际输出的 CVSS 版本对齐,CVSS v3.1 与 v4.0 的分数不能直接混着比较;CVSS 只是分诊输入,不是风险本身——一个需要管理员认证才能触达的 9.8,和一个匿名可达的 7.5,处置优先级不该只由分数决定,环境因素要在例外评审里体现。

误报:先确认,再抑制,最后进例外清单
误报未分级就长期关闭门禁,等于用“永远绿灯”换“永远不知道”。处理误报应该有三步,不能跳步。
确认。 由提交者或值班安全工程师复现一次,判断是规则不适用、数据流分析错误,还是确实存在但被框架默认行为抵消。确认结论要写在 issue 里,而不是只在聊天里说一句“这个是误报”。
抑制。 规则级误报用规则排除或基线文件处理;单点误报用行内注释抑制,并在注释中写明原因和关联 issue。行内抑制要克制使用,因为它对后续所有扫描都生效,包括同一行被改坏之后。
例外清单。 需要临时放行的发现,进仓库内的例外文件,至少包含规则 ID 或 CVE、文件路径、责任人、到期日和关联单据。例外必须有到期日,到期自动失效并重新触发门禁,这是防止“临时豁免变成永久豁免”的唯一有效手段。
门禁触发后的修复回归与临时回滚
门禁红了之后,流程要明确到不需要讨论:
- 在当前分支修复,重新触发同一套扫描,用同一条命令、同一版本规则集验证,不要靠本地跑一遍就宣布通过。
- 确认复现路径已消失。SAST 看规则是否不再命中;依赖扫描看锁文件里被标记的版本是否已升级并重新解析;DAST 看对应请求是否返回安全响应,而不是只看扫描器退出码。
- 晋升前保留一次完整扫描记录,附在发布单或流水线产物里。
如果短时间内修不完,临时回退优先于放行:把该提交回退,回到上一个通过门禁的镜像或提交,让发布继续,修复在独立分支进行。预发布阶段的 DAST 阻断不应该阻止部署到 staging——否则永远没有可扫的实例——正确的阻断点是“禁止晋升到生产”。依赖扫描发现的 Critical 则应该在推送镜像前就阻断,因为一个已经进仓库的坏镜像,回退成本远高于重新构建。
扫描器版本与规则集变更同样要记录
最后一个经常被忽略的假设是“扫描结论可复现”。同一个提交,换一个扫描器镜像 tag、换一版规则集,结论就可能翻转;如果这些没有记录,几个月后没人说得清某次放行到底是修好了还是规则漏了。把扫描器镜像固定到 digest,把规则集版本和更新日志写入扫描产物,规则集升级本身走一次变更评审并观察对阻断率的影响。扫描器配置属于安全控制的一部分,它和其他代码一样需要版本管理。
- article
- it2021
- it2021.com
- OWASP
- tag

上海 上海 1F
全都塞一个阶段确实顶不住,深有同感