Skip to content

Commit bd35123

Browse files
committed
Add CVE list word indexing options
1 parent 72eb6f6 commit bd35123

5 files changed

Lines changed: 152 additions & 10 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ 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. 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`.
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`. Use `--index-words` if you also want to populate `w:<word>` and `s:<word>` from CVE v5 data, and review `set:missing_words_from_cvelistv5` for words that were not already indexed in Valkey.
2828
5. Take a cup of black or green tea ().
2929
6. `python3 ./bin/server.py` to run the local HTTP server.
3030

@@ -204,14 +204,15 @@ cpe (vendor:product) per version to give a probability of the CPE appearance.
204204

205205
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
206206
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.
207+
`rank:vendor_product` sorted sets using the normalized `cpe:2.3:<part>:<vendor>:<product>` tuple. It also records any vendor/product words whose `w:<word>` set does not already exist into `set:missing_words_from_cvelistv5`, and `--index-words` lets the CVE v5 importer populate `w:<word>` and `s:<word>` just like the NVD importer.
208208

209209
### Valkey structure
210210

211211
- `w:<word>` set
212212
- `s:<word>` sorted set with a score depending of the number of appearance
213213
- `rank:cpe` sorted set of common `vendor:product` tuples
214214
- `rank:vendor_product` alias sorted set of the same tuple ranking, populated by both importers
215+
- `set:missing_words_from_cvelistv5` set of vendor/product words seen in CVE v5 CPEs that were not already present as `w:<word>` keys before the record was processed
215216

216217
## License
217218

bin/import_cvelistv5.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
valkey_port = settings.get("valkey.port", 6379)
2323
valkey_db = settings.get("valkey.db", 8)
2424

25+
DEFAULT_MISSING_WORD_SET = "set:missing_words_from_cvelistv5"
26+
2527
rdb = valkey.Valkey(host=valkey_host, port=valkey_port, db=valkey_db)
2628

2729

@@ -40,7 +42,27 @@
4042
"--replace-rank",
4143
action="store_true",
4244
default=False,
43-
help="Delete existing rank:cpe and rank:vendor_product sorted sets before importing.",
45+
help=(
46+
"Delete existing rank:cpe, rank:vendor_product, and the missing-word set "
47+
"before importing."
48+
),
49+
)
50+
argparser.add_argument(
51+
"--index-words",
52+
action="store_true",
53+
default=False,
54+
help=(
55+
"Also index vendor/product words into w:<word> and s:<word> like "
56+
"bin/import.py does."
57+
),
58+
)
59+
argparser.add_argument(
60+
"--missing-word-set",
61+
default=DEFAULT_MISSING_WORD_SET,
62+
help=(
63+
"Valkey set name used to store words seen in CVE v5 CPEs that were "
64+
"not already present in w:<word>."
65+
),
4466
)
4567
args = argparser.parse_args()
4668

@@ -60,18 +82,24 @@
6082
cvelist_file = cvelist_path
6183

6284
if args.replace_rank:
63-
removed = rdb.delete("rank:cpe", "rank:vendor_product")
85+
removed = rdb.delete("rank:cpe", "rank:vendor_product", args.missing_word_set)
6486
print(f"Deleted {removed} existing rank key(s) from the database.")
6587

66-
handler = CVEListV5Handler(rdb)
88+
handler = CVEListV5Handler(
89+
rdb,
90+
index_words=args.index_words,
91+
missing_word_key=args.missing_word_set,
92+
)
6793
label = f"{handler.__class__.__name__}[{os.path.basename(cvelist_file)}]"
6894
print(f"Using {handler.__class__.__name__} to parse file {cvelist_file}...")
6995
handler.parse_file(cvelist_file, label=label)
7096

7197
rank_size = rdb.zcard("rank:cpe")
7298
alias_size = rdb.zcard("rank:vendor_product")
99+
missing_word_count = rdb.scard(args.missing_word_set)
73100
print(
74101
"Done! "
75-
f"{rank_size} vendor/product tuples stored in rank:cpe and "
76-
f"{alias_size} in rank:vendor_product."
102+
f"{rank_size} vendor/product tuples stored in rank:cpe, "
103+
f"{alias_size} in rank:vendor_product, and "
104+
f"{missing_word_count} words tracked in {args.missing_word_set}."
77105
)

lib/cpeimport/base.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,25 @@ def process_rank_batch(self, cpes, rdb=None):
112112
pipeline.execute()
113113
return itemcount, 0
114114

115+
def collect_missing_words(self, words, missing_word_key, rdb=None):
116+
"""Record words that are not yet indexed in Valkey."""
117+
if not missing_word_key or not words:
118+
return 0
119+
120+
client = rdb or self.rdb
121+
pipeline = client.pipeline(transaction=False)
122+
new_words = 0
123+
124+
for word in words:
125+
if client.exists(f"w:{word}"):
126+
continue
127+
pipeline.sadd(missing_word_key, word)
128+
new_words += 1
129+
130+
if new_words:
131+
pipeline.execute()
132+
return new_words
133+
115134
def record_progress(self, itemcount, wordcount):
116135
self.itemcount += itemcount
117136
self.wordcount += wordcount

lib/cpeimport/cvelistv5.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ class CVEListV5Handler(CPEImportHandler):
1515
"Expecting property name enclosed in double quotes",
1616
)
1717

18+
def __init__(
19+
self,
20+
rdb,
21+
index_words=False,
22+
missing_word_key="set:missing_words_from_cvelistv5",
23+
):
24+
super().__init__(rdb)
25+
self.index_words = index_words
26+
self.missing_word_key = missing_word_key
27+
1828
def _parse_impl(self, path):
1929
if not path.endswith(".ndjson"):
2030
raise ValueError(f"Unsupported file type: {path}")
@@ -72,7 +82,16 @@ def _process_record(self, record):
7282
self.skipped += 1
7383
return
7484

75-
itemcount, wordcount = self.process_rank_batch(cpes)
85+
words = sorted(
86+
{word for cpe in cpes for word in self.build_insert_words(cpe)[1]}
87+
)
88+
self.collect_missing_words(words, self.missing_word_key)
89+
90+
if self.index_words:
91+
itemcount, wordcount = self.process_cpe_batch(cpes)
92+
else:
93+
itemcount, wordcount = self.process_rank_batch(cpes)
94+
7695
self.record_progress(itemcount=itemcount, wordcount=wordcount)
7796

7897
def _load_record(self, payload):

tests/test_cvelistv5.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,34 @@ def __init__(self, rdb):
1010
self.rdb = rdb
1111
self.operations = []
1212

13+
def sadd(self, key, value):
14+
self.operations.append(("sadd", key, value))
15+
1316
def zadd(self, key, mapping, incr=False):
1417
self.operations.append(("zadd", key, mapping, incr))
1518

1619
def execute(self):
1720
for operation in self.operations:
18-
_, key, mapping, incr = operation
19-
self.rdb.zadd(key, mapping, incr=incr)
21+
if operation[0] == "sadd":
22+
_, key, value = operation
23+
self.rdb.sadd(key, value)
24+
elif operation[0] == "zadd":
25+
_, key, mapping, incr = operation
26+
self.rdb.zadd(key, mapping, incr=incr)
2027
self.operations.clear()
2128

2229

2330
class FakeRDB:
2431
def __init__(self):
32+
self.sets = {}
2533
self.sorted_sets = {}
2634

2735
def pipeline(self, transaction=False):
2836
return FakePipeline(self)
2937

38+
def sadd(self, key, value):
39+
self.sets.setdefault(key, set()).add(value)
40+
3041
def zadd(self, key, mapping, incr=False):
3142
zset = self.sorted_sets.setdefault(key, {})
3243
for member, score in mapping.items():
@@ -35,6 +46,9 @@ def zadd(self, key, mapping, incr=False):
3546
else:
3647
zset[member] = score
3748

49+
def exists(self, key):
50+
return key in self.sets or key in self.sorted_sets
51+
3852

3953
class CVEListV5HandlerTestCase(unittest.TestCase):
4054
def test_extracts_cpes_from_metadata_and_configurations(self):
@@ -137,6 +151,67 @@ def test_process_ndjson_counts_unique_vendor_product_tuples_per_record(self):
137151
self.assertEqual(
138152
rdb.sorted_sets["rank:vendor_product"]["cpe:2.3:a:acme:widget"], 2
139153
)
154+
self.assertEqual(
155+
rdb.sets["set:missing_words_from_cvelistv5"],
156+
{"acme", "gadget", "widget"},
157+
)
158+
159+
def test_index_words_adds_w_and_s_entries(self):
160+
record = {
161+
"containers": {
162+
"cna": {
163+
"affected": [
164+
{
165+
"cpes": [
166+
"cpe:2.3:a:acme:rocket_launcher:1.0:*:*:*:*:*:*:*",
167+
"cpe:2.3:a:acme:rocket_launcher:2.0:*:*:*:*:*:*:*",
168+
]
169+
}
170+
]
171+
}
172+
}
173+
}
174+
payload = io.StringIO(json.dumps(record))
175+
176+
rdb = FakeRDB()
177+
handler = CVEListV5Handler(rdb, index_words=True)
178+
handler.process_ndjson_file(payload)
179+
180+
self.assertEqual(handler.itemcount, 1)
181+
self.assertEqual(handler.wordcount, 3)
182+
self.assertEqual(rdb.sets["w:acme"], {"cpe:2.3:a:acme:rocket_launcher"})
183+
self.assertEqual(rdb.sets["w:rocket"], {"cpe:2.3:a:acme:rocket_launcher"})
184+
self.assertEqual(rdb.sets["w:launcher"], {"cpe:2.3:a:acme:rocket_launcher"})
185+
self.assertEqual(
186+
rdb.sorted_sets["s:rocket"]["cpe:2.3:a:acme:rocket_launcher"], 1
187+
)
188+
self.assertEqual(
189+
rdb.sorted_sets["rank:cpe"]["cpe:2.3:a:acme:rocket_launcher"], 3
190+
)
191+
192+
def test_only_tracks_missing_words_absent_from_existing_index(self):
193+
record = {
194+
"containers": {
195+
"cna": {
196+
"affected": [
197+
{
198+
"cpes": [
199+
"cpe:2.3:a:acme:widget:1.0:*:*:*:*:*:*:*",
200+
"cpe:2.3:a:acme:gadget:1.0:*:*:*:*:*:*:*",
201+
]
202+
}
203+
]
204+
}
205+
}
206+
}
207+
208+
rdb = FakeRDB()
209+
rdb.sadd("w:acme", "cpe:2.3:a:acme:existing")
210+
rdb.sadd("w:widget", "cpe:2.3:a:acme:existing")
211+
handler = CVEListV5Handler(rdb)
212+
handler.process_ndjson_file(io.StringIO(json.dumps(record)))
213+
214+
self.assertEqual(rdb.sets["set:missing_words_from_cvelistv5"], {"gadget"})
140215

141216
def test_skips_single_invalid_multiline_record_and_continues(self):
142217
record_one = {

0 commit comments

Comments
 (0)