为遗留代码库设置渐进式 SAST 门禁:按新增问题阻断并用基线验证修复

枫少@KillBoy
枫少@KillBoy
枫少@KillBoy
管理员
419
文章
0
粉丝
安全开发313阅读模式
AI智能摘要
周一全量SAST扫描让PR全红,团队却因上百条历史债务而瘫痪,门禁沦为摆设。遗留代码库的难题不在扫描器,而在如何划定阻断边界。文章提出渐进式策略:先固化基线隔离历史问题,仅针对新引入的高危缺陷执行阻断,并建立分级响应机制。通过误报台账和修复回归验证,让安全门禁从对抗性检查转变为可持续的工程实践,避免工具沦为噪音。
— AI 生成的文章内容摘要

周一早上,仓库里多了一个名为 sast 的 CI job。第一次全量扫描返回几百条告警,从 SQL 注入到硬编码令牌,分布在五年前就没人动过的目录里。当天打开的 PR 全部变红,作者们发现自己的改动只有十几行,却要为上百条历史问题负责。两周后,这个 job 被加上了 continue-on-error: true,再过一周,有人把它从必需检查里移除。工具本身没有失效;真正失效的是门禁的边界:它把“仓库里已经存在什么”和“这次变更引入了什么”混在了一起。

对遗留代码库来说,第一次接入 SAST 最难的一步不是让扫描器跑起来,而是让结果有人看、有人修,并且团队敢用阻断。比较可行的做法是:先把历史告警固定在某个 commit 上,形成基线;只对变更新引入的问题执行阻断;等团队建立起处理节奏,再逐步提高严重度门槛和规则覆盖范围。

先分清三种结果,再谈阻断

很多团队把 SAST 输出当成一个布尔值:有告警就失败,没告警就通过。这在遗留仓库里几乎必然导致两种结局——门禁被长期绕过,或者开发者花大量时间处理与本次改动无关的历史问题。更合理的做法是把结果拆成三类:

  • 阻断:本次变更新引入、高严重度、高置信度,且位于攻击者可达的代码路径。
  • 告警:新引入但置信度不足、可达性不明,或严重度中等,需要安全负责人限时给出结论。
  • 仅报告:基线中已存在的历史问题,以及低风险规则命中,不参与本次合并判断,但进入台账。

这里的“可达”不是扫描器自动给出的绝对结论。它通常需要结合入口点、鉴权中间件、框架默认防护和调用链人工判断。门禁策略可以把“可达性未知”的高危问题设为告警而不是直接阻断,但必须有人跟进,否则告警会退化成噪音。

第一步:先测量,不要先阻断

在打开任何阻断开关之前,先用当前规则集对目标分支做一次全量扫描,保留 SARIF 或 JSON 结果。需要看的不是总数,而是分布:哪些规则贡献了最多告警,集中在哪些目录,哪些是框架误报,哪些规则在测试代码和生成代码里反复命中。

首批规则不要一次性全开。选择 5 到 15 条与你的技术栈匹配、置信度较高、后果明确的规则,例如注入、路径穿越、不安全反序列化、命令执行、硬编码凭据。先让这些规则在“仅报告”模式下跑两周,观察它们在新代码上的命中率和误报率。规则开得少但每条都有人认领,比开几百条然后集体沉默更有效。

第二步:建立并固化基线

基线可以理解为“在某个确定的 commit 上,仓库中已经存在的告警集合”。门禁比较的是本次变更相对于这个集合新增了什么,而不是仓库当前一共有多少问题。

以 Semgrep 为例,--baseline-commit 会让扫描器比较目标分支的合并基点,减去该基点已存在的告警,只对本次变更引入的 finding 返回失败。其他工具也有类似机制,有的叫 new code 周期,有的叫 diff-aware 扫描,名称不同,核心都是把历史债务排除在本次判断之外。

# 生成一次全量基线,存档并与 commit SHA 关联
semgrep scan \
  --config=p/security-audit \
  --sarif --output=sast-baseline.sarif

# PR 中只对新增问题失败
semgrep scan \
  --config=p/security-audit \
  --baseline-commit="$BASE_SHA" \
  --sarif --output=sast-pr.sarif

BASE_SHA 应该是目标分支的合并基点,不是当前 HEAD。CI 中需要完整历史,GitHub Actions 通常要设置 fetch-depth: 0,否则浅克隆拿不到合并基点。副作用也要提前说明:baseline 模式需要比较两次扫描结果,耗时可能接近翻倍;大仓库应缓存规则包和依赖,避免每次 PR 都重新下载。

基线要作为工件保存,至少记录规则集版本、扫描器版本、commit SHA 和生成时间。静态基线文件需要随目标分支更新;如果使用工具内置的 diff 模式,则要确保每次 PR 都拿得到正确的目标分支引用。文件重命名、目录迁移或大规模格式化可能让基线匹配失效,表现为旧问题“重新出现”,这类情况要在基线更新流程里处理,而不是直接关掉门禁。

遗留代码库基线归档与新增告警检查的俯视场景

第三步:设计分级门禁,不要让“通过/失败”变成唯一动作

一个只有阻断和放行两种状态的门禁,在遗留仓库里很容易走向极端:要么阻断太多被绕过,要么放行太多失去意义。更可操作的是把动作分成阻断、告警、仅报告三档,并把“新增”作为前提。

本次变更引入的告警判断条件示例默认动作
高严重度、高置信度、入口可达请求参数进入 SQL 拼接、命令执行或反序列化路径阻断合并
高严重度、低置信度或可达性不明数据流不完整,或命中位置在测试、示例、生成代码告警,安全负责人限时复核
中低严重度信息泄露、弱随机数、日志中包含敏感字段仅报告并建单,不阻断
基线内历史问题目标分支合并基点已存在仅报告,不参与本次门禁

策略文件里最好把规则 ID、严重度、动作和负责人写清楚,而不是在 CI 脚本里散落一堆 if。当规则集升级或扫描器版本变化时,先跑一次对比扫描,确认新增命中是代码变化还是规则变化,再决定是否调整门槛。

一个容易被忽略的安全假设是:只要扫描器报了,就说明漏洞真实存在;只要扫描器没报,就说明代码安全。前者会导致大量时间花在不可达的测试代码上,后者更危险。SAST 基于模式和数据流,它能发现一类可识别的缺陷,但不能证明没有漏洞,也不能覆盖业务逻辑越权、竞态条件和配置错误。

第四步:误报处置要有台账,不要用目录级排除

误报处置是门禁能不能长期运行的关键。常见做法有三种,优先级从高到低:

  1. 修规则:如果某条规则在你的框架下系统性误报,写自定义规则或增加精确的排除条件,而不是排除整个目录。
  2. 精确抑制:在具体行使用 # nosemgrep: rule-id 之类的注释,写明原因和关联 issue。抑制范围要尽可能小。
  3. 例外台账:无法立即修复但确认风险可接受的问题,登记规则、文件、行号、判断人、理由、补偿控制、到期日和 issue 链接。

目录级排除和全局关闭规则是最省事也最危险的做法。它们会把未来真正的新问题一起静音,而且很难被审计发现。例外必须有到期日,到期后自动重新打开;每季度复核一次例外分布,如果某条规则的例外数量持续增长,说明规则需要调整,而不是继续加例外。

第五步:修复回归要同时验证“消失”和“没有新增”

修复一个 SAST 告警时,只看到目标 finding 消失还不够。需要确认三件事:目标问题确实被消除,修复没有引入新的告警,相关行为有测试覆盖。

以一个被 SQL 注入规则命中的搜索接口为例:

# 修复前:字符串拼接,用户输入直接进入 SQL
sql = f"SELECT id, title FROM documents WHERE title LIKE '%{q}%'"
rows = db.session.execute(text(sql)).fetchall()

# 修复后:参数化查询,输入只作为绑定值
sql = "SELECT id, title FROM documents WHERE title LIKE :q"
rows = db.session.execute(text(sql), {"q": f"%{q}%"}).fetchall()

这段示例是 Python 与 SQLAlchemy 的写法,重点是数据流边界,不是完整应用。修复后应在同一个 PR 里重跑扫描,确认目标规则不再命中,并对比修复前后的 SARIF 结果,确认没有新增其他规则的问题。对于输入校验、授权判断这类逻辑,最好补一个回归测试,否则下一次重构可能又把拼接写法带回来。

基线也是修复验证的参照:修复前该问题位于基线内,修复后应从基线中消失,并且不再被 diff 扫描判定为新增,才算闭环。基线更新要和修复合并放在同一个窗口。如果修复已经进入目标分支,基于合并基点的 diff 扫描通常会自然把该问题视为“已存在”而不再阻断;如果使用静态基线文件,则要在同一个 PR 里重新生成并提交基线,避免出现“修复已合并,但基线里还留着旧记录”或“基线提前更新,导致旧问题被误判为新增”的情况。

第六步:逐步收紧,而不是一次性提高门槛

渐进式门禁的关键是让团队先建立处理能力,再提高要求。一个可参考的节奏:

阶段时间门禁动作重点
观察期第 0–2 周全部仅报告建立基线,统计规则分布和误报
初始阻断第 3–6 周阻断新增高危、高置信规则控制在 5–10 条规则,明确负责人
扩展覆盖第 7–12 周增加中危规则和框架特定规则告警建单,开始按模块清理历史高危
稳定运行第 13 周以后按季度评审门槛跟踪例外、误报率和修复时长

每个阶段都需要看几个指标:每个 PR 平均新增多少告警、门禁阻断次数、误报占阻断的比例、从告警到修复的中位时间、未关闭例外数量,以及绕过门禁的次数。如果阻断的误报率过高,先修规则或调整可达性判断,不要直接提高严重度门槛;如果新增告警长期无人处理,说明告警通道没有责任人,继续收紧只会增加绕过。

渐进收紧 SAST 门禁策略的实体控制旋钮特写

门禁误阻断时,怎么临时回退

再谨慎的门禁也会遇到误阻断。关键不是保证永远不误判,而是让回退有边界、有记录、有时限。

先判断原因。工具故障表现为规则下载失败、扫描超时、解析错误或 SARIF 上传失败,这类问题重跑通常能解决;规则误报表现为代码路径实际不可达或框架已有防护,应走精确抑制或例外流程;真实问题则不应回退,按修复或带补偿控制的例外处理。

临时回退只针对当前 PR 或当前事件,不要直接删除 job 或长期保留 continue-on-error: true。可以设置一个仓库级开关,例如 SAST_MODE=report-only,只允许安全负责人或仓库维护者在事故分支上使用,开关变更自动记录到审计日志,并在 24 小时内恢复。回退期间仍然上传 SARIF、创建 issue,保证结果不丢失。恢复后补一次复盘,确认是规则、配置还是流程问题,并把对应修复写回策略文件。

适用边界:门禁只约束新增,不消灭历史债务

按新增问题阻断的 SAST 门禁,解决的是“不让新债务继续进入主分支”,不是“把遗留代码库变干净”。历史高危仍然需要单独排期,按模块、按入口、按业务重要性逐步清理。门禁也不能替代人工代码审查、DAST、软件成分分析、密钥扫描和渗透测试;它只是 CI/CD 中的一道自动化检查,擅长以较低成本拦住已知模式的新增缺陷。

如果团队还没有基线、没有例外台账、没有明确的告警负责人,先不要打开阻断开关。把扫描结果跑准、把规则选少、把误报处置流程走通,再谈收紧。门禁的价值不在于一次拦下多少条告警,而在于让每一次新增的风险都有地方停下来。

 
枫少@KillBoy
评论  3  访客  3
    • 聚会C位担当
      聚会C位担当 2

      这个渐进式思路很实用,避免了一上来就全员崩溃

      • 墨流云
        墨流云 1

        基线要随分支更新这点容易忽略,得定期同步

        • 爱偷懒的南瓜
          爱偷懒的南瓜 1

          误报台账加到期日是个好习惯,不然例外会越积越多

        匿名

        发表评论

        匿名网友

        拖动滑块以完成验证