做安全工作这么多年,真正让我觉得“数据驱动”这四个字有重量的时刻,不是SOC里堆了多少贵的平台,而是当你把全球公开的漏洞信息全部抓下来、按企业资产的维度整理好之后,每天早晨那封预警邮件带来的确定性。这套基于Python爬虫搭建的企业级漏洞数据采集系统,核心就是解决CVE数据的增量爬取、智能分类、威胁评估、实时预警和可视化分析问题,数据源锁定在NVD和CVE Details两个公开渠道,最后输出一份结构化的威胁情报报告,让安全团队不用再靠人工去翻公告。
如果你是安全工程师、运维负责人或者正在做安全数据产品的开发者,这篇文章值得从头看完。它不会只丢给你一段能跑的代码,而是把整个系统的设计思路、数据源差异、增量同步原理、分类与评分逻辑、预警接入方式,以及我踩过的坑全部拆开讲。项目本身不需要特别高深的算法,主要靠requests、lxml、APScheduler、pyecharts这类常规库就能落地,但对工程细节的要求比较高——比如增量怎么判断、接口限流怎么绕开、CVE编号怎么去重,这些才是决定系统能不能长期稳定跑下去的关键。
1. 整体方案设计:两个数据源怎么选、怎么搭
1.1 核心需求拆解与模块划分
标题里提到的功能点很多,但拆开看其实就是一条流水线:采集、清洗、入库、分析、预警、展示。最忌讳一上来就写爬虫脚本,爬完发现字段对不上、重复数据一堆、调度又没着落。我建议先按模块拆:
- 采集层:负责对接NVD API和CVE Details页面,控制请求频率,做最基本的HTML/JSON解析。
- 存储层:设计CVE主表、分类标签表、预警记录表,增量爬取的元数据表也放在这里。
- 分析层:根据CWE分类、CVSS指标、引用链接做威胁评级,产出结构化威胁情报。
- 预警层:按规则把新增的严重漏洞推送到邮件、钉钉或企业微信机器人。
- 展示层:用Flask+pyecharts搭一个轻量Dashboard,输出漏洞趋势、供应商排行等可视化图表。
这样拆的好处是每个模块都能独立调试。比如NVD接口挂了,采集层单独重试,不会影响分析层和展示层;预警规则要改,直接改分析层的输出逻辑就行。实际开发中我强烈建议用类来封装每一层,不要写成几百行的脚本堆在一个文件里。
1.2 NVD与CVE Details数据源对比
选择数据源是第一个容易踩坑的地方。NVD(National Vulnerability Database)提供官方JSON API,数据字段完整、更新及时,还带CVSS评分和CPE受影响产品信息,是主数据源的不二之选。CVE Details则是一个聚合平台,页面结构适合爬虫抓取,它有按年、按月、按厂商的列表页,历史数据回溯非常方便,适合用来补全NVD中偶尔缺失的CWE编号或漏洞类型描述。
| 对比维度 | NVD API | CVE Details |
|---|---|---|
| 数据格式 | 官方JSON,结构化好 | 需要解析HTML表格 |
| 更新频率 | 实时更新,CVSS数据完整 | 依赖聚合来源,更新略慢 |
| 历史数据 | 支持分页+时间窗口查询 | 按年月归档,翻页方便 |
| 反爬策略 | 接口有限流,无封禁风险 | 页面频率高会触发风控 |
| 适用场景 | 增量同步、全量初始化 | 数据补全、按厂商统计 |
真实项目中我的用法是:初始化全量数据时两个源都跑一遍,互相校验CVE条目数量;日常增量同步以NVD为主,一旦NVD某个时间窗口返回异常或者发现缺失记录,再触发CVE Details的补偿抓取。这种双源融合策略能显著提高数据完整性,但也引入了去重问题——同一个CVE编号在两个源的描述字段可能有细微差别,处理方式放在后面数据库设计部分细说。
2. 增量爬取:不重不漏的关键实现
2.1 增量策略的思路
所谓增量爬取,本质是回答一个问题:上次跑到哪了,这次从哪开始?很多初学爬虫的人会把所有数据全量抓一遍,然后用CVE编号去重。这在数据量小时可行,但NVD目前公开的CVE条目已经超过二十万条,每天还在新增,全量拉取不仅慢,还非常容易被接口限流。
我采用的方案是“时间窗口+本地游标”双保险。NVD API 2.0版本支持lastModStartDate和lastModEndDate参数,可以按修改时间过滤CVE。每次调度任务启动时,读取数据库中记录的last_sync_time,作为本次窗口的起点,终点是当前时间。然后调用API翻页拉取这个窗口内的所有CVE记录。因为NVD的lastModified字段会随任何元数据变更而更新,所以这种方式天然能捕捉到“新增”和“修改”两类变化,比单纯按公开日期判断更可靠。
2.2 数据库表设计与CVE去重
CVE主表是整系统的核心,设计上需要平衡查询性能和灵活性。我实际使用的表结构大概是这样:
CREATE TABLE cve_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, cve_id TEXT UNIQUE NOT NULL, -- CVE-2024-12345 description TEXT, -- 漏洞描述(英文) published_date TEXT, last_modified_date TEXT, cvss_v31_score REAL, cvss_severity TEXT, -- LOW / MEDIUM / HIGH / CRITICAL cvss_vector TEXT, -- CVSS:3.1/AV:N/AC:L/... cwe_id TEXT, -- CWE-79 vuln_type TEXT, -- 智能分类后的漏洞类型 affected_vendor TEXT, affected_product TEXT, exploit_references TEXT, -- 是否含exploit-db等链接 threat_level INTEGER, -- 综合威胁评估分数 source TEXT, -- nvd / cvedetails / merged raw_hash TEXT, -- 原始内容哈希,用于去重 created_at TEXT DEFAULT CURRENT_TIMESTAMP );去重逻辑有个容易忽略的细节:不能只看cve_id,因为同一CVE在NVD和CVE Details的描述可能被截断或改写。我的做法是:先按cve_id做唯一索引,插入时用INSERT OR IGNORE;如果CVE已存在,则对比raw_hash字段(对description+last_modified_date取MD5),哈希不一致说明数据有更新,执行UPDATE。这样既避免重复插入,又不会漏掉字段修正。
2.3 增量任务调度与失败续跑
调度我用的APScheduler,cron表达式每4小时执行一次增量任务。这里要提醒一个实操问题:增量任务必须支持断点续跑。比如窗口内总共有500条数据,API要求每次最多取2000条,看起来一次能拉完,但企业网络环境或者服务偶发超时会让任务中断。如果每次中断后都从头开始,前面处理过的数据等于白跑。
解决方案是维护一个crawl_meta表,记录每个时间窗口的progress偏移量。伪代码如下:
def sync_nvd_window(window_start, window_end): offset = get_meta('nvd_offset', 0) total = None while True: params = { 'lastModStartDate': window_start.isoformat() + '.000', 'lastModEndDate': window_end.isoformat() + '.000', 'startIndex': offset, 'resultsPerPage': 2000 } data = nvd_api_request(params) if total is None: total = data['totalResults'] save_cve_records(data['vulnerabilities']) offset += data['resultsPerPage'] update_meta('nvd_offset', offset) if offset >= total: break这个方案实测下来很稳。哪怕中途断掉,下次启动时offset还在,继续拉剩余部分即可。等窗口跑完,再把last_sync_time更新成新的window_end,归档本次进度。
3. 爬虫核心细节:NVD API与页面解析实战
3.1 NVD API的请求封装与限流规避
NVD API免费Key的速率限制大约是每30秒最多几个请求,不同时段可能有调整。没Key的匿名访问限制更严。所以第一件事:去NVD官网申请一个免费API Key,然后封装请求函数时做好退避重试。
import requests import time from datetime import datetime, timedelta NVD_API = "https://services.nvd.nist.gov/rest/json/cves/2.0" API_KEY = "your-free-api-key" def nvd_api_request(params, max_retries=5): headers = {"apiKey": API_KEY, "User-Agent": "SecurityCrawler/1.0"} for attempt in range(max_retries): try: resp = requests.get(NVD_API, params=params, headers=headers, timeout=30) if resp.status_code == 200: return resp.json() elif resp.status_code == 403: # 限流了,指数退避等待 wait_time = 30 * (attempt + 1) print(f"[NVD] Rate limited, wait {wait_time}s") time.sleep(wait_time) else: resp.raise_for_status() except requests.exceptions.Timeout: print(f"[NVD] Timeout, retry {attempt+1}/{max_retries}") time.sleep(10 * (attempt + 1)) return None有一个坑:NVD返回的JSON里,vulnerabilities数组里每条记录的cve对象结构很复杂,不能只拿顶层字段。实际解析时要深入到cve.metrics.cvssMetricV31[0].cvssData取baseScore和vectorString,再从cve.descriptions里筛选lang == "en"的description。这些字段层级如果没搞清楚,解析代码会频繁抛KeyError。
3.2 CVE Details页面解析:xpath与text函数的实战用法
CVE Details的列表页没有官方API,只能爬HTML。以漏洞列表页为例,每一行包含CVE编号、CWE编号、漏洞类型、公开日期、更新日期、CVSS评分、厂商、产品等列。用requests拉取页面后,配合lxml的xpath解析,这里有一个非常实用的技巧:用xpath的text()函数配合normalize-space()清洗单元格内容。
from lxml import html import requests def parse_cvedetails_page(year, month): url = f"https://www.cvedetails.com/vulnerability-list-{year}-{month}.html" resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=20) tree = html.fromstring(resp.text) rows = tree.xpath("//*[@id='pagetable']/tr") for row in rows[1:]: # 跳过表头 cve_id = row.xpath("./td[1]/a/text()") cwe_id = row.xpath("./td[2]/a/text()") vuln_type = row.xpath("./td[3]/text()") date_pub = row.xpath("./td[4]/text()") cvss = row.xpath("./td[6]/text()") product = row.xpath("./td[9]/a/text()") # 用text()取出来的内容经常带空白符,必须清洗 clean = lambda x: x[0].strip() if x and x[0].strip() else None yield { "cve_id": clean(cve_id), "cwe_id": clean(cwe_id), "vuln_type": clean(vuln_type), "published": clean(date_pub), "cvss_score": clear_cvss(clean(cvss)), "product": clean(product) }很多教程会忽略text()返回的是一个列表,而且原始HTML里单元格内可能有换行符、多个空格,直接取第一项会得到脏数据。我习惯写一个clean函数统一清洗,同时因为CVSS评分一列里可能包含“7.5 5.0”这种多个值的情况,需要单独处理:取第一个数值即可,缺失的置为None,等待NVD数据补齐。CVE Details适合做NVD的补充,而不是替代,因为它的描述字段会截断,而且某些CVE记录的CVSS版本可能混乱。
3.3 双源融合与冲突解决策略
双源融合是项目的加分项,也是复杂度来源。我的策略是:以NVD为准主表数据,CVE Details只补充cwe_id和vuln_type。合并逻辑运行在数据入库后的清洗阶段,避免爬虫代码和分析逻辑耦合。
def merge_cvedetails_record(main_record, supplement_record): if not main_record.get("cwe_id") and supplement_record.get("cwe_id"): main_record["cwe_id"] = supplement_record["cwe_id"] if not main_record.get("vuln_type"): main_record["vuln_type"] = supplement_record["vuln_type"] main_record["source"] = "merged" if supplement_record.get("cwe_id") else "nvd" return main_record这里有个决策值得聊聊:为什么不直接用CVE Details的漏洞类型字段做分类?因为它的类型粒度太粗,很多条目标的是“XSS”或者“Sql Injection”,但也有大量“Others”“Unknown”的脏值。我后面做智能分类时,主要依靠CWE编号映射表,CVE Details的类型文本只作为兜底参考。
4. 智能分类与威胁评估:从原始数据到威胁等级
4.1 基于CWE的漏洞类型映射
CVE数据里的CWE编号,比如CWE-89、CWE-79,是对漏洞根因的标准分类。但CWE编号层多达上千个,对安全运营来说粒度太细了。智能分类要做的是把CWE编号映射到业务视角的大类,比如注入类、跨站脚本类、信息泄露类、权限提升类、拒绝服务类、资源管理类等。
做法很直接:维护一张映射字典,再配合关键词兜底。
CWE_MAPPING = { "CWE-89": "SQL注入", "CWE-78": "命令注入", "CWE-79": "跨站脚本", "CWE-22": "路径遍历", "CWE-502": "反序列化", "CWE-200": "信息泄露", "CWE-269": "权限提升", "CWE-287": "认证绕过", "CWE-400": "拒绝服务", # ... 更多映射 } def classify_vulnerability(cwe_id, description): # 优先使用CWE映射 if cwe_id in CWE_MAPPING: return CWE_MAPPING[cwe_id] # 兜底:从描述中匹配关键词 desc_lower = (description or "").lower() for keyword, category in KEYWORD_CATEGORY_MAP.items(): if keyword in desc_lower: return category return "未分类"KEYWORD_CATEGORY_MAP里我可以放类似"sql"映射到“SQL注入”、"cross-site"映射到“跨站脚本”这样的关键词。这套方案不需要训练模型,纯规则就能覆盖70%以上的CVE,剩下归类为“未分类”的,可以每周人工复核一次,把新出现的漏洞模式补充进映射表。
4.2 CVSS评分指标拆解与威胁等级计算
智能分类解决“是什么”,威胁评估解决“有多危险”。CVSS v3.1评分本身已经给出基础等级:0.1-3.9为Low,4.0-6.9为Medium,7.0-8.9为High,9.0-10.0为Critical。但这只是基础分,不足以反映企业视角下的真实威胁程度。
我在基础分之上叠加了三个维度:可利用性(是否存在公开PoC或Exploit-DB链接)、时效性(最近7天内新发布或更新的漏洞加权)、影响范围(CVSS向量中AV网络可达且PR较低,说明利用门槛低)。综合评分按如下规则计算:
| 维度 | 权重 | 判断条件 | 加成 |
|---|---|---|---|
| CVSS基础分 | 0.6 | baseScore直接参与 | 保留原始分 |
| 可利用性 | 0.2 | references包含exploit | 加分2.0 |
| 时效性 | 0.1 | 发布/修改时间距今天数 < 7 | 加分1.0 |
| 利用门槛 | 0.1 | AV:N与PR:N且UI:N | 加分1.0 |
实际实现时,我用一个函数把CVSS向量里的AV、PR、UI等字段解析出来,组合成“威胁附加分”。最终威胁等级大于等于8.5且CVSS基础分大于等于7.0的,进入严重预警队列;威胁附加分大于等于2的,即使基础分只有6.0,也会触发中危提醒。这套规则的目的,是避免纯粹依赖CVSS导致漏掉那些“分数不高但已经有公开利用工具”的漏洞。
4.3 结构化威胁情报报告生成
报告输出是整个系统的最终落脚点。我的做法不是生成一篇写死的HTML,而是按标准JSON schema输出,方便对接企业内部的工单系统或SIEM。报告里包含:本次采集周期内的新增漏洞总数、按严重等级分布、受影响供应商TOP10、高危漏洞清单(每个漏洞的CVE编号、描述、CVSS向量、参考链接、修复建议)、以及分类图谱。
{ "report_id": "TI-20250110-001", "window": {"start": "2025-01-09T00:00:00", "end": "2025-01-10T00:00:00"}, "cve_count_new": 98, "severity_stats": {"critical": 12, "high": 31, "medium": 38, "low": 17}, "vendor_top10": ["Microsoft", "Google", "Apple", "..."], "critical_list": [ { "cve_id": "CVE-2025-12345", "description": "...", "base_score": 9.8, "threat_extra_score": 2.8, "refs": ["https://...", "https://www.exploit-db.com/..."] } ] }生成JSON之后,再写一个模板渲染函数把JSON转成Markdown或PDF。实际运营中,运维同事更喜欢看Markdown,因为它可以直接粘贴到知识库或聊天工具里。
5. 实时预警与可视化:让数据跑起来
5.1 预警通知渠道接入
预警是“采集系统”里最能体现价值的一环。没有预警,数据入库再多也是死的。我的预警模块设计成多通道分发:邮件、钉钉群机器人、企业微信群机器人。核心是定义统一的告警事件结构,然后各渠道根据结构渲染消息。
钉钉机器人的接入非常简单,只需要在钉钉群添加自定义机器人,拿到webhook地址,然后POST一段JSON:
def send_dingtalk(message_dict): webhook = "https://oapi.dingtalk.com/robot/send?access_token=xxxx" payload = { "msgtype": "markdown", "markdown": { "title": "新增高危漏洞预警", "text": f"### 新增高危漏洞 {message_dict['cve_id']}\n\n" f"- CVSS评分:{message_dict['base_score']}\n" f"- 威胁等级:{message_dict['threat_level']}\n" f"- 描述:{message_dict['description'][:100]}\n\n" f"[查看详情]({message_dict['ref_url']})" } } requests.post(webhook, json=payload, timeout=10)企业微信机器人也是类似套路,只是字段名换成{"msgtype": "markdown"},格式稍微不同。邮件则用smtplib发送HTML正文。有个细节容易被忽略:预警必须做摘要和合并。如果窗口内新增100个高危漏洞,一条条发会瞬间刷屏,最终被管理员设置免打扰。我设置了合并规则:窗口内按严重等级聚合,每30分钟最多推一条,包含TOP5高危漏洞和完整清单的CSV附件。
5.2 可视化Dashboard搭建
可视化我采用的是Flask + pyecharts的组合,因为pyecharts可以直接在Python里生成ECharts的前端代码,不用单独写JS。Dashboard按“概览-趋势-分布”三层组织:
- 概览卡片:今日新增漏洞数、严重漏洞数、待人工复核数。
- 趋势折线图:最近30天每日新增CVE数量,以及严重漏洞趋势线。
- 分布图:漏洞类型饼图、受影响供应商TOP15柱状图、CVSS评分分布散点图。
from pyecharts.charts import Line, Pie, Bar from pyecharts import options as opts def build_trend_chart(days, counts): line = ( Line() .add_xaxis(days) .add_yaxis("新增CVE数", counts, is_smooth=True, markline_opts=opts.MarkLineOpts(data=[opts.MarkLineItem(type_="average")])) .set_global_opts(title_opts=opts.TitleOpts(title="近30天漏洞趋势")) ) return line.render_embed() # 生成HTML片段这里有一个坑:Flask渲染pyecharts时,用render_embed()可以避免磁盘上的临时文件,但每次请求都要重新计算图表,数据量大时响应会慢。我的优化方案是在数据入库后直接提前生成静态HTML片段,缓存到内存或Redis,Dashboard接口只负责读缓存。这样打开页面时基本无感。
5.3 预警与可视化的衔接
预警和可视化不是独立的,很多团队把两者割裂了,导致预警过来还要手动去系统里查详情。我的做法是:预警消息里带上一个/cve/{cve_id}的链接,点击进去是单漏洞详情页,包含完整描述、CVSS向量解析、参考链接、关联的CWE分类。这样运营人员从收到告警到完成判定的路径最短,一篇文章里能讲清楚整个链路,也是系统展示层真正“企业级”的体现。
6. 踩坑实录与常用排查技巧
6.1 高频问题速查表
这套系统上线半年,我维护了一份问题排查清单,在这里完整分享:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| NVD API返回403 | 限流或API Key失效 | 检查请求频率,增加退避时间;确认Key仍有配额 |
| 爬CVE Details偶尔空列表 | 页面结构改版或反爬验证 | 对比浏览器实际HTML,更新xpath路径;加Cookie头 |
| 数据库cve_id冲突 | 主键重复或大小写不一致 | 统一转换为大写再去重;使用INSERT OR IGNORE |
| 增量任务重复跑 | 时间窗口未持久化 | 检查crawl_meta中的last_sync_time是否提交事务 |
| 任务中途崩掉导致数据不全 | 没有断点续跑机制 | 维护offset游标,启动时恢复进度 |
| 邮件预警发不出去 | SMTP服务25端口被运营商封禁 | 换465端口SSL加密,或使用企业邮箱API |
| Dashboard图表不刷新 | 缓存未失效 | 入库任务完成后主动清除缓存键 |
6.2 我列得出来的几个重要经验
第一个经验:不要把爬虫和业务逻辑写在一起。爬虫负责拿数据,清洗入库是另一个模块,这样NVD接口变动时,影响的只是采集层,不会拖垮整个系统。我吃过亏:初版代码把解析逻辑直接写在requests请求后面,结果CVE Details一次页面结构调整,整个任务脚本崩了三天。
第二个经验:请求频率一定要做全局统一控制。不要只在NVD封装里控制limit,CVE Details页面也要限速。我的方案是用一个TokenBucket类统一控制所有出站请求的速率,默认每分钟60个请求,NVD和CVE Details共用这个令牌桶。否则NVD限流刚解决,CVE Details的反爬检测又触发。
第三个经验:测试环境必须和生产环境隔离。我专门搭了一套SQLite版本用于日常联调,生产则用PostgreSQL或MySQL。因为SQLite在并发写入上限制太多,一旦预警模块和采集模块同时操作数据库,会出现数据库被锁的报错。虽然SQLite支持WAL模式能缓解,但当数据量到几十万条后,性能确实扛不住。
第四个经验:日志要留够上下文。很多爬虫脚本出问题后一脸懵,就是因为日志只打了“请求失败”,没记录请求参数和响应状态。我在采集层每个关键节点都记录结构化日志,至少包含:目标URL、请求参数、HTTP状态码、耗时、异常类型。出问题时,第一件事是翻日志,而不是重新跑任务。
6.3 一个小技巧:用测试CVE编号做全链路验证
系统上线前,可以用几个已知的CVE编号做端到端验证。比如用CVE-2023-1234、CVE-2024-5678这些特定编号,在增量窗口内故意包含它们,然后确认整条链路:采集→入库→分类→预警→Dashboard展示。如果某个环节没跑通,马上就能定位。这个方法帮我节省了无数调试时间,强烈建议你在开发阶段就建立一套验证样例。
整体做完这个项目,我个人最大的感受是:安全数据系统真正难的地方从来不是爬虫本身,而是如何让数据在你需要的时候以正确的方式被使用。增量爬取只是敲门砖,后面的清洗规则、分类映射、评分策略,每一个决策都会直接影响漏洞响应的效率和准确度。如果你也打算搭建类似的系统,我建议先用SQLite和单机脚本把整条链路跑通,确认数据流没有任何问题之后,再逐步引入消息队列、分布式采集这些重型组件。在数据量还没起来之前,过度设计才是团队最大的敌人。