Coverage for /home/benjarobin/Bootlin/projects/Schneider-Electric-Senux/sbom-cve-check/src/sbom_cve_check/sbom/component.py: 93%
183 statements
« prev ^ index » next coverage.py v7.11.1, created at 2025-11-28 15:37 +0100
« prev ^ index » next coverage.py v7.11.1, created at 2025-11-28 15:37 +0100
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: GPL-2.0-only
4import abc
5import dataclasses
6import functools
7import logging
8import pathlib
9from collections.abc import Iterable
10from enum import Enum
11from typing import Any, Literal, Optional
13from ..vuln.cpe import Cpe23, LogicalValue
14from ..vuln.cve import CveInfo
16_logger = logging.getLogger(__name__)
19@dataclasses.dataclass(frozen=True, kw_only=True)
20@functools.total_ordering
21class CompId:
22 """
23 Object that represent a package identifier, to identify a software component.
24 This object only contain information to identify the component: typically the
25 product name or package name.
26 This object does not contain version information.
27 """
29 part: Literal["a", "o", "h"] | None = None
30 vendor: str | None = None
31 name: str
33 def to_str(self, full: bool = False) -> str:
34 els: list[str] = []
35 part: str | None = self.part if full and self.part else None
36 if part:
37 els.append(part)
38 if self.vendor:
39 els.append(self.vendor)
40 elif part:
41 els.append("")
42 els.append(self.name)
43 return ":".join(els)
45 def __str__(self) -> str:
46 return self.to_str(True)
48 def is_matching(self, other: "CompId") -> bool:
49 """
50 Checks if another component, identified by a CPE, is the same component that is
51 identified by this CPE. For now only check part and product/vendor.
52 """
53 if other.name != self.name:
54 return False
56 if (
57 (self.part is not None)
58 and (other.part is not None)
59 and (self.part != other.part)
60 ):
61 return False
63 return not (
64 self.vendor is not None
65 and other.vendor is not None
66 and self.vendor != other.vendor
67 )
69 def is_matching_one_of(self, others: Iterable["CompId"]) -> bool:
70 """
71 :return: True if one of the component identifier specified as input matches
72 with this component identifier
73 """
74 return any(self.is_matching(other) for other in others)
76 def __tuple_for_cmp(self) -> tuple[str, ...]:
77 return self.name, str(self.vendor or ""), str(self.part or "")
79 def __lt__(self, other: object) -> bool:
80 if not isinstance(other, CompId):
81 return NotImplemented
82 return self.__tuple_for_cmp() < other.__tuple_for_cmp()
84 @staticmethod
85 def filter_best_ids(comp_ids: Iterable["CompId"]) -> set["CompId"]:
86 """
87 Keep only the best component identifiers (with vendor), but if in the list
88 there is no identifier with vendor, return the original list
89 """
90 best_comp_ids: set[CompId] = set()
91 list_has_vendor = False
92 for comp_id in comp_ids:
93 id_has_vendor = comp_id.vendor is not None
94 if id_has_vendor and not list_has_vendor:
95 best_comp_ids.clear()
96 list_has_vendor = True
98 if id_has_vendor == list_has_vendor:
99 best_comp_ids.add(comp_id)
101 return best_comp_ids
103 @staticmethod
104 def build_from_cpe(cpe: Cpe23 | str | None) -> Optional["CompId"]:
105 if isinstance(cpe, str):
106 cpe = Cpe23.parse(cpe)
108 if cpe is None:
109 return None
111 product = cpe.product
112 if not isinstance(product, str):
113 return None
115 if cpe.part == LogicalValue.NA:
116 return None
117 part: Literal["a", "o", "h"] | None = None
118 if isinstance(cpe.part, str):
119 part = cpe.part
121 if cpe.vendor == LogicalValue.NA:
122 return None
123 vendor: str | None = None
124 if isinstance(cpe.vendor, str):
125 vendor = cpe.vendor
127 return CompId(part=part, vendor=vendor, name=product)
129 @staticmethod
130 def build_from_vendor_product_str(vendor_product: str) -> "CompId":
131 parts = vendor_product.split(":", maxsplit=1)
132 if len(parts) == 1:
133 return CompId(name=parts[0])
134 return CompId(vendor=parts[0], name=parts[1])
136 def build_cpe(self, **kwargs: Any) -> Cpe23:
137 # noinspection PyTypeChecker
138 return Cpe23(
139 part=self.part or LogicalValue.ANY,
140 vendor=self.vendor or LogicalValue.ANY,
141 product=self.name,
142 version=kwargs.get("version", LogicalValue.ANY),
143 update=kwargs.get("update", LogicalValue.ANY),
144 edition=kwargs.get("edition", LogicalValue.ANY),
145 language=kwargs.get("language", LogicalValue.ANY),
146 sw_edition=kwargs.get("sw_edition", LogicalValue.ANY),
147 target_sw=kwargs.get("target_sw", LogicalValue.ANY),
148 target_hw=kwargs.get("target_hw", LogicalValue.ANY),
149 other=kwargs.get("other", LogicalValue.ANY),
150 )
153def comp_ids_is_matching_one_of(
154 lst1_ids: Iterable[CompId], lst2_ids: Iterable[CompId]
155) -> bool:
156 """
157 :return: True if one of the component identifier of the first list matches with
158 one of the component identifier of the second list
159 """
160 return any(comp_id.is_matching_one_of(lst2_ids) for comp_id in lst1_ids)
163@dataclasses.dataclass(frozen=True, order=True, init=False)
164class CompVersIds:
165 """
166 Allow to identify a software component with the version.
167 A software component may have multiple "identifier"
168 (for example different vendor/product).
169 """
171 # Sorted component identifiers
172 ids: tuple[CompId, ...]
173 # Component version: Version used to identify a CVE is preferred
174 version: str
176 def __init__(self, *, ids: Iterable[CompId], version: str) -> None:
177 object.__setattr__(self, "ids", tuple(sorted(set(ids))))
178 object.__setattr__(self, "version", version)
181class CompType(Enum):
182 APPLICATION = ("application",)
183 LIBRARY = "library"
184 OS = "operating-system"
185 DEVICE = "device"
186 FIRMWARE = "firmware"
187 FILE = "file"
190class Component(abc.ABC):
191 """Object that provides information about one package / software component."""
193 @property
194 @abc.abstractmethod
195 def name(self) -> str:
196 """:return: Component / package / product name"""
198 @property
199 @abc.abstractmethod
200 def version(self) -> str | None:
201 """:return: The component version"""
203 @property
204 @abc.abstractmethod
205 def supplier(self) -> str | None:
206 """:return: The organization that supplied the component"""
208 @property
209 @abc.abstractmethod
210 def comp_type(self) -> CompType | None:
211 """:return: Specifies the type of component"""
213 @property
214 @abc.abstractmethod
215 def identifiers(self) -> set[CompId]:
216 """:return: A set of component identifier to be able to compare 2 components"""
218 @property
219 def purls(self) -> set[str]:
220 """:return: A set of PURL that identify this component"""
221 return set()
223 @property
224 def cpes(self) -> set[Cpe23]:
225 """:return: A set of CPEs that identify this component"""
226 return set()
228 @property
229 def description(self) -> str | None:
230 """:return: A description of this component"""
231 return None
233 @property
234 def license_expression(self) -> str | None:
235 """:return: A license expression"""
236 return None
238 @property
239 def source_urls(self) -> set[tuple[str, str]]:
240 """
241 :return: The set of sources used to build this component.
242 The list contain a tuple of source type and URL.
243 """
244 return set()
246 @property
247 def depend_on(self) -> list["Component"]:
248 """:return: The list of component that this component depends on"""
249 return []
251 def add_cve_vulnerability(
252 self, cve_info: CveInfo, *, update_vuln_info: bool = True
253 ) -> None:
254 """
255 Add new CVE vulnerability associated with this component, or if already exists,
256 update the CVE information.
257 """
258 raise NotImplementedError
261class CompBuild(abc.ABC):
262 """
263 Represent a group of components (split packages) that are built from the same
264 "recipe". This object must only contain components with the same version and same
265 identifiers.
266 """
268 def __init__(self, vers_ids: CompVersIds, comps: Iterable[Component]) -> None:
269 self._vers_ids = vers_ids
270 self._comps: tuple[Component, ...] = tuple(sorted(comps, key=lambda t: t.name))
271 self._sources: set[str] | None = None
272 self._srcs_index: dict[str, list[str]] | None = None
274 @staticmethod
275 def group_components_by_id(
276 comps: Iterable[Component],
277 ) -> dict[CompVersIds, list[Component]]:
278 comps_grouped: dict[CompVersIds, list[Component]] = {}
279 for comp in comps:
280 comp_vers = comp.version
281 if comp_vers is None:
282 _logger.warning(
283 "Component %s (%s) doesn't have a version",
284 comp.name,
285 comp.identifiers,
286 )
287 else:
288 comp_vers_ids = CompVersIds(ids=comp.identifiers, version=comp_vers)
289 comps_grouped.setdefault(comp_vers_ids, []).append(comp)
290 return comps_grouped
292 @property
293 def version(self) -> str:
294 """:return: The components version"""
295 return self._vers_ids.version
297 @property
298 def identifiers(self) -> tuple[CompId, ...]:
299 """:return: The components identifiers (sorted)"""
300 return self._vers_ids.ids
302 def is_matching_one_of(self, others: Iterable[CompId]) -> bool:
303 """
304 :return: True if one of the component identifier matches with the ones provided
305 by this build "recipe"
306 """
307 return comp_ids_is_matching_one_of(self._vers_ids.ids, others)
309 @property
310 def components(self) -> tuple[Component, ...]:
311 """
312 :return: The list of components (sorted by name) built by this "recipe". These
313 components have the same version and same identifiers
314 """
315 return self._comps
317 @property
318 @abc.abstractmethod
319 def build_name(self) -> str | None:
320 """
321 :return: The recipe name, script package name, used to generate these
322 components/packages
323 """
325 @abc.abstractmethod
326 def _get_compiled_sources(self) -> set[str]:
327 """
328 :return: Retrieve the sources files used to compile these components:
329 Set of file paths
330 """
332 @property
333 def compiled_sources(self) -> set[str]:
334 """
335 :return: The sources files used to compile these components:
336 Set of file paths
337 """
338 if self._sources is None:
339 self._sources = self._get_compiled_sources()
340 return self._sources
342 @property
343 def compiled_sources_index(self) -> dict[str, list[str]]:
344 """
345 Provide sources files used to compile these components indexed by file name.
346 :return: A map with the key containing a file name to a list of file paths
347 """
348 if self._srcs_index is None:
349 index: dict[str, list[str]] = {}
350 for compiled_source in self.compiled_sources:
351 name = pathlib.Path(compiled_source).name
352 if name:
353 index.setdefault(name, []).append(compiled_source)
354 self._srcs_index = index
355 return self._srcs_index