Skip to content

Commit 23c0923

Browse files
authored
Merge pull request #32 from vulnerability-lookup/codex/add-optional-filter-for-cpe-part-parameter
Add optional CPE part filtering to search, unique, and CLI
2 parents b3749ff + d3a652d commit 23c0923

5 files changed

Lines changed: 105 additions & 8 deletions

File tree

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ Alternatively, you can call the web server (after running `server.py`). For exam
1717

1818
```bash
1919
curl -s -X POST http://localhost:8000/search -d '{"query": ["tomcat"]}' | jq .
20+
21+
# Optional CPE part filter (a=application, h=hardware, o=operating system)
22+
curl -s -X POST http://localhost:8000/search -d '{"query": ["cisco", "router"], "part": "h"}' | jq .
23+
24+
# GET is also supported
25+
curl -s "http://localhost:8000/search?q=cisco+router&part=h" | jq .
2026
```
2127

2228
### Installation
@@ -93,16 +99,17 @@ curl -s -X POST https://cpe-guesser.cve-search.org/unique -d "{\"query\": [\"out
9399
### Command line - `lookup.py`
94100

95101
```text
96-
usage: lookup.py [-h] [--unique] WORD [WORD ...]
102+
usage: lookup.py [-h] [--unique] [--part {a,h,o}] WORD [WORD ...]
97103
98104
Find potential CPE names from a list of keyword(s) and return a JSON of the results
99105
100106
positional arguments:
101107
WORD One or more keyword(s) to lookup
102108
103109
options:
104-
-h, --help show this help message and exit
105-
--unique Return the best CPE matching the keywords given
110+
-h, --help show this help message and exit
111+
--unique Return the best CPE matching the keywords given
112+
--part {a,h,o} Optional CPE part filter: a (application), h (hardware), o (operating system)
106113
```
107114

108115
```bash

bin/lookup.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@
2727
help="Return the best CPE matching the keywords given",
2828
default=False,
2929
)
30+
parser.add_argument(
31+
"--part",
32+
choices=sorted(CPEGuesser.VALID_CPE_PARTS),
33+
help="Optional CPE part filter: a (application), h (hardware), o (operating system)",
34+
)
3035
args = parser.parse_args()
3136

3237
cpeGuesser = CPEGuesser()
33-
r = cpeGuesser.guessCpe(args.word)
38+
r = cpeGuesser.guessCpe(args.word, part=args.part)
3439
if not args.unique:
3540
print(json.dumps(r))
3641
else:

bin/server.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,26 @@
1919

2020

2121
class Search:
22+
def _is_valid_part(self, part):
23+
return part in CPEGuesser.VALID_CPE_PARTS
24+
25+
def _split_query(self, query):
26+
return [keyword for keyword in query.split() if keyword]
27+
28+
def _search(self, query, part, resp):
29+
if not query:
30+
resp.status = falcon.HTTP_400
31+
resp.media = "Missing query array or incorrect JSON format"
32+
return
33+
34+
if part is not None and not self._is_valid_part(part):
35+
resp.status = falcon.HTTP_400
36+
resp.media = "Invalid part parameter. Allowed values are: a, h, o"
37+
return
38+
39+
cpeGuesser = CPEGuesser()
40+
resp.media = cpeGuesser.guessCpe(query, part=part)
41+
2242
def on_post(self, req, resp):
2343
data_post = req.bounded_stream.read()
2444
js = data_post.decode("utf-8")
@@ -36,8 +56,20 @@ def on_post(self, req, resp):
3656
resp.media = "Missing query array or incorrect JSON format"
3757
return
3858

39-
cpeGuesser = CPEGuesser()
40-
resp.media = cpeGuesser.guessCpe(q["query"])
59+
part = q.get("part")
60+
if isinstance(part, str):
61+
part = part.lower()
62+
63+
self._search(q["query"], part, resp)
64+
65+
def on_get(self, req, resp):
66+
query = req.get_param("q")
67+
part = req.get_param("part")
68+
69+
if isinstance(part, str):
70+
part = part.lower()
71+
72+
self._search(self._split_query(query or ""), part, resp)
4173

4274

4375
class Unique:
@@ -58,9 +90,20 @@ def on_post(self, req, resp):
5890
resp.media = "Missing query array or incorrect JSON format"
5991
return
6092

93+
part = q.get("part")
94+
if isinstance(part, str):
95+
part = part.lower()
96+
elif part is not None:
97+
part = None
98+
99+
if part is not None and part not in CPEGuesser.VALID_CPE_PARTS:
100+
resp.status = falcon.HTTP_400
101+
resp.media = "Invalid part parameter. Allowed values are: a, h, o"
102+
return
103+
61104
cpeGuesser = CPEGuesser()
62105
try:
63-
r = cpeGuesser.guessCpe(q["query"])[:1][0][1]
106+
r = cpeGuesser.guessCpe(q["query"], part=part)[:1][0][1]
64107
except:
65108
r = []
66109
resp.media = r

lib/cpeguesser.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313

1414
class CPEGuesser:
15+
VALID_CPE_PARTS = {"a", "h", "o"}
16+
1517
def __init__(self, rdb=None):
1618
self.rdb = rdb or valkey.Valkey(
1719
host=valkey_host,
@@ -28,7 +30,20 @@ def _rank_score(self, cpe):
2830
score = self.rdb.zscore("rank:cpe", cpe)
2931
return score or 0
3032

31-
def guessCpe(self, words):
33+
def _is_matching_part(self, cpe, part):
34+
if part is None:
35+
return True
36+
fields = cpe.split(":")
37+
if len(fields) < 3:
38+
return False
39+
return fields[2] == part
40+
41+
def guessCpe(self, words, part=None):
42+
if part is not None:
43+
part = part.lower()
44+
if part not in self.VALID_CPE_PARTS:
45+
return []
46+
3247
k = []
3348
for keyword in words:
3449
k.append(f"w:{keyword.lower()}")
@@ -44,6 +59,8 @@ def guessCpe(self, words):
4459
lowered_words = [word.lower() for word in words]
4560

4661
for cpe in result:
62+
if not self._is_matching_part(cpe, part):
63+
continue
4764
search_score = sum(self._word_score(word, cpe) for word in lowered_words)
4865
rank_score = self._rank_score(cpe)
4966
total_score = search_score + rank_score

tests/test_cpeguesser.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,31 @@ def test_guess_cpe_uses_rank_score_when_word_scores_are_missing(self):
8383
],
8484
)
8585

86+
def test_guess_cpe_filters_results_by_part(self):
87+
rdb = FakeRDB()
88+
application = "cpe:2.3:a:cisco:router"
89+
hardware = "cpe:2.3:h:cisco:router"
90+
91+
for key in ("w:cisco", "w:router"):
92+
rdb.sadd(key, application)
93+
rdb.sadd(key, hardware)
94+
95+
rdb.zadd("rank:cpe", {application: 5, hardware: 5})
96+
97+
guesser = CPEGuesser(rdb=rdb)
98+
99+
self.assertEqual(guesser.guessCpe(["cisco", "router"], part="h"), [(5, hardware)])
100+
self.assertEqual(
101+
guesser.guessCpe(["cisco", "router"], part="a"), [(5, application)]
102+
)
103+
104+
def test_guess_cpe_rejects_unknown_part(self):
105+
rdb = FakeRDB()
106+
rdb.sadd("w:cisco", "cpe:2.3:a:cisco:router")
107+
guesser = CPEGuesser(rdb=rdb)
108+
109+
self.assertEqual(guesser.guessCpe(["cisco"], part="x"), [])
110+
86111

87112
if __name__ == "__main__":
88113
unittest.main()

0 commit comments

Comments
 (0)