38.9 安全开发 (SDL / DevSecOps / SAST / DAST)
一句话
安全开发不是“上线前扫一遍”,而是把同一类缺陷的修复成本按发现阶段摊开:需求期靠威胁建模,编码期靠 SAST 与依赖扫描,测试期靠 DAST,上线后只剩应急;左移的收益来自“早发现”,不是“多扫描”。
四个误区,先拆掉
| 误区 | 现实 |
|---|---|
| 安全是安全团队的事 | 安全团队只能抽样,代码是研发写的。能被安全团队发现的缺陷不到一成,其余九成靠工具与流程拦在提交前 |
| 多扫一遍就安全了 | 扫描器的价值取决于误报率:误报 80% 时,工程师会关掉告警,有效发现率从 35% 掉到 7%(见本节「可运行的成本模型」) |
| 零告警才算过关 | 零告警通常意味着“规则被关掉了”。健康状态是已知豁免清单 + 新增零告警 |
| 安全流程拖慢交付 | 一次生产事故的处置与公关成本,通常超过一整年的安全工具与人力投入;拖慢交付的是返工,不是检查 |
五道闸:每道闸能发现什么、漏什么
| 阶段 | 手段 | 能发现 | 发现不了 | 误报特征 | 单缺陷修复工时(示例) |
|---|---|---|---|---|---|
| 需求/设计 | 威胁建模(38.1 威胁建模 (STRIDE))、数据流评审 | 缺鉴权、信任边界错误、越权设计 | 具体代码实现错误 | 低(靠人判断) | 2h |
| 编码 | SAST、密钥扫描、依赖漏洞扫描(SCA) | 注入拼接、硬编码密钥、危险函数、已知 CVE | 业务逻辑漏洞、配置错误 | 高(需人工确认数据流) | 10h |
| CI | 单测、DAST 冒烟、镜像扫描 | 参数校验缺失、错误信息泄露、镜像层漏洞 | 需要多步交互的越权、时序问题 | 中 | 20h |
| 预发 | DAST 全量、渗透测试、IAST | 越权、SSRF、业务逻辑绕过 | 需要真实数据/真实账号才能触发的路径 | 中 | 60h |
| 生产 | RASP、WAF、应急响应 | 已被利用的漏洞、异常调用 | 已经泄露的数据 | 低(但已经晚了) | 200h |
🔑 工时倍数是 1 : 5 : 10 : 30 : 100(2h → 200h)。它贵的不是“改代码”那几十分钟,而是:已经发出去的版本要回归、要通知客户、要写事故报告、要赔钱。
五种工具,各管一段
| 工具 | 看什么 | 覆盖 | 误报 | 何时跑 | 盲区 |
|---|---|---|---|---|---|
| SAST(静态) | 源码里的模式与数据流 | 全量代码 | 高 | 提交/CI | 跨服务调用、运行时配置、第三方二进制 |
| DAST(动态) | 跑起来的服务的输入输出 | 已部署的接口 | 中 | CI/预发 | 需要登录态与多步交互的路径 |
| IAST(交互) | 插桩后的运行时代码 | 被测到的路径 | 低 | 测试环境 | 覆盖率取决于测试用例 |
| SCA(依赖) | 依赖版本 → 已知 CVE | 全部依赖 | 中(同 CVE 不同上下文) | 提交/CI | 未公开的 0day、投毒包 |
| RASP(运行时) | 运行中的攻击行为 | 真实流量 | 低 | 生产 | 只能挡已发生的攻击,挡不了设计缺陷 |
一句话分工:SAST 找“你可能写错的地方”,DAST 找“你已经暴露的东西”,SCA 找“别人写错的地方”,RASP 找“正在打你的人”。 四者不互相替代。
可运行的成本模型:左移到底省多少
潜在缺陷 1000 个,五个阶段依次过滤;每阶段有两个参数——发现率与误报导致的忽略率:
package main
import "fmt"
// 缺陷成本模型:同一类缺陷,越晚发现,修它的代价越高。
// 贵的不是"改代码"那几十分钟,而是已经流出去的版本、要回归的范围、要通知的人。
type stage struct {
name string
catch float64 // 本阶段能发现的剩余缺陷比例
hours float64 // 本阶段单缺陷修复工时(含回归、发布、协调)
falseOK float64 // 误报率:被误报淹没时,真实告警被忽略的比例
}
// 有效发现率:真实发现率 × (1 - 被误报淹没而忽略的比例)
func (s stage) effective() float64 { return s.catch * (1 - s.falseOK) }
func run(name string, stages []stage, total int) (int, float64) {
left := float64(total)
var cost float64
fmt.Printf("%s\n", name)
fmt.Printf("%-22s %10s %10s %10s %12s %12s\n", "阶段", "进入缺陷", "发现", "剩余", "单缺陷工时", "阶段成本(小时)")
for _, s := range stages {
found := left * s.effective()
left -= found
c := found * s.hours
cost += c
fmt.Printf("%-22s %10.0f %10.1f %10.1f %12.0f %12.0f\n", s.name, left+found, found, left, s.hours, c)
}
return int(left + 0.5), cost
}
func main() {
total := 1000
shiftLeft := []stage{
{"需求/设计(威胁建模)", 0.30, 2, 0.10},
{"编码(SAST + 依赖扫描)", 0.35, 10, 0.25},
{"CI(单测 + DAST 冒烟)", 0.40, 20, 0.20},
{"预发(渗透 + IAST)", 0.45, 60, 0.10},
{"生产(应急 + 热修)", 0.70, 200, 0.00},
}
scanAtEnd := []stage{
{"需求/设计(威胁建模)", 0.00, 2, 0.00},
{"编码(SAST + 依赖扫描)", 0.00, 10, 0.25},
{"CI(单测 + DAST 冒烟)", 0.00, 20, 0.20},
{"预发(渗透 + IAST)", 0.55, 60, 0.10},
{"生产(应急 + 热修)", 0.70, 200, 0.00},
}
noisy := []stage{
{"需求/设计(威胁建模)", 0.10, 2, 0.10},
{"编码(SAST + 依赖扫描)", 0.35, 10, 0.80}, // 误报 80%:告警被当噪音
{"CI(单测 + DAST 冒烟)", 0.40, 20, 0.20},
{"预发(渗透 + IAST)", 0.45, 60, 0.10},
{"生产(应急 + 热修)", 0.70, 200, 0.00},
}
a1, c1 := run("【A】左移:每道工序都拦(需求 30% / 编码 35% / CI 40% / 预发 45%)", shiftLeft, total)
fmt.Println()
a2, c2 := run("【B】只在预发扫一遍(前面全放行)", scanAtEnd, total)
fmt.Println()
a3, c3 := run("【C】有 SAST,但误报率 80%,告警被忽略", noisy, total)
fmt.Printf("\n【汇总】潜在缺陷 %d 个\n", total)
fmt.Printf("%-4s %16s %14s %14s\n", "", "逃逸到生产", "总修复成本", "相对 A")
fmt.Printf("%-4s %16d %14.0f %14s\n", "A", a1, c1, "1.00×")
fmt.Printf("%-4s %16d %14.0f %14.2f×\n", "B", a2, c2, c2/c1)
fmt.Printf("%-4s %16d %14.0f %14.2f×\n", "C", a3, c3, c3/c1)
}
本机运行结果(GO111MODULE=off go run main.go,Go 1.27.1 darwin/arm64):
【A】左移:每道工序都拦(需求 30% / 编码 35% / CI 40% / 预发 45%)
阶段 进入缺陷 发现 剩余 单缺陷工时 阶段成本(小时)
需求/设计(威胁建模) 1000 270.0 730.0 2 540
编码(SAST + 依赖扫描) 730 191.6 538.4 10 1916
CI(单测 + DAST 冒烟) 538 172.3 366.1 20 3446
预发(渗透 + IAST) 366 148.3 217.8 60 8896
生产(应急 + 热修) 218 152.5 65.3 200 30496
【B】只在预发扫一遍(前面全放行)
阶段 进入缺陷 发现 剩余 单缺陷工时 阶段成本(小时)
需求/设计(威胁建模) 1000 0.0 1000.0 2 0
编码(SAST + 依赖扫描) 1000 0.0 1000.0 10 0
CI(单测 + DAST 冒烟) 1000 0.0 1000.0 20 0
预发(渗透 + IAST) 1000 495.0 505.0 60 29700
生产(应急 + 热修) 505 353.5 151.5 200 70700
【C】有 SAST,但误报率 80%,告警被忽略
阶段 进入缺陷 发现 剩余 单缺陷工时 阶段成本(小时)
需求/设计(威胁建模) 1000 90.0 910.0 2 180
编码(SAST + 依赖扫描) 910 63.7 846.3 10 637
CI(单测 + DAST 冒烟) 846 270.8 575.5 20 5416
预发(渗透 + IAST) 575 233.1 342.4 60 13984
生产(应急 + 热修) 342 239.7 102.7 200 47938
【汇总】潜在缺陷 1000 个
逃逸到生产 总修复成本 相对 A
A 65 45294 1.00×
B 152 100400 2.22×
C 103 68155 1.50×
逐条读这份输出:
- 左移把逃逸量从 152 降到 65(−57%),成本从 10 万小时降到 4.5 万小时。但注意 A 的生产阶段仍花了 30496 小时——占总成本的 67.3%。即使每道闸都在拦,最后一关仍然最贵,因为没有一道闸能拦 100%,而生产阶段的单价是 200 小时。
- B(只在预发扫)的成本是 A 的 2.22 倍,逃逸量是 A 的 2.3 倍。它的特征很明显:前三个阶段成本为 0,预发一次性花 29700 小时——看起来“集中力量办大事”,实际是把 1000 个缺陷全堆到单价 60 小时的关口。
- C 最值得警惕:它每个阶段都做,但因为 SAST 误报 80%,有效发现率只有
0.35 × 0.2 = 7%,编码阶段只拦下 63.7 个。总成本 68155 小时,比“只在预发扫”略好,比真正左移差 1.5 倍。误报不是“体验问题”,是直接的成本问题。 - 三个场景的差异不在工具数量,在“有效发现率”。A 的编码阶段用 10 小时单价拦下 191.6 个缺陷(1916 小时),B 的同一批缺陷留到生产要花 38320 小时——同一个缺陷,放在对的阶段修,单价差 20 倍。
门禁怎么定:只拦新增,别追存量
| 做法 | 判据 | 为什么 |
|---|---|---|
| 只看新增 | 与基线分支对比,只报本次改动引入的问题 | 存量告警几万条,全量阻断等于关掉门禁 |
| 误报率门槛 | 任一规则误报 > 30% 先降级为提示,不进阻断 | 误报高的规则会带走整类告警的可信度(见场景 C) |
| 分级阻断 | 高危(可 RCE / 可越权)阻断合并;中危进 backlog 并设期限 | 一刀切阻断会催生 #nosec 满天飞 |
| 豁免留痕 | 豁免必须写理由 + 到期日 + owner | 无主豁免三年后被当成“安全” |
可退:新规则先 report-only 跑两周,看命中量与误报率,再决定阻断或降级。不要一次上线就阻断——第一周的噪音会让人永久关掉它。
什么时候不该全套上
- 原型 / Demo(无真实用户数据、不对外):SAST + 密钥扫描足够,不需要 DAST 与渗透。
- 一次性数据修复脚本:跑完即删,重点在“别把凭证写进脚本”,不在代码质量。
- 强合规但代码老旧(无法改):此时该做的是运行时防护 + 网络隔离(WAF、RASP、最小暴露面),而不是硬推 SAST 清洗存量。
反模式
| 反模式 | 后果 |
|---|---|
| 把 SAST 结果全量阻断 | 存量告警上万,团队集体申请豁免,门禁名存实亡 |
| 只买工具不改流程 | 扫描结果没人看,缺陷照样流到生产 |
| 安全指标用“扫描次数/告警数” | 告警数可以通过调规则刷,真实逃逸量才是结果指标 |
| 渗透测试一年一次 | 一年 50 次发布,只覆盖 1/50 的版本;应改成“重大变更必测 + 季度全量” |
| 用“零高危”结案 | 高危的定义随工具变,且逻辑漏洞(越权)大多不在高危规则里 |
| 安全团队替研发改代码 | 安全团队成为瓶颈,且改完没人维护;正确做法是给修法 + 回归用例 |
关联
- 前置:38.1 威胁建模 (STRIDE)(需求期那道闸,决定后面工具该重点查什么)、38.6 常见攻击(规则的靶子:注入、XSS、越权分别长什么样)
- 并列:38.10 供应链安全(SCA 只管已知 CVE,投毒与 typosquatting 要另建防线)
- 下游:38.11 安全应急响应(闸门漏过去之后的事)、52.2 静态检查、52.3 依赖与漏洞扫描
自检
Q1 团队把 SAST 从“提示”改成“阻断合并”,两周后高危逃逸量反而上升。最可能的原因是什么?怎么验证?
▸ 答案:误报把门禁变成了“关掉/豁免”的练习。阻断后工程师遇到误报的第一反应是加 #nosec 或把规则加进排除名单,于是整类规则失效(模型中场景 C 的形态:误报 80% → 有效发现率 7%)。验证方法:统计两周内 #nosec/豁免条目的增量,以及被排除规则覆盖的代码行数;若豁免集中在两三条规则上,说明是规则质量而非人员态度问题。修法是先降误报再阻断:把这三条规则改写成“只报新增 + 人工确认”,其余继续阻断。
Q2 模型里 A 场景已经每道闸都拦,为什么生产阶段仍占 67% 的成本?这说明什么?
▸ 答案:因为没有一道闸的发现率是 100%:A 场景前四道闸的累计逃逸率是 21.8%(1000 → 218),这 218 个缺陷每个要花 200 小时。67% 不是“左移失败”,而是“最后一关的单价太高”的必然结果——只要逃逸不为 0,生产就永远是大头。它说明两件事:① 左移的收益来自降低逃逸量(152 → 65),而不是消灭生产阶段成本;② 想再压成本,只能提高前四道闸的有效发现率(降误报、补威胁建模覆盖面),或者降低生产阶段的单价(更好的检测与自动化回滚)。
Q3 老板问:“我们已经买了 SAST、DAST、SCA,为什么还要威胁建模?”用一句话回答,并给出一个只有威胁建模能发现的例子。
▸ 答案:工具只能发现“代码里写错的东西”,发现不了“设计上根本不该存在的路径”。 例子:/internal/order/export 这个接口,SAST 看它鉴权注解齐全、DAST 用管理员账号测也正常,两个工具都判通过;但威胁建模在“网关 → 服务”这条信任边界上会问“外部能不能路由到这个路径”,从而发现它其实对内网之外可达——这是设计层面的越权,工具没有“这个接口该不该存在”的知识。
Q4 一个 20 人的团队,每季度只有 40 小时的安全人力。这 40 小时该投在哪?
▸ 答案:按“每小时的逃逸量下降”排序,而不是按工具名气。优先级:① 密钥扫描 + 依赖漏洞自动修复(小时级投入,直接消掉一类高危,且几乎零误报);② 威胁建模工作坊跑一遍核心链路的 3–4 条信任边界(4 小时能出 20 条威胁,其中通常有 2–3 条是高危设计缺陷);③ 把 SAST 误报最高的两条规则调准(比新增五条规则更有效);④ 剩下的做季度渗透,只测“这一季改过鉴权的服务”。不要投在“买更多工具”上——没有人力消化,工具只增加告警。