0%

Grype 源码分析 10:如何计算漏洞优先级

拿到匹配结果只是"发现漏洞"的终点,"修复漏洞"的起点在哪里?想象这样一个场景:一次扫描产生了 200 条漏洞匹配,其中既有 Critical 级别的远程代码执行漏洞,也有 Low 级别的本地拒绝服务漏洞。安全工程师面临一个现实问题——应该先修哪一个?

优先级如果只按 Severity(严重性)来判断,就会出现一个矛盾:一个标记为 “High” 的漏洞如果从未在任何真实攻击中被利用过,它的实际威胁可能远小于一个标记为 “Medium” 但正在被勒索软件组织广泛利用的漏洞。Grype 的解决方案是引入一个综合的 Risk Score(风险评分) ——它将 Severity、CVSS 评分、EPSS 利用概率和 CISA KEV 已知利用信息整合为一个 0-100 的数值,帮助用户在众多漏洞中做出更理性的优先级决策。

本文将从风险数据的来源出发,逐一分析 Severity、CVSS、EPSS 和 KEV 如何进入 Grype 的数据模型,然后深入风险评分的计算逻辑,最后阐述不同排序策略的实现原理。

风险数据的来源全景

在深入具体实现之前,先建立整个风险数据链条的全局认识。

漏洞的风险评估依赖四个维度的数据,它们来自不同的数据源,在 Grype 内部通过不同路径汇聚到 vulnerability.Metadata 对象中:

1
2
3
4
5
6
7
8
9
漏洞数据库 (v6)

├── VulnerabilityBlob.Severities ──────► Severity 字符串 + CVSS 向量

├── KnownExploitedVulnerabilityHandle ─► KEV 数据(通过 CVE 关联)

├── EpssHandle ────────────────────────► EPSS 数据(通过 CVE 关联)

└── CWEHandle ─────────────────────────► CWE 数据(通过 CVE 关联)

前面分析过的 v6 数据库中存储的是漏洞记录与受影响包之间的关联关系,而风险相关的数据则是通过漏洞的 CVE 编号跨表关联查询获得的。这四类数据在查询时通过 vulnerabilityProvider.getVulnerabilityMetadata() 汇总为 vulnerability.Metadata 对象,然后附着在 Vulnerability 对象上随匹配结果一起输出。

值得注意的一个设计细节是:风险数据在数据库中独立存储,在查询时动态组装。KEV 和 EPSS 数据并不是直接存储在漏洞记录中,而是存储在以 CVE 为键的独立表中。这样做的好处是:KEV 和 EPSS 数据源可以独立更新,不需要重新构建整个漏洞数据库的核心关联记录。

Severity:漏洞严重性的第一印象

Severity 是一个字符串标签——“Critical”、“High”、“Medium”、“Low” 或 “Negligible”——它是用户看到漏洞时的第一印象。但它的来源并不像表面上那么简单。

Severity 的来源

在 v6 数据库中,每条漏洞记录(VulnerabilityHandle)的 Blob 中都有一个 Severities 数组,按优先级排序。Grype 取数组中的第一个(优先级最高的)条目来确定 Severity:

1
2
3
4
5
6
7
8
9
10
11
12
13
func extractSeverities(vuln *VulnerabilityHandle) (vulnerability.Severity, []vulnerability.Cvss, error) {
if vuln.BlobValue == nil {
return vulnerability.UnknownSeverity, nil, nil
}
sev := vulnerability.UnknownSeverity
if len(vuln.BlobValue.Severities) > 0 {
var err error
// grype DB v6+ will order the set of severities by rank, so we can just take the first one
sev, err = extractSeverity(vuln.BlobValue.Severities[0].Value)
// ...
}
return sev, toCvss(vuln.BlobValue.Severities...), nil
}

Severity 的确定有两种路径:

  • 如果数据源直接提供了字符串标签(如 “high”、“critical”),Grype 直接使用 ParseSeverity() 将其映射为内部的 Severity 枚举——从 UnknownSeverity(0)到 CriticalSeverity(5)的整数常量。
  • 如果数据源提供的是 CVSS 向量(如 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H),Grype 会解析向量提取 Base Score,然后根据 CVSS 版本将其映射为字符串 Severity。

CVSS 评分到 Severity 的映射

CVSS 有两个主要版本体系——CVSS v2 和 CVSS v3.x/4.0,它们的 Severity 阈值不同。Grype 为两个版本分别实现了映射:

1
2
3
4
5
6
7
8
func interpretCVSSv3Plus(score float64) vulnerability.Severity {
if score >= 9.0 { return vulnerability.CriticalSeverity }
if score >= 7.0 { return vulnerability.HighSeverity }
if score >= 4.0 { return vulnerability.MediumSeverity }
if score > 0 { return vulnerability.LowSeverity }
if score == 0 { return vulnerability.NegligibleSeverity }
return vulnerability.UnknownSeverity
}

为什么需要两套映射?因为 CVSS v2 没有 “Critical” 级别——它的最高级别是 “High”(7.0-10.0)。而 CVSS v3.x 在 9.0-10.0 之间增加了 “Critical” 级别。如果不对版本做区分,一个 CVSS v2 评分 8.0 的漏洞会显示为 “High”,而一个 CVSS v3 评分 8.0 的漏洞也是 “High”——但这个 CVSS v3 评分 9.5 的漏洞才是 “Critical”。这种区分能让用户更准确地理解漏洞的严重程度。

Severity 在风险评分中的角色

Severity 标签在风险评分中被转换为数值——不是直接用枚举序号,而是取每个级别的中间值

1
2
3
4
5
6
7
8
9
10
11
func severityToScore(severity string) float64 {
switch strings.ToLower(severity) {
case "negligible": return 0.5
case "low": return 3.0
case "medium": return 5.0
case "high": return 7.5
case "critical": return 9.0
}
// 未知的 severity 放在中间位置,而不是末尾
return 5.0
}

"取中间值"这个设计值得思考。一个 “High” 级别的漏洞在 CVSS v3 标准中对应 7.0-8.9 的范围,取中间值 7.5 是合理的近似。但更有趣的是对未知 severity 的处理:它返回 5.0(Medium 级别)而不是 0。注释解释了这样做的理由——未知的 severity 不应该被放在列表底部而被忽略,相反,放在中间位置能够确保它们不被埋没在大量低风险结果中。

CVSS:多维度严重性评估

CVSS(Common Vulnerability Scoring System,通用漏洞评分系统)是漏洞管理领域最广泛采用的标准之一。它不仅仅是一个分数,还包含一个向量字符串,编码了攻击向量(AV)、攻击复杂度(AC)、权限要求(PR)、用户交互(UI)、范围(S)等多个维度的评估。

多数据源的 CVSS

同一个漏洞(如 CVE-2023-xxxx)可能被多个数据源评估,每个数据源可能给出不同的 CVSS 评分——NVD 可能给出 8.8,Red Hat 可能给出 7.5,GitHub 可能给出 9.0。Grype 的处理策略是全部保留,不做偏袒

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func toCvss(severities ...Severity) []vulnerability.Cvss {
var out []vulnerability.Cvss
for _, sev := range severities {
if sev.Scheme != SeveritySchemeCVSS { continue }
cvssSev, ok := sev.Value.(CVSSSeverity)
if !ok { continue }
// 从向量字符串解析出完整的 metrics
metrics, err := cvss.ParseMetricsFromVector(cvssSev.Vector)
// ...
out = append(out, vulnerability.Cvss{
Source: sev.Source, // 谁评估的(NVD、Red Hat...)
Type: legacyCVSSType(sev.Rank), // Primary 或 Secondary
Version: cvssSev.Version, // 2.0、3.0、3.1、4.0
Vector: cvssSev.Vector, // 完整的向量字符串
Metrics: *metrics, // 解析出的 BaseScore 等指标
})
}
return out
}

两个设计要点在这里:

  • Type 字段反映了评分的优先级。Rank 为 1 的评分被标记为 “Primary”,其余的为 “Secondary”。这保持了向早期 Grype 版本的向后兼容。
  • 从向量重新解析 Metrics。数据库中存储了 CVSS 向量字符串,但 Grype 使用 cvss.ParseMetricsFromVector() 从向量中重新解析出 BaseScore、ExploitabilityScore 和 ImpactScore。这意味着 Grype 不依赖上游数据库已经计算好的分数,而是基于 CVSS 向量独立计算,避免了存储的不一致。

CVSS 在风险评分中的角色

severity() 函数中,CVSS 的 Base Score 和字符串 Severity 被平等对待——取两者的平均值:

1
2
3
4
5
6
7
8
9
func severity(m Metadata) float64 {
stringSeverityScore := severityToScore(m.Severity) / 10.0 // 归一化到 0-1
avgBaseScore := average(validBaseScores(m.Cvss...)...) / 10.0

if avgBaseScore == 0 {
return stringSeverityScore // 只有字符串 severity 可用
}
return average(stringSeverityScore, avgBaseScore) // 取两者平均
}

为什么这样设计?注释中给出了解释:有些供应商只更新字符串 severity,不更新 CVSS 评分。如果只使用 CVSS 评分,可能会遗漏供应商对严重性的更新。反之亦然——有些记录只有 CVSS 评分没有字符串 severity。通过取平均值,Grype 在不偏向任何一个数据源的前提下综合了两类信息。

这里还有一个重要的过滤逻辑:validBaseScores() 会过滤掉 BaseScore 为 0 的记录。因为 CVSS 的 Base Score 理论上不可能为 0——如果看到 0,说明数据有问题,不应将其纳入平均计算。

EPSS:从"漏洞有多严重"到"漏洞是否有人利用"

如果说 CVSS 回答的是"这个漏洞有多严重",EPSS(Exploit Prediction Scoring System,漏洞利用预测评分系统)回答的就是"这个漏洞在未来 30 天内被利用的概率有多大"。

为什么 EPSS 很重要

传统的漏洞优先级完全依赖 CVSS 评分,但 CVSS 存在一个内在局限:它评估的是漏洞的技术严重性,而不是现实威胁。一个 CVSS 10.0 的漏洞如果存在于一段几乎无人使用的代码路径中,其实际威胁可能远低于一个 CVSS 5.0 但正被活跃利用的漏洞。

EPSS 由 FIRST 组织发布,使用机器学习模型对 CVE 被利用的概率进行预测。它输出两个值:

  • EPSS 分数(0-1):漏洞在未来 30 天内被利用的估计概率。例如 0.05 表示 5% 的利用概率。
  • Percentile(百分位)(0-1):该漏洞的利用概率在所有 CVE 中的排名。例如 0.95 表示它比 95% 的已知漏洞更可能被利用。

EPSS 的数据库存储与查询

在 v6 数据库中,EPSS 数据存储在一个名为 EpssHandle 的独立表中:

1
2
3
4
5
6
7
type EpssHandle struct {
ID int64
Cve string // CVE 编号(索引,大小写不敏感)
Epss float64 // EPSS 分数(0-1)
Percentile float64 // 百分位(0-1)
Date time.Time // 从 EpssMetadata 中设置,不在本表存储
}

EPSS 数据的日期是一个全局属性,存储在 EpssMetadata 表中——所有 EPSS 记录共享同一个数据日期,因为这个数据是由 FIRST 每天整体发布的。这种设计避免了在每个 EPSS 句柄中重复存储相同的日期。

查询时,vulnerabilityProvider 首先提取漏洞的所有 CVE 编号(包括主 ID 和别名),然后逐个查询 EPSS 表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (vp vulnerabilityProvider) fetchEpss(cves []string) ([]vulnerability.EPSS, error) {
var out []vulnerability.EPSS
for _, cve := range cves {
entries, err := vp.reader.GetEpss(cve)
for _, entry := range entries {
out = append(out, vulnerability.EPSS{
CVE: entry.Cve,
EPSS: entry.Epss,
Percentile: entry.Percentile,
Date: entry.Date,
})
}
}
return out, nil
}

EPSS 兼容性控制

数据库的 schema version 决定了 EPSS 是否可用。vulnerabilityDecoratorStore 在初始化时检查版本:

1
2
3
minSupportedEPSSClientVersion := schemaver.New(6, 0, 2)
// ...
epssEnabled: dbVersion.GreaterOrEqualTo(minSupportedEPSSClientVersion),

如果数据库版本低于 6.0.2,EPSS 数据不可用,但 Grype 不会报错——它会优雅降级,返回 nil 而不报错。这确保了旧版本数据库的兼容性。

KEV:被证实的利用行为

如果说 EPSS 是预测,那么 CISA 的 KEV(Known Exploited Vulnerabilities,已知被利用漏洞)就是证实——它记录了已经在野外被观察到的真实利用行为。

KEV 的数据结构

KEV 不仅仅是"这个漏洞被利用过"的二元标记。它包含了丰富的上下文信息:

1
2
3
4
5
6
7
8
9
10
11
type KnownExploitedVulnerabilityBlob struct {
VendorProject string // 受影响的厂商/项目
Product string // 受影响的特定产品
DateAdded *time.Time // KEV 收录日期
RequiredAction string // 要求的修复措施
DueDate *time.Time // 修复截止日期(联邦机构)
KnownRansomwareCampaignUse string // 是否被勒索软件利用
Notes string // 补充说明
URLs []string // 参考链接
CWEs []string // 相关 CWE
}

这些信息在数据库中以 blob 形式存储(通过 KnownExploitedVulnerabilityHandle + BlobID),查询时通过 attachBlobValue() 加载。这种设计将大尺寸、可变的 blob 数据与用于索引的固定字段(cve)分离,在查询效率和数据完整性之间取得了平衡。

KEV 在威胁评估中的优先级

EPSS 的官方使用指南承认:一旦有确凿的证据表明漏洞正在野外被利用,EPSS 的概率预测就失去了参考价值——因为利用的概率已经是 100% 了。这正是 Grype 的实现逻辑:

1
2
3
4
5
6
7
8
9
10
11
func threat(m Metadata) float64 {
if len(m.KnownExploited) > 0 {
// 按照 EPSS 的官方指南,任何野外利用的证据
// (不仅仅是 PoC)应该优先于 EPSS 数据
return 1.0
}
if len(m.EPSS) == 0 {
return 0.0
}
return m.EPSS[0].EPSS // 返回 EPSS 概率(0-1)
}

threat() 函数中有清晰的优先级:

  • 有 KEV → 威胁值 1.0(最高,因为利用已经被证实了)
  • 没有 KEV 但有 EPSS → 使用 EPSS 的预测概率
  • 两者都没有 → 威胁值 0.0(无可用数据)

这个简单的 fall-through 逻辑体现了一个务实的工程原则:确定性信息优先于统计预测

Risk Score:多维归一的风险量化

有了 Severity 和 Threat 之后,Grype 将它们合并为一个 0-100 的风险评分。这是整个风险计算的核心公式:

1
2
3
func riskScore(m Metadata) float64 {
return min(threat(m) * severity(m) * kevModifier(m), 1.0) * 100.0
}

公式中各因子的含义:

因子 含义 取值范围
threat(m) 被利用的可能性 0.0 ~ 1.0
severity(m) 漏洞的严重程度 0.0 ~ 1.0
kevModifier(m) KEV 相关的修正系数 1.0 ~ 1.1
乘积 ÷ 100 原始风险 × 影响(目前固定为 1.0),缩放为百分比 0 ~ 100

KEV 的修正作用

KEV 出现在两个地方。首先,它在 threat() 中将威胁值直接设为 1.0(如上文所述)。其次,它还在 kevModifier() 中作为乘积系数再次起作用:

1
2
3
4
5
6
7
8
9
10
11
func kevModifier(m Metadata) float64 {
if len(m.KnownExploited) > 0 {
for _, kev := range m.KnownExploited {
if strings.ToLower(kev.KnownRansomwareCampaignUse) == "known" {
return 1.1 // 勒索软件利用 → 额外的 10% 加成
}
}
return 1.05 // 普通 KEV → 5% 加成
}
return 1.0
}

这里区分了两个层级:

  • 普通 KEV:漏洞被 CISA 确认在野外被利用 → 额外 +5% 风险
  • 勒索软件 KEV:漏洞被已知的勒索软件活动使用 → 额外 +10% 风险

为什么勒索软件打个更高的分?因为勒索软件的利用意味着直接的商业影响——加密数据、停运系统、勒索赎金——这比"一般性的野外利用"更紧急,通常需要立即响应。

几个计算实例

为了帮助理解这三个因子的组合效果,下面给出几个典型场景的计算:

场景一:Critical 漏洞 + EPSS 高概率(无 KEV)

  • Severity: Critical → severityToScore("critical") = 9.0 → 0.9
  • CVSS Base Score: 9.1 → 0.91
  • severity() = average(0.9, 0.91) = 0.905
  • EPSS: 0.24 → threat() = 0.24
  • kevModifier() = 1.0(无 KEV)
  • riskScore = min(0.24 × 0.905 × 1.0, 1.0) × 100 = 21.7

场景二:Medium 漏洞 + 被勒索软件利用(有 KEV)

  • Severity: Medium → severityToScore("medium") = 5.0 → 0.5
  • CVSS Base Score: 5.5 → 0.55
  • severity() = average(0.5, 0.55) = 0.525
  • 有 KEV → threat() = 1.0
  • 勒索软件 → kevModifier() = 1.1
  • riskScore = min(1.0 × 0.525 × 1.1, 1.0) × 100 = 57.75

场景三:High 漏洞 + 无 EPSS(无数据)

  • Severity: High → severityToScore("high") = 7.5 → 0.75
  • CVSS Base Score: 7.8 → 0.78
  • severity() = average(0.75, 0.78) = 0.765
  • 无 KEV,无 EPSS → threat() = 0.0
  • riskScore = min(0.0 × 0.765 × 1.0, 1.0) × 100 = 0.0

关键在于场景二的得分(57.75)远高于场景一(21.7),尽管场景一的 Serity 是 “Critical” 而场景二只是 “Medium”。这正是 Risk Score 的核心价值——它将一个纯理论上的"严重性"评估,综合了真实世界的威胁情报,使得用户看到一个"正在被勒索软件利用的 Medium 漏洞"应该在"一个暂时没有看到利用迹象的 Critical 漏洞"之前获得关注

Risk 的缓存

Risk Score 的计算有缓存机制——第一次计算后存储在 Metadata.risk 字段中:

1
2
3
4
5
6
7
8
func (m *Metadata) RiskScore() float64 {
if m == nil { return 0 }
if m.risk != 0 {
return m.risk // 已计算,直接返回缓存
}
m.risk = riskScore(*m) // 第一次计算,存储结果
return m.risk
}

这是因为 RiskScore() 可能在多处被调用(排序时多次比较、JSON 序列化时输出),而计算结果不会改变,直接返回缓存避免了重复计算。

排序:多维度比较策略

有了 Risk Score 之后,下一个问题是:如何对匹配结果进行排序,让最值得关注的漏洞排在最前面?

Grype 提供了五种排序策略,通过 --sort-by 参数控制。默认策略是 risk

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var matchSortStrategy = map[SortStrategy]sortStrategyImpl{
SortByRisk: {
compareByRisk,
compareBySeverity,
compareByEPSSPercentile,
comparePackageAttributes,
compareByVulnerabilityID,
},
SortBySeverity: {
compareBySeverity,
compareByRisk,
compareByEPSSPercentile,
comparePackageAttributes,
compareByVulnerabilityID,
},
SortByThreat: {
compareByEPSSPercentile,
compareByRisk,
compareBySeverity,
comparePackageAttributes,
compareByVulnerabilityID,
},
SortByKEV: {
compareByKEV,
compareByRisk,
compareBySeverity,
compareByEPSSPercentile,
comparePackageAttributes,
compareByVulnerabilityID,
},
// ... SortByPackage 和 SortByVulnerability
}

多级比较的"平局打破"机制

每种排序策略都不是单一维度的比较,而是一个比较函数的列表——每个比较函数只负责一个维度,如果这个维度上两个结果相等(返回 0),就交给下一个比较函数继续判断。这就是 combine() 函数的作用:

1
2
3
4
5
6
7
8
9
10
11
func combine(impls ...compareFunc) compareFunc {
return func(a, b Match) int {
for _, impl := range impls {
result := impl(a, b)
if result != 0 {
return result // 已经分出大小,立即返回
}
}
return 0 // 所有维度都相等
}
}

以默认的 SortByRisk 为例,比较的执行顺序是:

  1. compareByRisk:先按 Risk Score 降序排列。如果两个漏洞的 Risk Score 不同,排序就结束了——Risk 高的排前面。
  2. compareBySeverity:如果 Risk Score 相同,再按 Severity 降序排列(Critical > High > Medium > Low > Negligible)。
  3. compareByEPSSPercentile:如果 Severity 也相同,按 EPSS 百分位降序排列。
  4. comparePackageAttributes:如果以上风险指标都相同,按包名 → 版 本号 → 包类型排序,确保相同包的漏洞聚合在一起。
  5. compareByVulnerabilityID:最后按漏洞 ID 字符串排序,作为最终的去重标准。

这种多级比较策略确保排序结果是完全确定的——不管重复运行多少次,相同的输入总是产生相同的输出顺序。

五个排序策略的差异

每个策略的"主维度"不同,但它们的 fallback(平局打破)维度存在共同的模式:

策略 主维度 第一候补 第二候补 适用场景
risk(默认) Risk Score Severity EPSS Percentile 日常扫描,关注综合优先级
severity Severity Risk Score EPSS Percentile 关注最严重的漏洞
epss EPSS Percentile Risk Score Severity 关注最可能被利用的漏洞
kev KEV 条目数 Risk Score Severity 关注已知被主动利用的漏洞
package 包名 版本号 包类型 按包分组查看

KEV 排序的比较函数比较特殊——它比较的不是一个数值属性,而是 KEV 条目的数量:

1
2
3
4
5
func compareByKEV(a, b Match) int {
aKEV := len(a.Vulnerability.KnownExploited)
bKEV := len(b.Vulnerability.KnownExploited)
// 更多的 KEV 条目排前面
}

EPSS 排序使用的也不是 EPSS 分数本身,而是Percentile(百分位)——因为 Percentile 能更直观地反映一个漏洞在所有 CVE 中的相对位置:百分位 0.95 意味着"这个漏洞比 95% 的已知漏洞更可能被利用"。

排序的触发时机

排序不是在 Matcher 中进行的,而是在输出阶段——Document 构建过程中:

1
2
3
4
5
6
7
8
9
10
11
func NewDocument(..., strategy SortStrategy, ...) (Document, error) {
// 将 match.Matches 转为 presenter models
for _, m := range matches.Sorted() {
p := pkg.ByID(m.Package.ID, packages)
matchModel, err := newMatch(m, *p, metadataProvider)
findings = append(findings, *matchModel)
}
// 按策略排序
SortMatches(findings, strategy)
// ...
}

注意这里有一个容易被忽略的顺序:matches.Sorted() 是 match 包内部的排序(按包和匹配类型),而 SortMatches() 是在 presenter models 层按用户指定的策略(默认是 risk)重新排序。两层排序各自独立,保证了内部一致性和用户可配置性。

Fail-on:Severity 阈值与退出码

除了在结果排序中起作用外,Severity 还有另一个重要用途:--fail-on 控制扫描的退出码

FindMatchesContext() 完成所有匹配和过滤之后,会检查是否触发失败阈值:

1
2
3
if m.FailSeverity != nil && hasSeverityAtOrAbove(m.VulnerabilityProvider, *m.FailSeverity, *remainingMatches) {
err = grypeerr.ErrAboveSeverityThreshold
}

hasSeverityAtOrAbove() 的逻辑很直接——遍历所有匹配结果,只要发现一个 Severity 大于等于阈值的,就返回 true:

1
2
3
4
5
6
7
8
9
10
11
12
func hasSeverityAtOrAbove(store vulnerability.MetadataProvider, severity vulnerability.Severity, matches match.Matches) bool {
if severity == vulnerability.UnknownSeverity {
return false // 未知的阈值 → 不触发
}
for m := range matches.Enumerate() {
metadata, _ := store.VulnerabilityMetadata(m.Vulnerability.Reference)
if vulnerability.ParseSeverity(metadata.Severity) >= severity {
return true
}
}
return false
}

Severity 类型的值越大表示越严重(CriticalSeverity = 5 > UnknownSeverity = 0),所以直接使用整数比较。例如 --fail-on high 设置阈值为 HighSeverity,任何 Critical 或 High 级别的匹配都会触发非零退出码。

这意味着:--fail-on 只看 Severity 标签,不关心 Risk Score 或 EPSS。这种设计是合理的——CI 流水线需要快速判断"是否有高严重性问题阻止发布",而 Risk Score 主要用于人工审查时的优先级排序。

CWE:被低估的辅助信息

除了 Severity、CVSS、EPSS 和 KEV,Grype 还从数据库中查询了 CWE(Common Weakness Enumeration,通用弱点枚举)数据。CWE 对风险评分没有影响,但它是理解漏洞本质的关键——它回答的是"这是什么类型的弱点"(缓冲区溢出?跨站脚本?SQL 注入?),而非"这个漏洞严重吗"。

1
2
3
4
5
6
type CWEHandle struct {
CVE string
CWE string // 如 "CWE-79"(跨站脚本)
Source string // 数据来源
Type string
}

CWE 不能帮用户决定"先修哪一个",但能帮助用户回答"我们最常见的问题类型是什么",对安全治理和培训有持久的价值。

风险数据的完整旅程

让我们将视角拉远,回顾风险数据从源头到最终排序的完整旅程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
漏洞数据源(NVD、GHSA、Red Hat...)


漏洞数据库构建(vunnel → Grype DB v6)
├── Severities Blob(严重性标签 + CVSS 向量)
├── KEV 表(通过 CVE 关联)
├── EPSS 表(通过 CVE 关联,独立更新频率)
└── CWE 表(通过 CVE 关联)


vulnerabilityProvider.getVulnerabilityMetadata()
├── extractSeverities() → Severity + CVSS
├── fetchKnownExploited() → KEV
├── fetchEpss() → EPSS
└── fetchCWE() → CWE
│ (按 metadataCache 缓存,同一 CVE 只查一次)


vulnerability.Metadata(附着在 Vulnerability 上)


Matcher 匹配,生成 match.Matches


Document 构建
├── newMatch() → Metadata.RiskScore()(计算/缓存 Risk)
└── SortMatches(findings, strategy)(按策略排序)


输出(JSON/Table/CycloneDX...)

从这张全景图中可以看到一些重要的设计原则:

  • 数据与评分分离:原始数据(Severity、CVSS、EPSS、KEV)存储在数据库中,Risk Score 在使用时动态计算——这使得评分算法可以独立于数据库版本迭代。
  • 查询合并metadataCache 按 CVE ID 缓存 Metadata,同一个 CVE 影响多个包时,只需要从数据库查询一次风险数据。
  • 优雅降级无处不在:数据库版本不满足要求时返回 nil 而不报错;EEPS 数据缺失时威胁值按 0 处理;未知 severity 放在中间而不是末尾。每一步都对失败场景做了设计。

总结

Grype 的风险评估系统可以看作三个层次的叠加:

  1. 基础严重性(Severity + CVSS):回答"漏洞技术上的危害有多大"。它来源于漏洞数据库提供方的评估——NVD、GitHub Advisory、Red Hat 安全公告等。Severity 字符串和 CVSS 评分同等重要,取平均以避免偏袒任何一个数据源。

  2. 现实威胁(EPSS + KEV):回答"漏洞在现实世界中被利用的可能性有多大"。EPSS 是统计预测,KEV 是观察证实;一旦被证实,预测就失去意义——威胁值直接设为 1.0。

  3. 综合优先级(Risk Score):Severity × Threat × KEV Modifier,三条输入合并为一个 0-100 的分数。看似简单的乘法,但在每个因子的取值上都有精细的设计决策——KEV 的额外加成(1.05 vs 1.1 区分勒索软件)、severity 的计算(CVSS 和标签取平均)、threat 的 fallback 逻辑(KEV > EPSS > 0)。

这套系统不追求绝对的精确性——漏洞管理领域的"精确"本身就难以定义——而是追求合理的排序,让真正需要关注的漏洞排在前面。一个"正在被勒索软件利用的 Medium 漏洞"得分(57.75)远比一个"暂时没有利用迹象的 Critical 漏洞"得分(21.7)高,正体现了这种设计理念。

掌握了风险评分和排序系统后,下一篇文章将进入 Grype 扫描流程的最后一环——结果的过滤、VEX 处理与报告输出,看 ignore 规则、VEX 文档和多种输出格式如何将"内部匹配结果"转化为用户可操作的"最终报告"。