FOSSology  4.7.1
Open Source License Compliance by Open Source Software
fossologyscanner.py
1 #!/usr/bin/env python3
2 
3 # SPDX-FileCopyrightText: © 2020,2023,2025 Siemens AG
4 # SPDX-FileCopyrightText: © anupam.ghosh@siemens.com
5 # SPDX-FileCopyrightText: © mishra.gaurav@siemens.com
6 
7 # SPDX-License-Identifier: GPL-2.0-only
8 
9 import argparse
10 import json
11 import logging
12 import os
13 import sys
14 import textwrap
15 from typing import IO
16 
17 # Configure logging
18 logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
19 
20 from FoScanner.ApiConfig import (ApiConfig, Runner)
21 from FoScanner.CliOptions import (CliOptions, ReportFormat)
22 from FoScanner.FormatResults import FormatResult
23 from FoScanner.Packages import Packages
24 from FoScanner.RepoSetup import RepoSetup
25 from FoScanner.Scanners import (Scanners, ScanResult)
26 from FoScanner.SpdxReport import SpdxReport
27 from FoScanner.Spdx3Report import Spdx3Report
28 from FoScanner.DashboardReport import DashboardReport
29 from FoScanner.Utils import (
30  validate_keyword_conf_file, copy_keyword_file_to_destination
31 )
32 from ScanDeps.Downloader import Downloader
33 from ScanDeps.Parsers import Parser, PythonParser, NPMParser
34 
35 SPDX3_FORMATS = (ReportFormat.SPDX3_JSON, ReportFormat.SPDX3_TTL, ReportFormat.SPDX3_RDF)
36 
37 
38 def get_api_config() -> ApiConfig:
39  """
40  Set the API configuration based on CI the job is running on
41 
42  :return: ApiConfig object
43  """
44  api_config = ApiConfig()
45  if 'GITLAB_CI' in os.environ:
46  api_config.running_on = Runner.GITLAB
47  api_config.api_url = os.environ.get('CI_API_V4_URL', '')
48  api_config.project_id = os.environ.get('CI_PROJECT_ID', '')
49  api_config.mr_iid = os.environ.get('CI_MERGE_REQUEST_IID', '')
50  api_config.api_token = os.environ.get('API_TOKEN', '')
51  api_config.project_name = os.environ.get('CI_PROJECT_NAME', '')
52  api_config.project_desc = os.environ.get('CI_PROJECT_DESCRIPTION', '').strip()
53  if not api_config.project_desc:
54  api_config.project_desc = None
55  api_config.project_orig = os.environ.get('CI_PROJECT_NAMESPACE', '')
56  api_config.project_url = os.environ.get('CI_PROJECT_URL', '')
57  elif os.environ.get('TRAVIS') == 'true':
58  api_config.running_on = Runner.TRAVIS
59  api_config.travis_repo_slug = os.environ.get('TRAVIS_REPO_SLUG', '')
60  api_config.travis_pull_request = os.environ.get('TRAVIS_PULL_REQUEST', '')
61  if api_config.travis_repo_slug:
62  api_config.project_name = api_config.travis_repo_slug.split("/")[-1]
63  api_config.project_orig = "/".join(api_config.travis_repo_slug.split("/")[:-2])
64  api_config.project_url = f"https://github.com/{api_config.travis_repo_slug}"
65  elif os.environ.get('GITHUB_ACTIONS') == 'true':
66  api_config.running_on = Runner.GITHUB
67  api_config.api_url = os.environ.get('GITHUB_API', 'https://api.github.com')
68  api_config.api_token = os.environ.get('GITHUB_TOKEN', '')
69  api_config.github_repo_slug = os.environ.get('GITHUB_REPOSITORY', '')
70  api_config.github_pull_request = os.environ.get('GITHUB_PULL_REQUEST', '')
71  if api_config.github_repo_slug:
72  api_config.project_name = api_config.github_repo_slug.split("/")[-1]
73  api_config.project_orig = os.environ.get('GITHUB_REPO_OWNER', '')
74  api_config.project_url = os.environ.get('GITHUB_REPO_URL', '')
75  return api_config
76 
77 
78 def get_allow_list(path: str = '') -> dict:
79  """
80  Decode json from `allowlist.json`
81 
82  :param path: path to allowlist file. Default=''
83  :return: allowlist dictionary
84 
85  """
86  file_name = 'allowlist.json'
87  if not path:
88  if os.path.exists('whitelist.json'):
89  file_name = 'whitelist.json'
90  logging.warning(
91  "Name 'whitelist.json' is deprecated. "
92  "Please use 'allowlist.json' instead."
93  )
94  logging.info(f"Reading {file_name} file...")
95  else:
96  file_name = path
97  logging.info(f"Reading allowlist.json file from the path: '{file_name}'")
98  with open(file_name, 'r', encoding='utf-8') as f:
99  data = json.load(f)
100  return data
101 
102 
103 def print_results(
104  name: str, failed_results: list[ScanResult],
105  scan_results_with_line_number: list[dict[str, set[str]]],
106  result_file: IO
107 ):
108  """
109  Print the formatted scanner results
110 
111  :param name: Name of the scanner
112  :param failed_results: formatted scanner results to be printed
113  :param scan_results_with_line_number: List of words mapped to their line
114  numbers
115  :param result_file: File to write results to
116  """
117  line_number_map: dict[str, set[str]] = {}
118  for item in scan_results_with_line_number:
119  if item:
120  line_number_map.update(item)
121 
122  for files in failed_results:
123  logging.info(f"File: {files.file}")
124  result_file.write(f"File: {files.file}\n")
125 
126  plural_name = "s" if len(files.result) > 1 else ""
127  logging.info(f"{name}{plural_name}:")
128  result_file.write(f"{name}{plural_name}:\n")
129 
130  for result_item in files.result:
131  if isinstance(result_item, dict):
132  scanned_word = result_item.get('content') or result_item.get('license')
133  else:
134  scanned_word = str(result_item)
135 
136  if scanned_word in line_number_map:
137  lines = line_number_map[scanned_word]
138  plural_lines = "s" if len(lines) > 1 else ""
139  lines_str = ", ".join(lines)
140  formatted_output = f"{scanned_word} at line{plural_lines} {lines_str}"
141  else:
142  formatted_output = scanned_word
143 
144  logging.info(f"\t{formatted_output}")
145  result_file.write(f"\t{formatted_output}\n")
146 
147 
148 def print_log_message(
149  filename: str,
150  failed_list: bool | list[ScanResult],
151  check_value: bool, failure_text: str,
152  acceptance_text: str, scan_type: str,
153  return_val: int, scan_results_with_line_number: list[dict[str, set[str]]]
154 ) -> int:
155  """
156  Common helper function to print scan results.
157 
158  :param filename: File where results are to be stored.
159  :param failed_list: Failed scan results.
160  :param check_value: Boolean value which failed_list should have.
161  :param failure_text: Message to print in case of failures.
162  :param acceptance_text: Message to print in case of no failures.
163  :param scan_type: Type of scan to print.
164  :param return_val: Return value for program
165  :param scan_results_with_line_number: List of words mapped to their line
166  numbers
167  :return: New return value
168  """
169  with open(filename, 'w', encoding='utf-8') as report_file:
170  has_failures = False
171  if isinstance(failed_list, bool):
172  has_failures = (failed_list != check_value)
173  elif isinstance(failed_list, list):
174  has_failures = (len(failed_list) > 0)
175 
176  if has_failures:
177  logging.error(f"\u2718 {failure_text}:") # Cross mark
178  report_file.write(f"{failure_text}:\n")
179  print_results(
180  scan_type, failed_list, scan_results_with_line_number, report_file
181  )
182  if scan_type == "License":
183  return_val |= 2
184  elif scan_type == "Copyright":
185  return_val |= 4
186  elif scan_type == "Keyword":
187  return_val |= 8
188  else:
189  logging.info(f"\u2714 {acceptance_text}") # Check mark
190  report_file.write(f"{acceptance_text}\n")
191 
192  logging.info("")
193  return return_val
194 
195 
196 def _format_results_with_line_numbers(
197  scanner: Scanners, format_results: FormatResult, result_type: str, key: str
198 ) -> list[dict[str, set[str]]]:
199  """
200  Generic function to format scanner results with line numbers.
201 
202  :param scanner: Scanner object
203  :param format_results: FormatResult object
204  :param result_type: Type of results to retrieve ('keyword', 'copyright',
205  'license')
206  :param key: The key within the scan result dictionary to use for the word (
207  e.g., 'content' for copyrights and 'licenses' for license scans)
208  :return: List of dicts with key as word and value as list of line numbers of the words
209  """
210  if result_type == 'keyword':
211  scan_results = scanner.get_keyword_results()
212  elif result_type == 'copyright':
213  scan_results = scanner.get_copyright_results()
214  elif result_type == 'license':
215  # license_results can be True/None or a list, ensure it's a list
216  license_res = scanner.results_are_allow_listed(whole=True)
217  scan_results = license_res if isinstance(license_res, list) else []
218  else:
219  return []
220 
221  formatted_list_of_line_numbers = []
222  for scan_result_item in scan_results:
223  list_of_scan_results = (
224  list(scan_result_item.result)
225  if scan_result_item and scan_result_item.result
226  else []
227  )
228 
229  words_with_line_numbers = format_results.find_word_line_numbers(
230  scan_result_item.path, list_of_scan_results, key=key
231  )
232  if words_with_line_numbers:
233  formatted_list_of_line_numbers.append(words_with_line_numbers)
234  return formatted_list_of_line_numbers
235 
236 
237 def text_report(
238  cli_options: CliOptions, result_dir: str, return_val: int,
239  scanner: Scanners, format_results: FormatResult
240 ) -> int:
241  """
242  Run scanners and print results in text format.
243 
244  :param cli_options: CLI options
245  :param result_dir: Result directory location
246  :param return_val: Return value of program
247  :param scanner: Scanner object
248  :param format_results: FormatResult object
249  :return: Program's return value
250  """
251  return perform_scans(
252  cli_options, format_results, result_dir, return_val, scanner
253  )
254 
255 
256 def perform_scans(cli_options, format_results, result_dir, return_val, scanner):
257  if cli_options.nomos or cli_options.ojo:
258  logging.info("Scanning for licenses...")
259  scanner.set_scanner_results(whole=True)
260  scan_results_with_line_number = _format_results_with_line_numbers(
261  scanner=scanner, format_results=format_results,
262  result_type='license', key='license'
263  )
264  failed_licenses = scanner.results_are_allow_listed()
265  return_val = print_log_message(
266  f"{result_dir}/licenses.txt", failed_licenses, True,
267  "Following licenses found which are not allow listed",
268  "No license violation found", "License", return_val,
269  scan_results_with_line_number
270  )
271  if cli_options.copyright:
272  logging.info("Scanning for copyrights...")
273  scanner.set_copyright_list(all_results=True, whole=True)
274  failed_copyrights = scanner.get_non_allow_listed_copyrights()
275  scan_results_with_line_number = _format_results_with_line_numbers(
276  scanner=scanner, format_results=format_results,
277  result_type='copyright', key='content'
278  )
279  return_val = print_log_message(
280  f"{result_dir}/copyrights.txt",
281  failed_copyrights, False, "Following copyrights found",
282  "No copyright violation found", "Copyright", return_val,
283  scan_results_with_line_number
284  )
285  if cli_options.keyword:
286  logging.info("Scanning keywords...")
287  scanner.set_keyword_list(whole=True)
288  scan_results_with_line_number = _format_results_with_line_numbers(
289  scanner=scanner, format_results=format_results,
290  result_type='keyword', key='content'
291  )
292  keyword_results = [
293  r.result.get('content') for r in scanner.get_keyword_results()
294  if r.result and r.result.get('content')
295  ]
296 
297  return_val = print_log_message(
298  f"{result_dir}/keywords.txt",
299  keyword_results, False, "Following keywords found",
300  "No keyword violation found", "Keyword", return_val,
301  scan_results_with_line_number
302  )
303  return return_val
304 
305 
306 def bom_report(
307  cli_options: CliOptions, result_dir: str, return_val: int,
308  scanner: Scanners, format_results: FormatResult
309 ) -> int:
310  """
311  Run scanners and generate a report.
312 
313  Handles all SPDX formats (2.3 and 3.0) via a single entry point.
314  The report class and file name are selected based on report_format.
315 
316  :param cli_options: CLI options
317  :param result_dir: Result directory location
318  :param return_val: Return value
319  :param scanner: Scanner object
320  :param format_results: FormatResult object
321  :return: Program's return value
322  """
323 
324  if cli_options.report_format in SPDX3_FORMATS:
325  report_obj = Spdx3Report(cli_options, scanner)
326  else:
327  report_obj = SpdxReport(cli_options, scanner)
328 
329  return_val = perform_scans(
330  cli_options, format_results, result_dir, return_val, scanner
331  )
332  logging.info("Finalizing reports...")
333  report_obj.finalize_document()
334 
335  # Pick file name based on format
336  report_name = f"{result_dir}/sbom_"
337  if cli_options.report_format == ReportFormat.SPDX_JSON:
338  report_name += "spdx.json"
339  elif cli_options.report_format == ReportFormat.SPDX_RDF:
340  report_name += "spdx.rdf"
341  elif cli_options.report_format == ReportFormat.SPDX_TAG_VALUE:
342  report_name += "spdx.spdx"
343  elif cli_options.report_format == ReportFormat.SPDX_YAML:
344  report_name += "spdx.yaml"
345  elif cli_options.report_format == ReportFormat.SPDX3_JSON:
346  report_name += "spdx3.jsonld"
347  elif cli_options.report_format == ReportFormat.SPDX3_TTL:
348  report_name += "spdx3.ttl"
349  elif cli_options.report_format == ReportFormat.SPDX3_RDF:
350  report_name += "spdx3.rdf"
351 
352  logging.info(f"Validating and writing report to file {report_name}...")
353  try:
354  report_obj.write_report(report_name)
355  logging.info(f"\u2714 Saved SBOM as {report_name}")
356  except RuntimeError as e:
357  logging.error(f"Failed to write SBOM report: {e}")
358  return_val |= 1
359 
360  return return_val
361 
362 
363 def get_scan_packages(api_config: ApiConfig) -> Packages:
364  scan_packages = Packages()
365  scan_packages.parent_package = {
366  'name': api_config.project_name,
367  'description': api_config.project_desc,
368  'author': api_config.project_orig,
369  'url': api_config.project_url
370  }
371 
372  return scan_packages
373 
374 
375 def main(parsed_args):
376  """
377  Main
378 
379  :param parsed_args:
380  :return: 0 for success, error code on failure.
381  """
382  api_config = get_api_config()
383  cli_options = CliOptions()
384  cli_options.update_args(parsed_args)
385  save_dir = 'pkg_downloads'
386  scan_packages = get_scan_packages(api_config)
387 
388  try:
389  if cli_options.allowlist_path:
390  cli_options.allowlist = get_allow_list(path=cli_options.allowlist_path)
391  else:
392  cli_options.allowlist = get_allow_list()
393  except FileNotFoundError:
394  logging.warning("Unable to find allowlist.json in current dir. "
395  "Continuing without it.")
396  except json.JSONDecodeError:
397  logging.error("Error parsing allowlist.json. Please ensure it's valid JSON."
398  " Continuing without it.")
399  except Exception as e:
400  logging.error(f"An unexpected error occurred while reading allowlist: {e}."
401  " Continuing without it.")
402 
403  if cli_options.keyword and cli_options.keyword_conf_file_path:
404  keyword_conf_file_path = cli_options.keyword_conf_file_path
405  destination_path = '/usr/local/share/fossology/keyword/agent/keyword.conf'
406  is_valid, message = validate_keyword_conf_file(keyword_conf_file_path)
407  if is_valid:
408  logging.info(f"Validation of keyword file successful: {message}")
409  copy_keyword_file_to_destination(keyword_conf_file_path, destination_path)
410  else:
411  logging.error(f"Could not validate keyword file: {message}")
412 
413  if (cli_options.scan_only_deps or cli_options.repo) and cli_options.sbom_path:
414  sbom_file_path = cli_options.sbom_path
415  cli_options.parser = Parser(sbom_file_path)
416  cli_options.parser.classify_components(save_dir)
417 
418  if cli_options.parser.python_components:
419  python_parser = PythonParser()
420  python_parser.parse_components(cli_options.parser)
421 
422  if cli_options.parser.npm_components:
423  npm_parser = NPMParser()
424  npm_parser.parse_components(cli_options.parser)
425 
426  if cli_options.parser.unsupported_components:
427  for comp in cli_options.parser.unsupported_components:
428  logging.warning(
429  f"The purl {comp.get('purl', 'N/A')} is not supported. "
430  "Package will not be downloaded."
431  )
432 
433  scan_packages.dependencies = cli_options.parser.parsed_components
434 
435  try:
436  downloader = Downloader()
437  downloader.download_concurrently(cli_options.parser)
438  except Exception as e:
439  logging.error(
440  f"Something went wrong while downloading the dependencies: {e}")
441 
442  if cli_options.scan_dir:
443  cli_options.diff_dir = cli_options.dir_path
444  elif not cli_options.repo and not cli_options.scan_only_deps:
445  repo_setup = RepoSetup(cli_options, api_config)
446  cli_options.diff_dir = repo_setup.get_diff_dir()
447 
448  scanner = Scanners(cli_options, scan_packages)
449  return_val = 0
450 
451  # Populate tmp dir in unified diff format
452  format_results = FormatResult(cli_options)
453  format_results.process_files(scanner.cli_options.diff_dir)
454 
455  # Create result dir
456  result_dir = "results"
457  os.makedirs(name=result_dir, exist_ok=True)
458 
459  logging.info("Preparing scan reports...")
460  if cli_options.report_format == ReportFormat.TEXT:
461  return_val = text_report(
462  cli_options, result_dir, return_val, scanner,
463  format_results
464  )
465  else:
466  return_val = bom_report(
467  cli_options, result_dir, return_val, scanner,
468  format_results
469  )
470 
471  # Generate compliance dashboard
472  try:
473  logging.info("Generating compliance dashboard...")
474  dashboard = DashboardReport(cli_options, scanner)
475  dashboard.finalize_document()
476  dashboard_file = f"{result_dir}/dashboard.md"
477  dashboard.write_report(dashboard_file)
478  logging.info("Dashboard generated successfully.")
479  except Exception as exc:
480  logging.critical(f"Dashboard generation failed: {exc}")
481 
482  return return_val
483 
484 
485 if __name__ == "__main__":
486  parser = argparse.ArgumentParser(
487  description=textwrap.dedent("""fossology scanner designed for CI""")
488  )
489  parser.add_argument(
490  "operation", type=str, help="Operations to run.", nargs='*',
491  choices=[
492  "nomos", "copyright", "keyword", "ojo", "repo", "differential",
493  "scan-only-deps", "scan-dir"
494  ]
495  )
496  parser.add_argument(
497  "--tags", type=str, nargs=2,
498  help="Tags for differential scan. Required if 'differential' is specified."
499  )
500  parser.add_argument(
501  "--report", type=str, help="Type of report to generate. Default 'TEXT'.",
502  choices=[member.name for member in ReportFormat],
503  default=ReportFormat.TEXT.name
504  )
505  parser.add_argument(
506  '--keyword-conf', type=str, help='Path to the keyword configuration file. '
507  'Use only when keyword argument is true'
508  )
509  parser.add_argument(
510  '--dir-path', type=str, help='Path to directory for scanning.'
511  )
512 
513  parser.add_argument(
514  "--allowlist-path", type=str,
515  help="Pass allowlist.json to allowlist dependencies."
516  )
517  parser.add_argument(
518  "--sbom-path", type=str,
519  help="Path to SBOM file for downloading dependencies."
520  )
521 
522  args = parser.parse_args()
523  sys.exit(main(args))
Store the options sent through the CLI.
list_t type structure used to keep various lists. (e.g. there are multiple lists).
Definition: nomos.h:308