FOSSology  4.7.1
Open Source License Compliance by Open Source Software
Scanners.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 fnmatch
9 import json
10 import multiprocessing
11 import os
12 from subprocess import Popen, PIPE
13 from typing import Any
14 
15 from .CliOptions import CliOptions
16 from .Packages import Packages
17 
18 
19 # ---------------------------------------------------------------------------
20 # Single source of truth for "no-license" sentinel values.
21 # Used by both scanner processing (here) and dashboard reporting.
22 # ---------------------------------------------------------------------------
23 _NO_LICENSE = frozenset({
24  'No_license_found', 'NOASSERTION', 'NONE', 'UnclassifiedLicense',
25 })
26 
27 
28 class ScanResult:
29  """
30  Store scan results from agents.
31 
32  :ivar file: File location
33  :ivar path: Actual location of file
34  :ivar result: License list for file
35  """
36  file: str = None
37  path: str = None
38  result: set[str] = None
39 
40  def __init__(self, file: str, path: str, result: set[str]):
41  self.filefile = file
42  self.pathpath = path
43  self.resultresult = result
44 
45 
47  """
48  Store scan results from agents with result as a list of dictionaries.
49 
50  :ivar file: File location
51  :ivar path: Actual location of file
52  :ivar result: License list for file as a list of dictionaries
53  """
54  file: str = None
55  path: str = None
56  result: list[dict] = None
57 
58  def __init__(self, file: str, path: str, result: list[dict]):
59  self.filefilefile = file
60  self.pathpathpath = path
61  self.resultresultresult = result
62 
63 
64 class Scanners:
65  """
66  Handle all the data from different scanners.
67 
68  :ivar nomos_path: path to nomos bin
69  :ivar copyright_path: path to copyright bin
70  :ivar keyword_path: path to keyword bin
71  :ivar ojo_path: path to ojo bin
72  :ivar cli_options: CliOptions object
73  """
74  nomos_path: str = '/bin/nomossa'
75  copyright_path: str = '/bin/copyright'
76  keyword_path: str = '/bin/keyword'
77  ojo_path: str = '/bin/ojo'
78 
79  def __init__(self, cli_options: CliOptions, scan_packages: Packages):
80  """
81  Initialize the cli_options
82 
83  :param cli_options: CliOptions object to use
84  :type cli_options: CliOptions
85  :param scan_packages: ScanPackages for references
86  :type scan_packages: Packages
87  """
88  self.cli_options: CliOptions = cli_options
89  self.scan_packages: Packages = scan_packages
90  self._allowlist_licenses_set_allowlist_licenses_set = set(
91  self.cli_options.allowlist.get('licenses', [])
92  )
93 
94  def get_scan_packages(self) -> Packages:
95  return self.scan_packages
96 
97  def is_excluded_path(self, path: str) -> bool:
98  """
99  Check if the path is allow listed
100 
101  The function used fnmatch to check if the path is in allow list or not.
102 
103  :param path: path to check
104  :return: True if the path is in allow list, False otherwise
105  """
106  for pattern in self.cli_options.allowlist.get('exclude', []):
107  if fnmatch.fnmatchcase(path, pattern):
108  return True
109  return False
110 
111  def __normalize_path(self, path: str, against: str) -> str:
112  """
113  Normalize the given path against the given directory.
114 
115  :param path: path to normalize
116  :param against: directory to normalize against
117  :return: Normalized path
118  """
119  if not against.endswith(os.sep):
120  against += os.sep
121  start_index_of_prefix = path.find(against)
122  if start_index_of_prefix == -1:
123  return path
124 
125  relative_path_start_index = start_index_of_prefix + len(against)
126  return path[relative_path_start_index:]
127 
129  self, scanner_path: str, dir_to_scan: str, extra_args: list[str] = None
130  ) -> dict:
131  """
132  Helper to execute a scanner command and return its JSON output.
133  """
134  command = [scanner_path, "-J", "-d", dir_to_scan]
135  if extra_args:
136  command.extend(extra_args)
137 
138  try:
139  # Use text=True for universal newlines and automatic decoding
140  process = Popen(command, stdout=PIPE, text=True, encoding='UTF-8')
141  stdout, stderr = process.communicate()
142 
143  if process.returncode != 0:
144  msg = (f"Scanner {scanner_path} exited with error code "
145  f"{process.returncode}. Stderr: {stderr}")
146  print(msg)
147  raise RuntimeError(msg)
148 
149  # Handle potential empty or malformed JSON output
150  if not stdout.strip():
151  return {}
152 
153  return json.loads(stdout.strip())
154  except FileNotFoundError as e:
155  print(f"Error: Scanner executable not found at {scanner_path}")
156  raise e
157  except json.JSONDecodeError as e:
158  print(f"Error: Failed to decode JSON from scanner {scanner_path} output.")
159  print(f"Raw output: {stdout}")
160  raise e
161  except Exception as e:
162  print(f"An unexpected error occurred while running {scanner_path}: {e}")
163  raise e
164 
165  def __get_nomos_result(self, dir_to_scan: str) -> dict:
166  """
167  Get the raw results from nomos scanner
168 
169  :return: raw json from nomos
170  """
171  extra_args = ["-S", "-l", "-n", str(multiprocessing.cpu_count() - 1)]
172  return self._execute_scanner_command_execute_scanner_command(
173  self.nomos_path, dir_to_scan, extra_args
174  )
175 
176  def __get_ojo_result(self, dir_to_scan: str) -> dict:
177  """
178  Get the raw results from ojo scanner
179 
180  :return: raw json from ojo, normalized to nomos format
181  """
182  raw = self._execute_scanner_command_execute_scanner_command(self.ojo_path, dir_to_scan)
183  # Normalize ojo output to match nomos format:
184  # ojo returns: [{"file": "...", "results": [...]}]
185  # nomos returns: {"results": [{"file": "...", "licenses": [...]}]}
186  if isinstance(raw, list):
187  transformed = []
188  for entry in raw:
189  transformed.append({
190  'file': entry.get('file', ''),
191  'licenses': entry.get('results') or []
192  })
193  return {'results': transformed}
194  return raw
195 
196  def __get_copyright_results(self, dir_to_scan: str) -> dict:
197  """
198  Get the raw results from copyright scanner
199 
200  :return: raw json from copyright
201  """
202  return self._execute_scanner_command_execute_scanner_command(self.copyright_path, dir_to_scan)
203 
204  def __get_keyword_results(self, dir_to_scan: str) -> dict:
205  """
206  Get the raw results from keyword scanner
207 
208  :return: raw json from keyword
209  """
210  return self._execute_scanner_command_execute_scanner_command(self.keyword_path, dir_to_scan)
211 
213  self, component: dict, is_parent: bool, scanner_func: callable,
214  result_key: str, whole: bool = False, all_results: bool = False
215  ) -> list[ScanResult] | list[ScanResultList]:
216  """
217  Generalized function to process results from a single scanner for a given
218  component. Set `result_key` to 'results' for copyrights and 'licenses' for
219  license scanning.
220  """
221  dir_to_scan = self.cli_options.diff_dir if is_parent else os.path.join(
222  component['download_dir'], component['base_dir']
223  )
224 
225  raw_results = scanner_func(dir_to_scan)
226  processed_list: list[ScanResult] | list[ScanResultList] = []
227  raw_results_list: list[
228  dict[str, str | list[dict[str, str | int]] | None]] = []
229 
230  if isinstance(raw_results, dict):
231  if 'results' in raw_results:
232  raw_results_list = raw_results['results']
233  elif isinstance(raw_results, list):
234  raw_results_list = raw_results
235 
236  if not raw_results_list:
237  return processed_list
238 
239  for result_entry in raw_results_list:
240  # Skip if 'file' or 'results'/'licenses' key is missing or malformed
241  if (
242  'file' not in result_entry
243  or result_key not in result_entry
244  or result_entry.get(result_key) == "Unable to read file"
245  ):
246  continue
247 
248  file_path = self.__normalize_path__normalize_path(result_entry['file'], dir_to_scan)
249 
250  if self.cli_options.repo and not all_results and self.is_excluded_pathis_excluded_path(
251  file_path
252  ):
253  continue
254 
255  current_findings: set[str] | list[dict[str, Any]] = set() if not whole \
256  else []
257 
258  findings_list = result_entry.get(result_key, None)
259  if findings_list is None:
260  continue
261 
262  for finding in findings_list:
263  if finding is None:
264  continue
265 
266  if whole:
267  # Need whole JSON for ScanResultList
268  if (
269  result_key == 'results'
270  and 'type' in finding
271  and finding['type'] == 'statement'
272  and finding.get('content')
273  ):
274  current_findings.append(finding)
275  elif (
276  result_key == 'licenses'
277  and finding.get('license') not in _NO_LICENSE
278  ):
279  current_findings.append(finding)
280  else:
281  # Need set of string for ScanResult
282  content = finding.get('content') or finding.get('license')
283  content = content.strip()
284  if (
285  result_key == 'results'
286  and 'type' in finding
287  and finding['type'] != 'statement'
288  ):
289  continue
290 
291  if content and content not in _NO_LICENSE:
292  current_findings.add(content)
293 
294  # Always create a result entry when the scanner returned findings for
295  # this file, even if all values were NO_LICENSE — the dashboard needs
296  # these to count them in "Total Files" and "Files Without License".
297  # `findings_list` is non-None here (checked above).
298  if whole:
299  processed_list.append(
300  ScanResultList(file_path, result_entry['file'], current_findings)
301  )
302  else:
303  processed_list.append(
304  ScanResult(file_path, result_entry['file'], current_findings)
305  )
306 
307  return processed_list
308 
310  self, all_results: bool = False, whole: bool = False
311  ) -> None:
312  """
313  Set the formatted results from copyright scanner for the components.
314  """
315  if not self.cli_options.scan_only_deps:
316  self.scan_packages.parent_package[
317  'COPYRIGHT_RESULT'] = self._process_single_scanner_package_process_single_scanner_package(
318  component=self.scan_packages.parent_package, is_parent=True,
319  scanner_func=self.__get_copyright_results__get_copyright_results, result_key='results',
320  whole=whole, all_results=all_results
321  )
322  for purl in self.scan_packages.dependencies.keys():
323  component = self.scan_packages.dependencies[purl]
324  component['COPYRIGHT_RESULT'] = self._process_single_scanner_package_process_single_scanner_package(
325  component=component, is_parent=False,
326  scanner_func=self.__get_copyright_results__get_copyright_results, result_key='results',
327  whole=whole, all_results=all_results
328  )
329 
330  def set_keyword_list(self, whole: bool = False) -> None:
331  """
332  Get the formatted results from keyword scanner
333  """
334  if not self.cli_options.scan_only_deps:
335  self.scan_packages.parent_package[
336  'KEYWORD_RESULT'] = self._process_single_scanner_package_process_single_scanner_package(
337  component=self.scan_packages.parent_package, is_parent=True,
338  scanner_func=self.__get_keyword_results__get_keyword_results, result_key='results',
339  whole=whole
340  )
341  for purl in self.scan_packages.dependencies.keys():
342  component = self.scan_packages.dependencies[purl]
343  component['KEYWORD_RESULT'] = self._process_single_scanner_package_process_single_scanner_package(
344  component=component, is_parent=False,
345  scanner_func=self.__get_keyword_results__get_keyword_results, result_key='results',
346  whole=whole
347  )
348 
349  def __set_license_nomos(self, whole: bool = False) -> None:
350  """
351  Update the packages with formatted results of nomos scanner
352  """
353  if not self.cli_options.scan_only_deps:
354  self.scan_packages.parent_package[
355  'NOMOS_RESULT'] = self._process_single_scanner_package_process_single_scanner_package(
356  component=self.scan_packages.parent_package, is_parent=True,
357  scanner_func=self.__get_nomos_result__get_nomos_result, result_key='licenses', whole=whole
358  )
359  for purl in self.scan_packages.dependencies.keys():
360  component = self.scan_packages.dependencies[purl]
361  component['NOMOS_RESULT'] = self._process_single_scanner_package_process_single_scanner_package(
362  component=component, is_parent=False,
363  scanner_func=self.__get_nomos_result__get_nomos_result, result_key='licenses', whole=whole
364  )
365 
366  def __set_license_ojo(self, whole: bool = False) -> None:
367  """
368  Update the packages with formatted results of ojo scanner
369  """
370  if not self.cli_options.scan_only_deps:
371  self.scan_packages.parent_package[
372  'OJO_RESULT'] = self._process_single_scanner_package_process_single_scanner_package(
373  component=self.scan_packages.parent_package, is_parent=True,
374  scanner_func=self.__get_ojo_result__get_ojo_result, result_key='licenses', whole=whole
375  )
376  for purl in self.scan_packages.dependencies.keys():
377  component = self.scan_packages.dependencies[purl]
378  component['OJO_RESULT'] = self._process_single_scanner_package_process_single_scanner_package(
379  component=component, is_parent=False,
380  scanner_func=self.__get_ojo_result__get_ojo_result, result_key='licenses', whole=whole
381  )
382 
384  self, nomos_licenses: list[ScanResult] | list[ScanResultList],
385  ojo_licenses: list[ScanResult] | list[ScanResultList],
386  whole: bool = False
387  ) -> list[ScanResult] | list[ScanResultList]:
388  """
389  Merge the results from nomos and ojo based on file name
390  """
391  nomos_dict = {entry.file: entry for entry in nomos_licenses}
392 
393  for ojo_entry in ojo_licenses:
394  if ojo_entry.file in nomos_dict:
395  existing = nomos_dict[ojo_entry.file]
396  if whole:
397  seen = {
398  item.get('license') for item in existing.result
399  if isinstance(item, dict)
400  }
401  for item in ojo_entry.result:
402  lic = item.get('license') if isinstance(item, dict) else item
403  if lic not in seen:
404  existing.result.append(item)
405  seen.add(lic)
406  else:
407  existing.result.update(ojo_entry.result)
408  else:
409  # If an ojo entry doesn't have a corresponding nomos entry, add it
410  nomos_licenses.append(ojo_entry)
411  return nomos_licenses
412 
414  self, scan_results: list[ScanResult] = None,
415  scan_results_whole: list[ScanResultList] = None, whole: bool = False
416  ) -> list[ScanResult] | list[ScanResultList]:
417  """
418  Get results where license check failed.
419  """
420  final_results = []
421  if whole and scan_results_whole is not None:
422  for row in scan_results_whole:
423  if self.cli_options.repo and self.is_excluded_pathis_excluded_path(row.file):
424  continue
425 
426  # Filter licenses that are NOT in the allowlist
427  failed_licenses_list = [
428  lic for lic in row.result if
429  lic.get('license') not in self._allowlist_licenses_set_allowlist_licenses_set
430  ]
431  if failed_licenses_list:
432  final_results.append(
433  ScanResultList(row.file, row.path, failed_licenses_list)
434  )
435  elif not whole and scan_results is not None:
436  for row in scan_results:
437  if self.cli_options.repo and self.is_excluded_pathis_excluded_path(row.file):
438  continue
439 
440  # Filter licenses that are NOT in the allowlist
441  failed_licenses = {
442  lic for lic in row.result if
443  lic not in self._allowlist_licenses_set_allowlist_licenses_set
444  }
445  if failed_licenses:
446  final_results.append(ScanResult(row.file, row.path, failed_licenses))
447  return final_results
448 
449  def get_non_allow_listed_copyrights(self) -> list[ScanResult]:
450  """
451  Get copyrights from files which are not allow listed.
452  """
453  copyright_results = self.get_copyright_resultsget_copyright_results()
454  return [row for row in copyright_results if
455  self.cli_options.repo and not self.is_excluded_pathis_excluded_path(row.file)]
456 
457  def get_copyright_results(self) -> list[ScanResultList]:
458  """
459  Get list of copyright scan results from the package list.
460  """
461  copyright_results = []
462  copyright_results.extend(
463  self.scan_packages.parent_package.get('COPYRIGHT_RESULT', [])
464  )
465  for dep in self.scan_packages.dependencies.values():
466  copyright_results.extend(dep.get('COPYRIGHT_RESULT', []))
467  return copyright_results
468 
469  def get_keyword_results(self) -> list[ScanResultList]:
470  """
471  Get list of keywords scan results from the package list.
472  """
473  keyword_results = []
474  keyword_results.extend(
475  self.scan_packages.parent_package.get('KEYWORD_RESULT', [])
476  )
477  for dep in self.scan_packages.dependencies.values():
478  keyword_results.extend(dep.get('KEYWORD_RESULT', []))
479  return keyword_results
480 
481  def get_license_results(self) -> list[ScanResultList]:
482  """
483  Get list of license scan results from the package list.
484  """
485  scanner_results = []
486  scanner_results.extend(
487  self.scan_packages.parent_package.get('SCANNER_RESULTS', [])
488  )
489  for dep in self.scan_packages.dependencies.values():
490  scanner_results.extend(dep.get('SCANNER_RESULTS', []))
491  return scanner_results
492 
494  self, whole: bool = False
495  ) -> list[ScanResult] | list[ScanResultList]:
496  """
497  Get the formatted list of license scanner findings
498 
499  The list contains the merged result of nomos/ojo scanner based on
500  cli_options passed
501  """
502  scanner_results = self.get_license_resultsget_license_results()
503 
504  failed_licenses = self.get_non_allow_listed_resultsget_non_allow_listed_results(
505  scan_results_whole=scanner_results, whole=True
506  )
507 
508  if whole:
509  return failed_licenses
510  else:
511  # Convert ScanResultList to ScanResult for non-whole output
512  return [
513  ScanResult(
514  item.file, item.path,
515  {res['license'] for res in item.result if 'license' in res}
516  ) for item in failed_licenses
517  ]
518 
519  def set_scanner_results(self, whole: bool = False) -> None:
520  """
521  Set the key `SCANNER_RESULTS` for all components in scan_packages using
522  nomos and ojo scanners (whichever is selected).
523  """
524  if self.cli_options.nomos:
525  self.__set_license_nomos__set_license_nomos(whole)
526  if self.cli_options.ojo:
527  self.__set_license_ojo__set_license_ojo(whole)
528 
529  if self.cli_options.nomos and self.cli_options.ojo:
530  # Merge nomos and ojo per file so a file scanned by both scanners
531  # produces a single SCANNER_RESULTS entry (avoids duplicate file rows
532  # in the text report, dashboard and SBOM).
533  self.scan_packages.parent_package[
534  'SCANNER_RESULTS'] = self.__merge_nomos_ojo__merge_nomos_ojo(
535  self.scan_packages.parent_package.get('NOMOS_RESULT', []),
536  self.scan_packages.parent_package.get('OJO_RESULT', []),
537  whole=whole
538  )
539  for purl in self.scan_packages.dependencies.keys():
540  component = self.scan_packages.dependencies[purl]
541  component['SCANNER_RESULTS'] = self.__merge_nomos_ojo__merge_nomos_ojo(
542  component.get('NOMOS_RESULT', []), component.get('OJO_RESULT', []),
543  whole=whole
544  )
545  else:
546  scanner_key = 'NOMOS_RESULT' if self.cli_options.nomos else 'OJO_RESULT'
547  # Handle parent package separately
548  self.scan_packages.parent_package[
549  'SCANNER_RESULTS'] = self.scan_packages.parent_package.get(
550  scanner_key, []
551  )
552  for purl in self.scan_packages.dependencies.keys():
553  component = self.scan_packages.dependencies[purl]
554  component['SCANNER_RESULTS'] = component.get(scanner_key, [])
list[ScanResultList] get_keyword_results(self)
Definition: Scanners.py:469
def __init__(self, CliOptions cli_options, Packages scan_packages)
Definition: Scanners.py:79
list[ScanResultList] get_license_results(self)
Definition: Scanners.py:481
dict __get_keyword_results(self, str dir_to_scan)
Definition: Scanners.py:204
None set_scanner_results(self, bool whole=False)
Definition: Scanners.py:519
None set_copyright_list(self, bool all_results=False, bool whole=False)
Definition: Scanners.py:311
dict __get_ojo_result(self, str dir_to_scan)
Definition: Scanners.py:176
dict __get_copyright_results(self, str dir_to_scan)
Definition: Scanners.py:196
list[ScanResult]|list[ScanResultList] _process_single_scanner_package(self, dict component, bool is_parent, callable scanner_func, str result_key, bool whole=False, bool all_results=False)
Definition: Scanners.py:215
dict _execute_scanner_command(self, str scanner_path, str dir_to_scan, list[str] extra_args=None)
Definition: Scanners.py:130
list[ScanResult]|list[ScanResultList] results_are_allow_listed(self, bool whole=False)
Definition: Scanners.py:495
str __normalize_path(self, str path, str against)
Definition: Scanners.py:111
dict __get_nomos_result(self, str dir_to_scan)
Definition: Scanners.py:165
None set_keyword_list(self, bool whole=False)
Definition: Scanners.py:330
None __set_license_nomos(self, bool whole=False)
Definition: Scanners.py:349
list[ScanResult]|list[ScanResultList] get_non_allow_listed_results(self, list[ScanResult] scan_results=None, list[ScanResultList] scan_results_whole=None, bool whole=False)
Definition: Scanners.py:416
None __set_license_ojo(self, bool whole=False)
Definition: Scanners.py:366
list[ScanResultList] get_copyright_results(self)
Definition: Scanners.py:457
list[ScanResult]|list[ScanResultList] __merge_nomos_ojo(self, list[ScanResult]|list[ScanResultList] nomos_licenses, list[ScanResult]|list[ScanResultList] ojo_licenses, bool whole=False)
Definition: Scanners.py:387
list[ScanResult] get_non_allow_listed_copyrights(self)
Definition: Scanners.py:449
bool is_excluded_path(self, str path)
Definition: Scanners.py:97