FOSSology  4.7.1
Open Source License Compliance by Open Source Software
SpdxReport.py
1 #!/usr/bin/env python3
2 
3 # SPDX-FileCopyrightText: © 2023,2025 Siemens AG
4 # SPDX-FileContributor: Gaurav Mishra <mishra.gaurav@siemens.com>
5 #
6 # SPDX-License-Identifier: GPL-2.0-only
7 
8 import hashlib
9 import logging
10 import re
11 from datetime import datetime
12 
13 from license_expression import (
14  get_spdx_licensing, LicenseExpression, combine_expressions
15 )
16 from spdx_tools.spdx.model import (
17  Actor, ActorType, Checksum, ChecksumAlgorithm, CreationInfo, Document, File,
18  FileType, Package, PackageVerificationCode, Relationship, RelationshipType,
19  SpdxNoAssertion, ExternalPackageRef, ExternalPackageRefCategory, SpdxNone,
20  ExtractedLicensingInfo
21 )
22 from spdx_tools.spdx.validation.document_validator import \
23  validate_full_spdx_document
24 from spdx_tools.spdx.validation.validation_message import ValidationMessage
25 from spdx_tools.spdx.writer.write_anything import write_file
26 
27 from .CliOptions import CliOptions
28 from .ReportBase import ReportBase
29 from .Scanners import Scanners, ScanResultList
30 
31 
33  """
34  Handle SPDX reports.
35 
36  :ivar cli_options: CliOptions object
37  :ivar report_files: Dictionary of SPDX files with SPDX ID as key
38  :ivar license_package_set: Set of licenses found in package
39  :ivar creation_info: Report creation info
40  :ivar document: Report document
41  :ivar package: Report package
42  :ivar scanner: Scanners object
43  """
44 
45  def __init__(self, cli_options: CliOptions, scanner: Scanners):
46  """
47  :param cli_options: CliOptions to use
48  :param scanner: Scanners to use
49  """
50  self.cli_optionscli_optionscli_options = cli_options
51  self._allowed_licenses_set_allowed_licenses_set = set(
52  self.cli_optionscli_optionscli_options.allowlist.get('licenses', [])
53  )
54  self._license_cache: dict[str, LicenseExpression] = {}
55  self._spdx_lic_cache_spdx_lic_cache = get_spdx_licensing()
56  self.scannerscannerscanner = scanner
57  self.report_files: dict[str, File] = {}
58  self.license_package_set: set[str] = set()
59  self.package_verification_set: dict[str, dict[str, list[str]]] = {}
60  self.creation_info: CreationInfo = CreationInfo(
61  spdx_version="SPDX-2.3",
62  spdx_id="SPDXRef-DOCUMENT",
63  name="FOSSology CI Report",
64  data_license="CC0-1.0",
65  document_namespace="https://fossology.org",
66  creators=[
67  Actor(
68  ActorType.ORGANIZATION, "FOSSology",
69  "fossology@fossology.org"
70  )
71  ], created=datetime.now(),
72  )
73  self.document: Document = Document(self.creation_info)
74 
75  parent_package = self.scannerscannerscanner.get_scan_packages().parent_package
76  project_name = parent_package.get('name', '').strip()
77  if not project_name:
78  project_name = self.cli_optionscli_optionscli_options.parser.root_component_name
79  if not project_name:
80  project_name = "NA"
81 
82  self.package: Package = Package(
83  name=project_name,
84  spdx_id="SPDXRef-Package",
85  files_analyzed=True,
86  download_location=SpdxNoAssertion(),
87  release_date=datetime.now(),
88  )
89  if parent_package.get('description') is not None:
90  self.package.description = parent_package['description']
91 
92  author = parent_package.get('author')
93  if author and author != "":
94  self.package.originator = Actor(
95  ActorType.ORGANIZATION,
96  author
97  )
98  else:
99  self.package.originator = SpdxNoAssertion()
100 
101  url = parent_package.get('url')
102  if url and url != "":
103  self.package.download_location = url
104  else:
105  self.package.download_location = SpdxNoAssertion()
106 
107  self.document.packages = [self.package]
108  self.dependent_packages: dict[str, Package] = {}
109  self.extracted_licenses: dict[str, ExtractedLicensingInfo] = {}
110 
111  def __get_license_or_ref(self, lic: str) -> LicenseExpression:
112  if lic in self._license_cache:
113  return self._license_cache[lic]
114  license_spdx = lic
115  if self._spdx_lic_cache_spdx_lic_cache.validate(lic).invalid_symbols:
116  license_spdx = re.sub(
117  r'[^\da-zA-Z.\-_]', '-',
118  f"LicenseRef-fossology-{lic}"
119  )
120  if license_spdx not in self.extracted_licenses:
121  self.extracted_licenses[license_spdx] = ExtractedLicensingInfo(
122  license_id=license_spdx,
123  license_name=lic,
124  extracted_text=f"The license text for {license_spdx} has to be "
125  "entered."
126  )
127  lic_expression = self._spdx_lic_cache_spdx_lic_cache.parse(license_spdx)
128  self._license_cache[lic] = lic_expression
129  return lic_expression
130 
131  def __add_license_file(self, package: Package, scan_result: ScanResultList):
132  """
133  Add scan result from license scanner to report.
134 
135  :param package: Package to which the file belongs.
136  :param scan_result: Scan result from license scanner.
137  """
138  raw_licenses_strings = [lic['license'] for lic in scan_result.result]
139  parsed_expressions_set = {self.__get_license_or_ref__get_license_or_ref(lic_str) for lic_str in
140  raw_licenses_strings}
141  parsed_expressions_list = list(parsed_expressions_set)
142 
143  all_allowed_licenses = all(
144  lic_str in self._allowed_licenses_set_allowed_licenses_set for lic_str in raw_licenses_strings
145  )
146 
147  file = self.__get_spdx_file__get_spdx_file(scan_result, package)
148 
149  if all_allowed_licenses:
150  file.license_concluded = combine_expressions(
151  expressions=parsed_expressions_list, relation='AND', unique=False
152  )
153  else:
154  file.license_concluded = SpdxNoAssertion()
155 
156  file.license_info_in_file = parsed_expressions_list
157 
158  # Update licenses found in the files of the package
159  package.license_info_from_files = list(
160  set(package.license_info_from_files) | parsed_expressions_set
161  )
162 
163  # Update package.license_concluded
164  if file.license_concluded != SpdxNoAssertion():
165  if package.license_concluded in (SpdxNoAssertion(), SpdxNone()):
166  package.license_concluded = file.license_concluded
167  else:
168  package.license_concluded = (
169  package.license_concluded & file.license_concluded).simplify()
170 
172  self, scan_result: ScanResultList, package: Package
173  ) -> File:
174  """
175  Create a new SPDX File for given scan result and populate common fields.
176 
177  :param scan_result: Scan result from scanner.
178  :param package: Package to which the file belongs.
179  :return: New SPDX File
180  """
181  md5_hash, sha1_hash, sha256_hash = self.__get_file_info__get_file_info(scan_result)
182  file_spdx_id = self.__get_file_spdx_id__get_file_spdx_id(sha256_hash, package.name)
183  if file_spdx_id not in self.report_files:
184  spdx_file = File(
185  name=scan_result.file,
186  spdx_id=file_spdx_id,
187  checksums=[
188  Checksum(ChecksumAlgorithm.MD5, md5_hash),
189  Checksum(ChecksumAlgorithm.SHA1, sha1_hash),
190  Checksum(ChecksumAlgorithm.SHA256, sha256_hash),
191  ],
192  file_types=[FileType.SOURCE],
193  license_concluded=SpdxNoAssertion()
194  )
195  self.report_files[file_spdx_id] = spdx_file
196  contains_relationship = Relationship(
197  package.spdx_id,
198  RelationshipType.CONTAINS,
199  file_spdx_id
200  )
201  self.document.relationships.append(contains_relationship)
202 
203  pkg_verification_data = self.package_verification_set.setdefault(
204  package.spdx_id, {
205  'checksums': [], 'excluded_files': []
206  }
207  )
208 
209  if self.scannerscannerscanner.is_excluded_path(spdx_file.name):
210  pkg_verification_data['excluded_files'].append(spdx_file.name)
211  else:
212  pkg_verification_data['checksums'].append(sha1_hash)
213 
214  return self.report_files[file_spdx_id]
215 
217  self, package: Package, copyright_result: ScanResultList
218  ):
219  """
220  Add scan result from copyright agent. If the file does not exist, creates a
221  new one.
222 
223  :param copyright_result: Scan result from copyright scanner.
224  """
225  file = self.__get_spdx_file__get_spdx_file(copyright_result, package)
226  file.copyright_text = "\n".join(
227  [
228  cpy.get('content', '') for cpy in copyright_result.result
229  ]
230  )
231 
232  @staticmethod
233  def __get_file_info(scan_result: ScanResultList) -> tuple[str, str, str]:
234  """
235  Get different hash for the file in scan result.
236 
237  :param scan_result: Scan result from scanners.
238  :return: Tuple of md5, sha1 and sha256 checksums.
239  """
240  md5_hash = hashlib.md5()
241  sha1_hash = hashlib.sha1()
242  sha256_hash = hashlib.sha256()
243  with open(scan_result.path, "rb") as f:
244  for byte_block in iter(lambda: f.read(4096), b""):
245  md5_hash.update(byte_block)
246  sha1_hash.update(byte_block)
247  sha256_hash.update(byte_block)
248  return md5_hash.hexdigest(), sha1_hash.hexdigest(), sha256_hash.hexdigest()
249 
250  @staticmethod
251  def __get_file_spdx_id(sha256_hash: str, pkg_name: str) -> str:
252  """
253  Generate SPDX ID for file in scan result.
254 
255  :param sha256_hash: SHA 256 checksum of the file
256  :param pkg_name: Package to which the file belongs.
257  :return: SPDX ID for the file.
258  """
259  return f"SPDXRef-File-{pkg_name}-{sha256_hash}"
260 
261  @staticmethod
262  def __get_package_spdx_id(component: dict) -> str:
263  """
264  Generate SPDX ID for a package/component.
265 
266  :param component: Package/component to get SPDX ID for.
267  :return: SPDX ID for the package.
268  """
269  pkg_name = component.get('name', '')
270  pkg_version = component.get('version', '')
271  return "SPDXRef-Package-" + hashlib.md5(
272  f"{pkg_name}_{pkg_version}".encode('utf-8', errors='ignore')
273  ).hexdigest()
274 
275  def write_report(self, file_name: str):
276  """
277  Validate the document and write the SPDX file.
278 
279  :param file_name: Location to store the report.
280  """
281  validation_messages: list[ValidationMessage] = validate_full_spdx_document(
282  self.document
283  )
284 
285  if validation_messages:
286  for message in validation_messages:
287  logging.warning(
288  f"SPDX Validation Warning: {message.validation_message}\n"
289  f"Context: {message.context}"
290  )
291  raise RuntimeError(
292  "SPDX document validation failed. See logs for details."
293  )
294  else:
295  logging.info("SPDX document validated successfully.")
296 
297  # validate=False here as we validated above
298  write_file(
299  self.document, file_name, validate=False
300  )
301 
302  def finalize_document(self):
303  """
304  Finalize the document by setting relations between packages and files.
305  At the same time, add all the licenses from files to the package and
306  calculate the verification code, without the excluded files.
307  """
308  self.__create_packages__create_packages()
309  self.__create_license_files__create_license_files()
310  self.__create_copyright_files__create_copyright_files()
311  self.__add_files_to_document__add_files_to_document()
312  self.__add_extracted_licenses__add_extracted_licenses()
313  self.__update_package_verification_code__update_package_verification_code()
314 
315  def __create_packages(self) -> None:
316  parent_name = self.scannerscannerscanner.get_scan_packages().parent_package.get(
317  'name', ''
318  )
319  if parent_name:
320  self.package.spdx_id = re.sub(
321  r'[^A-Za-z0-9\-_.]', '-',
322  f"SPDXRef-Package-{parent_name}"
323  )
324  describes_relationship = Relationship(
325  "SPDXRef-DOCUMENT",
326  RelationshipType.DESCRIBES,
327  self.package.spdx_id
328  )
329  self.document.relationships.append(describes_relationship)
330 
331  for purl, component in (
332  self.scannerscannerscanner.get_scan_packages().dependencies.items()
333  ):
334  package = self.__get_package_for_component__get_package_for_component(component)
335  self.document.packages.append(package)
336  depends_on_relationship = Relationship(
337  self.package.spdx_id,
338  RelationshipType.DEPENDS_ON,
339  package.spdx_id
340  )
341  self.document.relationships.append(depends_on_relationship)
342 
343  def __get_package_for_component(self, component: dict) -> Package:
344  """
345  For a given component, create a package and add it to the list.
346 
347  :param component: Component to create package for.
348  :return: Create or get existing package.
349  """
350  pkg_spdx_id = self.__get_package_spdx_id__get_package_spdx_id(component)
351  if pkg_spdx_id not in self.dependent_packages:
352  self.dependent_packages[pkg_spdx_id] = Package(
353  spdx_id=pkg_spdx_id,
354  name=component.get('name', 'UNKNOWN'),
355  version=component.get('version', 'UNKNOWN'),
356  download_location=component.get(
357  'fossology_download_url', SpdxNoAssertion()
358  ),
359  license_info_from_files=[],
360  license_concluded=SpdxNone(),
361  files_analyzed=True
362  )
363  purl_ref = ExternalPackageRef(
364  category=ExternalPackageRefCategory.PACKAGE_MANAGER,
365  reference_type='purl',
366  locator=component.get('purl')
367  )
368  self.dependent_packages[pkg_spdx_id].external_references.append(purl_ref)
369 
370  vcs_url = component.get('vcs_url')
371  if vcs_url:
372  vcs_ref = ExternalPackageRef(
373  category=ExternalPackageRefCategory.OTHER, reference_type='vcs',
374  locator=vcs_url
375  )
376  self.dependent_packages[pkg_spdx_id].external_references.append(vcs_ref)
377 
378  homepage_url = component.get('homepage_url')
379  if homepage_url:
380  homepage_ref = ExternalPackageRef(
381  category=ExternalPackageRefCategory.OTHER, reference_type='homepage',
382  locator=homepage_url
383  )
384  self.dependent_packages[pkg_spdx_id].external_references.append(
385  homepage_ref
386  )
387  return self.dependent_packages[pkg_spdx_id]
388 
389  def __create_license_files(self) -> None:
390  self.__create_license_file_from_component__create_license_file_from_component(
391  self.scannerscannerscanner.get_scan_packages().parent_package, self.package
392  )
393  for component in self.scannerscannerscanner.get_scan_packages().dependencies.values():
394  self.__create_license_file_from_component__create_license_file_from_component(
395  component, self.__get_package_for_component__get_package_for_component(
396  component
397  )
398  )
399 
400  def __create_copyright_files(self) -> None:
401  self.__create_copyright_file_from_component__create_copyright_file_from_component(
402  self.scannerscannerscanner.get_scan_packages().parent_package, self.package
403  )
404  for component in self.scannerscannerscanner.get_scan_packages().dependencies.values():
405  self.__create_copyright_file_from_component__create_copyright_file_from_component(
406  component, self.__get_package_for_component__get_package_for_component(
407  component
408  )
409  )
410 
411  def __create_license_file_from_component(
412  self, component: dict, package: Package
413  ) -> None:
414  for result in component.get('SCANNER_RESULTS', []):
415  self.__add_license_file__add_license_file(package, result)
416 
417  def __create_copyright_file_from_component(
418  self, component: dict, package: Package
419  ) -> None:
420  for result in component.get('COPYRIGHT_RESULT', []):
421  self.__add_copyright_file__add_copyright_file(package, result)
422 
423  def __add_files_to_document(self) -> None:
424  self.document.files = list(self.report_files.values())
425 
426  def __add_extracted_licenses(self) -> None:
427  self.document.extracted_licensing_info = list(
428  self.extracted_licenses.values()
429  )
430 
431  def __update_package_verification_code(self) -> None:
432  for package in self.document.packages:
433  code = self.__calculate_verification_code__calculate_verification_code(package.spdx_id)
434  if code is not None:
435  package.verification_code = code
436 
438  self, package_spdx_id: str
439  ) -> PackageVerificationCode | None:
440  """
441  Calculate package verification code for the list of checksums and return it.
442 
443  :param package_spdx_id: Package SPDX ID to calculate the verification
444  code for.
445  :return: Package Verification Code based on SPDX specification.
446  """
447  pkg_data = self.package_verification_set.get(package_spdx_id)
448  if not pkg_data:
449  return None
450 
451  checksums = pkg_data.get('checksums', [])
452  excluded_files = pkg_data.get('excluded_files', [])
453 
454  checksums.sort()
455 
456  verification_code = hashlib.sha1(
457  "".join(checksums).encode('utf-8', errors='ignore')
458  ).hexdigest()
459 
460  return PackageVerificationCode(
461  value=verification_code,
462  excluded_files=excluded_files
463  )
None __update_package_verification_code(self)
Definition: SpdxReport.py:431
def __init__(self, CliOptions cli_options, Scanners scanner)
Definition: SpdxReport.py:45
File __get_spdx_file(self, ScanResultList scan_result, Package package)
Definition: SpdxReport.py:173
None __create_copyright_file_from_component(self, dict component, Package package)
Definition: SpdxReport.py:419
def __add_copyright_file(self, Package package, ScanResultList copyright_result)
Definition: SpdxReport.py:218
Package __get_package_for_component(self, dict component)
Definition: SpdxReport.py:343
LicenseExpression __get_license_or_ref(self, str lic)
Definition: SpdxReport.py:111
None __create_license_file_from_component(self, dict component, Package package)
Definition: SpdxReport.py:413
def __add_license_file(self, Package package, ScanResultList scan_result)
Definition: SpdxReport.py:131
tuple[str, str, str] __get_file_info(ScanResultList scan_result)
Definition: SpdxReport.py:233
str __get_package_spdx_id(dict component)
Definition: SpdxReport.py:262
PackageVerificationCode|None __calculate_verification_code(self, str package_spdx_id)
Definition: SpdxReport.py:439
str __get_file_spdx_id(str sha256_hash, str pkg_name)
Definition: SpdxReport.py:251
def write_report(self, str file_name)
Definition: SpdxReport.py:275
list_t type structure used to keep various lists. (e.g. there are multiple lists).
Definition: nomos.h:308