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

565 lines
16 KiB
Python

import logging
from enum import Enum
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Generator,
List,
Optional,
Tuple,
Union,
Literal,
)
from pydantic import BaseModel, ConfigDict
import typer
from safety.auth.constants import SAFETY_PLATFORM_URL
from safety.errors import SafetyException
from safety.scan.command import (
ScannableEcosystems,
initialize_file_finder,
scan_project_directory,
)
from safety.scan.main import (
download_policy,
load_policy_file,
process_files,
resolve_policy,
)
from safety_schemas.models import (
Ecosystem,
FileModel,
FileType,
RemediationModel,
ReportModel,
ReportSchemaVersion,
ScanType,
VulnerabilitySeverityLabels,
MetadataModel,
TelemetryModel,
ProjectModel,
Stage,
AuthenticationType,
)
from safety.scan.util import GIT
from safety.util import build_telemetry_data
# Define typed models for scan results
class ScanResultType(str, Enum):
"""Types of scan results that can be yielded by the init_scan function"""
INIT = "init"
PROGRESS = "progress"
UPLOADING = "uploading"
STATUS = "status"
COMPLETE = "complete"
class BaseScanResult(BaseModel):
"""
Base class for all scan results with common attributes
"""
# No fields here - each subclass will define its own type
pass
class InitScanResult(BaseScanResult):
"""
Initial scan result with basic dependency info
"""
model_config = ConfigDict(frozen=True)
type: Literal[ScanResultType.INIT]
dependencies: int
progress: int = 0
class ProgressScanResult(BaseScanResult):
"""
Progress update during scanning with current counts
"""
model_config = ConfigDict(frozen=True)
type: Literal[ScanResultType.PROGRESS]
percent: int
dependencies: int
critical: Optional[int] = None
high: Optional[int] = None
medium: Optional[int] = None
low: Optional[int] = None
others: Optional[int] = None
fixes: Optional[int] = None
fixed_vulns: Optional[int] = None
file: str
file_pkg_count: int
file_count: int
venv_count: int
vulns_count: int
class CompleteScanResult(BaseScanResult):
"""
Final scan result with complete vulnerability counts
"""
model_config = ConfigDict(frozen=True)
type: Literal[ScanResultType.COMPLETE]
scan_id: Optional[str] = None
percent: int = 100
dependencies: int
critical: int
high: int
medium: int
low: int
others: int
vulns_count: int
fixes: int
fixed_vulns: int
codebase_url: Optional[str] = None
class StatusScanResult(BaseScanResult):
"""
Generic status update that can be used for any process
"""
model_config = ConfigDict(frozen=True)
type: Literal[ScanResultType.STATUS]
message: str
action: str # The specific action being performed (e.g., "analyzing", "preparing")
percent: Optional[int] = None
class UploadingScanResult(BaseScanResult):
"""
Status update when uploading results to server
"""
model_config = ConfigDict(frozen=True)
type: Literal[ScanResultType.UPLOADING]
message: str
percent: Optional[int] = None
# Union type for all possible result types
ScanResult = Union[
InitScanResult,
ProgressScanResult,
StatusScanResult,
UploadingScanResult,
CompleteScanResult,
]
LOG = logging.getLogger(__name__)
if TYPE_CHECKING:
from safety_schemas.models import (
ConfigModel,
ProjectModel,
MetadataModel,
TelemetryModel,
ReportModel,
FileModel,
)
def init_scan(
ctx: Any,
target: Path,
config: "ConfigModel",
metadata: "MetadataModel",
telemetry: "TelemetryModel",
project: "ProjectModel",
use_server_matching: bool = False,
) -> Generator[ScanResult, None, Tuple["ReportModel", List["FileModel"]]]:
"""
Core scanning logic that yields results as they become available.
Contains no UI-related code - purely logic for scanning.
Args:
ctx: The context object with necessary configurations
target: The target directory to scan
config: The application configuration
metadata: Metadata to include in the report
telemetry: Telemetry data to include in the report
project: The project object
version: The schema version
use_server_matching: Whether to use server-side vulnerability matching
Yields:
Dict containing scan progress information and results as they become available
Returns:
Tuple containing the final report model and list of files
"""
# Emit status that scan is starting
yield StatusScanResult(
type=ScanResultType.STATUS,
message="Starting safety scan",
action="initializing",
percent=0,
)
# Initialize ecosystems
ecosystems = [Ecosystem(member.value) for member in list(ScannableEcosystems)]
# Initialize file finder and locate project files
from rich.console import Console
console = Console()
console.quiet = True
yield StatusScanResult(
type=ScanResultType.STATUS,
message="Locating project files",
action="discovering",
percent=5,
)
file_finder = initialize_file_finder(ctx, target, None, ecosystems)
yield StatusScanResult(
type=ScanResultType.STATUS,
message="Scanning project directory",
action="scanning",
percent=10,
)
_, file_paths = scan_project_directory(file_finder, console)
total_files = sum(len(file_set) for file_set in file_paths.values())
yield StatusScanResult(
type=ScanResultType.STATUS,
message=f"Found {total_files} files to analyze",
action="analyzing",
percent=15,
)
# Initialize counters and data structures
files: List[FileModel] = []
count = 0 # Total dependencies processed
affected_count = 0
critical_vulns_count = 0
high_vulns_count = 0
medium_vulns_count = 0
low_vulns_count = 0
others_vulns_count = 0
vulns_count = 0
fixes_count = 0
total_resolved_vulns = 0
file_count = 0
venv_count = 0
scan_id = None
# Count the total number of files across all types
# Initial yield with dependency info
yield InitScanResult(type=ScanResultType.INIT, dependencies=count)
# Status update before processing files
yield StatusScanResult(
type=ScanResultType.STATUS,
message="Processing files for dependencies and vulnerabilities",
action="analyzing",
percent=20,
)
# Process each file for dependencies and vulnerabilities
for idx, (path, analyzed_file) in enumerate(
process_files(
paths=file_paths,
config=config,
use_server_matching=use_server_matching,
obj=ctx.obj,
target=target,
)
):
# Calculate progress percentage
# Calculate progress and ensure it never exceeds 100%
if total_files > 0:
progress = min(int((idx + 1) / total_files * 100), 100)
else:
progress = 100
# Update counts for dependencies
file_pkg_count = len(analyzed_file.dependency_results.dependencies)
count += file_pkg_count
# Track environment/file types
if analyzed_file.file_type is FileType.VIRTUAL_ENVIRONMENT:
venv_count += 1
else:
file_count += 1
# Get affected specifications
affected_specifications = (
analyzed_file.dependency_results.get_affected_specifications()
)
affected_count += len(affected_specifications)
# Count vulnerabilities by severity
current_critical = 0
current_high = 0
current_medium = 0
current_low = 0
current_others = 0
current_fixes = 0
current_resolved_vulns = 0
# Process each affected specification
for spec in affected_specifications:
# Access vulnerabilities
for vuln in spec.vulnerabilities:
if vuln.ignored:
continue
vulns_count += 1
# Determine vulnerability severity
severity = severity = VulnerabilitySeverityLabels.UNKNOWN
if (
hasattr(vuln, "CVE")
and vuln.CVE
and hasattr(vuln.CVE, "cvssv3")
and vuln.CVE.cvssv3
):
severity_str = vuln.CVE.cvssv3.get("base_severity", "none").lower()
severity = VulnerabilitySeverityLabels(severity_str)
# Count based on severity
if severity is VulnerabilitySeverityLabels.CRITICAL:
current_critical += 1
elif severity is VulnerabilitySeverityLabels.HIGH:
current_high += 1
elif severity is VulnerabilitySeverityLabels.MEDIUM:
current_medium += 1
elif severity is VulnerabilitySeverityLabels.LOW:
current_low += 1
else:
current_others += 1
# Check for available fixes - safely access remediation attributes
if spec.remediation:
# Access remediation properties safely without relying on specific attribute names
remediation: RemediationModel = spec.remediation
has_recommended_version = True if remediation.recommended else False
if has_recommended_version:
current_fixes += 1
current_resolved_vulns += len(
[v for v in spec.vulnerabilities if not v.ignored]
)
# Update total counts
critical_vulns_count += current_critical
high_vulns_count += current_high
medium_vulns_count += current_medium
low_vulns_count += current_low
others_vulns_count += current_others
fixes_count += current_fixes
total_resolved_vulns += current_resolved_vulns
# Save file data for further processing
file = FileModel(
location=path,
file_type=analyzed_file.file_type,
results=analyzed_file.dependency_results,
)
files.append(file)
# Yield current analysis results
yield ProgressScanResult(
type=ScanResultType.PROGRESS,
percent=progress,
dependencies=count,
critical=critical_vulns_count,
high=high_vulns_count,
medium=medium_vulns_count,
low=low_vulns_count,
others=others_vulns_count,
vulns_count=vulns_count,
fixes=fixes_count,
fixed_vulns=total_resolved_vulns,
file=str(path),
file_pkg_count=file_pkg_count,
file_count=file_count,
venv_count=venv_count,
)
# All files processed, create the report
project.files = files
yield StatusScanResult(
type=ScanResultType.STATUS,
message="Creating final report",
action="reporting",
percent=90,
)
# Convert dictionaries to model objects if needed
if isinstance(metadata, dict):
metadata_model = MetadataModel(**metadata)
else:
metadata_model = metadata
if isinstance(telemetry, dict):
telemetry_model = TelemetryModel(**telemetry)
else:
telemetry_model = telemetry
report = ReportModel(
version=ReportSchemaVersion.v3_0,
metadata=metadata_model,
telemetry=telemetry_model,
files=[],
projects=[project],
)
# Emit uploading status before starting upload
yield UploadingScanResult(
type=ScanResultType.UPLOADING, message="Preparing to upload scan results"
)
# TODO: Decouple platform upload logic
try:
# Convert report to JSON format
yield UploadingScanResult(
type=ScanResultType.UPLOADING,
message="Converting report to JSON format",
percent=25,
)
json_format = report.as_v30().json()
# Start upload
yield UploadingScanResult(
type=ScanResultType.UPLOADING,
message="Uploading results to Safety platform",
percent=50,
)
result = ctx.obj.auth.client.upload_report(json_format)
# Upload complete
yield UploadingScanResult(
type=ScanResultType.UPLOADING,
message="Upload completed successfully",
percent=100,
)
scan_id = result.get("uuid")
codebase_url = f"{SAFETY_PLATFORM_URL}{result['url']}"
except Exception as e:
# Emit error status
yield UploadingScanResult(
type=ScanResultType.UPLOADING, message=f"Error uploading results: {str(e)}"
)
raise e
# Final yield with completed flag
yield CompleteScanResult(
type=ScanResultType.COMPLETE,
dependencies=count,
critical=critical_vulns_count,
high=high_vulns_count,
medium=medium_vulns_count,
low=low_vulns_count,
others=others_vulns_count,
vulns_count=vulns_count,
fixes=fixes_count,
fixed_vulns=total_resolved_vulns,
codebase_url=codebase_url,
scan_id=scan_id,
)
# Return the complete report and files
return report, files
def start_scan(
ctx: "typer.Context",
auth_type: AuthenticationType,
is_authenticated: bool,
target: Path,
client: Any,
project: ProjectModel,
branch: Optional[str] = None,
stage: Stage = Stage.development,
platform_enabled: bool = False,
telemetry_enabled: bool = True,
use_server_matching: bool = False,
) -> Generator["ScanResult", None, Tuple["ReportModel", List["FileModel"]]]:
"""
Initialize and start a scan, returning an iterator that yields scan results.
This function handles setting up all required parameters for the scan.
Args:
ctx: The Typer context object containing configuration and project information
target: The target directory to scan
use_server_matching: Whether to use server-side vulnerability matching
Returns:
An iterator that yields scan results
"""
if not branch:
if git_data := GIT(root=target).build_git_data():
branch = git_data.branch
command_name = "scan"
telemetry = build_telemetry_data(
telemetry=telemetry_enabled, command=command_name, subcommand=None
)
scan_type = ScanType(command_name)
targets = [target]
if not scan_type:
raise SafetyException("Missing scan_type.")
metadata = MetadataModel(
scan_type=scan_type,
stage=stage,
scan_locations=targets,
authenticated=is_authenticated,
authentication_type=auth_type,
telemetry=telemetry,
schema_version=ReportSchemaVersion.v3_0,
)
policy_file_path = target / Path(".safety-policy.yml")
# Load Policy file and pull it from CLOUD
local_policy = load_policy_file(policy_file_path)
cloud_policy = None
if platform_enabled:
cloud_policy = download_policy(
client, project_id=project.id, stage=stage, branch=branch
)
project.policy = resolve_policy(local_policy, cloud_policy)
config = (
project.policy.config
if project.policy and project.policy.config
else ConfigModel()
)
return init_scan(
ctx=ctx,
target=target,
config=config,
metadata=metadata,
telemetry=telemetry,
project=project,
use_server_matching=use_server_matching,
)