1532 lines
51 KiB
Python
1532 lines
51 KiB
Python
# 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
|