Files
Hotel-Booking/Backend/venv/lib/python3.12/site-packages/safety/scan/render.py
Iliyan Angelov 62c1fe5951 updates
2025-12-01 06:50:10 +02:00

825 lines
27 KiB
Python

# type: ignore
import datetime
import itertools
import json
import logging
import time
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import sys
import typer
from rich.console import Console
from rich.padding import Padding
from rich.prompt import Prompt
from rich.text import Text
from safety_schemas.models import (
Ecosystem,
FileType,
IgnoreCodes,
PolicyFileModel,
PolicySource,
ProjectModel,
PythonDependency,
ReportModel,
Vulnerability,
)
from safety import safety
from safety.auth.constants import SAFETY_PLATFORM_URL
from safety.errors import SafetyException
from safety.meta import get_version
from safety.output_utils import parse_html
from safety.scan.constants import DEFAULT_SPINNER
from safety.util import clean_project_id, get_basic_announcements
LOG = logging.getLogger(__name__)
def render_header(targets: List[Path], is_system_scan: bool) -> Text:
"""
Render the header text for the scan.
Args:
targets (List[Path]): List of target paths for the scan.
is_system_scan (bool): Indicates if the scan is a system scan.
Returns:
Text: Rendered header text.
"""
version = get_version()
scan_datetime = datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S %Z"
)
action = f"scanning {', '.join([str(t) for t in targets])}"
if is_system_scan:
action = "running [bold]system scan[/bold]"
return Text.from_markup(f"[bold]Safety[/bold] {version} {action}\n{scan_datetime}")
def print_header(console, targets: List[Path], is_system_scan: bool = False) -> None:
"""
Print the header for the scan.
Args:
console (Console): The console for output.
targets (List[Path]): List of target paths for the scan.
is_system_scan (bool): Indicates if the scan is a system scan.
"""
console.print(render_header(targets, is_system_scan), markup=True)
def print_announcements(console: Console, ctx: typer.Context):
"""
Print announcements from Safety.
Args:
console (Console): The console for output.
ctx (typer.Context): The context of the Typer command.
"""
colors = {"error": "red", "warning": "yellow", "info": "default"}
announcements = safety.get_announcements(
ctx.obj.auth.client,
telemetry=ctx.obj.config.telemetry_enabled,
with_telemetry=ctx.obj.telemetry,
)
basic_announcements = get_basic_announcements(announcements, False)
if any(basic_announcements):
console.print()
console.print("[bold]Safety Announcements:[/bold]")
console.print()
for announcement in announcements:
color = colors.get(announcement.get("type", "info"), "default")
console.print(f"[{color}]* {announcement.get('message')}[/{color}]")
def print_detected_ecosystems_section(
console: Console, file_paths: Dict[str, Set[Path]], include_safety_prjs: bool = True
) -> None:
"""
Print detected ecosystems section.
Args:
console (Console): The console for output.
file_paths (Dict[str, Set[Path]]): Dictionary of file paths by type.
include_safety_prjs (bool): Whether to include safety projects.
"""
detected: Dict[Ecosystem, Dict[FileType, int]] = {}
for file_type_key, f_paths in file_paths.items():
file_type = FileType(file_type_key)
if file_type.ecosystem:
if file_type.ecosystem not in detected:
detected[file_type.ecosystem] = {}
detected[file_type.ecosystem][file_type] = len(f_paths)
for ecosystem, f_type_count in detected.items():
if not include_safety_prjs and ecosystem is Ecosystem.SAFETY_PROJECT:
continue
brief = "Found "
file_types = []
for f_type, count in f_type_count.items():
file_types.append(f"{count} {f_type.human_name(plural=count > 1)}")
if len(file_types) > 1:
brief += ", ".join(file_types[:-1]) + " and " + file_types[-1]
else:
brief += file_types[0]
msg = f"{ecosystem.name.replace('_', ' ').title()} detected. {brief}"
console.print(msg)
def print_fixes_section(
console: Console,
requirements_txt_found: bool = False,
is_detailed_output: bool = False,
) -> None:
"""
Print the section on applying fixes.
Args:
console (Console): The console for output.
requirements_txt_found (bool): Indicates if a requirements.txt file was found.
is_detailed_output (bool): Indicates if detailed output is enabled.
"""
console.print("-" * console.size.width)
console.print("Apply Fixes")
console.print("-" * console.size.width)
console.print()
if requirements_txt_found:
console.print(
"[green]Run `safety scan --apply-fixes`[/green] to update these packages and fix these vulnerabilities. "
"Documentation, limitations, and configurations for applying automated fixes: [link]https://docs.safetycli.com/safety-docs/vulnerability-remediation/applying-fixes[/link]"
)
console.print()
console.print(
"Alternatively, use your package manager to update packages to their secure versions. Always check for breaking changes when updating packages."
)
else:
msg = "Use your package manager to update packages to their secure versions. Always check for breaking changes when updating packages."
console.print(msg)
if not is_detailed_output:
console.print(
"[tip]Tip[/tip]: For more detailed output on each vulnerability, add the `--detailed-output` flag to safety scan."
)
console.print()
console.print("-" * console.size.width)
def print_summary(
console: Console,
total_issues_with_duplicates: int,
total_ignored_issues: int,
project: ProjectModel,
dependencies_count: int = 0,
fixes_count: int = 0,
resolved_vulns_per_fix: int = 0,
is_detailed_output: bool = False,
ignored_vulns_data: Optional[Dict[str, Vulnerability]] = None,
) -> None:
"""
Prints a concise summary of scan results including vulnerabilities, fixes, and ignored vulnerabilities.
This function summarizes the results of a security scan, displaying the number of dependencies scanned,
vulnerabilities found, suggested fixes, and the impact of those fixes. It also optionally provides a
detailed breakdown of ignored vulnerabilities based on predefined policies.
Args:
console (Console): The console object used to print formatted output.
total_issues_with_duplicates (int): The total number of security issues, including duplicates.
total_ignored_issues (int): The number of issues that were ignored based on project policies.
project (ProjectModel): The project model containing the scanned project details and policies.
dependencies_count (int, optional): The total number of dependencies scanned for vulnerabilities. Defaults to 0.
fixes_count (int, optional): The number of fixes suggested by the scan. Defaults to 0.
resolved_vulns_per_fix (int, optional): The number of vulnerabilities that can be resolved by the suggested fixes. Defaults to 0.
is_detailed_output (bool, optional): Flag to indicate whether detailed output, especially for ignored vulnerabilities, should be shown. Defaults to False.
ignored_vulns_data (Optional[Dict[str, Vulnerability]], optional): A dictionary of vulnerabilities that were ignored, categorized by their reason for being ignored. Defaults to None.
Returns:
None: This function does not return any value. It prints the summary to the console.
Usage:
Call this function after a vulnerability scan to display the results in a clear, formatted manner.
Example:
print_summary(console, unique_issues, 10, 2, project_model, dependencies_count=5, fixes_count=2)
"""
from ..util import pluralize
# Set the policy message based on the project source
if project.policy:
policy_msg = (
"policy fetched from Safety Platform"
if project.policy.source is PolicySource.cloud
else f"local {project.id or 'scan policy file'} project scan policy"
)
else:
policy_msg = "default Safety CLI policies"
console.print(
f"Tested [number]{dependencies_count}[/number] {pluralize('dependency', dependencies_count)} for security issues using {policy_msg}"
)
if total_issues_with_duplicates == 0:
console.print("0 security issues found, 0 fixes suggested.")
else:
# Print security issues and ignored vulnerabilities
console.print(
f"[number]{total_issues_with_duplicates}[/number] {pluralize('vulnerability', total_issues_with_duplicates)} found, "
f"[number]{total_ignored_issues}[/number] ignored due to policy."
)
console.print(
f"[number]{fixes_count}[/number] {pluralize('fix', fixes_count)} suggested, resolving [number]{resolved_vulns_per_fix}[/number] vulnerabilities."
)
if is_detailed_output:
if not ignored_vulns_data:
ignored_vulns_data = iter([])
manual_ignored = {}
cvss_severity_ignored = {}
cvss_severity_ignored_pkgs = set()
unpinned_ignored = {}
unpinned_ignored_pkgs = set()
environment_ignored = {}
environment_ignored_pkgs = set()
for vuln_data in ignored_vulns_data:
code = IgnoreCodes(vuln_data.ignored_code)
if code is IgnoreCodes.manual:
manual_ignored[vuln_data.vulnerability_id] = vuln_data
elif code is IgnoreCodes.cvss_severity:
cvss_severity_ignored[vuln_data.vulnerability_id] = vuln_data
cvss_severity_ignored_pkgs.add(vuln_data.package_name)
elif code is IgnoreCodes.unpinned_specification:
unpinned_ignored[vuln_data.vulnerability_id] = vuln_data
unpinned_ignored_pkgs.add(vuln_data.package_name)
elif code is IgnoreCodes.environment_dependency:
environment_ignored[vuln_data.vulnerability_id] = vuln_data
environment_ignored_pkgs.add(vuln_data.package_name)
if manual_ignored:
count = len(manual_ignored)
console.print(
f"[number]{count}[/number] were manually ignored due to the project policy:"
)
for vuln in manual_ignored.values():
render_to_console(
vuln,
console,
rich_kwargs={"emoji": True, "overflow": "crop"},
detailed_output=is_detailed_output,
)
if cvss_severity_ignored:
count = len(cvss_severity_ignored)
console.print(
f"[number]{count}[/number] {pluralize('vulnerability', count)} {pluralize('was', count)} ignored because "
"of their severity or exploitability impacted the following"
f" {pluralize('package', len(cvss_severity_ignored_pkgs))}: {', '.join(cvss_severity_ignored_pkgs)}"
)
if environment_ignored:
count = len(environment_ignored)
console.print(
f"[number]{count}[/number] {pluralize('vulnerability', count)} {pluralize('was', count)} ignored because "
"they are inside an environment dependency."
)
if unpinned_ignored:
count = len(unpinned_ignored)
console.print(
f"[number]{count}[/number] {pluralize('vulnerability', count)} {pluralize('was', count)} ignored because "
f"{pluralize('this', len(unpinned_ignored_pkgs))} {pluralize('package', len(unpinned_ignored_pkgs))} {pluralize('has', len(unpinned_ignored_pkgs))} unpinned specs: "
f"{', '.join(unpinned_ignored_pkgs)}"
)
def print_wait_project_verification(
console: Console,
project_id: str,
closure: Tuple[Any, Dict[str, Any]],
on_error_delay: int = 1,
) -> Any:
"""
Print a waiting message while verifying a project.
Args:
console (Console): The console for output.
project_id (str): The project ID.
closure (Tuple[Any, Dict[str, Any]]): The function and its arguments to call.
on_error_delay (int): Delay in seconds on error.
Returns:
Any: The status of the project verification.
"""
status = None
wait_msg = f"Verifying project {project_id} with Safety Platform."
with console.status(wait_msg, spinner=DEFAULT_SPINNER):
try:
f, kwargs = closure
status = f(**kwargs)
except Exception as e:
LOG.exception(f"Unable to verify the project, reason: {e}")
reason = (
"We are currently unable to verify the project, "
"and it is necessary to link the scan to a specific "
f"project. \n\nAdditional Information: \n{e}"
)
raise SafetyException(message=reason)
if not status:
wait_msg = f'Unable to verify "{project_id}". Starting again...'
time.sleep(on_error_delay)
return status
def print_project_info(console: Console, project: ProjectModel):
"""
Print information about the project.
Args:
console (Console): The console for output.
project (ProjectModel): The project model.
"""
config_msg = "loaded without policies or custom configuration."
if project.policy:
if project.policy.source is PolicySource.local:
rel_location = (
project.policy.location.name if project.policy.location else ""
)
config_msg = f"configuration and policies fetched from {rel_location}."
else:
config_msg = " policies fetched from Safety Platform."
msg = f"[bold]{project.id} project found[/bold] - {config_msg}"
console.print(msg)
def print_wait_policy_download(
console: Console, closure: Tuple[Any, Dict[str, Any]]
) -> Optional[PolicyFileModel]:
"""
Print a waiting message while downloading a policy from the cloud.
Args:
console (Console): The console for output.
closure (Tuple[Any, Dict[str, Any]]): The function and its arguments to call.
Returns:
Optional[PolicyFileModel]: The downloaded policy file model.
"""
policy = None
wait_msg = "Looking for a policy from cloud..."
with console.status(wait_msg, spinner=DEFAULT_SPINNER):
try:
f, kwargs = closure
policy = f(**kwargs)
except Exception as e:
LOG.exception(f"Policy download failed, reason: {e}")
console.print("Not using cloud policy file.")
if policy:
wait_msg = "Policy fetched from Safety Platform."
else:
# TODO: Send a log
pass
return policy
def prompt_project_id(console: Console, default_id: str) -> str:
"""
Prompt the user to set a project ID, on a non-interactive mode it will
fallback to the default ID parameter.
"""
default_prj_id = clean_project_id(default_id)
if not sys.stdin.isatty():
LOG.info("Fallback to default project id, because of non-interactive mode.")
return default_prj_id
prompt_text = f"\nEnter a name for this codebase (or press [bold]Enter[/bold] to use '\\[{default_prj_id}]')"
while True:
result = Prompt.ask(
prompt_text, console=console, default=default_prj_id, show_default=False
)
return clean_project_id(result) if result != default_prj_id else default_prj_id
def prompt_link_project(console: Console, prj_name: str, prj_admin_email: str) -> bool:
"""
Prompt the user to link the scan with an existing project. If the console is not interactive
it will fallback to True.
Args:
console (Console): The console for output.
prj_name (str): The project name.
prj_admin_email (str): The project admin email.
Returns:
bool: True if the user wants to link the scan, False otherwise.
"""
if not sys.stdin.isatty():
LOG.info("Linking to existing project because of non-interactive mode.")
return True
console.print(
"\n[bold]Safety found an existing codebase with this name in your organization:[/bold]"
)
for detail in (
f"[bold]Codebase name:[/bold] {prj_name}",
f"[bold]Codebase admin:[/bold] {prj_admin_email}",
):
console.print(Padding(detail, (0, 0, 0, 2)), emoji=True)
console.print()
prompt_question = "Do you want to link it with this existing codebase?"
answer = Prompt.ask(
prompt=prompt_question,
choices=["y", "n"],
default="y",
show_default=True,
console=console,
).lower()
return answer == "y"
def render_to_console(
cls: Vulnerability,
console: Console,
rich_kwargs: Dict[str, Any],
detailed_output: bool = False,
) -> None:
"""
Render a vulnerability to the console.
Args:
cls (Vulnerability): The vulnerability instance.
console (Console): The console for output.
rich_kwargs (Dict[str, Any]): Additional arguments for rendering.
detailed_output (bool): Indicates if detailed output is enabled.
"""
cls.__render__(console, detailed_output, rich_kwargs)
def get_render_console(entity_type: Any) -> Any:
"""
Get the render function for a specific entity type.
Args:
entity_type (Any): The entity type.
Returns:
Any: The render function.
"""
if entity_type is Vulnerability:
def __render__(self, console: Console, detailed_output: bool, rich_kwargs):
if not rich_kwargs:
rich_kwargs = {}
pre = " Ignored:" if self.ignored else ""
severity_detail = None
if self.severity and self.severity.source:
severity_detail = self.severity.source
if self.severity.cvssv3 and "base_severity" in self.severity.cvssv3:
severity_detail += f", CVSS Severity {self.severity.cvssv3['base_severity'].upper()}"
advisory_length = 200 if detailed_output else 110
console.print(
Padding(
f"->{pre} Vuln ID [vuln_id]{self.vulnerability_id}[/vuln_id]: {severity_detail if severity_detail else ''}",
(0, 0, 0, 2),
),
**rich_kwargs,
)
console.print(
Padding(
f"{self.advisory[:advisory_length]}{'...' if len(self.advisory) > advisory_length else ''}",
(0, 0, 0, 5),
),
**rich_kwargs,
)
if detailed_output:
console.print(
Padding(
f"For more information: [link]{self.more_info_url}[/link]",
(0, 0, 0, 5),
),
**rich_kwargs,
)
return __render__
def render_scan_html(report: ReportModel, obj: Any) -> str:
"""
Render the scan report to HTML.
Args:
report (ReportModel): The scan report model.
obj (Any): The object containing additional settings.
Returns:
str: The rendered HTML report.
"""
from safety.scan.command import ScannableEcosystems
project = report.projects[0] if any(report.projects) else None
scanned_packages = 0
affected_packages = 0
ignored_packages = 0
remediations_recommended = 0
ignored_vulnerabilities = 0
vulnerabilities = 0
vulns_per_file = defaultdict(int)
remed_per_file = defaultdict(int)
for file in project.files:
scanned_packages += len(file.results.dependencies)
affected_packages += len(file.results.get_affected_dependencies())
ignored_vulnerabilities += len(file.results.ignored_vulns)
for spec in file.results.get_affected_specifications():
vulnerabilities += len(spec.vulnerabilities)
vulns_per_file[file.location] += len(spec.vulnerabilities)
if spec.remediation:
remed_per_file[file.location] += 1
remediations_recommended += 1
ignored_packages += len(file.results.ignored_vulns)
# TODO: Get this information for the report model (?)
summary = {
"scanned_packages": scanned_packages,
"affected_packages": affected_packages,
"remediations_recommended": remediations_recommended,
"ignored_vulnerabilities": ignored_vulnerabilities,
"vulnerabilities": vulnerabilities,
}
vulnerabilities = []
# TODO: This should be based on the configs per command
ecosystems = [
(
f"{ecosystem.name.title()}",
[file_type.human_name(plural=True) for file_type in ecosystem.file_types],
)
for ecosystem in [
Ecosystem(member.value) for member in list(ScannableEcosystems)
]
]
settings = {
"audit_and_monitor": True,
"platform_url": SAFETY_PLATFORM_URL,
"ecosystems": ecosystems,
}
template_context = {
"report": report,
"summary": summary,
"announcements": [],
"project": project,
"platform_enabled": obj.platform_enabled,
"settings": settings,
"vulns_per_file": vulns_per_file,
"remed_per_file": remed_per_file,
}
return parse_html(kwargs=template_context, template="scan/index.html")
def generate_spdx_creation_info(spdx_version: str, project_identifier: str) -> Any:
"""
Generate SPDX creation information.
Args:
spdx_version (str): The SPDX version.
project_identifier (str): The project identifier.
Returns:
Any: The SPDX creation information.
"""
from spdx_tools.spdx.model import (
Actor,
ActorType,
CreationInfo,
)
version = int(time.time())
SPDX_ID_TYPE = "SPDXRef-DOCUMENT"
DOC_NAME = f"{project_identifier}-{version}"
DOC_NAMESPACE = f"https://spdx.org/spdxdocs/{DOC_NAME}"
# DOC_NAMESPACE = f"urn:safety:{project_identifier}:{version}"
DOC_COMMENT = f"This document was created using SPDX {spdx_version}"
CREATOR_COMMENT = (
"Safety CLI automatically created this SPDX document from a scan report."
)
TOOL_ID = "safety"
TOOL_VERSION = get_version()
doc_creator = Actor(
actor_type=ActorType.TOOL, name=f"{TOOL_ID}-{TOOL_VERSION}", email=None
)
creation_info = CreationInfo(
spdx_version=f"SPDX-{spdx_version}",
spdx_id=SPDX_ID_TYPE,
name=DOC_NAME,
document_namespace=DOC_NAMESPACE,
creators=[doc_creator],
created=datetime.datetime.now(),
document_comment=DOC_COMMENT,
creator_comment=CREATOR_COMMENT,
)
return creation_info
def create_pkg_ext_ref(*, package: PythonDependency, version: Optional[str]) -> Any:
"""
Create an external package reference for SPDX.
Args:
package (PythonDependency): The package dependency.
version (Optional[str]): The package version.
Returns:
Any: The external package reference.
"""
from spdx_tools.spdx.model import (
ExternalPackageRef,
ExternalPackageRefCategory,
)
version_detail = f"@{version}" if version else ""
pkg_ref = ExternalPackageRef(
ExternalPackageRefCategory.PACKAGE_MANAGER,
"purl",
f"pkg:pypi/{package.name}{version_detail}",
)
return pkg_ref
def create_packages(dependencies: List[PythonDependency]) -> List[Any]:
"""
Create a list of SPDX packages.
Args:
dependencies (List[PythonDependency]): List of Python dependencies.
Returns:
List[Any]: List of SPDX packages.
"""
from spdx_tools.spdx.model import (
Package,
)
from spdx_tools.spdx.model.spdx_no_assertion import SpdxNoAssertion
doc_pkgs = []
pkgs_added = set([])
for dependency in dependencies:
for spec in dependency.specifications:
pkg_version = (
next(iter(spec.specifier)).version
if spec.is_pinned()
else f"{spec.specifier}"
)
dep_name = dependency.name.replace("_", "-")
pkg_id = (
f"SPDXRef-pip-{dep_name}-{pkg_version}"
if spec.is_pinned()
else f"SPDXRef-pip-{dep_name}"
)
if pkg_id in pkgs_added:
continue
pkg_ref = create_pkg_ext_ref(package=dependency, version=pkg_version)
pkg = Package(
spdx_id=pkg_id,
name=f"pip:{dep_name}",
download_location=SpdxNoAssertion(),
version=pkg_version,
file_name="",
supplier=SpdxNoAssertion(),
files_analyzed=False,
license_concluded=SpdxNoAssertion(),
license_declared=SpdxNoAssertion(),
copyright_text=SpdxNoAssertion(),
external_references=[pkg_ref],
)
pkgs_added.add(pkg_id)
doc_pkgs.append(pkg)
return doc_pkgs
def create_spdx_document(*, report: ReportModel, spdx_version: str) -> Optional[Any]:
"""
Create an SPDX document.
Args:
report (ReportModel): The scan report model.
spdx_version (str): The SPDX version.
Returns:
Optional[Any]: The SPDX document.
"""
from spdx_tools.spdx.model import (
Document,
Relationship,
RelationshipType,
)
project = report.projects[0] if any(report.projects) else None
if not project:
return None
prj_id = project.id
if not prj_id:
parent_name = project.project_path.parent.name
prj_id = parent_name if parent_name else str(int(time.time()))
creation_info = generate_spdx_creation_info(
spdx_version=spdx_version, project_identifier=prj_id
)
depedencies = iter([])
for file in project.files:
depedencies = itertools.chain(depedencies, file.results.dependencies)
packages = create_packages(depedencies)
# Requirement for document to have atleast one relationship
relationship = Relationship(
"SPDXRef-DOCUMENT", RelationshipType.DESCRIBES, "SPDXRef-DOCUMENT"
)
spdx_doc = Document(creation_info, packages, [], [], [], [relationship], [])
return spdx_doc
def render_scan_spdx(
report: ReportModel, obj: Any, spdx_version: Optional[str]
) -> Optional[Any]:
"""
Render the scan report to SPDX format.
Args:
report (ReportModel): The scan report model.
obj (Any): The object containing additional settings.
spdx_version (Optional[str]): The SPDX version.
Returns:
Optional[Any]: The rendered SPDX document in JSON format.
"""
from spdx_tools.spdx.writer.write_utils import convert, validate_and_deduplicate
# Set to latest supported if a version is not specified
if not spdx_version:
spdx_version = "2.3"
document_obj = create_spdx_document(report=report, spdx_version=spdx_version)
document_obj = validate_and_deduplicate(
document=document_obj, validate=True, drop_duplicates=True
)
doc = None
if document_obj:
doc = convert(document=document_obj, converter=None)
return json.dumps(doc) if doc else None