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

1# -*- coding: utf-8 -*- 

2# SPDX-License-Identifier: GPL-2.0-only 

3 

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 

12 

13from ..vuln.cpe import Cpe23, LogicalValue 

14from ..vuln.cve import CveInfo 

15 

16_logger = logging.getLogger(__name__) 

17 

18 

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 """ 

28 

29 part: Literal["a", "o", "h"] | None = None 

30 vendor: str | None = None 

31 name: str 

32 

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) 

44 

45 def __str__(self) -> str: 

46 return self.to_str(True) 

47 

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 

55 

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 

62 

63 return not ( 

64 self.vendor is not None 

65 and other.vendor is not None 

66 and self.vendor != other.vendor 

67 ) 

68 

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) 

75 

76 def __tuple_for_cmp(self) -> tuple[str, ...]: 

77 return self.name, str(self.vendor or ""), str(self.part or "") 

78 

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() 

83 

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 

97 

98 if id_has_vendor == list_has_vendor: 

99 best_comp_ids.add(comp_id) 

100 

101 return best_comp_ids 

102 

103 @staticmethod 

104 def build_from_cpe(cpe: Cpe23 | str | None) -> Optional["CompId"]: 

105 if isinstance(cpe, str): 

106 cpe = Cpe23.parse(cpe) 

107 

108 if cpe is None: 

109 return None 

110 

111 product = cpe.product 

112 if not isinstance(product, str): 

113 return None 

114 

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 

120 

121 if cpe.vendor == LogicalValue.NA: 

122 return None 

123 vendor: str | None = None 

124 if isinstance(cpe.vendor, str): 

125 vendor = cpe.vendor 

126 

127 return CompId(part=part, vendor=vendor, name=product) 

128 

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]) 

135 

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 ) 

151 

152 

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) 

161 

162 

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 """ 

170 

171 # Sorted component identifiers 

172 ids: tuple[CompId, ...] 

173 # Component version: Version used to identify a CVE is preferred 

174 version: str 

175 

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) 

179 

180 

181class CompType(Enum): 

182 APPLICATION = ("application",) 

183 LIBRARY = "library" 

184 OS = "operating-system" 

185 DEVICE = "device" 

186 FIRMWARE = "firmware" 

187 FILE = "file" 

188 

189 

190class Component(abc.ABC): 

191 """Object that provides information about one package / software component.""" 

192 

193 @property 

194 @abc.abstractmethod 

195 def name(self) -> str: 

196 """:return: Component / package / product name""" 

197 

198 @property 

199 @abc.abstractmethod 

200 def version(self) -> str | None: 

201 """:return: The component version""" 

202 

203 @property 

204 @abc.abstractmethod 

205 def supplier(self) -> str | None: 

206 """:return: The organization that supplied the component""" 

207 

208 @property 

209 @abc.abstractmethod 

210 def comp_type(self) -> CompType | None: 

211 """:return: Specifies the type of component""" 

212 

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""" 

217 

218 @property 

219 def purls(self) -> set[str]: 

220 """:return: A set of PURL that identify this component""" 

221 return set() 

222 

223 @property 

224 def cpes(self) -> set[Cpe23]: 

225 """:return: A set of CPEs that identify this component""" 

226 return set() 

227 

228 @property 

229 def description(self) -> str | None: 

230 """:return: A description of this component""" 

231 return None 

232 

233 @property 

234 def license_expression(self) -> str | None: 

235 """:return: A license expression""" 

236 return None 

237 

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() 

245 

246 @property 

247 def depend_on(self) -> list["Component"]: 

248 """:return: The list of component that this component depends on""" 

249 return [] 

250 

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 

259 

260 

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 """ 

267 

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 

273 

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 

291 

292 @property 

293 def version(self) -> str: 

294 """:return: The components version""" 

295 return self._vers_ids.version 

296 

297 @property 

298 def identifiers(self) -> tuple[CompId, ...]: 

299 """:return: The components identifiers (sorted)""" 

300 return self._vers_ids.ids 

301 

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) 

308 

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 

316 

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 """ 

324 

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 """ 

331 

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 

341 

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