import logging from pathlib import Path import sys import threading import time from typing import ( TYPE_CHECKING, Optional, Iterator, ) from rich.live import Live from rich.padding import Padding from rich.panel import Panel from rich.prompt import Prompt from rich.syntax import Syntax from rich.table import Table from rich.text import Text from safety.codebase_utils import load_unverified_project_from_config from safety.events.utils.emission import ( emit_codebase_detection_status, emit_codebase_setup_completed, emit_init_exited, emit_init_scan_completed, ) from safety.init.models import StepTracker from safety_schemas.models.events.types import ToolType from .render import ( ask_codebase_setup, ask_continue, ask_firewall_setup, progressive_print, render_header, typed_print, ) from safety.scan.init_scan import start_scan from ..cli_util import ( CommandType, FeatureType, SafetyCLICommand, SafetyCLISubGroup, ) from safety.error_handlers import handle_cmd_exception import typer from safety.init.constants import ( MSG_ANALYZE_CODEBASE_TITLE, MSG_CODEBASE_FAILED_TO_SCAN, MSG_CODEBASE_NOT_CONFIGURED, MSG_CODEBASE_URL_DESCRIPTION, MSG_COMPLETE_SECURED, MSG_COMPLETE_TOOL_SECURED, MSG_FIREWALL_UNINSTALL, MSG_LAST_MANUAL_STEP, MSG_NO_VULNERABILITIES_FOUND, MSG_NO_VULNS_CODEBASE_URL_DESCRIPTION, MSG_OPEN_DASHBOARD_PROMPT, MSG_SETUP_CODEBASE_NO_PROJECT, MSG_SETUP_COMPLETE_SUBTITLE, MSG_SETUP_COMPLETE_TITLE, MSG_SETUP_INCOMPLETE, MSG_SETUP_NEXT_STEPS, MSG_SETUP_NEXT_STEPS_MANUAL_STEP, MSG_SETUP_NEXT_STEPS_NO_PROJECT, MSG_SETUP_NEXT_STEPS_NO_VULNS, MSG_SETUP_NEXT_STEPS_SUBTITLE, MSG_SETUP_PACKAGE_FIREWALL_DESCRIPTION, MSG_SETUP_PACKAGE_FIREWALL_TITLE, MSG_SETUP_CODEBASE_DESCRIPTION, MSG_SETUP_CODEBASE_TITLE, CODEBASE_INIT_CMD_NAME, CODEBASE_INIT_HELP, CODEBASE_INIT_DIRECTORY_HELP, MSG_TOOLS_NOT_CONFIGURED, MSG_WELCOME_TITLE, MSG_WELCOME_DESCRIPTION, ) from safety.init.main import create_project, launch_auth_if_needed, setup_firewall from safety.console import ( get_spinner_animation, main_console as console, should_use_ascii, ) from ..tool.main import ( configure_local_directory, find_local_tool_files, ) from ..constants import CONTEXT_COMMAND_TYPE, CONTEXT_FEATURE_TYPE from safety.decorators import notify from safety.events.utils import emit_firewall_configured, emit_init_started from safety_schemas.models.events.payloads import AliasConfig, IndexConfig, InitExitStep if TYPE_CHECKING: import typer from safety.scan.init_scan import ScanResult try: from typing import Annotated # type: ignore except ImportError: from typing_extensions import Annotated init_app = typer.Typer(rich_markup_mode="rich", cls=SafetyCLISubGroup) logger = logging.getLogger(__name__) class InitScanState: """ Class to track scan state for vulnerability scans Attributes: scan_id: ID of the scan dependencies: Number of dependencies found critical: Count of critical vulnerabilities high: Count of high severity vulnerabilities medium: Count of medium severity vulnerabilities low: Count of low severity vulnerabilities fixes: Number of fixes available fixed_vulns: Number of vulnerabilities with fixes url: URL to view the scan results completed: Whether the scan has completed progress: Percentage progress of the scan status_message: Current status message from the scanner status_action: Current action being performed by the scanner current_file: Current file being processed """ def __init__(self): self.scan_id: Optional[str] = None self.dependencies: int = 0 self.critical: int = 0 self.high: int = 0 self.medium: int = 0 self.low: int = 0 self.others: int = 0 self.vulns_count: int = 0 self.fixes: int = 0 self.fixed_vulns: int = 0 self.codebase_url: Optional[str] = None self.completed: bool = False self.progress: int = 0 self.status_message: Optional[str] = None self.status_action: Optional[str] = None self.current_file: Optional[str] = None def generate_summary(state: InitScanState, spinner_phase=0) -> Text: """ Generate the summary text based on current scan state """ spinner = get_spinner_animation() text_markup = f"Tested {state.dependencies} dependenc{'y' if state.dependencies == 1 else 'ies'} for security issues\n" text_markup += "\nFound:\n\n" categories = [ { "name": "CRITICAL", "icon": ":fire:", "style": "bold red", "dim_style": "dim red", "count_attr": "critical", "spinner_offset": 0, }, { "name": "HIGH", "icon": ":yellow_circle:", "style": "bold yellow", "dim_style": "dim yellow", "count_attr": "high", "spinner_offset": 2, }, { "name": "MEDIUM", "icon": "!!", "style": "yellow", "dim_style": "dim yellow", "count_attr": "medium", "spinner_offset": 4, }, { "name": "LOW", "icon": ":icon_info: ", "style": "bold blue", "dim_style": "dim blue", "count_attr": "low", "spinner_offset": 6, }, { "name": "OTHER", "icon": "**", "style": "blue", "dim_style": "dim blue", "count_attr": "others", "spinner_offset": 8, }, ] # No vulnerabilities case prepend_text_codebase_url = MSG_CODEBASE_URL_DESCRIPTION if state.completed and state.vulns_count <= 0: text_markup += MSG_NO_VULNERABILITIES_FOUND prepend_text_codebase_url = MSG_NO_VULNS_CODEBASE_URL_DESCRIPTION else: for category in categories: category_count = getattr(state, category["count_attr"]) if state.completed and category_count == 0: continue style = category["style"] text_markup += f"[{style}]{category['icon']} {category['name']}: [/{style}]" if category_count > 0: vulns_word = f"vulnerabilit{'y' if category_count == 1 else 'ies'}" text_markup += f"[{style}]{category_count}[/{style}] {vulns_word}\n" else: spinner_status = spinner[ (spinner_phase + category["spinner_offset"]) % len(spinner) ] style = category["dim_style"] text_markup += f"[{style}]{spinner_status}[/{style}] Scanning\n" # Show fixes info if we have vulnerabilities if state.vulns_count > 0 and state.fixes is not None: text_markup += "\n" if state.fixes > 0: fix_word = f"fix{'' if state.fixes == 1 else 'es'}" vulns_word = f"vulnerabilit{'y' if state.fixed_vulns == 1 else 'ies'}" text_markup += f":sparkles: [green]{state.fixes}[/green] automatic {fix_word} available, resolving {state.fixed_vulns} {vulns_word}\n" else: text_markup += ( " No automatic fixes available for these vulnerabilities\n" ) # Dashboard link if URL is available if state.codebase_url is not None: text_markup += f"\n{prepend_text_codebase_url}[blue underline]:link: {state.codebase_url}\n[/blue underline]" elif state.completed: text_markup += "\n" return console.render_str(text_markup) def generate_status_updates(state: InitScanState, spinner_phase: int = 0) -> Text: """ Generate text displaying current status updates and progress information Args: state: The InitScanState object containing status information spinner_phase: Current phase of the spinner animation Returns: Rich Text object containing formatted status updates """ spinner = get_spinner_animation() markup_text = f"[cyan]{spinner[spinner_phase % len(spinner)]} Scanning...[/cyan]" # Display current status message if available if state.status_message: action_symbol = { "init": ":arrows_counterclockwise:", "scanning": ":magnifying_glass_tilted_left:", "uploading": ":cloud: ", "complete": ":white_heavy_check_mark:", "error": ":cross_mark:", } status_key = state.status_action if state.status_action is not None else "init" action_symbol = action_symbol.get(status_key, ":information_source: ") markup_text = f"\n[bold cyan]{action_symbol} Status: [/bold cyan][cyan]{state.status_message}[/cyan]\n" # If we're processing a file, show that if state.current_file and state.status_action == "scanning": markup_text += f"[bold cyan]:file_folder: Current file: [/bold cyan][dim cyan]{state.current_file}[/dim cyan]\n" # Ensure progress is capped at 100% display_progress = min(state.progress, 100) markup_text += f"[cyan]:bar_chart: Progress: {display_progress}%[/cyan]\n" return console.render_str(markup_text) def process_scan_results( scan_results: Iterator["ScanResult"], state: InitScanState ) -> None: """Process the scan iterator and update state from typed result models Args: scan_results: Iterator yielding scan results from init_scan state: The InitScanState object to update with scan results """ # Import the scan result types to handle typed results from safety.scan.init_scan import ( ScanResultType, ) try: for result in scan_results: # Now result is a typed model with proper attributes if result.type == ScanResultType.INIT: # Initial state with dependency count state.dependencies = result.dependencies state.status_message = "Initializing scan" state.status_action = "init" elif result.type == ScanResultType.PROGRESS: # Update all the state fields from the progress result # Type checker knows result is ProgressScanResult # Ensure progress never exceeds 100% state.progress = min(result.percent, 100) state.dependencies = result.dependencies # Track current file being processed state.current_file = result.file state.status_message = f"Processing {result.file}" state.status_action = "scanning" # Update severity counts if present if result.critical is not None: state.critical = result.critical if result.high is not None: state.high = result.high if result.medium is not None: state.medium = result.medium if result.low is not None: state.low = result.low if result.others is not None: state.others = result.others # Update vulnerability count if result.vulns_count is not None: state.vulns_count = result.vulns_count # Update fix information if present if result.fixes is not None: state.fixes = result.fixes if result.fixed_vulns is not None: state.fixed_vulns = result.fixed_vulns elif result.type == ScanResultType.STATUS: # Generic status update state.status_message = result.message state.status_action = result.action if result.percent is not None: state.progress = min(result.percent, 100) elif result.type == ScanResultType.UPLOADING: # Status update for uploading phase state.status_message = result.message state.status_action = "uploading" if result.percent is not None: state.progress = min(result.percent, 100) elif result.type == ScanResultType.COMPLETE: # Final update with complete data # Type checker knows result is CompleteScanResult state.progress = 100 state.dependencies = result.dependencies state.critical = result.critical state.high = result.high state.medium = result.medium state.low = result.low state.others = result.others state.fixes = result.fixes state.fixed_vulns = result.fixed_vulns state.status_message = "Scan completed" state.status_action = "complete" state.vulns_count = result.vulns_count # Update project URL if available if result.codebase_url: state.codebase_url = result.codebase_url if result.scan_id: state.scan_id = result.scan_id # We're done processing state.completed = True # Add a small delay between updates to allow UI thread to refresh time.sleep(0.05) except Exception as e: console.print(f"Error processing scan results: {e}", style="bold red") state.status_message = f"Error: {str(e)}" state.status_action = "error" finally: # Ensure we mark as completed even if there was an exception state.completed = True def init_scan_ui(ctx: "typer.Context", prompt_user: bool = False) -> InitScanState: """ Initialize and run a scan for the init command, showing a live UI with scan progress. Uses the start_scan function to get an iterator of scan results and displays UI based on them. Args: ctx: The Typer context object containing configuration and project information """ # Initialize state for tracking scan progress state = InitScanState() # Set up scan parameters and get the scan iterator target = ctx.obj.project.project_path.parent use_server_matching = False # Start the scan using the dedicated function scan_results = start_scan( ctx=ctx, target=target, use_server_matching=use_server_matching, auth_type=ctx.obj.auth.client.get_authentication_type(), is_authenticated=ctx.obj.auth.client.is_using_auth_credentials(), client=ctx.obj.auth.client, project=ctx.obj.project, platform_enabled=ctx.obj.platform_enabled, ) # Process the scan results in a separate thread scan_thread = threading.Thread( target=process_scan_results, args=(scan_results, state) ) scan_thread.daemon = True scan_thread.start() # Handle UI updates in the main thread spinner_phase = 0 render_header( MSG_ANALYZE_CODEBASE_TITLE.format(project_name=ctx.obj.project.id), emoji=":magnifying_glass_tilted_left:", ) time.sleep(0.8) # Detect if running on Windows is_windows = sys.platform == "win32" or should_use_ascii() # Alternate screen in Windows is buggy, so we disable it live_kwargs = { "refresh_per_second": 10, "screen": False if is_windows else True, "transient": False, } refresh_sleep = 0.1 with Live(**live_kwargs) as live: while not state.completed or scan_thread.is_alive(): # Update spinner phase for animation spinner_phase = (spinner_phase + 1) % 10 # Summary information shown below status updates summary = generate_summary(state, spinner_phase) if is_windows: content = summary else: # Create a container for all UI elements container = Table.grid(padding=0, expand=True) container.add_row(None) container.add_row( Panel( generate_status_updates(state, spinner_phase), border_style="cyan", padding=(0, 1), ) ) container.add_row(None) container.add_row(summary) content = container # Display the updated UI live.update(content) time.sleep(refresh_sleep) # Last sync if state.completed: live.update(generate_summary(state, spinner_phase)) time.sleep(2) # Windows is not using alternate screen, so summary is already rendered if not is_windows: # Final update to ensure completion state is shown console.print(generate_summary(state)) if state.codebase_url: typed_print(MSG_OPEN_DASHBOARD_PROMPT, end_line=False) should_open = "y" if prompt_user: should_open = Prompt.ask( "", choices=["y", "n", "Y", "N"], default="y", show_default=False, show_choices=False, console=console, ).lower() if should_open == "y": typer.launch(state.codebase_url) return state @init_app.command( cls=SafetyCLICommand, help=CODEBASE_INIT_HELP, name=CODEBASE_INIT_CMD_NAME, options_metavar="[OPTIONS]", context_settings={ "allow_extra_args": True, "ignore_unknown_options": True, CONTEXT_COMMAND_TYPE: CommandType.BETA, CONTEXT_FEATURE_TYPE: FeatureType.FIREWALL, }, ) @handle_cmd_exception @notify def init( ctx: typer.Context, directory: Annotated[ Path, typer.Argument( # type: ignore exists=True, file_okay=False, dir_okay=True, writable=False, readable=True, resolve_path=True, show_default=False, help=CODEBASE_INIT_DIRECTORY_HELP, ), ] = Path("."), ): emit_init_started(ctx.obj.event_bus, ctx) # TODO: check if tty is available tracker = StepTracker() try: do_init(ctx, directory, tracker, prompt_user=console.is_interactive) except KeyboardInterrupt as e: emit_init_exited(ctx.obj.event_bus, ctx, exit_step=tracker.current_step) raise e def do_init( ctx: typer.Context, directory: Path, tracker: StepTracker, prompt_user: bool = True ): """ Initialize Safety CLI with the new onboarding flow. Args: ctx: The CLI context directory: The target directory to initialize prompt_user: Whether to prompt the user for input or use defaults """ project_dir = directory.resolve() typed_print(MSG_WELCOME_TITLE) progressive_print(MSG_WELCOME_DESCRIPTION) tracker.current_step = InitExitStep.PRE_AUTH org_slug = launch_auth_if_needed(ctx, console) tracker.current_step = InitExitStep.POST_AUTH render_header(MSG_SETUP_PACKAGE_FIREWALL_TITLE, margin_right=1) console.print(MSG_SETUP_PACKAGE_FIREWALL_DESCRIPTION) console.print( Syntax( MSG_FIREWALL_UNINSTALL, "bash", theme="monokai", background_color="default" ) ) completed_tools = "" all_completed = False all_missing = True status = { ToolType.PIP: { "alias": AliasConfig(is_configured=False), "index": IndexConfig(is_configured=False), }, ToolType.POETRY: { "alias": AliasConfig(is_configured=False), "index": IndexConfig(is_configured=False), }, ToolType.UV: { "alias": AliasConfig(is_configured=False), "index": IndexConfig(is_configured=False), }, ToolType.NPM: { "alias": AliasConfig(is_configured=False), "index": IndexConfig(is_configured=False), }, } tracker.current_step = InitExitStep.PRE_FIREWALL_SETUP if ask_firewall_setup(ctx, prompt_user): completed_tools, all_completed, all_missing, status = setup_firewall( ctx, status, org_slug, console ) console.line() ask_continue(ctx, prompt_user) console.line() tracker.current_step = InitExitStep.POST_FIREWALL_SETUP render_header(MSG_SETUP_CODEBASE_TITLE, emoji=":locked:") console.print(MSG_SETUP_CODEBASE_DESCRIPTION) project_scan_state = None tracker.current_step = InitExitStep.PRE_CODEBASE_SETUP local_files = find_local_tool_files(project_dir) emit_codebase_detection_status( event_bus=ctx.obj.event_bus, ctx=ctx, detected=any(local_files), detected_files=local_files if local_files else None, ) if local_files: progressive_print( [ f":pushpin: We found a `{file.name}` file in this directory." for file in local_files ] ) console.line() if ask_codebase_setup(ctx, prompt_user): unverified_project = load_unverified_project_from_config( project_root=project_dir ) link_behavior = "prompt" if unverified_project.created: link_behavior = "always" project_created, project_status = create_project( ctx, console, project_dir, unverified_project=unverified_project, link_behavior=link_behavior, ) configure_local_directory(project_dir, org_slug, ctx.obj.project.id) emit_codebase_setup_completed( event_bus=ctx.obj.event_bus, ctx=ctx, is_created=project_created, codebase_id=ctx.obj.project.id if project_created else None, ) if project_created: console.print( "\n" + f"{ctx.obj.project.id} codebase {project_status} :white_heavy_check_mark:" ) else: progressive_print([":x: Failed to create codebase"]) console.line() tracker.current_step = InitExitStep.PRE_SCAN project_scan_state = init_scan_ui(ctx, prompt_user) tracker.current_step = InitExitStep.POST_SCAN emit_init_scan_completed( event_bus=ctx.obj.event_bus, ctx=ctx, scan_id=project_scan_state.scan_id, ) else: console.print(MSG_SETUP_CODEBASE_NO_PROJECT) tracker.current_step = InitExitStep.POST_CODEBASE_SETUP console.line() render_header(MSG_SETUP_COMPLETE_TITLE, emoji=":trophy:") is_setup_complete = all_completed and project_scan_state wrap_up_msg = [] if all_completed: wrap_up_msg.append( MSG_COMPLETE_TOOL_SECURED.format( tools=completed_tools, firewall_url="https://platform.safetycli.com/firewall/", ) ) elif all_missing: wrap_up_msg.append(MSG_TOOLS_NOT_CONFIGURED) else: wrap_up_msg.append(MSG_SETUP_INCOMPLETE) wrap_up_msg.append("") if project_scan_state: if project_scan_state.scan_id: wrap_up_msg.append( MSG_COMPLETE_SECURED.format( codebase_url=project_scan_state.codebase_url ) ) else: msg = ( project_scan_state.status_message if project_scan_state.status_message else "Unknown" ) wrap_up_msg.append(MSG_CODEBASE_FAILED_TO_SCAN.format(reason=msg)) else: wrap_up_msg.append(MSG_CODEBASE_NOT_CONFIGURED) if wrap_up_msg: progressive_print(wrap_up_msg) console.line() if is_setup_complete: typed_print(MSG_SETUP_COMPLETE_SUBTITLE) console.line() typed_print(MSG_LAST_MANUAL_STEP) console.line() render_header(title=MSG_SETUP_NEXT_STEPS_SUBTITLE, emoji=":rocket:") console.line() next_steps_msg = MSG_SETUP_NEXT_STEPS if not project_scan_state: next_steps_msg = MSG_SETUP_NEXT_STEPS_NO_PROJECT elif project_scan_state.vulns_count <= 0: next_steps_msg = MSG_SETUP_NEXT_STEPS_NO_VULNS progressive_print( [Padding(console.render_str(line), (0, 0, 1, 0)) for line in next_steps_msg] ) console.line() typed_print(MSG_SETUP_NEXT_STEPS_MANUAL_STEP, delay=0.04) console.line() # Emit event for firewall configuration emit_firewall_configured( event_bus=ctx.obj.event_bus, status=status, ) tracker.current_step = InitExitStep.COMPLETED