825 lines
27 KiB
Python
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
|