资讯动态

CVE标识符处理全流程:从解析到入库的工程实践

发布时间:2026/10/19 10:22:12 来源:尧图企业网站定制
1. 为什么需要一套完整的CVE标识符处理流程先说说我自己的经历。有段时间我维护一个内部的安全通告平台每天要对接好几个漏洞数据源有官方发布的有第三方厂商推送的也有从开源情报渠道爬下来的。最让人头疼的不是漏洞本身有多复杂而是同一个漏洞在不同数据源里长得完全不一样有些叫“CVE-2024-1234”有些只写了“CVE-1234”还有些干脆把描述文本和编号混在一起甚至同一个CVE会以不同格式重复推送好几遍。那时候我就意识到如果不在入口处把CVE标识符的处理逻辑统一起来后端的存储、检索、关联分析全都会乱套。后来我把整套处理流程梳理成了标准化方案这里整理出来分享给大家。这篇内容主要面向需要自建安全通告系统、漏洞管理平台的开发者也适合那些需要在业务系统里集成漏洞情报数据的PHP工程师。所谓的“CVE标识符完整处理指南”核心就四件事识别、清洗、关联、入库。识别解决的是“这条数据里到底有没有CVE编号”的问题清洗解决的是“编号格式是否规范、能不能直接入库”的问题关联解决的是“这个CVE影响哪些组件、哪个版本”的问题入库解决的是“怎么存、存哪里、怎么去重才能高效查询”的问题。这套流程看起来简单实际落地的时候坑特别多。后面我会从数据格式讲起一步一步把每一环节的实现细节、参数选择和踩坑经验都摊开来说。如果你正在做类似的方向这篇文章应该能帮你省掉不少弯路。2. CVE标识符的基础认知与数据格式2.1 CVE编号的结构与演变CVE编号的全称是Common Vulnerabilities and Exposures由美国MITRE公司维护本质上是给每个公开的安全漏洞分配一个全球唯一的标识符。它的格式经历了几个阶段的变化。2014年之前CVE编号的格式为“CVE-YYYY-NNNN”其中YYYY表示年份NNNN是该年份内的序号。比如CVE-2013-1234就是2013年的第1234号漏洞。2014年之后由于漏洞数量激增MITRE调整了编号规则序号从4位固定长度改成了4位以上可变长度。比如CVE-2024-12345甚至CVE-2024-123456都是合法格式。早期很多系统在处理CVE编号时只匹配4位数字这导致大量新格式的CVE被漏掉或解析错误这是一个很容易踩的坑。除了MITRE官方分配的CVE编号还有一类“保留编号”Reserved由各CVE编号授权机构CNA预先申请但不一定立即公开。在处理这些数据时你可能看到一个CVE只有编号没有详细描述这时候需要根据数据源的字段标记来判断漏洞信息的完整度而不能直接当成解析异常处理。2.2 CVE与CNNVD、CNVD等编号体系的差异在国内的安全通告数据中除了CVE编号还会遇到CNNVD编号和CNVD编号。CNNVD是中国国家信息安全漏洞库的编号体系CNVD是国家信息安全漏洞共享平台的编号体系。同一个漏洞可能同时拥有CVE、CNNVD、CNVD三个编号。我们在设计数据库字段时不能只存一个CVE编号而是要留出多编号映射的扩展空间。举个例子假设某个漏洞三编号映射关系是CVE编号CVE-2024-12345CNNVD编号CNNVD-202401-678CNVD编号CNVD-2024-56789如果只把CVE作为主键后续做关联查询时用户用CNNVD编号就查不到这条漏洞记录。我的建议是设计一张漏洞编号映射表专门存储不同编号体系之间的对应关系不要试图在一张表里用多个字段硬塞。2.3 JSON数据中CVE标识符的常见形态目前主流的数据源都以JSON格式提供漏洞信息。以NVD美国国家漏洞数据库为例其JSON数据中CVE标识符会出现在多个层级。顶层有cve.id字段里面存放的是完整的CVE编号字符串。在cve.references、cve.description等多个子对象中也可能出现CVE编号的引用。而在一些第三方聚合数据源里CVE标识符可能嵌套在更深的层级比如data.items[].cve_id这种结构。海外数据源常见的JSON格式如下{ cve: { id: CVE-2024-12345, sourceIdentifier: cveexample.org, published: 2024-06-01T10:00:00.000, lastModified: 2024-06-10T08:30:00.000, vulnStatus: Analyzed, descriptions: [ { lang: en, value: An issue was discovered... } ], metrics: { cvssMetricV31: [ { source: nvdexample.gov, type: Primary, cvssData: { version: 3.1, baseScore: 9.8, baseSeverity: CRITICAL } } ] } } }而国内一些数据源会用自己的字段名比如vul_id、cve_code、cveNo等。字段名的差异很容易让没有经验的新手在处理时栽跟头。我见过有人在写采集脚本时只适配了某单一数据源的cve.id字段结果切换数据源后整个解析流程全部失效。所以在一开始就要做一个字段映射层把不同数据源的不同字段名统一映射成内部标准字段。这个映射层的设计其实很简单就是维护一个数组$fieldMap [ nvd [id cve.id, desc cve.descriptions[0].value], cnnvd [id vul_id, desc vul_desc], cnvd [id cve_code, desc detail], ];这样后续接新数据源只需要往数组里加一组映射配置而不是去改解析器的核心逻辑。3. 数据获取渠道与采集策略3.1 主流数据源对比与选型CVE数据的获取渠道很多每个渠道的更新频率、数据完整度、接口限制都不一样。我把常用数据源整理成了一个对比表方便你根据实际需求选型。数据源更新频率数据完整度接口限制推荐场景NVD官方JSON每2小时增量更新高包含CVSS评分、参考链接、CPE配置无API Key限流较严格基础数据底座厂商安全公告实时中偏重自家产品漏洞通常无限制产品漏洞专项追踪开源情报聚合源分钟级中来源多元视具体服务而定早期预警、情报补充国内官方漏洞库每日更新中部分编号映射一般较宽松国内合规场景从我的实践经验看NVD的JSON数据是最适合作为底层数据源的因为它的字段规范、结构稳定。但NVD有比较严格的频率限制特别是没有申请API Key的情况下频繁全量拉取容易被封禁IP。厂商安全公告适合做专项监控比如你所在的公司正好大量使用某厂商的产品那就应该把该厂商的公告数据源加进来第一时间感知风险。开源情报聚合源通常更新最快但噪声也大适合做补充而不是主数据源。3.2 增量采集与全量采集的取舍CVE数据采集分为全量采集和增量采集两种模式。全量采集适用于系统初始搭建时需要把历史数据一次性导入。增量采集用于日常运维只拉取新增或变更的数据。NVD官方提供了两种接口按时间范围检索的增量接口以及按CVE编号区间检索的历史接口。增量接口的核心参数是pubStartDate和pubEndDate例如GET /rest/json/cves/2.0?pubStartDate2024-06-01T00:00:00.000pubEndDate2024-06-02T00:00:00.000第一次做全量拉取的时候我建议按年份分段拉不要一口气把几十年的数据全请求完。NVD的接口默认每页最多返回2000条如果某个年份的数据量超过这个数需要带上startIndex参数翻页。我实际拉下来2023年大约有2万多个CVE条目分页拉取大概要请求十几次每次间隔控制在一秒以上否则很容易触发限流。增量采集的难点在于时间粒度的选择。如果你设置一个太大的时间窗口比如拉取最近7天的数据确实不会漏但会产生大量重复数据加重去重压力。如果你把窗口设得太小比如只拉取最近1小时的数据又可能因为数据源更新不及时而漏掉内容。我实测下来合理的做法是设置一个3小时的时间窗口每小时执行一次定时任务这样相邻任务之间有2小时的重叠缓冲既能保证不漏数据又不会产生太多重复。3.3 多数据源合并时的优先级设计当你同时采集多个数据源时同一漏洞可能会以不同格式出现在多个源里。这时候必须设计一个数据源优先级规则。我的做法是以NVD作为基础底库优先采用NVD的字段作为主记录。其他数据源推送的同一条CVE只提取NVD里缺失的补充信息比如国内编号映射、本地化描述等然后合并到主记录中。优先级设计还有一层考虑对于字段冲突的处理。比如NVD里某个CVE的CVSS评分是7.5但厂商安全公告里给的评分是8.1以哪个为准我的经验是不同类型的数据源权威性不同。厂商对自己产品的评分往往更接近真实风险但评分标准可能与NVD不完全一致。稳妥做法是保留多份评分数据在系统前端展示时标注评分来源而不是强行统一成一个值。4. CVE标识符的解析与标准化处理4.1 正则表达式的正确写法CVE标识符的解析是整个流程中最核心的一环。很多人第一反应是用正则表达式去匹配但写出来的正则要么太宽泛、要么太严格。正确适配当前CVE格式的正则表达式是$pattern /\bCVE-(19|20)\d{2}-(?:[1-9]\d{3,}|\d{4,})\b/i;我来拆解一下这个正则的逻辑方便你根据自己的业务场景灵活调整。CVE-后面的年份部分我用(19|20)\d{2}限制了范围只匹配1999到2099年之间的年份。CVE编号是1999年才开始分配的所以这个范围是合理的。序号部分(?:[1-9]\d{3,}|\d{4,})看起来有点复杂它实际表达的是如果首位是0必须有更多位数字来弥补如果首位不为0至少要4位。这覆盖了旧格式的4位固定序号以及2014年之后的可变长度序号比如1234、12345、123456都能匹配但不会错误匹配到CVE-2024-123这种不存在的编号。如果你处理的文本里CVE编号可能和其他数字连在一起比如“CVE-2024-12345::CVE-2024-67890”这时候普通正则可能会把整个串匹配成一个CVE。我的建议是在正则前后加边界断言$pattern /(?![\w\d:])CVE-(19|20)\d{2}-(?:[1-9]\d{3,}|\d{4,})(?![\w\d:])/i;这里的(?![\w\d:])和(?![\w\d:])分别是前后向断言确保CVE编号的前后不是字母数字或冒号避免在连续多个编号的场景下出现误匹配。4.2 提升解析性能的预处理策略正则匹配看起来简单但在海量数据面前性能差距很大。我用一个简单的基准测试对比过不同写法的耗时处理同样一份10万行文本提前用字符串截取定位“CVE-”关键字再做正则校验比直接全文正则匹配快了约30%。具体做法是先用strpos找到文本中所有出现“CVE-”的位置然后从每个位置取一段固定长度的子串比如150个字符再对这个子串做正则匹配。这样做的好处有两个一是减少了正则引擎的搜索范围二是避免了大文本回溯带来的性能损耗。示例代码如下function extractCveIds($text) { $result []; $offset 0; $search CVE-; $len strlen($text); while (($pos stripos($text, $search, $offset)) ! false) { $chunk substr($text, $pos, 150); if (preg_match(/\b(CVE-(19|20)\d{2}-(?:[1-9]\d{3,}|\d{4,}))/i, $chunk, $matches)) { $result[] strtoupper($matches[1]); } $offset $pos 4; } return array_values(array_unique($result)); }需要注意的是从这个函数里返回的是大写CVE编号。在实际应用中数据源里可能出现小写cve-2024-12345的情况所以我在匹配后做了strtoupper转换。统一大小写是标准化中非常基础但特别容易被忽视的一步。4.3 标准化字段设计解析出CVE编号之后下一步是标准化。我建议至少标准化以下几个字段编号本身统一转大写去除首尾空白字符发布时间统一转成ISO 8601标准格式存储到数据库时使用UTC时间描述信息去除超链接URL和特殊符号但保留完整的漏洞描述文本参考链接统一格式化为标准URL去掉UTM跟踪参数发布时间是一个很典型的坑。不同数据源返回的时间格式可能完全不同有Unix时间戳有YYYY-MM-DD有带时区的ISO字符串。PHP的DateTimeImmutable可以很好地处理这些格式转换$time new DateTimeImmutable($rawTime); $standardTime $time-format(Y-m-d\TH:i:s\Z);这里最后的\Z表示UTC零时区。如果原始时间是带时区偏移的比如08:00DateTimeImmutable会自动换算成UTC时间这就保证了数据库里存的时间统一。以后做跨时区的漏洞发布时间对比就不用再担心数据对不上的问题。5. 核心实现CVE数据的存储方案与去重逻辑5.1 数据库表结构设计思路CVE数据的存储方案直接决定了后期的检索效率和数据维护成本。我三次重构了漏洞库的表结构目前的最终方案是“主表 映射表 关联表”的三层架构。主表只存CVE的核心信息每一行代表一条唯一的漏洞记录CREATE TABLE cve_main ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, cve_id VARCHAR(32) NOT NULL UNIQUE, description TEXT, published_date DATETIME, modified_date DATETIME, cvss_score DECIMAL(3,1), cvss_severity VARCHAR(16), raw_data LONGTEXT, source_name VARCHAR(64), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_published (published_date), KEY idx_severity (cvss_severity) );映射表用来存储不同编号体系之间的对应关系CREATE TABLE cve_map ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, cve_id INT UNSIGNED NOT NULL, alias_type VARCHAR(16) NOT NULL, alias_code VARCHAR(64) NOT NULL, UNIQUE KEY uk_type_code (alias_type, alias_code), KEY idx_cve_id (cve_id) );关联表用来存漏洞和受影响组件、产品版本的对应关系这一层对漏洞影响范围的分析至关重要CREATE TABLE cve_affect ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, cve_id INT UNSIGNED NOT NULL, vendor VARCHAR(128), product VARCHAR(128), version_start VARCHAR(64), version_end VARCHAR(64), version_type VARCHAR(16), KEY idx_vendor_product (vendor, product), KEY idx_cve_id (cve_id) );三张表通过cve_id字段的整型主键关联而不是直接用字符串CVE编号关联。这样做的好处是节省存储空间同时让关联查询走索引更快。不过要注意一点cve_main表的cve_id字符串字段仍然要建唯一索引否则无法有效去重。5.2 去重逻辑的正确实现顺序去重是整个入库流程中戏份最多的环节。一个CVE从多个数据源采集进来可能经过多次重复入库去重顺序不对会导致大量的数据覆盖冲突。我总结的实践顺序是先按编号去重再按内容相似度去重。按编号去重相对简单入库前先查cve_main表里是否已存在相同cve_id存在则跳过或更新。这里有个性能优化技巧不要逐条查库而是每次入库前先批量查询function filterExistingCveIds(PDO $pdo, array $cveIds): array { $chunks array_chunk($cveIds, 200); $exists []; foreach ($chunks as $chunk) { $placeholders implode(,, array_fill(0, count($chunk), ?)); $stmt $pdo-prepare(SELECT cve_id FROM cve_main WHERE cve_id IN ($placeholders)); $stmt-execute($chunk); $exists array_merge($exists, $stmt-fetchAll(PDO::FETCH_COLUMN)); } return $exists; }按内容相似度去重主要用于没有CVE编号的漏洞数据比如厂商只用自己内部编号发布的通告。这时候要根据漏洞描述、影响组件、发布时间等多个维度做模糊匹配。实现上有一个简单有效的方法将描述文本去除空格和标点后取其MD5值作为指纹。指纹相同的记录视为同一漏洞合并到同一条记录中。这个方法虽然粗暴但在实际业务场景中命中率很高。5.3 版本号对比的边界问题CVE数据里经常包含受影响的版本范围比如“ 1.0.0, 1.2.3”。要做版本对比PHP的version_compare函数是最常用的工具if (version_compare(1.2.0, 1.2.3, )) { // 1.2.0 在受影响范围内 }但version_compare对版本号中的特殊后缀支持有限比如1.2.3-beta、1.2.3.RELEASE这些格式处理起来容易出偏差。我的经验是抽屉式处理版本号先把版本号拆成主版本、次版本、修订号、预发布标识符四段然后逐段对比。具体的拆解逻辑如下function parseVersion($version) { $parts preg_split(/(\.|-|\)/, strtolower(trim($version))); return [ major isset($parts[0]) ? (int)$parts[0] : 0, minor isset($parts[1]) ? (int)$parts[1] : 0, patch isset($parts[2]) ? (int)$parts[2] : 0, pre isset($parts[3]) ? $parts[3] : ]; }这样处理之后无论原始版本号带什么后缀都能落到一个统一的四段结构里后续做范围判断就非常方便了。这里要特别提醒的是预发布版本在语义化版本规范中优先级低于正式版你在判断漏洞影响范围时要决定是否把预发布版本算进受影响范围。不同业务对这个问题的答案可能不一样建议把版本类型独立成一个字段来记录决策逻辑方便将来追溯。6. 验证测试与持续更新策略6.1 构建一套CVE解析测试用例集解析正则和标准化逻辑写完之后必须用测试用例来验证。我自己的做法是维护了一个名为cve_test_samples.php的测试文件里面包含正常格式、边界格式、干扰格式三类用例。正常格式包括旧4位格式、新5位格式、6位格式。边界格式包括cve编号前面带空格、编号中间有换行符、编号后紧跟标点符号。干扰格式包括类似CVE-2024-12这种缺位数的、CVE-2024-1234-EXTRA这种带扩展后缀的、以及纯数字文本中出现的相似编号模式。每一类用例都对应一个预期输出测试时遍历所有用例把实际输出和预期输出做对比。这个测试文件是实时演进的每当在生产环境里发现一个新的解析错误样本就把它补进测试文件里确保后续重构不会再次引入同样的Bug。6.2 定时采集任务的状态监控采集任务跑起来之后自然会出现各种异常情况。所以监控采集任务的健康状态非常重要。我从五个维度做监控采集频率是否按照计划执行单次采集耗时是否异常升高采集到的数据量是否在合理范围内波动解析失败的条数是否超过阈值入库去重率达到多少用于判断采集窗口设置是否合理这五个维度中解析失败率是最直接的信号。如果单次采集后解析失败超过5%我基本可以断定数据源结构变了或者网络传输出了问题系统会自动发出告警通知。另外采集任务的日志一定要记录原始请求和响应摘要。排查问题时有日志和没日志完全是两种体验。我踩过一次教训某次数据源改了JSON字段结构我的解析脚本完全没报错但解析结果全是空的后来翻日志发现响应里多了一个warnings字段而我的脚本没有处理这个字段导致后续解析全部走进了异常分支。从此以后我所有采集脚本的第一原则就是永远输出原始响应的摘要日志。6.3 长期维护的增量更新策略CVE数据不是采集一次就结束了漏洞的生命周期覆盖了从发现、公开、分析、修补到失效的完整过程。一个CVE的cvss评分、状态字段、受影响的版本范围都可能随时间变化。所以我的增量更新策略是每3小时拉取一次近24小时内修改过的CVE记录对已有的记录做字段级更新而不是直接覆盖整个文档。具体实现上我采用了一种简单但有效的字段级更新方案。对数据库里的每条记录额外存储一个data_hash字段表示整条记录内容的哈希值。增量拉取时如果新数据的哈希值和库里的不一致才执行更新操作。这个方案避免了每次拉取都重复更新全部字段大幅减少了数据库写入压力。哈希值对比还有一个好处它可以精确识别出数据源中对历史记录的微小修改。有一次我发现某个CVE的描述文本里被数据源更正了一个关键词用哈希对比一眼就发现了。这个细节如果靠人工检查是不可能发现的但恰恰是这类信息修正对安全运营很有价值。7. 一些踩过的坑和经验心得最后聊几个实操中容易踩的坑每一个我都付出过真实代价。坑一CVE编号的正则没有加词边界导致JSON里嵌套的CVE编号被解析成多个。这个问题出现的原因是不理解(?!)和(?!)断言的用法后来在正则里补上了边界断言才算彻底解决。坑二不同数据源对同一CVE的描述文本不一致时盲目用新数据覆盖旧数据结果把更完整的中文描述冲掉了。现在的做法是以记录创建时选择的主数据源为基准其他数据源只补充缺失字段不做覆盖。坑三timezone处理不当导致漏洞发布时间全部偏移8小时。问题出在我直接用date函数格式化时间没有指定时区。后来统一改用DateTimeImmutable并且入库前全部转UTC这个问题就再也没出现过。坑四测试环境里造数据时随意用了真实的CVE编号结果测试数据混进了生产库造成线上查出来的某些漏洞信息是虚假的。现在的做法是测试环境一律用保留的测试编号段比如CVE-2099-0001到CVE-2099-9999这个区间在真实数据里永远不会出现。还有一个小技巧如果处理的数据量特别大正则预过滤比完整正则快得多。先用preg_match找一个宽松的初筛模式比如只要字符串里包含“CVE-”就算是候选然后在候选集上跑严格模式。这个两段式过滤在百万级文本处理场景下能省将近一半的CPU时间。最后想特别强调一下CVE标识符处理本质上是一个数据工程问题而不是一个安全问题。你不需要成为漏洞研究专家但你需要对自己处理的每一份数据来源、每一种格式变化、每一个字段含义都足够敏感。保持对数据的好奇心是最好的防坑手段。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑