CWE Classifier: Improvements Report#
Technical report on changes made to the CWE classification pipeline (dataset
generation, CWE knowledge base, and cwe_guesser_patches.py trainer) following
the analysis in VulnTrain#17.
Context#
An external review of the CWE suggestions surfaced through VLAgentIc noted that most suggested CWEs were Discouraged for mapping by MITRE (CWE-703, CWE-707, CWE-284, …) while obvious, Allowed candidates (such as CWE-620 for an unverified password change) were missed.
The root cause was in the training labels: the trainer mapped every CWE of the
dataset to its deepest ancestor via deep_child_to_ancestor.json, and those
deep ancestors are mostly Pillars and high-level Classes — exactly the entries
MITRE marks as Discouraged. The model could only ever predict discouraged
CWEs.
Changes#
Label set restricted to allowed CWEs#
The CWE knowledge base is now refreshed from the
Vulnerability-Lookup API by
tools/cwe/update_cwe_knowledge_base.py, which stores each CWE’s
Mapping_Notes usage (Allowed, Allowed-with-Review, Discouraged,
Prohibited) in tools/cwe/cwe_usage.json. As of July 2026: 749 Allowed,
93 Allowed-with-Review, 44 Discouraged, 83 Prohibited.
tools/cwe/build_child_to_ancestor.py then regenerates
vulntrain/data/deep_child_to_ancestor.json: each CWE is mapped to its
highest ancestor whose usage is Allowed or Allowed-with-Review. The label
set went from 26 discouraged deep ancestors to 303 allowed CWEs. Since a
classifier can only predict labels it was trained on, the model structurally
cannot suggest a Discouraged or Prohibited CWE anymore.
90 CWEs have no allowed entry anywhere on their ancestor path (including common ones like CWE-20, CWE-200, CWE-287, CWE-400 — this is MITRE’s actual guidance). Examples carrying only such CWEs are excluded from training rather than being mapped to a discouraged label.
Dataset generation fixes#
Two bugs in vulntrain/datasets/cwe-guesser-dataset.py reduced the quantity
and quality of the training data:
Stale CWE labels (cvelistv5): the
cwevariable was only assigned when a record hadproblemTypesdescriptions, so records without them silently inherited the CWE of the previously processed vulnerability. Each record now gets a fresh list, allproblemTypesdescriptions are collected (not just the first of the first), and the structuredcweIdis preferred over the free-text description.GHSA examples silently dropped: GHSA records stored their CWEs in a separate
cwescolumn while the trainer only readscwe, so no GHSA example ever contributed to training. Both sources now emit acwecolumn as a list of strings.
Because the cwe column type changed, the
CIRCL/vulnerability-cwe-patch
dataset must be rebuilt once with the new --from-scratch flag:
python vulntrain/datasets/cwe-guesser-dataset.py --sources cvelistv5,github,pysec,csaf_redhat --repo-id=CIRCL/vulnerability-cwe-patch --from-scratch
(The flag was added together with a fix to the incremental push: the previous
concatenation call used a non-existent Dataset.concatenate method, whose
AttributeError was silently swallowed — appends actually replaced the
dataset.)
Top-k accuracy metrics#
The model is consumed as a suggester of candidate CWEs, so whether the right
CWE appears among the top suggestions matters more than exact top-1 accuracy —
especially with 303 fine-grained classes. compute_metrics now reports
accuracy_top3 and accuracy_top5 alongside accuracy and macro F1, and the
best checkpoint is selected by f1_macro instead of eval loss
(metric_for_best_model).
Long-context input and ModernBERT#
The input is a vulnerability description plus commit messages and diffs, but only the first patch was used and everything was truncated at 512 tokens (roberta-base’s limit), so most of the patch content was never seen by the model. Changes:
all patches of a record are concatenated into the input text;
the tokenizer truncation length defaults to the model’s maximum input length (capped at 8192), overridable with
--max-length;padding is now dynamic per batch (
DataCollatorWithPadding) instead of padding everything to the maximum length.
This makes ModernBERT-base (8192-token context, code-aware pretraining) the recommended base model:
vulntrain-train-cwe-classification --base-model answerdotai/ModernBERT-base --dataset-id CIRCL/vulnerability-cwe-patch --repo-id CIRCL/vulnerability-cwe-classification-modernbert-base --batch-size 8
Lower the batch size compared to roberta-base: sequences are up to 16× longer.
Class weighting strategies#
With 303 heavily imbalanced classes, fully balanced class weights (the
previous hard-coded behaviour) over-boost classes with a handful of examples.
The --class-weights flag from the CNVD severity trainer was ported:
none (uniform loss), sqrt (sqrt-dampened weights), balanced (previous
behaviour, still the default) and focal (focal loss, gamma=2.0, balanced
alpha). Worth comparing sqrt and focal against the balanced baseline.
Baseline to beat#
First model trained with the allowed-only label set (roberta-base, 40 epochs, balanced weights, 512-token context, before the dataset regeneration):
Metric |
Value |
|---|---|
Accuracy |
0.5683 |
F1 macro |
0.2101 |
Labels |
303 |
Train examples |
6,460 |
The accuracy/F1-macro gap shows the long tail of rare CWEs is barely learned. Expected levers, in order: dataset regeneration (recovers all GHSA examples and fixes mislabeled cvelistv5 rows), long-context ModernBERT (sees the full patches), then class-weighting experiments.
Possible future work#
Multi-label training: records often carry several CWEs; the trainer currently keeps only the first mappable one. Sigmoid multi-label targets would use all of them and match reality (a vulnerability can legitimately be CWE-620 and CWE-640).
Minimum label support: collapse labels with fewer than ~5 training examples out of the label set instead of teaching the model classes it cannot learn.
Hierarchy-aware evaluation: report a soft accuracy where predicting an ancestor or descendant of the reference CWE counts as partially correct.