# type: ignore from enum import Enum import logging from pathlib import Path import json import sys from typing import Any, Dict, List, Optional, Set, Tuple, Callable from safety.constants import EXIT_CODE_VULNERABILITIES_FOUND, DEFAULT_EPILOG from safety.safety import process_fixes_scan from safety.scan.finder.handlers import ECOSYSTEM_HANDLER_MAPPING, FileHandler from safety.scan.validators import output_callback, save_as_callback from safety.util import pluralize from ..cli_util import SafetyCLICommand, SafetyCLISubGroup from safety.error_handlers import handle_cmd_exception from rich.padding import Padding import typer from safety.auth.constants import SAFETY_PLATFORM_URL from safety.cli_util import get_command_for, get_git_branch_name from rich.console import Console from safety.decorators import notify from safety.errors import SafetyError from safety.scan.finder import FileFinder from safety.scan.constants import ( CMD_PROJECT_NAME, CMD_SYSTEM_NAME, DEFAULT_SPINNER, SCAN_OUTPUT_HELP, SCAN_POLICY_FILE_HELP, SCAN_SAVE_AS_HELP, SCAN_TARGET_HELP, SYSTEM_SCAN_OUTPUT_HELP, SYSTEM_SCAN_POLICY_FILE_HELP, SYSTEM_SCAN_SAVE_AS_HELP, SYSTEM_SCAN_TARGET_HELP, SCAN_APPLY_FIXES, SCAN_DETAILED_OUTPUT, CLI_SCAN_COMMAND_HELP, CLI_SYSTEM_SCAN_COMMAND_HELP, ) from safety.scan.decorators import ( inject_metadata, scan_project_command_init, scan_system_command_init, ) from safety.scan.finder.file_finder import should_exclude from ..codebase_utils import load_unverified_project_from_config from safety.scan.main import load_policy_file, process_files, save_report_as from safety.scan.models import ( ScanExport, ScanOutput, SystemScanExport, SystemScanOutput, ) from safety.scan.render import ( print_detected_ecosystems_section, print_fixes_section, print_summary, render_scan_html, render_scan_spdx, render_to_console, ) from safety_schemas.models import ( Ecosystem, FileModel, FileType, ProjectModel, ReportModel, ScanType, VulnerabilitySeverityLabels, SecurityUpdates, Vulnerability, Stage, ) from safety.scan.fun_mode.easter_eggs import run_easter_egg try: from typing import Annotated except ImportError: from typing_extensions import Annotated LOG = logging.getLogger(__name__) # CONSTANTS # Icons and Emojis ICON_PENCIL = ":pencil:" ICON_CHECKMARK = ":white_check_mark:" ICON_STOP_SIGN = ":stop_sign:" ICON_UPLOAD = ":arrow_up:" # Rich Markup Tags TAG_FILE_TITLE_START = "[file_title]" TAG_FILE_TITLE_END = "[/file_title]" TAG_DEP_NAME_START = "[dep_name]" TAG_DEP_NAME_END = "[/dep_name]" TAG_SPECIFIER_START = "[specifier]" TAG_SPECIFIER_END = "[/specifier]" TAG_BRIEF_SEVERITY = "[brief_severity]" TAG_REM_SEVERITY = "[rem_severity]" TAG_RECOMMENDED_VER = "[recommended_ver]" # Thresholds CRITICAL_VULN_THRESHOLD = 3 MIN_CRITICAL_COUNT = 0 MIN_DETAILED_OUTPUT_THRESHOLD = 3 # Padding PADDING_VALUES = (0, 0, 0, 1) # Rich Defaults RICH_DEFAULT_KWARGS = {"emoji": True, "overflow": "crop"} # Messages WAIT_MSG_PROCESSING_REPORT = "Processing report" WAIT_MSG_FETCHING_DB = "Fetching Safety's vulnerability database..." WAIT_MSG_SCANNING_DIRECTORY = "Scanning project directory" WAIT_MSG_ANALYZING_TARGETS = ( "Analyzing {0} files and environments for security findings" ) MSG_UPLOADING_REPORT = "Uploading report to: {0}" MSG_REPORT_UPLOADED = "Report uploaded" MSG_PROJECT_DASHBOARD = "Project dashboard: [link]{0}[/link]" MSG_SYSTEM_SCAN_REPORT = "System scan report: [link]{0}[/link]" MSG_AUTH_REQUIRED = "Authentication required. Please run 'safety auth login' to authenticate before using this command." MSG_DEPENDENCY_VULNERABILITIES_DETECTED = "Dependency vulnerabilities detected:" MSG_NO_KNOWN_FIX = "No known fix for [dep_name]{0}[/dep_name][specifier]{1}[/specifier] to fix [number]{2}[/number] {3}" MSG_RECOMMENDED_UPDATE = ( "[rem_brief]Update {0} to {1}=={2}[/rem_brief] to fix [number]{3}[/number] {4}" ) MSG_NO_VULNERABILITIES = "Versions of {0} with no known vulnerabilities: [recommended_ver]{1}[/recommended_ver]" MSG_LEARN_MORE = "Learn more: [link]{0}[/link]" MSG_NO_ISSUES_FOUND = ( f"{ICON_CHECKMARK} [file_title]{{0}}: No issues found.[/file_title]" ) MSG_SAFETY_UPDATES_RUNNING = "Safety updates running" MSG_EXIT_CODE_FAILURE = ":stop_sign: Scan-failing vulnerabilities were found, returning non-zero exit code: {0}" MSG_EXIT_CODE_SUCCESS = ( "No scan-failing vulnerabilities were matched, returning success exit code: 0" ) cli_apps_opts = {"rich_markup_mode": "rich", "cls": SafetyCLISubGroup} scan_project_app = typer.Typer(**cli_apps_opts) scan_system_app = typer.Typer(**cli_apps_opts) class ScannableEcosystems(Enum): """Enum representing scannable ecosystems.""" PYTHON = Ecosystem.PYTHON.value def process_report( obj: Any, console: Console, report: ReportModel, output: str, save_as: Optional[Tuple[str, Path]], detailed_output: bool = False, filter_keys: Optional[List[str]] = None, **kwargs, ) -> Optional[str]: """ Processes and outputs the report based on the given parameters. Args: obj (Any): The context object. console (Console): The console object. report (ReportModel): The report model. output (str): The output format. save_as (Optional[Tuple[str, Path]]): The save-as format and path. detailed_output (bool): Whether detailed output is enabled. filter_keys (Optional[List[str]]): Keys to filter from the JSON output. kwargs: Additional keyword arguments. Returns: Optional[str]: The URL of the report if uploaded, otherwise None. """ with console.status(WAIT_MSG_PROCESSING_REPORT, spinner=DEFAULT_SPINNER) as status: json_format = report.as_v30().json() export_type, export_path = None, None if save_as: export_type, export_path = save_as export_type = ScanExport(export_type) output = ScanOutput(output) report_to_export = None report_to_output = None with console.status(WAIT_MSG_PROCESSING_REPORT, spinner=DEFAULT_SPINNER) as status: spdx_format, html_format = None, None if ScanExport.is_format(export_type, ScanExport.SPDX) or ScanOutput.is_format( output, ScanOutput.SPDX ): spdx_version = None if export_type: spdx_version = ( export_type.version if export_type.version and ScanExport.is_format(export_type, ScanExport.SPDX) else None ) if not spdx_version and output: spdx_version = ( output.version if output.version and ScanOutput.is_format(output, ScanOutput.SPDX) else None ) spdx_format = render_scan_spdx(report, obj, spdx_version=spdx_version) if export_type is ScanExport.HTML or output is ScanOutput.HTML: html_format = render_scan_html(report, obj) save_as_format_mapping = { ScanExport.JSON: json_format, ScanExport.HTML: html_format, ScanExport.SPDX: spdx_format, ScanExport.SPDX_2_3: spdx_format, ScanExport.SPDX_2_2: spdx_format, } output_format_mapping = { ScanOutput.JSON: json_format, ScanOutput.HTML: html_format, ScanOutput.SPDX: spdx_format, ScanOutput.SPDX_2_3: spdx_format, ScanOutput.SPDX_2_2: spdx_format, } report_to_export = save_as_format_mapping.get(export_type, None) report_to_output = output_format_mapping.get(output, None) if report_to_export: msg = f"Saving {export_type} report at: {export_path}" status.update(msg) LOG.debug(msg) save_report_as( report.metadata.scan_type, export_type, Path(export_path), report_to_export, ) report_url = None if obj.platform_enabled: status.update( console.render_str( f"{ICON_UPLOAD} {MSG_UPLOADING_REPORT.format(SAFETY_PLATFORM_URL)}" ) ) try: result = obj.auth.client.upload_report(json_format) status.update(MSG_REPORT_UPLOADED) report_url = f"{SAFETY_PLATFORM_URL}{result['url']}" except Exception as e: raise e if output is ScanOutput.SCREEN: console.print() lines = [] if obj.platform_enabled and report_url: if report.metadata.scan_type is ScanType.scan: project_url = f"{SAFETY_PLATFORM_URL}{obj.project.url_path}" # Get the current branch name branch_name = get_git_branch_name() # Append the branch name if available if branch_name: project_url_with_branch = f"{project_url}?branch={branch_name}" else: project_url_with_branch = project_url lines.append(MSG_PROJECT_DASHBOARD.format(project_url_with_branch)) elif report.metadata.scan_type is ScanType.system_scan: lines.append(MSG_SYSTEM_SCAN_REPORT.format(report_url)) for line in lines: console.print(line, emoji=True) if output.is_silent(): console.quiet = False if output is ScanOutput.JSON or ScanOutput.is_format(output, ScanOutput.SPDX): if output is ScanOutput.JSON: if detailed_output: report_to_output = add_cve_details_to_report( report_to_output, obj.project.files ) if filter_keys: report_to_output = filter_json_keys(report_to_output, filter_keys) kwargs = {"json": report_to_output} else: kwargs = {"data": report_to_output} console.print_json(**kwargs) else: console.print(report_to_output) console.quiet = True return report_url def filter_json_keys(json_string: str, keys: List[str]) -> str: """ Filters the given JSON string by the specified top-level keys. Args: json_string (str): The JSON string to filter. keys (List[str]): List of top-level keys to include in the output. Returns: str: A JSON string containing only the specified keys. """ report_dict = json.loads(json_string) filtered_data = {key: report_dict[key] for key in keys if key in report_dict} return json.dumps(filtered_data, indent=4) def filter_valid_cves(vulnerabilities: List[Any]) -> List[Dict[str, Any]]: """ Filters and returns valid CVE details from a list of vulnerabilities. Args: vulnerabilities (List[Any]): A list of vulnerabilities, which may include invalid data types. Returns: List[Dict[str, Any]]: A list of filtered CVE details that are either strings or dictionaries. """ return [ cve for cve in vulnerabilities if isinstance(cve, str) or isinstance(cve, dict) ] # type:ignore def sort_cve_data(cve_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Sorts CVE details by severity in descending order. Args: cve_data (List[Dict[str, Any]]): A list of CVE details dictionaries, each containing a 'severity' key. Returns: List[Dict[str, Any]]: The sorted list of CVE details, prioritized by severity (e.g., CRITICAL > HIGH > MEDIUM). """ severity_order = { key.name: id for (id, key) in enumerate(VulnerabilitySeverityLabels) } return sorted( cve_data, key=lambda x: severity_order.get(x["severity"].upper(), 0), reverse=True, ) def generate_cve_details(files: List[FileModel]) -> List[Dict[str, Any]]: """ Generate CVE details from the scanned files. Args: files (List[FileModel]): List of scanned file models. Returns: List[Dict[str, Any]]: List of CVE details sorted by severity. """ cve_data = [] for file in files: for spec in file.results.get_affected_specifications(): for vuln in spec.vulnerabilities: if vuln.CVE: cve_data.append( { "package": spec.name, "affected_version": str(spec.specifier), "safety_vulnerability_id": vuln.vulnerability_id, "CVE": filter_valid_cves(vuln.CVE), "more_info": vuln.more_info_url, "advisory": vuln.advisory, "severity": vuln.severity.cvssv3.get( "base_severity", "Unknown" ) if vuln.severity and vuln.severity.cvssv3 else "Unknown", } ) return sort_cve_data(cve_data) def add_cve_details_to_report(report_to_output: str, files: List[FileModel]) -> str: """ Add CVE details to the JSON report output. Args: report_to_output (str): The current JSON string of the report. files (List[FileModel]): List of scanned files containing vulnerability data. Returns: str: The updated JSON string with CVE details added. """ cve_details = generate_cve_details(files) report_dict = json.loads(report_to_output) report_dict["cve_details"] = cve_details return json.dumps(report_dict) def generate_updates_arguments() -> List: """ Generates a list of file types and update limits for apply fixes. Returns: List: A list of file types and update limits. """ fixes = [] limit_type = SecurityUpdates.UpdateLevel.PATCH DEFAULT_FILE_TYPES = [ FileType.REQUIREMENTS_TXT, FileType.PIPENV_LOCK, FileType.POETRY_LOCK, FileType.VIRTUAL_ENVIRONMENT, ] fixes.extend( [(default_file_type, limit_type) for default_file_type in DEFAULT_FILE_TYPES] ) return fixes def validate_authentication(ctx: typer.Context) -> None: """ Validates that the user is authenticated. Args: ctx (typer.Context): The Typer context object. Raises: SafetyError: If the user is not authenticated. """ if not ctx.obj.metadata.authenticated: raise SafetyError(MSG_AUTH_REQUIRED) def generate_fixes_target(apply_updates: bool) -> List: """ Generates a list of update targets if `apply_updates` is enabled. Args: apply_updates (bool): Whether to generate fixes target. Returns: List: A list of update targets if enabled, otherwise an empty list. """ return generate_updates_arguments() if apply_updates else [] def validate_save_as( ctx: typer.Context, save_as: Optional[Tuple[ScanExport, Path]] ) -> None: """ Ensures the `save_as` parameters are valid. Args: ctx (typer.Context): The Typer context object. save_as (Optional[Tuple[ScanExport, Path]]): The save-as parameters. """ if not all(save_as): ctx.params["save_as"] = None def initialize_file_finder( ctx: typer.Context, target: Path, console: Optional[Console], ecosystems: List[Ecosystem], ) -> FileFinder: """ Initializes the FileFinder for scanning files in the target directory. Args: ctx (typer.Context): The Typer context object. target (Path): The target directory to scan. console (Optional[Console]): The console object for logging. ecosystems (List[Ecosystem]): The list of scannable ecosystems. Returns: FileFinder: An initialized FileFinder object. """ to_include = { file_type: paths for file_type, paths in ctx.obj.config.scan.include_files.items() if file_type.ecosystem in ecosystems } file_finder = FileFinder( target=target, ecosystems=ecosystems, max_level=ctx.obj.config.scan.max_depth, exclude=ctx.obj.config.scan.ignore, include_files=to_include, ) # Download necessary assets for each handler for handler in file_finder.handlers: if handler.ecosystem: if console: with console.status(WAIT_MSG_FETCHING_DB, spinner=DEFAULT_SPINNER): handler.download_required_assets(ctx.obj.auth.client) else: handler.download_required_assets(ctx.obj.auth.client) return file_finder def scan_project_directory( file_finder: FileFinder, console: Optional[Console] ) -> Tuple[Path, Dict]: """ Scans the project directory and identifies relevant files for analysis. Args: file_finder (FileFinder): Initialized file finder object. console (Console): Console for logging output. Returns: Tuple[Path, Dict]: The base path of the project and a dictionary of file paths grouped by type. """ if console: with console.status(WAIT_MSG_SCANNING_DIRECTORY, spinner=DEFAULT_SPINNER): path, file_paths = file_finder.search() print_detected_ecosystems_section( console, file_paths, include_safety_prjs=True ) else: path, file_paths = file_finder.search() return path, file_paths def detect_dependency_vulnerabilities( console: Console, dependency_vuln_detected: bool ) -> bool: """ Prints a message indicating that dependency vulnerabilities were detected. Args: console (Console): The console object for printing. dependency_vuln_detected (bool): Whether vulnerabilities have been detected. Returns: bool: True if vulnerabilities are detected, False otherwise. """ if not dependency_vuln_detected: console.print() console.print(MSG_DEPENDENCY_VULNERABILITIES_DETECTED) return True return dependency_vuln_detected def print_file_info(console: Console, path: Path, target: Path) -> None: """ Prints the file information for vulnerabilities. Args: console (Console): The console object for printing. path (Path): The file path of the current file. target (Path): The base path to which the file path is relative. """ console.print() msg = f"{ICON_PENCIL} {TAG_FILE_TITLE_START}{path.relative_to(target)}:{TAG_FILE_TITLE_END}" console.print(msg) def sort_and_filter_vulnerabilities( vulnerabilities: List[Any], key_func: Callable[[Any], int], reverse: bool = True ) -> List[Any]: """ Sorts and filters vulnerabilities. Args: vulnerabilities (List[Any]): A list of vulnerabilities to sort and filter. key_func (Callable[[Any], int]): A function to determine the sort key. reverse (bool): Whether to sort in descending order (default is True). Returns: List[Any]: The sorted and filtered list of vulnerabilities. """ return sorted( [vuln for vuln in vulnerabilities if not vuln.ignored], key=key_func, reverse=reverse, ) def count_critical_vulnerabilities(vulnerabilities: List[Vulnerability]) -> int: """ Count the number of critical vulnerabilities in a list of vulnerabilities. Args: vulnerabilities (List[Vulnerability]): List of vulnerabilities to evaluate. Returns: int: The number of vulnerabilities with a critical severity level. """ return sum( 1 for vuln in vulnerabilities if vuln.severity and vuln.severity.cvssv3 and vuln.severity.cvssv3.get("base_severity", "none").lower() == VulnerabilitySeverityLabels.CRITICAL.value.lower() ) def generate_vulnerability_message( spec_name: str, spec_raw: str, vulns_found: int, critical_vulns_count: int, vuln_word: str, ) -> str: """ Generate a formatted message for vulnerabilities in a specific dependency. Args: spec_name (str): Name of the dependency. spec_raw (str): Raw specification string of the dependency. vulns_found (int): Number of vulnerabilities found. critical_vulns_count (int): Number of critical vulnerabilities found. vuln_word (str): Pluralized form of the word "vulnerability." Returns: str: Formatted vulnerability message. """ msg = f"{TAG_DEP_NAME_START}{spec_name}{TAG_DEP_NAME_END}{TAG_SPECIFIER_START}{spec_raw.replace(spec_name, '')}{TAG_SPECIFIER_END} [{vulns_found} {vuln_word} found" if ( vulns_found > CRITICAL_VULN_THRESHOLD and critical_vulns_count > MIN_CRITICAL_COUNT ): msg += f", {TAG_BRIEF_SEVERITY}including {critical_vulns_count} critical severity {pluralize('vulnerability', critical_vulns_count)}{TAG_BRIEF_SEVERITY}" return msg def render_vulnerabilities( vulns_to_report: List[Vulnerability], console: Console, detailed_output: bool ) -> None: """ Render vulnerabilities to the console. Args: vulns_to_report (List[Vulnerability]): List of vulnerabilities to render. console (Console): Console object for printing. detailed_output (bool): Whether to display detailed output. """ for vuln in vulns_to_report: render_to_console( vuln, console, rich_kwargs=RICH_DEFAULT_KWARGS, detailed_output=detailed_output, ) def generate_remediation_details( spec: Any, vuln_word: str, critical_vulns_count: int ) -> Tuple[List[str], int, int]: """ Generate remediation details for a specific dependency. Args: spec (Any): Dependency specification object. vuln_word (str): Pluralized word for vulnerabilities. critical_vulns_count (int): Number of critical vulnerabilities. Returns: Tuple[List[str], int, int]: A tuple containing: - List of remediation lines. - Total resolved vulnerabilities. - Fixes count. """ lines = [] total_resolved_vulns = 0 fixes_count = 0 if not spec.remediation.recommended: lines.append( MSG_NO_KNOWN_FIX.format( spec.name, spec.raw.replace(spec.name, ""), spec.remediation.vulnerabilities_found, vuln_word, ) ) else: total_resolved_vulns += spec.remediation.vulnerabilities_found msg = MSG_RECOMMENDED_UPDATE.format( spec.raw, spec.name, spec.remediation.recommended, spec.remediation.vulnerabilities_found, vuln_word, ) if ( spec.remediation.vulnerabilities_found > CRITICAL_VULN_THRESHOLD and critical_vulns_count > MIN_CRITICAL_COUNT ): msg += f", {TAG_REM_SEVERITY}including {critical_vulns_count} critical severity {pluralize('vulnerability', critical_vulns_count)}{TAG_REM_SEVERITY} {ICON_STOP_SIGN}" fixes_count += 1 lines.append(msg) if spec.remediation.other_recommended: other_versions = "[/recommended_ver], [recommended_ver]".join( spec.remediation.other_recommended ) lines.append(MSG_NO_VULNERABILITIES.format(spec.name, other_versions)) return lines, total_resolved_vulns, fixes_count def should_display_fix_suggestion( ctx: typer.Context, analyzed_file: Any, affected_specifications: List[Any], apply_updates: bool, ) -> bool: """ Determine whether to display a fix suggestion based on the current context and file analysis. Args: ctx (typer.Context): The Typer context object. analyzed_file (Any): The file currently being analyzed. affected_specifications (List[Any]): List of affected specifications. apply_updates (bool): Whether fixes are being applied. Returns: bool: True if the fix suggestion should be displayed, False otherwise. """ return ( ctx.obj.auth.stage == Stage.development and analyzed_file.ecosystem == Ecosystem.PYTHON and analyzed_file.file_type == FileType.REQUIREMENTS_TXT and any(affected_specifications) and not apply_updates ) def process_file_fixes( file_to_fix: FileModel, specs_to_fix: List[Any], options: Dict, policy_limits: List[SecurityUpdates.UpdateLevel], output: ScanOutput, no_output: bool, prompt: bool, ) -> Any: """ Process fixes for a given file and its specifications. Args: file_to_fix (FileModel): The file to fix. specs_to_fix (List[Any]): The specifications to fix in the file. options (Dict): Mapping of file types to update limits. policy_limits (List[SecurityUpdates.UpdateLevel]): Policy-defined update limits. output (ScanOutput): The scan output format. no_output (bool): Whether to suppress output. prompt (bool): Whether to prompt the user for confirmation. Returns: Any: The result of the `process_fixes_scan` function. """ try: limit = options[file_to_fix.file_type] except KeyError: try: limit = options[file_to_fix.file_type.value] except KeyError: limit = SecurityUpdates.UpdateLevel("patch") # Set defaults update_limits = [limit.value] if any(policy_limits): update_limits = [policy_limit.value for policy_limit in policy_limits] return process_fixes_scan( file_to_fix, specs_to_fix, update_limits, output, no_output=no_output, prompt=prompt, ) @scan_project_app.command( cls=SafetyCLICommand, help=CLI_SCAN_COMMAND_HELP, name=CMD_PROJECT_NAME, epilog=DEFAULT_EPILOG, options_metavar="[OPTIONS]", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, ) @handle_cmd_exception @scan_project_command_init @inject_metadata @notify def scan( ctx: typer.Context, target: Annotated[ Path, typer.Option( exists=True, file_okay=False, dir_okay=True, writable=False, readable=True, resolve_path=True, show_default=False, help=SCAN_TARGET_HELP, ), ] = Path("."), output: Annotated[ ScanOutput, typer.Option( help=SCAN_OUTPUT_HELP, show_default=False, callback=output_callback ), ] = ScanOutput.SCREEN, detailed_output: Annotated[ bool, typer.Option( "--detailed-output", help=SCAN_DETAILED_OUTPUT, show_default=False ), ] = False, save_as: Annotated[ Optional[Tuple[ScanExport, Path]], typer.Option( help=SCAN_SAVE_AS_HELP, show_default=False, callback=save_as_callback ), ] = (None, None), policy_file_path: Annotated[ Optional[Path], typer.Option( "--policy-file", exists=False, file_okay=True, dir_okay=False, writable=True, readable=True, resolve_path=True, help=SCAN_POLICY_FILE_HELP, show_default=False, ), ] = None, apply_updates: Annotated[ bool, typer.Option("--apply-fixes", help=SCAN_APPLY_FIXES, show_default=False) ] = False, use_server_matching: Annotated[ bool, typer.Option( "--use-server-matching", help="Flag to enable using server side vulnerability matching. This just sends data to server for now.", show_default=False, ), ] = False, filter_keys: Annotated[ Optional[List[str]], typer.Option("--filter", help="Filter output by specific top-level JSON keys."), ] = None, ): """ Scans a project (defaulted to the current directory) for supply-chain security and configuration issues """ # Step 1: Validate inputs and initialize settings validate_authentication(ctx) fixes_target = generate_fixes_target(apply_updates) # Determine targets for updates validate_save_as(ctx, save_as) # Step 2: Setup console and ecosystems for scanning console = ctx.obj.console ecosystems = [Ecosystem(member.value) for member in list(ScannableEcosystems)] # Step 3: Initialize file finder and locate project files file_finder = initialize_file_finder(ctx, target, console, ecosystems) path, file_paths = scan_project_directory(file_finder, console) # Step 4: Prepare metadata for analysis if ecosystems: target_ecosystems = ", ".join([member.value for member in ecosystems]) wait_msg = WAIT_MSG_ANALYZING_TARGETS.format(target_ecosystems) else: # Handle the case where no ecosystems are detected target_ecosystems = "No ecosystems detected" wait_msg = "Analyzing files and environments for security findings" # Step 5: Initialize data structures and counters for analysis files: List[FileModel] = [] to_fix_files = [] ignored_vulns_data = iter([]) config = ctx.obj.config count = 0 # Total dependencies processed affected_count = 0 exit_code = 0 fixes_count = 0 total_resolved_vulns = 0 fix_file_types = [ fix_target[0] if isinstance(fix_target[0], str) else fix_target[0].value for fix_target in fixes_target ] dependency_vuln_detected = False requirements_txt_found = False display_apply_fix_suggestion = False # Process each file for dependencies and vulnerabilities with console.status(wait_msg, spinner=DEFAULT_SPINNER): for path, analyzed_file in process_files( paths=file_paths, config=config, use_server_matching=use_server_matching, obj=ctx.obj, target=target, ): # Update counts and track vulnerabilities count += len(analyzed_file.dependency_results.dependencies) if exit_code == 0 and analyzed_file.dependency_results.failed: exit_code = EXIT_CODE_VULNERABILITIES_FOUND affected_specifications = ( analyzed_file.dependency_results.get_affected_specifications() ) affected_count += len(affected_specifications) # Sort vulnerabilities by severity def sort_vulns_by_score(vuln: Vulnerability) -> int: if vuln.severity and vuln.severity.cvssv3: return vuln.severity.cvssv3.get("base_score", 0) return 0 # Prepare to collect files needing fixes to_fix_spec = [] file_matched_for_fix = analyzed_file.file_type.value in fix_file_types # Handle files with affected specifications if any(affected_specifications): dependency_vuln_detected = detect_dependency_vulnerabilities( console, dependency_vuln_detected ) print_file_info(console, path, target) for spec in affected_specifications: if file_matched_for_fix: to_fix_spec.append(spec) # Print vulnerabilities for each specification console.print() vulns_to_report = sort_and_filter_vulnerabilities( spec.vulnerabilities, key_func=sort_vulns_by_score ) critical_vulns_count = count_critical_vulnerabilities( vulns_to_report ) vulns_found = len(vulns_to_report) vuln_word = pluralize("vulnerability", vulns_found) msg = generate_vulnerability_message( spec.name, spec.raw, vulns_found, critical_vulns_count, vuln_word, ) console.print( Padding(f"{msg}]", PADDING_VALUES), emoji=True, overflow="crop" ) # Display detailed vulnerability information if applicable if detailed_output or vulns_found < MIN_DETAILED_OUTPUT_THRESHOLD: render_vulnerabilities( vulns_to_report, console, detailed_output ) # Generate remediation details and print them lines, resolved_vulns, fixes = generate_remediation_details( spec, vuln_word, critical_vulns_count ) total_resolved_vulns += resolved_vulns fixes_count += fixes for line in lines: console.print(Padding(line, PADDING_VALUES), emoji=True) # Provide a link for additional information console.print( Padding( MSG_LEARN_MORE.format(spec.remediation.more_info_url), PADDING_VALUES, ), emoji=True, ) else: # Handle files with no issues console.print() console.print( f"{ICON_CHECKMARK} [file_title]{path.relative_to(target)}: No issues found.[/file_title]", emoji=True, ) # Track whether to suggest applying fixes display_apply_fix_suggestion = should_display_fix_suggestion( ctx, analyzed_file, affected_specifications, apply_updates ) # Track if a requirements.txt file was found if ( not requirements_txt_found and analyzed_file.file_type is FileType.REQUIREMENTS_TXT ): requirements_txt_found = True # Save file data for further processing file = FileModel( location=path, file_type=analyzed_file.file_type, results=analyzed_file.dependency_results, ) if file_matched_for_fix: to_fix_files.append((file, to_fix_spec)) files.append(file) # Suggest fixes if applicable if display_apply_fix_suggestion: console.print() print_fixes_section(console, requirements_txt_found, detailed_output) # Finalize report metadata and print summary console.print() version = ctx.obj.schema metadata = ctx.obj.metadata telemetry = ctx.obj.telemetry ctx.obj.project.files = files report = ReportModel( version=version, metadata=metadata, telemetry=telemetry, files=[], projects=[ctx.obj.project], ) # Generate and print vulnerability summary total_issues_with_duplicates, total_ignored_issues = get_vulnerability_summary( report.as_v30() ) print_summary( console=console, total_issues_with_duplicates=total_issues_with_duplicates, total_ignored_issues=total_ignored_issues, project=ctx.obj.project, dependencies_count=count, fixes_count=fixes_count, resolved_vulns_per_fix=total_resolved_vulns, is_detailed_output=detailed_output, ignored_vulns_data=ignored_vulns_data, ) # Process report and upload if required report_url = process_report( obj=ctx.obj, console=console, report=report, output=output, save_as=save_as if save_as and all(save_as) else None, detailed_output=detailed_output, filter_keys=filter_keys, **{ k: v for k, v in ctx.params.items() if k not in {"detailed_output", "output", "save_as", "filter_keys"} }, ) project_url = f"{SAFETY_PLATFORM_URL}{ctx.obj.project.url_path}" # Handle fix application if enabled if apply_updates: options = dict(fixes_target) policy_limits = ctx.obj.config.depedendency_vulnerability.security_updates.auto_security_updates_limit no_output = output is not ScanOutput.SCREEN prompt = output is ScanOutput.SCREEN # TODO: rename that 'no_output' confusing name if not no_output: console.print() console.print("-" * console.size.width) console.print(MSG_SAFETY_UPDATES_RUNNING) console.print("-" * console.size.width) for file_to_fix, specs_to_fix in to_fix_files: fixes = process_file_fixes( file_to_fix, specs_to_fix, options, policy_limits, output, no_output, prompt, ) if not no_output: console.print("-" * console.size.width) # Print final exit messages and handle exit code if output is ScanOutput.SCREEN: run_easter_egg(console, exit_code) if output is not ScanOutput.NONE: if detailed_output: if exit_code > 0: console.print(MSG_EXIT_CODE_FAILURE.format(exit_code)) else: console.print(MSG_EXIT_CODE_SUCCESS) sys.exit(exit_code) return project_url, report, report_url @scan_system_app.command( cls=SafetyCLICommand, help=CLI_SYSTEM_SCAN_COMMAND_HELP, hidden=True, options_metavar="[COMMAND-OPTIONS]", name=CMD_SYSTEM_NAME, epilog=DEFAULT_EPILOG, ) @inject_metadata @scan_system_command_init @handle_cmd_exception @notify def system_scan( ctx: typer.Context, policy_file_path: Annotated[ Optional[Path], typer.Option( "--policy-file", exists=False, file_okay=True, dir_okay=False, writable=True, readable=True, resolve_path=True, help=SYSTEM_SCAN_POLICY_FILE_HELP, show_default=False, ), ] = None, targets: Annotated[ List[Path], typer.Option( "--target", exists=True, file_okay=False, dir_okay=True, writable=False, readable=True, resolve_path=True, help=SYSTEM_SCAN_TARGET_HELP, show_default=False, ), ] = [], output: Annotated[ SystemScanOutput, typer.Option(help=SYSTEM_SCAN_OUTPUT_HELP, show_default=False) ] = SystemScanOutput.SCREEN, save_as: Annotated[ Optional[Tuple[SystemScanExport, Path]], typer.Option(help=SYSTEM_SCAN_SAVE_AS_HELP, show_default=False), ] = (None, None), ): """ Scans a system (machine) for supply-chain security and configuration issues\n This will search for projects, requirements files and environment variables """ if not all(save_as): ctx.params["save_as"] = None console = ctx.obj.console version = ctx.obj.schema metadata = ctx.obj.metadata telemetry = ctx.obj.telemetry ecosystems = [Ecosystem(member.value) for member in list(ScannableEcosystems)] ecosystems.append(Ecosystem.SAFETY_PROJECT) config = ctx.obj.config console.print( "Searching for Python projects, requirements files and virtual environments across this machine." ) console.print( "If necessary, please grant Safety permission to access folders you want scanned." ) console.print() with console.status("...", spinner=DEFAULT_SPINNER) as status: handlers: Set[FileHandler] = set( ECOSYSTEM_HANDLER_MAPPING[ecosystem]() for ecosystem in ecosystems ) for handler in handlers: if handler.ecosystem: wait_msg = "Fetching Safety's proprietary vulnerability database..." status.update(wait_msg) handler.download_required_assets(ctx.obj.auth.client) file_paths = {} file_finders = [] to_include = { file_type: paths for file_type, paths in config.scan.include_files.items() if file_type.ecosystem in ecosystems } for target in targets: file_finder = FileFinder( target=target, ecosystems=ecosystems, max_level=config.scan.max_depth, exclude=config.scan.ignore, console=console, include_files=to_include, live_status=status, handlers=handlers, ) file_finders.append(file_finder) _, target_paths = file_finder.search() for file_type, paths in target_paths.items(): current = file_paths.get(file_type, set()) current.update(paths) file_paths[file_type] = current scan_project_command = get_command_for( name=CMD_PROJECT_NAME, typer_instance=scan_project_app ) projects_dirs = set() projects: List[ProjectModel] = [] project_data = {} with console.status(":mag:", spinner=DEFAULT_SPINNER) as status: # Handle projects first if FileType.SAFETY_PROJECT.value in file_paths.keys(): projects_file_paths = file_paths[FileType.SAFETY_PROJECT.value] basic_params = ctx.params.copy() basic_params.pop("targets", None) prjs_console = Console(quiet=True) for project_path in projects_file_paths: projects_dirs.add(project_path.parent) project_dir = str(project_path.parent) try: project = load_unverified_project_from_config(project_path.parent) local_policy_file = load_policy_file( project_path.parent / ".safety-policy.yml" ) except Exception as e: LOG.exception( f"Unable to load project from {project_path}. Reason {e}" ) console.print( f"{project_dir}: unable to load project found, skipped, use --debug for more details." ) continue if not project or not project.id: LOG.warning( f"{project_path} parsed but project id is not defined or valid." ) continue if not ctx.obj.platform_enabled: msg = f"project found and skipped, navigate to `{project.project_path}` and scan this project with ‘safety scan’" console.print(f"{project.id}: {msg}") continue msg = f"Existing project found at {project_dir}" console.print(f"{project.id}: {msg}") project_data[project.id] = { "path": project_dir, "report_url": None, "project_url": None, "failed_exception": None, } upload_request_id = None try: result = ctx.obj.auth.client.project_scan_request( project_id=project.id ) if "scan_upload_request_id" in result: upload_request_id = result["scan_upload_request_id"] else: raise SafetyError(message=str(result)) except Exception as e: project_data[project.id]["failed_exception"] = e LOG.exception(f"Unable to get a valid scan request id. Reason {e}") console.print( Padding( f":no_entry_sign: Unable to start project scan for {project.id}, reason: {e}", (0, 0, 0, 1), ), emoji=True, ) continue projects.append( ProjectModel(id=project.id, upload_request_id=upload_request_id) ) kwargs = { "target": project_dir, "output": str(ScanOutput.NONE.value), "save_as": (None, None), "upload_request_id": upload_request_id, "local_policy": local_policy_file, "console": prjs_console, } try: # TODO: Refactor to avoid calling invoke, also, launch # this on background. console.print( Padding( f"Running safety scan for {project.id} project", (0, 0, 0, 1), ), emoji=True, ) status.update(f":mag: Processing project scan for {project.id}") project_url, report, report_url = ctx.invoke( scan_project_command, **{**basic_params, **kwargs} ) project_data[project.id]["project_url"] = project_url project_data[project.id]["report_url"] = report_url except Exception as e: project_data[project.id]["failed_exception"] = e console.print( Padding( f":cross_mark: Failed project scan for {project.id}, reason: {e}", (0, 0, 0, 1), ), emoji=True, ) LOG.exception( f"Failed to run scan on project {project.id}, " f"Upload request ID: {upload_request_id}. Reason {e}" ) console.print() file_paths.pop(FileType.SAFETY_PROJECT.value, None) files: List[FileModel] = [] status.update(":mag: Finishing projects processing.") for k, f_paths in file_paths.items(): file_paths[k] = { fp for fp in f_paths if not should_exclude(excludes=projects_dirs, to_analyze=fp) } pkgs_count = 0 file_count = 0 venv_count = 0 for path, analyzed_file in process_files(paths=file_paths, config=config): status.update(f":mag: {path}") files.append( FileModel( location=path, file_type=analyzed_file.file_type, results=analyzed_file.dependency_results, ) ) file_pkg_count = len(analyzed_file.dependency_results.dependencies) affected_dependencies = ( analyzed_file.dependency_results.get_affected_dependencies() ) # Per file affected_pkgs_count = 0 critical_vulns_count = 0 other_vulns_count = 0 if any(affected_dependencies): affected_pkgs_count = len(affected_dependencies) for dep in affected_dependencies: for spec in dep.specifications: for vuln in spec.vulnerabilities: if vuln.ignored: continue if ( vuln.CVE and vuln.CVE.cvssv3 and VulnerabilitySeverityLabels( vuln.CVE.cvssv3.get("base_severity", "none").lower() ) is VulnerabilitySeverityLabels.CRITICAL ): critical_vulns_count += 1 else: other_vulns_count += 1 msg = pluralize("package", file_pkg_count) if analyzed_file.file_type is FileType.VIRTUAL_ENVIRONMENT: msg = f"installed {msg} found" venv_count += 1 else: file_count += 1 pkgs_count += file_pkg_count console.print(f":package: {file_pkg_count} {msg} in {path}", emoji=True) if affected_pkgs_count <= 0: msg = "No vulnerabilities found" else: msg = f"{affected_pkgs_count} vulnerable {pluralize('package', affected_pkgs_count)}" if critical_vulns_count > 0: msg += f", {critical_vulns_count} critical" if other_vulns_count > 0: msg += f" and {other_vulns_count} other {pluralize('vulnerability', other_vulns_count)} found" console.print(Padding(msg, (0, 0, 0, 1)), emoji=True) console.print() report = ReportModel( version=version, metadata=metadata, telemetry=telemetry, files=files, projects=projects, ) console.print() total_count = sum([finder.file_count for finder in file_finders], 0) console.print(f"Searched {total_count:,} files for dependency security issues") packages_msg = f"{pkgs_count:,} {pluralize('package', pkgs_count)} found across" files_msg = f"{file_count:,} {pluralize('file', file_count)}" venv_msg = f"{venv_count:,} virtual {pluralize('environment', venv_count)}" console.print( f":package: Python files and environments: {packages_msg} {files_msg} and {venv_msg}", emoji=True, ) console.print() proccessed = dict( filter( lambda item: item[1]["report_url"] and item[1]["project_url"], project_data.items(), ) ) if proccessed: run_word = "runs" if len(proccessed) == 1 else "run" console.print( f"Project {pluralize('scan', len(proccessed))} {run_word} on {len(proccessed)} existing {pluralize('project', len(proccessed))}:" ) for prj, data in proccessed.items(): console.print(f"[bold]{prj}[/bold] at {data['path']}") for detail in [f"{prj} dashboard: {data['project_url']}"]: console.print( Padding(detail, (0, 0, 0, 1)), emoji=True, overflow="crop" ) process_report(ctx.obj, console, report, **{**ctx.params}) def get_vulnerability_summary(report: Dict[str, Any]) -> Tuple[int, int]: """ Summarize vulnerabilities from the given report. Args: report (ReportModel): The report containing vulnerability data. Returns: Tuple[int, int]: A tuple containing: - Total number of issues (including duplicates) - Total number of ignored issues """ total_issues = 0 ignored_issues = 0 for project in report.scan_results.projects: for file in project.files: for dependency in file.results.dependencies: for specification in dependency.specifications: known_vulnerabilities = ( specification.vulnerabilities.known_vulnerabilities ) total_issues += len(known_vulnerabilities) ignored_issues += sum(1 for v in known_vulnerabilities if v.ignored) return total_issues, ignored_issues