18 logging.basicConfig(level=logging.INFO, format=
'%(levelname)s: %(message)s')
30 validate_keyword_conf_file, copy_keyword_file_to_destination
35 SPDX3_FORMATS = (ReportFormat.SPDX3_JSON, ReportFormat.SPDX3_TTL, ReportFormat.SPDX3_RDF)
38 def get_api_config() -> ApiConfig:
40 Set the API configuration based on CI the job is running on
42 :return: ApiConfig object
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',
'')
78 def get_allow_list(path: str =
'') -> dict:
80 Decode json from `allowlist.json`
82 :param path: path to allowlist file. Default=''
83 :return: allowlist dictionary
86 file_name =
'allowlist.json'
88 if os.path.exists(
'whitelist.json'):
89 file_name =
'whitelist.json'
91 "Name 'whitelist.json' is deprecated. "
92 "Please use 'allowlist.json' instead."
94 logging.info(f
"Reading {file_name} file...")
97 logging.info(f
"Reading allowlist.json file from the path: '{file_name}'")
98 with open(file_name,
'r', encoding=
'utf-8')
as f:
104 name: str, failed_results: list[ScanResult],
105 scan_results_with_line_number: list[dict[str, set[str]]],
109 Print the formatted scanner results
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
115 :param result_file: File to write results to
117 line_number_map: dict[str, set[str]] = {}
118 for item
in scan_results_with_line_number:
120 line_number_map.update(item)
122 for files
in failed_results:
123 logging.info(f
"File: {files.file}")
124 result_file.write(f
"File: {files.file}\n")
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")
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')
134 scanned_word = str(result_item)
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}"
142 formatted_output = scanned_word
144 logging.info(f
"\t{formatted_output}")
145 result_file.write(f
"\t{formatted_output}\n")
148 def print_log_message(
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]]]
156 Common helper function to print scan results.
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
167 :return: New return value
169 with open(filename,
'w', encoding=
'utf-8')
as report_file:
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)
177 logging.error(f
"\u2718 {failure_text}:")
178 report_file.write(f
"{failure_text}:\n")
180 scan_type, failed_list, scan_results_with_line_number, report_file
182 if scan_type ==
"License":
184 elif scan_type ==
"Copyright":
186 elif scan_type ==
"Keyword":
189 logging.info(f
"\u2714 {acceptance_text}")
190 report_file.write(f
"{acceptance_text}\n")
196 def _format_results_with_line_numbers(
197 scanner: Scanners, format_results: FormatResult, result_type: str, key: str
198 ) -> list[dict[str, set[str]]]:
200 Generic function to format scanner results with line numbers.
202 :param scanner: Scanner object
203 :param format_results: FormatResult object
204 :param result_type: Type of results to retrieve ('keyword', 'copyright',
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
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':
216 license_res = scanner.results_are_allow_listed(whole=
True)
217 scan_results = license_res
if isinstance(license_res, list)
else []
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
229 words_with_line_numbers = format_results.find_word_line_numbers(
230 scan_result_item.path, list_of_scan_results, key=key
232 if words_with_line_numbers:
233 formatted_list_of_line_numbers.append(words_with_line_numbers)
234 return formatted_list_of_line_numbers
238 cli_options: CliOptions, result_dir: str, return_val: int,
239 scanner: Scanners, format_results: FormatResult
242 Run scanners and print results in text format.
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
251 return perform_scans(
252 cli_options, format_results, result_dir, return_val, scanner
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'
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
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'
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
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'
293 r.result.get(
'content')
for r
in scanner.get_keyword_results()
294 if r.result
and r.result.get(
'content')
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
307 cli_options: CliOptions, result_dir: str, return_val: int,
308 scanner: Scanners, format_results: FormatResult
311 Run scanners and generate a report.
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.
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
324 if cli_options.report_format
in SPDX3_FORMATS:
325 report_obj = Spdx3Report(cli_options, scanner)
327 report_obj = SpdxReport(cli_options, scanner)
329 return_val = perform_scans(
330 cli_options, format_results, result_dir, return_val, scanner
332 logging.info(
"Finalizing reports...")
333 report_obj.finalize_document()
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"
352 logging.info(f
"Validating and writing report to file {report_name}...")
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}")
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
375 def main(parsed_args):
380 :return: 0 for success, error code on failure.
382 api_config = get_api_config()
384 cli_options.update_args(parsed_args)
385 save_dir =
'pkg_downloads'
386 scan_packages = get_scan_packages(api_config)
389 if cli_options.allowlist_path:
390 cli_options.allowlist = get_allow_list(path=cli_options.allowlist_path)
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.")
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)
408 logging.info(f
"Validation of keyword file successful: {message}")
409 copy_keyword_file_to_destination(keyword_conf_file_path, destination_path)
411 logging.error(f
"Could not validate keyword file: {message}")
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)
418 if cli_options.parser.python_components:
419 python_parser = PythonParser()
420 python_parser.parse_components(cli_options.parser)
422 if cli_options.parser.npm_components:
423 npm_parser = NPMParser()
424 npm_parser.parse_components(cli_options.parser)
426 if cli_options.parser.unsupported_components:
427 for comp
in cli_options.parser.unsupported_components:
429 f
"The purl {comp.get('purl', 'N/A')} is not supported. "
430 "Package will not be downloaded."
433 scan_packages.dependencies = cli_options.parser.parsed_components
436 downloader = Downloader()
437 downloader.download_concurrently(cli_options.parser)
438 except Exception
as e:
440 f
"Something went wrong while downloading the dependencies: {e}")
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()
448 scanner = Scanners(cli_options, scan_packages)
452 format_results = FormatResult(cli_options)
453 format_results.process_files(scanner.cli_options.diff_dir)
456 result_dir =
"results"
457 os.makedirs(name=result_dir, exist_ok=
True)
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,
466 return_val = bom_report(
467 cli_options, result_dir, return_val, scanner,
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}")
485 if __name__ ==
"__main__":
486 parser = argparse.ArgumentParser(
487 description=textwrap.dedent(
"""fossology scanner designed for CI""")
490 "operation", type=str, help=
"Operations to run.", nargs=
'*',
492 "nomos",
"copyright",
"keyword",
"ojo",
"repo",
"differential",
493 "scan-only-deps",
"scan-dir"
497 "--tags", type=str, nargs=2,
498 help=
"Tags for differential scan. Required if 'differential' is specified."
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
506 '--keyword-conf', type=str, help=
'Path to the keyword configuration file. '
507 'Use only when keyword argument is true'
510 '--dir-path', type=str, help=
'Path to directory for scanning.'
514 "--allowlist-path", type=str,
515 help=
"Pass allowlist.json to allowlist dependencies."
518 "--sbom-path", type=str,
519 help=
"Path to SBOM file for downloading dependencies."
522 args = parser.parse_args()
Store the options sent through the CLI.
list_t type structure used to keep various lists. (e.g. there are multiple lists).