Coverage for /home/benjarobin/Bootlin/projects/Schneider-Electric-Senux/sbom-cve-check/src/sbom_cve_check/sbom/sbom_spdx2.py: 35%

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

4Implements SBOMs in the SPDX2 format generated by Yocto. 

5""" 

6 

7import dataclasses 

8import datetime 

9import json 

10import pathlib 

11import subprocess 

12import tempfile 

13from collections import defaultdict 

14from collections.abc import Generator, Iterable 

15from typing import Any 

16 

17from sbom_cve_check.cve_db.annot_spdx2 import Spdx2AnnotDatabase 

18 

19from ..vuln.cpe import Cpe23 

20from .component import CompBuild, CompId, Component, CompType, CompVersIds 

21from .registry import register_sbom 

22from .sbom_base import Sbom 

23 

24 

25@dataclasses.dataclass 

26class _PackageInfo: 

27 recipe_ref: str 

28 name: str 

29 version: str 

30 license: str | None 

31 

32 

33def _parse_doc_package(doc_path: pathlib.Path) -> Generator[_PackageInfo, None, None]: 

34 with doc_path.open(encoding="utf-8") as f: 

35 doc: dict[str, Any] = json.load(f) 

36 

37 recipe_ref: str | None = None 

38 

39 for ext_ref in doc.get("externalDocumentRefs", []): 

40 recipe_ref = ext_ref.get("spdxDocument") 

41 if recipe_ref: 

42 break 

43 

44 if not recipe_ref: 

45 return None 

46 

47 for pkg in doc.get("packages", []): 

48 name: str | None = pkg.get("name") 

49 vers: str | None = pkg.get("versionInfo") 

50 if name and vers: 

51 yield _PackageInfo(recipe_ref, name, vers, pkg.get("licenseDeclared")) 

52 

53 return None 

54 

55 

56def parse_ext_refs_cpe(recipe_pkg: dict[str, Any]) -> set[Cpe23]: 

57 cpes: set[Cpe23] = set() 

58 for ext_ref in recipe_pkg.get("externalRefs", []): 

59 ref_type = ext_ref.get("referenceType") 

60 if ref_type != "http://spdx.org/rdf/references/cpe23Type": 

61 continue 

62 cpe = Cpe23.parse(ext_ref.get("referenceLocator")) 

63 if cpe is not None: 

64 cpes.add(cpe) 

65 return cpes 

66 

67 

68class Spdx2Component(Component): 

69 def __init__(self, recipe_pkg: dict[str, Any], pkg_info: _PackageInfo) -> None: 

70 self._recipe_pkg = recipe_pkg 

71 self._pkg_info = pkg_info 

72 self._cpes = parse_ext_refs_cpe(self._recipe_pkg) 

73 

74 @property 

75 def name(self) -> str: 

76 return self._pkg_info.name 

77 

78 @property 

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

80 for cpe in self._cpes: 

81 if isinstance(cpe.version, str): 

82 return cpe.version 

83 return self._pkg_info.version 

84 

85 @property 

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

87 return None 

88 

89 @property 

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

91 for cpe in self._cpes: 

92 if cpe.part == "o": 

93 return CompType.OS 

94 if cpe.part == "a": 

95 return CompType.APPLICATION 

96 return None 

97 

98 @property 

99 def identifiers(self) -> set[CompId]: 

100 comp_ids: set[CompId] = set() 

101 for cpe in self._cpes: 

102 comp_id = CompId.build_from_cpe(cpe) 

103 if comp_id is not None: 

104 comp_ids.add(comp_id) 

105 return comp_ids 

106 

107 @property 

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

109 return self._cpes 

110 

111 @property 

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

113 d = self._recipe_pkg.get("description") 

114 return d if isinstance(d, str) else None 

115 

116 @property 

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

118 return self._pkg_info.license 

119 

120 

121class Spdx2CompBuild(CompBuild): 

122 def __init__( 

123 self, 

124 vers_ids: CompVersIds, 

125 comps: Iterable[Component], 

126 recipe_doc: dict[str, Any], 

127 recipe_pkg: dict[str, Any], 

128 ) -> None: 

129 super().__init__(vers_ids, comps) 

130 self._recipe_doc = recipe_doc 

131 self._recipe_pkg = recipe_pkg 

132 

133 @property 

134 def build_name(self) -> str | None: 

135 name: str | None = self._recipe_pkg.get("name") 

136 return name 

137 

138 def _get_compiled_sources(self) -> set[str]: 

139 sources: set[str] = set() 

140 for file in self._recipe_doc.get("files", []): 

141 for file_type in file.get("fileTypes", []): 

142 if file_type == "SOURCE": 

143 break 

144 else: 

145 continue 

146 

147 filename = file.get("fileName") 

148 if filename: 

149 sources.add(filename) 

150 return sources 

151 

152 

153@register_sbom("spdx2") 

154class Spdx2Sbom(Sbom): 

155 @classmethod 

156 def can_handle_sbom(cls, path: pathlib.Path) -> bool: 

157 return ( 

158 path.is_dir() 

159 or path.name.endswith(".spdx.tar") 

160 or path.stem.endswith(".spdx.tar") 

161 ) 

162 

163 def __init__(self, path: pathlib.Path) -> None: 

164 super().__init__(path) 

165 self._tmp_dir: tempfile.TemporaryDirectory[str] | None = None 

166 self._sbom_dir: pathlib.Path | None = None 

167 self._pkgs: dict[str, list[_PackageInfo]] = defaultdict(list) 

168 

169 def __del__(self) -> None: 

170 if self._tmp_dir is not None: 

171 self._tmp_dir.cleanup() 

172 

173 def _initialize_sbom_dir(self) -> pathlib.Path: 

174 if self._sbom_dir is not None: 

175 return self._sbom_dir 

176 

177 # Extract SBOM archive if necessary 

178 spath = self._sbom_path 

179 if spath.is_dir(): 

180 self._sbom_dir = spath 

181 elif spath.name.endswith(".spdx.tar") or spath.stem.endswith(".spdx.tar"): 

182 self._tmp_dir = tempfile.TemporaryDirectory() 

183 self._sbom_dir = pathlib.Path(self._tmp_dir.name) 

184 

185 subprocess.check_call( 

186 ["tar", "-xaf", spath.resolve(strict=True).as_posix()], 

187 cwd=self._sbom_dir, 

188 stdout=subprocess.DEVNULL, 

189 stderr=subprocess.DEVNULL, 

190 ) 

191 else: 

192 raise ValueError(f"Unexpected SPDX2 SBOM file extension: {spath}") 

193 

194 # Read all packages information 

195 for path in self._sbom_dir.glob("*.spdx.json"): 

196 if path.name.startswith("recipe-") or path.name.startswith("runtime-"): 

197 continue 

198 for pkg in _parse_doc_package(path): 

199 self._pkgs[pkg.recipe_ref].append(pkg) 

200 

201 return self._sbom_dir 

202 

203 def create_annot_database(self, **kwargs: Any) -> Spdx2AnnotDatabase | None: 

204 return Spdx2AnnotDatabase(sbom=self, **kwargs) 

205 

206 def iterate_recipes( 

207 self, 

208 ) -> Generator[tuple[dict[str, Any], dict[str, Any]], None, None]: 

209 sbom_dir = self._initialize_sbom_dir() 

210 

211 for recipe_path in sorted(sbom_dir.glob("recipe-*.spdx.json")): 

212 with recipe_path.open(encoding="utf-8") as f: 

213 recipe_doc: dict[str, Any] = json.load(f) 

214 

215 recipe_pkgs = recipe_doc.get("packages") 

216 if not recipe_pkgs or len(recipe_pkgs) < 1: 

217 continue 

218 

219 recipe_pkg: dict[str, Any] = recipe_pkgs[0] 

220 if not recipe_pkg.get("SPDXID", "").startswith("SPDXRef-Recipe-"): 

221 continue 

222 

223 yield recipe_doc, recipe_pkg 

224 

225 def iterate_component_builds(self) -> Generator[CompBuild, None, None]: 

226 for recipe_doc, recipe_pkg in self.iterate_recipes(): 

227 recipe_ref = recipe_doc.get("documentNamespace") 

228 if not recipe_ref: 

229 continue 

230 

231 pkgs = self._pkgs.get(recipe_ref) 

232 if not pkgs: 

233 continue 

234 

235 comps = (Spdx2Component(recipe_pkg, pkg) for pkg in pkgs) 

236 for vers_id, group in CompBuild.group_components_by_id(comps).items(): 

237 yield Spdx2CompBuild(vers_id, group, recipe_doc, recipe_pkg) 

238 

239 @property 

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

241 return None 

242 

243 @property 

244 def timestamp(self) -> datetime.datetime | None: 

245 return None