简介这份资源面向安全研究、漏洞分析与机器学习方向的开发者与学习者围绕NVD与CNNVD两大权威漏洞库展开提供从原始数据到分类模型落地的完整实践素材。包内共54个文件以32个xml漏洞数据文件、11个Python脚本为主辅以xls与xlsx表格、jpg可视化图及vsd架构图压缩包约72.93MB覆盖数据预处理、特征提取与模型训练等环节。脚本涉及停用词处理、TF-IDF、信息增益、DNN分类及多份可视化代码可帮助读者理解漏洞文本清洗、词干提取、标点与数字标准化等流程并据此训练算法模型完成漏洞类别自动识别。目录中同时保留数据集来源与结果图表便于对照实验效果、复现分类流程并分析不同特征选择方法的影响。目前已有192人学习适合希望系统掌握漏洞文本挖掘与分类建模的读者参考。1. 漏洞分类这件事难的不是模型而是数据很多团队第一次做漏洞分类都会掉进同一个坑兴冲冲下载了 NVD 和 CNNVD 的公开数据拿 BERT 一跑发现准确率卡在 70% 上不去换模型、调学习率、加层数折腾两周毫无起色。问题根本不在模型而在数据本身——NVD 的英文描述和 CNNVD 的中文描述混在一起字段结构完全不同漏洞类型标签体系也不一致直接喂给模型就是让它在噪声里找规律。这个方向真正要解决的是把两个来源的漏洞文本统一成可训练的语料再训练一个能自动判断漏洞类型的分类模型。适合谁做安全运营团队想给内部漏洞库自动打标或者做漏洞情报聚合的开发者需要把每天新增的几百条漏洞自动归类到 CWE 或自定义类别下。整条链路的核心工作量七成在预处理三成在建模。2. NVD 与 CNNVD 的数据结构差异与合并策略2.1 两个数据源的字段拆解NVD 的数据以 JSON 为主一条典型的漏洞记录包含id、descriptions数组含lang和value、metricsCVSS 评分、weaknessesCWE 编号、configurations受影响产品等字段。其中descriptions里英文描述的value是我们做文本分类的主要输入weaknesses里的 CWE 编号可以直接作为分类标签。CNNVD 的数据格式相对不统一常见的是 CSV 或 JSON 导出字段一般有漏洞编号、漏洞名称、漏洞描述、危害等级、漏洞类型等。它的漏洞类型是中文标签比如“缓冲区溢出”“跨站脚本”“信息泄露”和 CWE 的英文分类不是一一对应关系。合并时最大的障碍有三个语言不同、标签体系不同、描述粒度不同。NVD 的描述通常是一段完整的技术叙述CNNVD 的描述有时只有一两句话。2.2 统一标签体系的设计我一般会以 CWE 的大类为骨架建一张映射表把 CNNVD 的中文类型映射到 CWE 顶层类别。不需要精确到 CWE-79 这种细粒度映射到 CWE-1000 级别的顶层分类就够了比如“跨站脚本”映射到 CWE-79 所在的“注入类”“缓冲区溢出”映射到“内存安全类”。# 标签映射表CNNVD中文类型 - 统一类别 CNNVD_TO_UNIFIED { 缓冲区溢出: memory_safety, 跨站脚本: injection, SQL注入: injection, 信息泄露: info_leak, 权限提升: privilege_escalation, 拒绝服务: dos, 代码执行: code_execution, 路径遍历: path_traversal, 资源管理错误: resource_mgmt, 输入验证错误: input_validation, } # NVD CWE - 统一类别取CWE编号前几位做粗粒度映射 def cwe_to_unified(cwe_id): 将CWE编号映射到统一类别 cwe_str str(cwe_id) # CWE-79/89/94 等属于注入类 if cwe_str in (79, 89, 94, 77, 78): return injection # CWE-119/120/125/787 等属于内存安全 if cwe_str in (119, 120, 125, 787, 121): return memory_safety # CWE-200/209 信息泄露 if cwe_str in (200, 209, 532): return info_leak # CWE-269/264 权限问题 if cwe_str in (269, 264, 250): return privilege_escalation # CWE-399 资源管理 if cwe_str in (399, 400, 404): return resource_mgmt # CWE-22 路径遍历 if cwe_str 22: return path_traversal return other这段映射逻辑的关键在于不要试图做精确的一对一映射而是做粗粒度归类。实际项目中10 到 15 个统一类别已经能覆盖 90% 以上的漏洞样本。映射表需要根据你手头数据的实际分布来调整不是固定的。2.3 合并后的数据格式约定合并后的每条样本统一成三个字段text漏洞描述文本、label统一类别、source来源标记nvd 或 cnnvd。source字段在后续分析模型偏差时很有用——如果模型在 CNNVD 来源的样本上表现明显差说明中文描述的预处理还需要加强。import json def merge_datasets(nvd_records, cnnvd_records): 合并NVD和CNNVD数据为统一格式 unified [] for rec in nvd_records: # 提取英文描述 desc for d in rec.get(descriptions, []): if d.get(lang) en: desc d.get(value, ) break if not desc: continue # 提取CWE标签 weaknesses rec.get(weaknesses, []) label other for w in weaknesses: for d in w.get(description, []): cwe_id d.get(value, ).replace(CWE-, ) if cwe_id.isdigit(): label cwe_to_unified(cwe_id) break unified.append({text: desc, label: label, source: nvd}) for rec in cnnvd_records: desc rec.get(漏洞描述, ) or rec.get(description, ) vuln_type rec.get(漏洞类型, ) or rec.get(type, ) if not desc: continue label CNNVD_TO_UNIFIED.get(vuln_type, other) unified.append({text: desc, label: label, source: cnnvd}) return unified合并时要注意去重同一个漏洞可能在两个库都有收录用漏洞编号做去重键优先保留描述更详细的那条。实际跑下来NVD 和 CNNVD 的重叠率大概在 15% 到 25% 之间取决于你拉取的时间窗口。3. 漏洞文本预处理从原始描述到模型输入3.1 文本清洗的五个必做步骤漏洞描述文本里充满了对分类无用的噪声。我一般按以下顺序清洗第一步去除 HTML 标签和转义字符。NVD 的描述里偶尔会嵌入这类转义CNNVD 导出时也可能带 HTML 片段。第二步统一中英文标点。中文描述里的全角逗号、句号要转成半角否则分词时会出问题。第三步去除版本号和具体路径。像“Apache HTTP Server 2.4.49 版本中 /server/path 路径下”这类信息对判断漏洞类型没有帮助反而会引入噪声。用正则把版本号模式和文件路径模式替换掉。第四步处理缩写和专有名词。比如“XSS”统一展开为“cross site scripting”“RCE”展开为“remote code execution”这样中英文样本在语义空间里更接近。第五步截断过长文本。漏洞描述超过 512 个 token 的部分直接截断因为大部分关键信息在前半段。import re def clean_vuln_text(text): 漏洞文本清洗流水线 if not text: return # 1. 去除HTML标签 text re.sub(r[^], , text) # 2. 反转义 text text.replace(lt;, ).replace(gt;, ).replace(amp;, ) # 3. 全角标点转半角 text text.replace(, ,).replace(。, .).replace(, ;) text text.replace(, :).replace(, ().replace(, )) # 4. 去除版本号如 2.4.49、v1.2.3 text re.sub(r\bv?\d\.\d(\.\d)*\b, , text) # 5. 去除文件路径 text re.sub(r[/\\][\w/\\.-]{3,}, , text) # 6. 缩写展开 abbrev_map { r\bXSS\b: cross site scripting, r\bRCE\b: remote code execution, r\bSQLi\b: sql injection, r\bDoS\b: denial of service, r\bLPE\b: local privilege escalation, } for pattern, replacement in abbrev_map.items(): text re.sub(pattern, replacement, text, flagsre.IGNORECASE) # 7. 合并多余空白 text re.sub(r\s, , text).strip() return text清洗顺序不能乱。如果先去空白再展开缩写缩写展开后可能又产生多余空格。版本号去除要放在路径去除之前因为路径里可能包含版本号先去掉版本号能让路径正则更干净。3.2 中文分词与英文Tokenize的并行处理中英文混合语料不能只用一种分词器。我的做法是对中文文本用 jieba 分词后空格连接对英文文本直接用空格切分加小写化然后统一送入同一个词表。import jieba def tokenize_mixed(text, langzh): 中英文混合分词 if lang zh: # 中文用jieba精确模式 tokens jieba.lcut(text) # 过滤单字和停用词 stopwords {的, 了, 在, 是, 和, 与, 或, 被, 把, 对} tokens [t for t in tokens if len(t) 1 and t not in stopwords] return .join(tokens) else: # 英文小写化后按空格切分 return .join(text.lower().split())jieba 分词有个坑安全领域的专有名词经常被切碎比如“跨站脚本”可能被切成“跨站”和“脚本”。解决办法是加载自定义词典把常见漏洞类型名称加进去。# 加载安全领域自定义词典 security_terms [ 跨站脚本, 缓冲区溢出, 权限提升, 拒绝服务, 代码执行, 路径遍历, 信息泄露, 输入验证, 资源管理, SQL注入, 命令注入, 整数溢出 ] for term in security_terms: jieba.add_word(term)3.3 构建词表与序列填充清洗和分词完成后需要构建词表并把文本转成固定长度的 ID 序列。词表大小一般控制在 30000 到 50000 之间太小会丢信息太大会增加嵌入层参数量。from collections import Counter def build_vocab(tokenized_texts, max_vocab40000, min_freq2): 构建词表 counter Counter() for text in tokenized_texts: counter.update(text.split()) # 按频率排序过滤低频词 vocab {PAD: 0, UNK: 1} idx 2 for word, freq in counter.most_common(max_vocab): if freq min_freq: break vocab[word] idx idx 1 return vocab def encode_text(text, vocab, max_len256): 将文本编码为固定长度的ID序列 tokens text.split() ids [vocab.get(t, vocab[UNK]) for t in tokens] # 截断或填充 if len(ids) max_len: ids ids[:max_len] else: ids ids [vocab[PAD]] * (max_len - len(ids)) return idsmax_len设 256 是个经验值。我统计过一批漏洞描述的长度分布90% 的样本在 200 个 token 以内256 能覆盖 95% 以上。设 512 会显著增加训练时间但精度提升不到 1 个百分点。4. 漏洞分类模型的训练与调参4.1 模型选型TextCNN 还是 BERT如果样本量在 5 万条以下TextCNN 加预训练词向量的组合性价比最高训练快、显存占用小、调参简单。如果样本量超过 10 万条且有 GPU 资源用 BERT 类预训练模型微调效果更好但要注意中文和英文样本可能需要不同的预训练模型。我一般先用 TextCNN 跑一个基线确认数据预处理没有大问题再上 BERT 做提升。TextCNN 的基线准确率如果在 80% 以上说明数据质量过关如果只有 60% 多先回去检查标签映射和文本清洗。import torch import torch.nn as nn class VulnTextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_classes10, filter_sizes(2, 3, 4, 5), num_filters128, dropout0.3): super().__init__() # 嵌入层 self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) # 多尺度卷积 self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (fs, embed_dim)) for fs in filter_sizes ]) self.dropout nn.Dropout(dropout) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) def forward(self, x): # x: (batch, seq_len) emb self.embedding(x) # (batch, seq_len, embed_dim) emb emb.unsqueeze(1) # (batch, 1, seq_len, embed_dim) # 每个卷积核做最大池化 conv_outs [] for conv in self.convs: c torch.relu(conv(emb)) # (batch, num_filters, seq_len-fs1, 1) c c.squeeze(3) c torch.max_pool1d(c, c.size(2)).squeeze(2) conv_outs.append(c) out torch.cat(conv_outs, dim1) out self.dropout(out) return self.fc(out)filter_sizes设 (2,3,4,5) 是为了捕捉不同长度的 n-gram 特征。漏洞描述里“缓冲区溢出”是 4 个字的短语“SQL 注入”是 2 个词多尺度卷积能同时覆盖。num_filters每个尺度 128 个四个尺度共 512 维特征对 10 到 15 个类别来说足够了。4.2 训练循环与关键超参数from torch.utils.data import DataLoader, TensorDataset def train_model(model, train_ids, train_labels, val_ids, val_labels, epochs20, batch_size64, lr1e-3): 训练漏洞分类模型 device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) train_ds TensorDataset( torch.LongTensor(train_ids), torch.LongTensor(train_labels)) train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue) optimizer torch.optim.Adam(model.parameters(), lrlr) criterion nn.CrossEntropyLoss() scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size5, gamma0.5) best_val_acc 0.0 for epoch in range(epochs): model.train() total_loss 0 for batch_x, batch_y in train_loader: batch_x, batch_y batch_x.to(device), batch_y.to(device) optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() scheduler.step() # 验证 model.eval() with torch.no_grad(): val_tensor torch.LongTensor(val_ids).to(device) val_logits model(val_tensor) preds val_logits.argmax(dim1).cpu().numpy() val_acc (preds val_labels).mean() if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_vuln_model.pt) print(fEpoch {epoch1}, Loss: {total_loss/len(train_loader):.4f}, fVal Acc: {val_acc:.4f}) return best_val_acc几个关键参数的经验值学习率 1e-3 配 Adam 是安全起点如果 loss 震荡明显降到 5e-4。batch_size64 在 8GB 显存上跑 TextCNN 绰绰有余BERT 的话要降到 16 或 32。梯度裁剪的max_norm设 5.0漏洞文本里偶尔有超长样本不裁剪容易梯度爆炸。4.3 类别不平衡的处理漏洞数据天然不平衡——“注入类”和“内存安全类”样本多“路径遍历”和“资源管理”样本少。直接训练会导致模型偏向多数类。我一般用加权交叉熵权重取类别频率的倒数。def compute_class_weights(labels, num_classes): 计算类别权重用于加权损失 counts np.bincount(labels, minlengthnum_classes).astype(float) # 避免除零 counts np.maximum(counts, 1.0) weights len(labels) / (num_classes * counts) return torch.FloatTensor(weights) # 使用时 class_weights compute_class_weights(train_labels, num_classes10) criterion nn.CrossEntropyLoss(weightclass_weights.to(device))如果某个类别样本少于 100 条加权损失也救不回来需要考虑过采样或直接合并到“其他”类别。我的经验是每个类别至少 200 条样本模型才能学到稳定特征。5. 避坑与排查漏洞分类项目里最容易翻车的五个地方5.1 标签映射错误导致模型学偏现象训练准确率很高但验证集上某些类别 F1 始终为 0。原因CNNVD 的中文类型映射到统一类别时映射表写错了。比如把“权限提升”映射到了“信息泄露”模型学到的是错误对应关系。解决映射表建好后抽样 50 条人工核对。特别检查那些中文类型名称相近的比如“信息泄露”和“信息披露”可能是同一个意思但映射表里只写了一个。5.2 中英文样本分布不均导致模型偏向现象模型在英文样本上准确率 90%中文样本上只有 65%。原因NVD 数据量远大于 CNNVD且英文描述更长、信息更丰富模型倾向于学英文特征。解决对中文样本做上采样或者在损失函数里给中文样本更高权重。更彻底的做法是分别训练中英文模型推理时根据输入语言路由到对应模型。5.3 文本清洗过度丢失关键信息现象清洗后模型准确率反而下降了 5 个百分点。原因版本号去除的正则太激进把“CVE-2021-44228”这种漏洞编号也去掉了而编号本身可能和漏洞类型有相关性。解决清洗规则要逐条验证。我的做法是保留一份未清洗的原始文本清洗后对比模型效果如果下降就回退最近加的规则。版本号去除只针对“数字.数字”模式不要匹配“CVE-”开头的编号。5.4 词表太小导致大量 UNK现象训练时 loss 下降很慢模型学不到有效特征。原因max_vocab设了 10000但安全领域词汇量大很多专业术语被映射成了UNK。解决统计 UNK 比例如果超过 5% 就扩大词表。安全领域的词表建议至少 30000。另外min_freq设 2 比较合适设 5 会过滤掉太多低频但重要的专业词。5.5 验证集泄露导致虚高准确率现象验证集准确率 95%上线后实际只有 70%。原因划分训练集和验证集时没有按漏洞编号去重同一个漏洞的不同描述版本分别进了训练集和验证集。解决划分前先按漏洞编号去重确保同一个编号只出现在一个集合里。另外如果数据有时间维度按时间划分比随机划分更接近真实场景。6. 用混淆矩阵和错误样本分析反推数据质量模型训练完之后不要只看准确率。我习惯先画混淆矩阵找出哪些类别之间容易混淆。比如“注入类”和“代码执行类”经常互相误判因为很多注入漏洞最终导致代码执行描述文本高度重叠。from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt def analyze_errors(model, val_ids, val_labels, label_names, device): 分析模型错误分布 model.eval() with torch.no_grad(): logits model(torch.LongTensor(val_ids).to(device)) preds logits.argmax(dim1).cpu().numpy() # 打印分类报告 print(classification_report(val_labels, preds, target_nameslabel_names)) # 混淆矩阵 cm confusion_matrix(val_labels, preds) plt.figure(figsize(10, 8)) sns.heatmap(cm, annotTrue, fmtd, xticklabelslabel_names, yticklabelslabel_names, cmapBlues) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150) # 找出错误样本 error_idx np.where(preds ! val_labels)[0] return error_idx拿到错误样本索引后逐条看文本内容。如果发现某类错误集中出现比如所有“路径遍历”被误判为“信息泄露”说明这两类的描述文本在预处理后区分度不够需要补充特征或者调整标签定义。一个实用的技巧把错误样本的文本单独拿出来用 TF-IDF 提取关键词看看模型到底在依赖哪些词做判断。如果发现模型依赖的是“版本”“系统”这类无关词说明清洗还不够彻底。最后说一个我踩过的坑有次模型在验证集上 F1 到了 0.92我直接部署了结果线上跑了三天发现“拒绝服务”类漏洞全部漏判。回头查原因是验证集里“拒绝服务”样本只有 8 条随机划分时全进了训练集验证集里一条都没有。从那以后我养成了一个习惯划分数据集后先打印每个类别的样本数确认验证集里每个类别至少有 20 条。这个检查花不了一分钟但能省掉三天后的后悔药。希望帮到你。本文还有配套的精品资源点击获取