4. CVE 匹配算法:精确匹配策略、模糊匹配策略、版本范围匹配、正则表达式匹配
好,咱们进入正题。CVE 匹配这件事,说白了就是回答一个问题:「我手里的这个软件版本,到底中没中招?」
听起来简单?我刚开始做漏洞运营那会儿,也觉得不就是字符串比一比嘛。结果第一次上线就出了岔子——一个 OpenSSL 1.0.2g 的漏洞告警,硬是把 1.0.2f 也给标记成了「已命中」。嗯,从那以后我再也不敢小看匹配算法了。
这一章,我把四种主流策略掰开揉碎了讲。每种策略都有它的适用场景,也有它的坑。你想想看,一个 CVE 数据库里几万条记录,匹配错了就是误报或漏报,哪个都够你喝一壶的。
4.1 精确匹配策略
精确匹配,就是最朴素的「字符串完全相等」。CVE ID 本身是精确匹配的典型代表——CVE-2024-1234 就是 CVE-2024-1234,多一个空格都不行。
核心原则:精确匹配只用于标识符(CVE ID、CPE 名称中的 vendor/product 字段),不用于版本号。
为什么版本号不能用精确匹配?我举个例子你就明白了。NVD 里记录的是 1.2.3,你资产里装的是 1.2.3.1——精确匹配直接判「不匹配」,但实际 1.2.3.1 很可能包含 1.2.3 的所有漏洞。这就是典型的漏报。
# 精确匹配示例(Python 伪代码)
def exact_match(cve_cpe, asset_cpe):
return cve_cpe == asset_cpe
# 只适合匹配 CVE ID
cve_id = "CVE-2024-1234"
asset_cve = "CVE-2024-1234"
print(exact_match(cve_id, asset_cve)) # True
| 场景 | 适用性 | 风险 |
|---|---|---|
| CVE ID 匹配 | ✅ 完全适用 | 无 |
| CPE vendor/product | ✅ 适用 | 大小写敏感需预处理 |
| 版本号匹配 | ❌ 不适用 | 漏报风险极高 |
我曾经踩过的坑:有一次用精确匹配去对 Apache Log4j 的版本号,NVD 里写的是 2.17.0,资产里是 2.17.0.1。结果呢?漏报了。从那以后,版本号匹配我再也不敢用精确策略。
4.2 模糊匹配策略
模糊匹配,就是「差不多就行」。它解决的是版本号写法不统一的问题。比如 1.2.3 和 1.2.3.0,在语义上是一样的,但字符串不一样。
我个人习惯用 Levenshtein 距离来做模糊匹配,但要注意阈值设置。设得太松,误报满天飞;设得太紧,又回到精确匹配的老路。
# 模糊匹配示例(基于 Levenshtein 距离)
def fuzzy_match(version_a, version_b, threshold=0.8):
distance = levenshtein_distance(version_a, version_b)
max_len = max(len(version_a), len(version_b))
similarity = 1 - (distance / max_len)
return similarity >= threshold
# 实际使用时要先做版本号标准化
def normalize_version(version):
# 去掉末尾的 .0
while version.endswith('.0'):
version = version[:-2]
return version
我的经验:模糊匹配之前,一定要先做版本号标准化。把 1.2.3.0.0 缩成 1.2.3,把 v1.2.3 去掉 v 前缀。标准化之后,很多模糊匹配的需求就变成了精确匹配。
4.3 版本范围匹配
这是最实用、也是最容易出错的策略。CVE 记录里通常会写「影响版本 < 2.0.0,且 >= 1.0.0」,你需要把这个范围翻译成代码逻辑。
版本范围匹配的核心是版本号比较。但版本号不是简单的数字,它可能是 1.2.3-alpha、2.0.0-rc1、3.1.2+build123。你想想看,1.0.0-alpha 和 1.0.0 哪个大?
版本号比较的黄金法则:先拆分成主版本、次版本、补丁版本、预发布标签、构建元数据,然后按优先级逐段比较。
# 版本范围匹配示例
class VersionRange:
def __init__(self, start, end, start_inclusive=True, end_inclusive=False):
self.start = start
self.end = end
self.start_inclusive = start_inclusive
self.end_inclusive = end_inclusive
def contains(self, version):
if self.start_inclusive:
if compare_versions(version, self.start) < 0:
return False
else:
if compare_versions(version, self.start) <= 0:
return False
if self.end_inclusive:
if compare_versions(version, self.end) > 0:
return False
else:
if compare_versions(version, self.end) >= 0:
return False
return True
# 实际使用
range_cve = VersionRange("1.0.0", "2.0.0")
print(range_cve.contains("1.5.0")) # True
print(range_cve.contains("2.0.0")) # False(默认右开区间)
print(range_cve.contains("0.9.9")) # False
| 范围表示 | 含义 | 示例 |
|---|---|---|
[1.0.0, 2.0.0) |
>= 1.0.0 且 < 2.0.0 | 1.5.0 ✅, 2.0.0 ❌ |
(1.0.0, 2.0.0] |
> 1.0.0 且 <= 2.0.0 | 1.0.0 ❌, 2.0.0 ✅ |
< 1.0.0 |
所有低于 1.0.0 的版本 | 0.9.9 ✅, 1.0.0 ❌ |
= 1.0.0 |
仅精确版本 | 1.0.0 ✅, 1.0.1 ❌ |
我曾经踩过的坑:处理 Spring Framework 的 CVE-2022-22965 时,官方说「影响版本 < 5.3.18」。我用左闭右开区间 [0, 5.3.18),结果把 5.3.17 标记为「已命中」——这是对的。但 5.3.18 本身呢?官方说它已修复,所以 5.3.18 不应该命中。右开区间正好符合这个语义。如果用了闭区间,就会把修复版本也误报进去。
4.4 正则表达式匹配
正则表达式匹配,是处理「非标准版本号」的终极武器。有些软件厂商的版本号写得随心所欲,比如 2024.1.1-beta2、3.2.1.1001、v2.0.0.20240101。这时候,正则表达式就是你的救星。
我个人习惯把正则匹配作为「兜底策略」——先用精确匹配和版本范围匹配,都匹配不上时,再用正则去捞。
# 正则表达式匹配示例
import re
# 匹配常见的版本号模式
VERSION_PATTERNS = [
r'(\d+)\.(\d+)\.(\d+)', # 主.次.补丁
r'(\d+)\.(\d+)\.(\d+)\.(\d+)', # 主.次.补丁.构建
r'v(\d+)\.(\d+)\.(\d+)', # v前缀
r'(\d{4})\.(\d+)\.(\d+)', # 年份.次.补丁
]
def extract_version(version_string):
for pattern in VERSION_PATTERNS:
match = re.search(pattern, version_string)
if match:
return match.groups()
return None
# 实际使用
print(extract_version("v2.0.0.20240101")) # ('2', '0', '0', '20240101')
print(extract_version("2024.1.1-beta2")) # ('2024', '1', '1')
我的建议:正则表达式匹配一定要做「边界限定」。比如 \b\d+\.\d+\.\d+\b 比 \d+\.\d+\.\d+ 更安全,否则 abc1.2.3def 也会被匹配到,这种半截版本号最容易导致误报。
4.5 四种策略的协同使用
实际项目中,我不会只用一种策略。我通常按优先级排一个 pipeline:
- 第一步:精确匹配——处理 CVE ID、CPE vendor/product 等标识符
- 第二步:版本范围匹配——处理标准版本号的范围判断
- 第三步:模糊匹配——处理版本号写法不一致的情况
- 第四步:正则表达式匹配——兜底,处理非标准版本号
为什么要这个顺序?因为精确匹配最快,版本范围匹配最准确,模糊匹配和正则匹配虽然灵活但容易误报。把容易出错的策略放在后面,能最大程度减少误报。
核心要点:四种策略不是互斥的,而是互补的。精确匹配保速度,版本范围匹配保准确,模糊匹配和正则匹配保覆盖。一个成熟的 CVE 匹配引擎,一定是这四种策略的组合体。