Skip to content

Commit ce76596

Browse files
authored
Merge pull request #24 from vulnerability-lookup/codex/add-cpe-extraction-component-in-cpe-guesser
Add CVE v5 NDJSON rank importer and CVEListV5Handler
2 parents e202bc4 + 4696b38 commit ce76596

8 files changed

Lines changed: 338 additions & 5 deletions

File tree

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@ curl -s -X POST http://localhost:8000/search -d '{"query": ["tomcat"]}' | jq .
2424
1. `git clone https://github.com/cve-search/cpe-guesser.git`
2525
2. `cd cpe-guesser`
2626
3. Download the CPE dictionary & populate the database with `python3 ./bin/import.py` (the NVD JSON importer now uses parallel workers by default; tune with `--workers` and `--batch-size` if needed).
27-
4. Take a cup of black or green tea ().
28-
5. `python3 ./bin/server.py` to run the local HTTP server.
27+
4. Optionally enrich the vendor/product ranking with CVE v5 data using `python3 ./bin/import_cvelistv5.py`. This downloads `./data/cvelistv5.ndjson` only when missing unless you pass `--download`.
28+
5. Take a cup of black or green tea ().
29+
6. `python3 ./bin/server.py` to run the local HTTP server.
2930

3031
If you don't want to install it locally, there is a public online version. Check below.
3132

@@ -201,10 +202,16 @@ Split vendor name and product name (such as `_`) into single word(s) and then ca
201202
the cpe vendor:product format as value and the canonized word as key. Then cpe guesser creates a ranked set with the most common
202203
cpe (vendor:product) per version to give a probability of the CPE appearance.
203204

205+
You can also build the ranking directly from the CVE v5 NDJSON dump. The importer scans the full record recursively so it can pick up
206+
CPE values from both affected-product metadata and vulnerable configuration blocks, then increments the `rank:cpe` and
207+
`rank:vendor_product` sorted sets using the normalized `cpe:2.3:<part>:<vendor>:<product>` tuple.
208+
204209
### Valkey structure
205210

206211
- `w:<word>` set
207212
- `s:<word>` sorted set with a score depending of the number of appearance
213+
- `rank:cpe` sorted set of common `vendor:product` tuples
214+
- `rank:vendor_product` alias sorted set of the same tuple ranking, populated by both importers
208215

209216
## License
210217

bin/import_cvelistv5.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
import argparse
5+
import os
6+
import sys
7+
import urllib.error
8+
9+
import valkey
10+
from dynaconf import Dynaconf
11+
12+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
13+
from lib.cpeimport import CPEDownloader, CVEListV5Handler
14+
15+
# Configuration
16+
settings = Dynaconf(settings_files=["../config/settings.yaml"])
17+
cvelist_path = settings.get("cvelistv5.path", "./data/cvelistv5.ndjson")
18+
cvelist_source = settings.get(
19+
"cvelistv5.source", "https://vulnerability.circl.lu/dumps/cvelistv5.ndjson"
20+
)
21+
valkey_host = settings.get("valkey.host", "127.0.0.1")
22+
valkey_port = settings.get("valkey.port", 6379)
23+
valkey_db = settings.get("valkey.db", 8)
24+
25+
rdb = valkey.Valkey(host=valkey_host, port=valkey_port, db=valkey_db)
26+
27+
28+
if __name__ == "__main__":
29+
argparser = argparse.ArgumentParser(
30+
description="Populate Valkey vendor/product tuple rankings from CVE v5 NDJSON."
31+
)
32+
argparser.add_argument(
33+
"--download",
34+
"-d",
35+
action="store_true",
36+
default=False,
37+
help="Force downloading the CVE v5 NDJSON even if it already exists locally.",
38+
)
39+
argparser.add_argument(
40+
"--replace-rank",
41+
action="store_true",
42+
default=False,
43+
help="Delete existing rank:cpe and rank:vendor_product sorted sets before importing.",
44+
)
45+
args = argparser.parse_args()
46+
47+
if args.download or not os.path.isfile(cvelist_path):
48+
downloader = CPEDownloader(url=cvelist_source, dest_path=cvelist_path)
49+
try:
50+
cvelist_file = downloader.download(force=args.download)
51+
except (
52+
urllib.error.HTTPError,
53+
urllib.error.URLError,
54+
FileNotFoundError,
55+
PermissionError,
56+
):
57+
sys.exit(1)
58+
else:
59+
print(f"Using existing file {cvelist_path} ...")
60+
cvelist_file = cvelist_path
61+
62+
if args.replace_rank:
63+
removed = rdb.delete("rank:cpe", "rank:vendor_product")
64+
print(f"Deleted {removed} existing rank key(s) from the database.")
65+
66+
handler = CVEListV5Handler(rdb)
67+
label = f"{handler.__class__.__name__}[{os.path.basename(cvelist_file)}]"
68+
print(f"Using {handler.__class__.__name__} to parse file {cvelist_file}...")
69+
handler.parse_file(cvelist_file, label=label)
70+
71+
rank_size = rdb.zcard("rank:cpe")
72+
alias_size = rdb.zcard("rank:vendor_product")
73+
print(
74+
"Done! "
75+
f"{rank_size} vendor/product tuples stored in rank:cpe and "
76+
f"{alias_size} in rank:vendor_product."
77+
)

config/settings.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ valkey:
77
cpe:
88
path: './data/nvdcpe-2.0.tar'
99
source: 'https://nvd.nist.gov/feeds/json/cpe/2.0/nvdcpe-2.0.tar.gz'
10+
cvelistv5:
11+
path: './data/cvelistv5.ndjson'
12+
source: 'https://vulnerability.circl.lu/dumps/cvelistv5.ndjson'

lib/cpeimport/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from .base import CPEImportHandler
2+
from .cvelistv5 import CVEListV5Handler
23
from .nvd_json import NVDCPEHandler
34
from .xml_dictionary import XMLCPEHandler
45
from .downloader import CPEDownloader
56

67
__all__ = [
78
"CPEImportHandler",
9+
"CVEListV5Handler",
810
"NVDCPEHandler",
911
"XMLCPEHandler",
1012
"CPEDownloader",

lib/cpeimport/base.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,22 @@ def parse_file(self, filepath, label=""):
3737

3838
def CPEExtractor(self, cpe):
3939
fields = cpe.split(":")
40-
vendor = fields[3]
41-
product = fields[4]
42-
cpeline = ":".join(fields[:5])
40+
if cpe.startswith("cpe:/"):
41+
if len(fields) < 4:
42+
raise ValueError(f"Invalid legacy CPE: {cpe}")
43+
part = fields[1].lstrip("/") or "*"
44+
vendor = fields[2]
45+
product = fields[3]
46+
cpeline = f"cpe:2.3:{part}:{vendor}:{product}"
47+
else:
48+
if len(fields) < 5:
49+
raise ValueError(f"Invalid CPE 2.3 entry: {cpe}")
50+
vendor = fields[3]
51+
product = fields[4]
52+
cpeline = ":".join(fields[:5])
53+
54+
if not vendor or not product:
55+
raise ValueError(f"Invalid vendor/product tuple in CPE: {cpe}")
4356
return {"vendor": vendor, "product": product, "cpeline": cpeline}
4457

4558
def canonize(self, value):
@@ -49,6 +62,7 @@ def insert(self, word, cpe):
4962
self.rdb.sadd(f"w:{word}", cpe)
5063
self.rdb.zadd(f"s:{word}", {cpe: 1}, incr=True)
5164
self.rdb.zadd("rank:cpe", {cpe: 1}, incr=True)
65+
self.rdb.zadd("rank:vendor_product", {cpe: 1}, incr=True)
5266

5367
def build_insert_words(self, cpe):
5468
to_insert = self.CPEExtractor(cpe=cpe)
@@ -73,12 +87,31 @@ def process_cpe_batch(self, cpes, rdb=None):
7387
pipeline.sadd(f"w:{word}", cpeline)
7488
pipeline.zadd(f"s:{word}", {cpeline: 1}, incr=True)
7589
pipeline.zadd("rank:cpe", {cpeline: 1}, incr=True)
90+
pipeline.zadd("rank:vendor_product", {cpeline: 1}, incr=True)
7691
wordcount += 1
7792
itemcount += 1
7893

7994
pipeline.execute()
8095
return itemcount, wordcount
8196

97+
def process_rank_batch(self, cpes, rdb=None):
98+
"""Insert only vendor/product tuple ranking data for a batch of CPEs."""
99+
if not cpes:
100+
return 0, 0
101+
102+
client = rdb or self.rdb
103+
pipeline = client.pipeline(transaction=False)
104+
itemcount = 0
105+
106+
for cpe in cpes:
107+
cpeline = self.CPEExtractor(cpe=cpe)["cpeline"]
108+
pipeline.zadd("rank:cpe", {cpeline: 1}, incr=True)
109+
pipeline.zadd("rank:vendor_product", {cpeline: 1}, incr=True)
110+
itemcount += 1
111+
112+
pipeline.execute()
113+
return itemcount, 0
114+
82115
def record_progress(self, itemcount, wordcount):
83116
self.itemcount += itemcount
84117
self.wordcount += wordcount

lib/cpeimport/cvelistv5.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import json
2+
3+
from .base import CPEImportHandler
4+
5+
6+
class CVEListV5Handler(CPEImportHandler):
7+
"""Handler for CVE Record Format v5 NDJSON exports."""
8+
9+
CPE_PREFIXES = ("cpe:2.3:", "cpe:/")
10+
11+
def _parse_impl(self, path):
12+
if not path.endswith(".ndjson"):
13+
raise ValueError(f"Unsupported file type: {path}")
14+
15+
with open(path, "r", encoding="utf-8") as f:
16+
self.process_ndjson_file(f)
17+
18+
def process_ndjson_file(self, fileobj):
19+
for line_number, line in enumerate(fileobj, start=1):
20+
line = line.strip()
21+
if not line:
22+
continue
23+
24+
try:
25+
record = json.loads(line)
26+
except json.JSONDecodeError as e:
27+
print(f"Skipping invalid NDJSON record on line {line_number}: {e}")
28+
self.skipped += 1
29+
continue
30+
31+
cpes = self.extract_cpes(record)
32+
if not cpes:
33+
self.skipped += 1
34+
continue
35+
36+
itemcount, wordcount = self.process_rank_batch(cpes)
37+
self.record_progress(itemcount=itemcount, wordcount=wordcount)
38+
39+
def extract_cpes(self, record):
40+
cpes = set()
41+
self._collect_cpes(record, cpes)
42+
valid = []
43+
44+
for cpe in cpes:
45+
try:
46+
valid.append(self.CPEExtractor(cpe)["cpeline"])
47+
except (IndexError, ValueError):
48+
self.skipped += 1
49+
50+
return sorted(set(valid))
51+
52+
def _collect_cpes(self, node, cpes):
53+
if isinstance(node, dict):
54+
for value in node.values():
55+
self._collect_cpes(value, cpes)
56+
return
57+
58+
if isinstance(node, list):
59+
for item in node:
60+
self._collect_cpes(item, cpes)
61+
return
62+
63+
if isinstance(node, str) and node.startswith(self.CPE_PREFIXES):
64+
cpes.add(node)

lib/cpeimport/downloader.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ def download(self, force=False):
1818
print(f"Using existing file {self.dest_path} ...")
1919
return self.dest_path
2020

21+
dest_dir = os.path.dirname(self.dest_path)
22+
if dest_dir:
23+
os.makedirs(dest_dir, exist_ok=True)
24+
2125
print(f"Downloading CPE data from {self.url} ...")
2226
download_path = (
2327
self.dest_path + ".gz" if self.url.endswith(".gz") else self.dest_path

0 commit comments

Comments
 (0)