在同一 CI/CD 流水线中编排 SAST、DAST 与依赖扫描:从阶段划分到分级阻断与回滚

枫少@KillBoy
枫少@KillBoy
枫少@KillBoy
管理员
319
文章
0
粉丝
安全开发1 22字数 2146阅读7分9秒阅读模式
AI智能摘要
扫描器堆得越多,门禁就越可靠?把源码、依赖和运行实例的检查塞进同一阶段,可能拖慢合并请求,甚至促使团队绕过门禁。文章主张按对象就绪时机编排:构建前做差异化静态扫描,依赖解析后检查锁文件,预发布部署后运行动态冒烟测试;阻断线先设为CVSS 9.0,待存量漏洞治理后再收紧至7.0,同时管理误报例外、复扫与回退。怎样在拦截风险的同时,避免安全检查卡住交付?
— AI 生成的文章内容摘要

一次合并请求触发流水线后,最容易被忽略的安全假设是:扫描器越多、位置越靠前,门禁就越可靠。按这个假设,很容易把 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 区间(示例)初始策略存量修复后
Critical9.0–10.0阻断合并,必须修复或走例外流程保持阻断
High7.0–8.9记录并开单,限期修复,不阻断阻断合并
Medium4.0–6.9记录,按迭代排期记录并开单
Low0.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、文件路径、责任人、到期日和关联单据。例外必须有到期日,到期自动失效并重新触发门禁,这是防止“临时豁免变成永久豁免”的唯一有效手段。

门禁触发后的修复回归与临时回滚

门禁红了之后,流程要明确到不需要讨论:

  1. 在当前分支修复,重新触发同一套扫描,用同一条命令、同一版本规则集验证,不要靠本地跑一遍就宣布通过。
  2. 确认复现路径已消失。SAST 看规则是否不再命中;依赖扫描看锁文件里被标记的版本是否已升级并重新解析;DAST 看对应请求是否返回安全响应,而不是只看扫描器退出码。
  3. 晋升前保留一次完整扫描记录,附在发布单或流水线产物里。

如果短时间内修不完,临时回退优先于放行:把该提交回退,回到上一个通过门禁的镜像或提交,让发布继续,修复在独立分支进行。预发布阶段的 DAST 阻断不应该阻止部署到 staging——否则永远没有可扫的实例——正确的阻断点是“禁止晋升到生产”。依赖扫描发现的 Critical 则应该在推送镜像前就阻断,因为一个已经进仓库的坏镜像,回退成本远高于重新构建。

扫描器版本与规则集变更同样要记录

最后一个经常被忽略的假设是“扫描结论可复现”。同一个提交,换一个扫描器镜像 tag、换一版规则集,结论就可能翻转;如果这些没有记录,几个月后没人说得清某次放行到底是修好了还是规则漏了。把扫描器镜像固定到 digest,把规则集版本和更新日志写入扫描产物,规则集升级本身走一次变更评审并观察对阻断率的影响。扫描器配置属于安全控制的一部分,它和其他代码一样需要版本管理。

 
枫少@KillBoy
    • 王大锤
      王大锤 2

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

    匿名

    发表评论

    匿名网友

    拖动滑块以完成验证