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
« 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"""
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
17from sbom_cve_check.cve_db.annot_spdx2 import Spdx2AnnotDatabase
19from ..vuln.cpe import Cpe23
20from .component import CompBuild, CompId, Component, CompType, CompVersIds
21from .registry import register_sbom
22from .sbom_base import Sbom
25@dataclasses.dataclass
26class _PackageInfo:
27 recipe_ref: str
28 name: str
29 version: str
30 license: str | None
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)
37 recipe_ref: str | None = None
39 for ext_ref in doc.get("externalDocumentRefs", []):
40 recipe_ref = ext_ref.get("spdxDocument")
41 if recipe_ref:
42 break
44 if not recipe_ref:
45 return None
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"))
53 return None
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
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)
74 @property
75 def name(self) -> str:
76 return self._pkg_info.name
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
85 @property
86 def supplier(self) -> str | None:
87 return None
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
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
107 @property
108 def cpes(self) -> set[Cpe23]:
109 return self._cpes
111 @property
112 def description(self) -> str | None:
113 d = self._recipe_pkg.get("description")
114 return d if isinstance(d, str) else None
116 @property
117 def license_expression(self) -> str | None:
118 return self._pkg_info.license
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
133 @property
134 def build_name(self) -> str | None:
135 name: str | None = self._recipe_pkg.get("name")
136 return name
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
147 filename = file.get("fileName")
148 if filename:
149 sources.add(filename)
150 return sources
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 )
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)
169 def __del__(self) -> None:
170 if self._tmp_dir is not None:
171 self._tmp_dir.cleanup()
173 def _initialize_sbom_dir(self) -> pathlib.Path:
174 if self._sbom_dir is not None:
175 return self._sbom_dir
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)
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}")
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)
201 return self._sbom_dir
203 def create_annot_database(self, **kwargs: Any) -> Spdx2AnnotDatabase | None:
204 return Spdx2AnnotDatabase(sbom=self, **kwargs)
206 def iterate_recipes(
207 self,
208 ) -> Generator[tuple[dict[str, Any], dict[str, Any]], None, None]:
209 sbom_dir = self._initialize_sbom_dir()
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)
215 recipe_pkgs = recipe_doc.get("packages")
216 if not recipe_pkgs or len(recipe_pkgs) < 1:
217 continue
219 recipe_pkg: dict[str, Any] = recipe_pkgs[0]
220 if not recipe_pkg.get("SPDXID", "").startswith("SPDXRef-Recipe-"):
221 continue
223 yield recipe_doc, recipe_pkg
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
231 pkgs = self._pkgs.get(recipe_ref)
232 if not pkgs:
233 continue
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)
239 @property
240 def supplier(self) -> str | None:
241 return None
243 @property
244 def timestamp(self) -> datetime.datetime | None:
245 return None