拿到匹配结果只是"发现漏洞"的终点,"修复漏洞"的起点在哪里?想象这样一个场景:一次扫描产生了 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 | 漏洞数据库 (v6) |
前面分析过的 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 | func extractSeverities(vuln *VulnerabilityHandle) (vulnerability.Severity, []vulnerability.Cvss, error) { |
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 | func interpretCVSSv3Plus(score float64) vulnerability.Severity { |
为什么需要两套映射?因为 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 | func severityToScore(severity string) float64 { |
"取中间值"这个设计值得思考。一个 “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 | func toCvss(severities ...Severity) []vulnerability.Cvss { |
两个设计要点在这里:
- Type 字段反映了评分的优先级。Rank 为 1 的评分被标记为 “Primary”,其余的为 “Secondary”。这保持了向早期 Grype 版本的向后兼容。
- 从向量重新解析 Metrics。数据库中存储了 CVSS 向量字符串,但 Grype 使用
cvss.ParseMetricsFromVector()从向量中重新解析出 BaseScore、ExploitabilityScore 和 ImpactScore。这意味着 Grype 不依赖上游数据库已经计算好的分数,而是基于 CVSS 向量独立计算,避免了存储的不一致。
CVSS 在风险评分中的角色
在 severity() 函数中,CVSS 的 Base Score 和字符串 Severity 被平等对待——取两者的平均值:
1 | func severity(m Metadata) float64 { |
为什么这样设计?注释中给出了解释:有些供应商只更新字符串 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 | type EpssHandle struct { |
EPSS 数据的日期是一个全局属性,存储在 EpssMetadata 表中——所有 EPSS 记录共享同一个数据日期,因为这个数据是由 FIRST 每天整体发布的。这种设计避免了在每个 EPSS 句柄中重复存储相同的日期。
查询时,vulnerabilityProvider 首先提取漏洞的所有 CVE 编号(包括主 ID 和别名),然后逐个查询 EPSS 表:
1 | func (vp vulnerabilityProvider) fetchEpss(cves []string) ([]vulnerability.EPSS, error) { |
EPSS 兼容性控制
数据库的 schema version 决定了 EPSS 是否可用。vulnerabilityDecoratorStore 在初始化时检查版本:
1 | minSupportedEPSSClientVersion := schemaver.New(6, 0, 2) |
如果数据库版本低于 6.0.2,EPSS 数据不可用,但 Grype 不会报错——它会优雅降级,返回 nil 而不报错。这确保了旧版本数据库的兼容性。
KEV:被证实的利用行为
如果说 EPSS 是预测,那么 CISA 的 KEV(Known Exploited Vulnerabilities,已知被利用漏洞)就是证实——它记录了已经在野外被观察到的真实利用行为。
KEV 的数据结构
KEV 不仅仅是"这个漏洞被利用过"的二元标记。它包含了丰富的上下文信息:
1 | type KnownExploitedVulnerabilityBlob struct { |
这些信息在数据库中以 blob 形式存储(通过 KnownExploitedVulnerabilityHandle + BlobID),查询时通过 attachBlobValue() 加载。这种设计将大尺寸、可变的 blob 数据与用于索引的固定字段(cve)分离,在查询效率和数据完整性之间取得了平衡。
KEV 在威胁评估中的优先级
EPSS 的官方使用指南承认:一旦有确凿的证据表明漏洞正在野外被利用,EPSS 的概率预测就失去了参考价值——因为利用的概率已经是 100% 了。这正是 Grype 的实现逻辑:
1 | func threat(m Metadata) float64 { |
threat() 函数中有清晰的优先级:
- 有 KEV → 威胁值 1.0(最高,因为利用已经被证实了)
- 没有 KEV 但有 EPSS → 使用 EPSS 的预测概率
- 两者都没有 → 威胁值 0.0(无可用数据)
这个简单的 fall-through 逻辑体现了一个务实的工程原则:确定性信息优先于统计预测。
Risk Score:多维归一的风险量化
有了 Severity 和 Threat 之后,Grype 将它们合并为一个 0-100 的风险评分。这是整个风险计算的核心公式:
1 | func riskScore(m Metadata) float64 { |
公式中各因子的含义:
| 因子 | 含义 | 取值范围 |
|---|---|---|
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 | func kevModifier(m Metadata) float64 { |
这里区分了两个层级:
- 普通 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 | func (m *Metadata) RiskScore() float64 { |
这是因为 RiskScore() 可能在多处被调用(排序时多次比较、JSON 序列化时输出),而计算结果不会改变,直接返回缓存避免了重复计算。
排序:多维度比较策略
有了 Risk Score 之后,下一个问题是:如何对匹配结果进行排序,让最值得关注的漏洞排在最前面?
Grype 提供了五种排序策略,通过 --sort-by 参数控制。默认策略是 risk:
1 | var matchSortStrategy = map[SortStrategy]sortStrategyImpl{ |
多级比较的"平局打破"机制
每种排序策略都不是单一维度的比较,而是一个比较函数的列表——每个比较函数只负责一个维度,如果这个维度上两个结果相等(返回 0),就交给下一个比较函数继续判断。这就是 combine() 函数的作用:
1 | func combine(impls ...compareFunc) compareFunc { |
以默认的 SortByRisk 为例,比较的执行顺序是:
- compareByRisk:先按 Risk Score 降序排列。如果两个漏洞的 Risk Score 不同,排序就结束了——Risk 高的排前面。
- compareBySeverity:如果 Risk Score 相同,再按 Severity 降序排列(Critical > High > Medium > Low > Negligible)。
- compareByEPSSPercentile:如果 Severity 也相同,按 EPSS 百分位降序排列。
- comparePackageAttributes:如果以上风险指标都相同,按包名 → 版 本号 → 包类型排序,确保相同包的漏洞聚合在一起。
- 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 | func compareByKEV(a, b Match) int { |
EPSS 排序使用的也不是 EPSS 分数本身,而是Percentile(百分位)——因为 Percentile 能更直观地反映一个漏洞在所有 CVE 中的相对位置:百分位 0.95 意味着"这个漏洞比 95% 的已知漏洞更可能被利用"。
排序的触发时机
排序不是在 Matcher 中进行的,而是在输出阶段——Document 构建过程中:
1 | func NewDocument(..., strategy SortStrategy, ...) (Document, error) { |
注意这里有一个容易被忽略的顺序:matches.Sorted() 是 match 包内部的排序(按包和匹配类型),而 SortMatches() 是在 presenter models 层按用户指定的策略(默认是 risk)重新排序。两层排序各自独立,保证了内部一致性和用户可配置性。
Fail-on:Severity 阈值与退出码
除了在结果排序中起作用外,Severity 还有另一个重要用途:--fail-on 控制扫描的退出码。
在 FindMatchesContext() 完成所有匹配和过滤之后,会检查是否触发失败阈值:
1 | if m.FailSeverity != nil && hasSeverityAtOrAbove(m.VulnerabilityProvider, *m.FailSeverity, *remainingMatches) { |
hasSeverityAtOrAbove() 的逻辑很直接——遍历所有匹配结果,只要发现一个 Severity 大于等于阈值的,就返回 true:
1 | func hasSeverityAtOrAbove(store vulnerability.MetadataProvider, severity vulnerability.Severity, matches match.Matches) bool { |
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 | type CWEHandle struct { |
CWE 不能帮用户决定"先修哪一个",但能帮助用户回答"我们最常见的问题类型是什么",对安全治理和培训有持久的价值。
风险数据的完整旅程
让我们将视角拉远,回顾风险数据从源头到最终排序的完整旅程:
1 | 漏洞数据源(NVD、GHSA、Red Hat...) |
从这张全景图中可以看到一些重要的设计原则:
- 数据与评分分离:原始数据(Severity、CVSS、EPSS、KEV)存储在数据库中,Risk Score 在使用时动态计算——这使得评分算法可以独立于数据库版本迭代。
- 查询合并:
metadataCache按 CVE ID 缓存 Metadata,同一个 CVE 影响多个包时,只需要从数据库查询一次风险数据。 - 优雅降级无处不在:数据库版本不满足要求时返回 nil 而不报错;EEPS 数据缺失时威胁值按 0 处理;未知 severity 放在中间而不是末尾。每一步都对失败场景做了设计。
总结
Grype 的风险评估系统可以看作三个层次的叠加:
-
基础严重性(Severity + CVSS):回答"漏洞技术上的危害有多大"。它来源于漏洞数据库提供方的评估——NVD、GitHub Advisory、Red Hat 安全公告等。Severity 字符串和 CVSS 评分同等重要,取平均以避免偏袒任何一个数据源。
-
现实威胁(EPSS + KEV):回答"漏洞在现实世界中被利用的可能性有多大"。EPSS 是统计预测,KEV 是观察证实;一旦被证实,预测就失去意义——威胁值直接设为 1.0。
-
综合优先级(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 文档和多种输出格式如何将"内部匹配结果"转化为用户可操作的"最终报告"。