Skip to content

Commit f6c22d2

Browse files
jstuckeeuwint
authored andcommitted
feat: cve_lookup v1
1 parent 65bb906 commit f6c22d2

File tree

14 files changed

+293
-228
lines changed

14 files changed

+293
-228
lines changed
Lines changed: 76 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,112 @@
11
from __future__ import annotations
22

3-
import sys
43
from pathlib import Path
5-
from typing import TYPE_CHECKING
4+
from typing import TYPE_CHECKING, List
5+
6+
from pydantic import BaseModel
7+
from semver import Version
68

79
import config
8-
from analysis.PluginBase import AnalysisBasePlugin
10+
from analysis.plugin import AnalysisPluginV0, Tag
911
from helperFunctions.tag import TagColor
12+
from plugins.analysis.cve_lookup.internal.database.db_connection import DbConnection
13+
from plugins.analysis.cve_lookup.internal.lookup import CveMatch, CvssScore, Lookup
1014
from plugins.mime_blacklists import MIME_BLACKLIST_NON_EXECUTABLE
1115

1216
if TYPE_CHECKING:
13-
from objects.file import FileObject
17+
from io import FileIO
1418

15-
try:
16-
from ..internal.database.db_connection import DbConnection
17-
from ..internal.lookup import Lookup
18-
except ImportError:
19-
sys.path.append(str(Path(__file__).parent.parent / 'internal'))
20-
from database.db_connection import DbConnection
21-
from lookup import Lookup
19+
from plugins.analysis.software_components.code.software_components import AnalysisPlugin as SoftwarePlugin
2220

2321
DB_PATH = str(Path(__file__).parent / '../internal/database/cve_cpe.db')
2422

2523

26-
class AnalysisPlugin(AnalysisBasePlugin):
24+
class CveResult(BaseModel):
25+
software_name: str
26+
cve_list: List[CveMatch]
27+
28+
def __lt__(self, other):
29+
if not isinstance(other, self.__class__):
30+
raise TypeError(f'Wrong type: {type(other)}')
31+
return self.software_name < other.software_name # to enable sorting
32+
33+
34+
class AnalysisPlugin(AnalysisPluginV0):
2735
"""
2836
lookup vulnerabilities from CVE feeds using ID from CPE dictionary
2937
"""
3038

31-
NAME = 'cve_lookup'
32-
DESCRIPTION = 'lookup CVE vulnerabilities'
33-
MIME_BLACKLIST = MIME_BLACKLIST_NON_EXECUTABLE
34-
DEPENDENCIES = ['software_components'] # noqa: RUF012
35-
VERSION = '0.2.0'
36-
FILE = __file__
37-
38-
def additional_setup(self):
39+
class Schema(BaseModel):
40+
cve_results: List[CveResult]
41+
42+
def __init__(self):
43+
super().__init__(
44+
metadata=(
45+
self.MetaData(
46+
name='cve_lookup',
47+
description='lookup CVE vulnerabilities',
48+
mime_blacklist=MIME_BLACKLIST_NON_EXECUTABLE,
49+
version=Version(1, 0, 0),
50+
dependencies=['software_components'],
51+
Schema=self.Schema,
52+
)
53+
)
54+
)
3955
self.min_crit_score = getattr(config.backend.plugin.get(self.NAME, {}), 'min-critical-score', 9.0)
4056
self.match_any = getattr(config.backend.plugin.get(self.NAME, {}), 'match-any', False)
4157

42-
def process_object(self, file_object: FileObject) -> FileObject:
58+
def analyze(self, file_handle: FileIO, virtual_file_path: dict, analyses: dict[str, BaseModel]) -> Schema:
4359
"""
4460
Process the given file object and look up vulnerabilities for each software component.
4561
"""
46-
cves = {'cve_results': {}}
62+
del virtual_file_path
4763
connection = DbConnection(f'sqlite:///{DB_PATH}')
48-
lookup = Lookup(file_object, connection, match_any=self.match_any)
49-
for sw_dict in file_object.processed_analysis['software_components']['result'].get('software_components', []):
50-
product = sw_dict['name']
51-
version = sw_dict['versions'][0] if sw_dict['versions'] else None
64+
65+
cve_results = []
66+
lookup = Lookup(file_handle.name, connection, match_any=self.match_any)
67+
sw_analysis: SoftwarePlugin.Schema = analyses['software_components']
68+
for sw_dict in sw_analysis.software_components:
69+
product = sw_dict.name
70+
version = sw_dict.versions[0] if sw_dict.versions else None
5271
if product and version:
5372
vulnerabilities = lookup.lookup_vulnerabilities(product, version)
5473
if vulnerabilities:
5574
component = f'{product} {version}'
56-
cves['cve_results'][component] = vulnerabilities
57-
58-
cves['summary'] = self._create_summary(cves['cve_results'])
59-
file_object.processed_analysis[self.NAME] = cves
60-
self.add_tags(cves['cve_results'], file_object)
61-
return file_object
62-
63-
def _create_summary(self, cve_results: dict[str, dict[str, dict[str, str]]]) -> list[str]:
64-
"""
65-
Creates a summary of the CVE results.
66-
"""
67-
return list(
68-
{
69-
software if not self._software_has_critical_cve(entry) else f'{software} (CRITICAL)'
70-
for software, entry in cve_results.items()
71-
}
72-
)
73-
74-
def _software_has_critical_cve(self, cve_dict: dict[str, dict[str, str]]) -> bool:
75+
cve_results.append(
76+
CveResult(
77+
software_name=component,
78+
cve_list=vulnerabilities,
79+
)
80+
)
81+
82+
return self.Schema(cve_results=cve_results)
83+
84+
def summarize(self, result: Schema) -> list[str]:
85+
summary = {
86+
entry.software_name
87+
if not self._software_has_critical_cve(entry.cve_list)
88+
else f'{entry.software_name} (CRITICAL)'
89+
for entry in result.cve_results
90+
}
91+
return sorted(summary)
92+
93+
def get_tags(self, result: Schema, summary: list[str]) -> list[Tag]:
94+
del summary
95+
return [
96+
Tag(name='CVE', value='critical CVE', color=TagColor.RED, propagate=True)
97+
for component in result.cve_results
98+
for cve in component.cve_list
99+
if self._entry_has_critical_rating(cve.scores)
100+
]
101+
102+
def _software_has_critical_cve(self, cve_list: List[CveMatch]) -> bool:
75103
"""
76104
Check if any entry in the given dictionary of CVEs has a critical rating.
77105
"""
78-
return any(self._entry_has_critical_rating(entry) for entry in cve_dict.values())
79-
80-
def add_tags(self, cve_results: dict[str, dict[str, dict[str, str]]], file_object: FileObject):
81-
"""
82-
Adds analysis tags to a file object based on the critical CVE results.
106+
return any(self._entry_has_critical_rating(entry.scores) for entry in cve_list)
83107

84-
Results structure: {'component': {'cve_id': {'score2': '6.4', 'score3': 'N/A'}}}
85-
"""
86-
for component in cve_results:
87-
for cve_id in cve_results[component]:
88-
entry = cve_results[component][cve_id]
89-
if self._entry_has_critical_rating(entry):
90-
self.add_analysis_tag(file_object, 'CVE', 'critical CVE', TagColor.RED, True)
91-
return
92-
93-
def _entry_has_critical_rating(self, entry: dict[str, dict[str, str]]) -> bool:
108+
def _entry_has_critical_rating(self, scores: list[CvssScore]) -> bool:
94109
"""
95110
Check if the given entry has a critical rating.
96111
"""
97-
return any(value != 'N/A' and float(value) >= self.min_crit_score for value in entry['scores'].values())
112+
return any(entry.score != 'N/A' and float(entry.score) >= self.min_crit_score for entry in scores)

src/plugins/analysis/cve_lookup/internal/busybox_cve_filter.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
from typing import TYPE_CHECKING
77

88
if TYPE_CHECKING:
9-
from objects.file import FileObject
10-
119
from .database.schema import Cve
1210

1311
BASE_DIR = Path(__file__).parent
@@ -18,31 +16,31 @@
1816
GROUP_1 = f1.read().splitlines()
1917
GROUP_2 = f2.read().splitlines()
2018

21-
PATTERNS_1 = [re.compile(rf'(?:\")(?:{re.escape(word)})(?:\-|\")') for word in GROUP_1]
22-
PATTERNS_2 = [re.compile(rf'(?:\b|\_)(?:{re.escape(word)})(?:\b|-)') for word in GROUP_2]
19+
PATTERNS_1 = [re.compile(rf'(?:"){re.escape(word)}(?:-|")') for word in GROUP_1]
20+
PATTERNS_2 = [re.compile(rf'(?:\b|_){re.escape(word)}(?:\b|-)') for word in GROUP_2]
2321

2422

25-
def filter_busybox_cves(file_object: FileObject, cves: dict[str, Cve]) -> dict[str, Cve]:
23+
def filter_busybox_cves(file_path: str, cves: dict[str, Cve]) -> dict[str, Cve]:
2624
"""
2725
Filters the BusyBox CVEs based on the components present in the binary file and the specified version.
2826
"""
29-
components = get_busybox_components(file_object)
30-
return filter_cves_by_component(file_object, cves, components)
27+
components = get_busybox_components(file_path)
28+
return filter_cves_by_component(file_path, cves, components)
3129

3230

33-
def get_busybox_components(file_object: FileObject) -> list[str]:
31+
def get_busybox_components(file_path: str) -> list[str]:
3432
"""
3533
Extracts the BusyBox components from the binary file.
3634
"""
37-
data = Path(file_object.file_path).read_bytes()
35+
data = Path(file_path).read_bytes()
3836
start_index = data.index(b'\x5b\x00\x5b\x5b\x00')
3937
end_index = data.index(b'\x00\x00', start_index + 5)
4038
extracted_bytes = data[start_index : end_index + 2]
4139
split_bytes = extracted_bytes.split(b'\x00')
4240
return [word.decode('ascii') for word in split_bytes if word]
4341

4442

45-
def filter_cves_by_component(file_object: FileObject, cves: dict[str, Cve], components: list[str]) -> dict[str, Cve]:
43+
def filter_cves_by_component(file_path: str, cves: dict[str, Cve], components: list[str]) -> dict[str, Cve]:
4644
"""
4745
Filters CVEs based on the components present in the BusyBox binary file.
4846
"""
@@ -54,7 +52,7 @@ def filter_cves_by_component(file_object: FileObject, cves: dict[str, Cve], comp
5452

5553
num_deleted = len(cves) - len(filtered_cves)
5654
if num_deleted > 0:
57-
logging.debug(f'{file_object}: Deleted {num_deleted} CVEs with components not found in this BusyBox binary')
55+
logging.debug(f'{file_path}: Deleted {num_deleted} CVEs with components not found in this BusyBox binary')
5856

5957
return filtered_cves
6058

src/plugins/analysis/cve_lookup/internal/lookup.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
import operator
55
import re
66
from itertools import combinations
7-
from typing import TYPE_CHECKING
7+
from typing import TYPE_CHECKING, List
88

99
from packaging.version import InvalidVersion, Version
1010
from packaging.version import parse as parse_version
11+
from pydantic import BaseModel
1112

1213
from .busybox_cve_filter import filter_busybox_cves
1314
from .database.db_interface import DbInterface
@@ -16,29 +17,46 @@
1617
if TYPE_CHECKING:
1718
from collections.abc import Callable
1819

19-
from objects.file import FileObject
20-
2120
from .database.db_connection import DbConnection
2221
from .database.schema import Association, Cpe
2322

2423
VALID_VERSION_REGEX = re.compile(r'v?(\d+!)?\d+(\.\d+)*([.-]?(a(lpha)?|b(eta)?|c|dev|post|pre(view)?|r|rc)?\d+)?')
2524

2625

26+
class CvssScore(BaseModel):
27+
version: str
28+
score: str
29+
30+
31+
class CveMatch(BaseModel):
32+
id: str
33+
cpe_version: str
34+
scores: List[CvssScore]
35+
36+
def __lt__(self, other):
37+
if not isinstance(other, self.__class__):
38+
raise TypeError(f'Wrong type: {type(other)}')
39+
return self.id < other.id # to enable sorting
40+
41+
def _get_scores_as_dict(self):
42+
return {cvss.version: cvss.score for cvss in self.scores}
43+
44+
2745
class Lookup:
28-
def __init__(self, file_object: FileObject, connection: DbConnection, match_any: bool = False):
29-
self.file_object = file_object
46+
def __init__(self, file_path: str, connection: DbConnection, match_any: bool = False):
47+
self.file_path = file_path
3048
self.db_interface = DbInterface(connection)
3149
self.match_any = match_any
3250

3351
def lookup_vulnerabilities(
3452
self,
3553
product_name: str,
3654
requested_version: str,
37-
) -> dict:
55+
) -> list[CveMatch]:
3856
"""
3957
Look up vulnerabilities for a given product and requested version.
4058
"""
41-
vulnerabilities = {}
59+
vulnerabilities: list[CveMatch] = []
4260
product_terms = self._generate_search_terms(product_name)
4361
version = replace_wildcards([requested_version])[0]
4462
cpe_matches = self.db_interface.match_cpes(product_terms)
@@ -49,16 +67,19 @@ def lookup_vulnerabilities(
4967
cve_ids = [association.cve_id for association in association_matches]
5068
cves = self.db_interface.get_cves(cve_ids)
5169
if 'busybox' in product_terms:
52-
cves = filter_busybox_cves(self.file_object, cves)
70+
cves = filter_busybox_cves(self.file_path, cves)
5371
for association in association_matches:
5472
cve = cves.get(association.cve_id)
5573
if cve:
5674
cpe = cpe_matches.get(association.cpe_id)
57-
vulnerabilities[cve.cve_id] = {
58-
'scores': cve.cvss_score,
59-
'cpe_version': self._build_version_string(association, cpe),
60-
}
61-
75+
scores = [CvssScore(version=version, score=str(score)) for version, score in cve.cvss_score.items()]
76+
vulnerabilities.append(
77+
CveMatch(
78+
id=association.cve_id,
79+
cpe_version=self._build_version_string(association, cpe),
80+
scores=scores,
81+
)
82+
)
6283
return vulnerabilities
6384

6485
@staticmethod
@@ -157,7 +178,8 @@ def _coerce_version(version: str) -> Version:
157178
# try to throw away revisions and other stuff at the end as a final measure
158179
return parse_version(re.split(r'[^v.\d]', fixed_version)[0])
159180

160-
def _build_version_string(self, association: Association, cpe: Cpe) -> str:
181+
@staticmethod
182+
def _build_version_string(association: Association, cpe: Cpe) -> str:
161183
"""
162184
Build a version string based on the cpe cve association boundaries.
163185
"""

src/plugins/analysis/cve_lookup/test/test_busybox_cve_filter.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22

3-
from ..internal.busybox_cve_filter import filter_cves_by_component
4-
from ..internal.database.schema import Cve
3+
from plugins.analysis.cve_lookup.internal.busybox_cve_filter import filter_cves_by_component
4+
from plugins.analysis.cve_lookup.internal.database.schema import Cve
55

66
CVE_DICT = {
77
'CVE-2021-42385': Cve(

0 commit comments

Comments
 (0)