This commit is contained in:
Iliyan Angelov
2025-12-01 06:50:10 +02:00
parent 91f51bc6fe
commit 62c1fe5951
4682 changed files with 544807 additions and 31208 deletions

View File

@@ -0,0 +1,765 @@
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

View File

@@ -0,0 +1,127 @@
# Codebase options
import sys
CODEBASE_INIT_CMD_NAME = "init"
CODEBASE_INIT_HELP = (
"[BETA] Used to install Safety Firewall globally, or to initialize a codebase in the current directory."
"\nExample: safety init"
)
CODEBASE_INIT_DIRECTORY_HELP = (
"[BETA] Defines a directory for creating a codebase. (default: current directory)\n\n"
"[bold]Example: safety init /path/to/codebase[/bold]"
)
# Welcome Section
MSG_WELCOME_TITLE = (
"\n\nWelcome to Safety, the AI-powered Software Supply Chain Firewall.\n\n"
)
MSG_WELCOME_DESCRIPTION = [
"[bold]Safety is designed to:[/bold]",
"1. Work with your existing package manager to block malicious or high-risk packages before they're installed.",
"2. Keep track of the dependencies in your codebase, and help you to quickly fix any vulnerabilities in them.",
"3. Integrate with your AI assistants to ensure they use secure packages.\n",
]
MSG_NEED_AUTHENTICATION = "To configure firewall and your codebase security settings, you'll need an account.\n"
MSG_AUTH_PROMPT = (
"Press [bold]R[/bold] to register (it's free & quick), or [bold]L[/bold] to log in"
)
MSG_SETUP_PACKAGE_FIREWALL_TITLE = " Set Up Package Firewall"
MSG_SETUP_PACKAGE_FIREWALL_DESCRIPTION = "Let's configure Safety Firewall to protect your package installations. This won't change the way you use pip and you'll only notice it when it blocks a malicious or vulnerable package. You can uninstall Firewall at any time with:\n"
MSG_FIREWALL_UNINSTALL = "`safety firewall uninstall`\n"
ASK_HINT = "[Press Enter to continue, n to cancel]"
MSG_SETUP_PACKAGE_FIREWALL_PROMPT = (
f"[bold]Do you want to continue with Firewall installation? {ASK_HINT}[/bold]"
)
SUPPORT_DETAILS = (
"[link]support@safetycli.com[/link] (we normally respond within 4 hours)"
)
MSG_SETUP_INCOMPLETE = f"[red bold]x[/red bold] The setup was not completed successfully, reach out to {SUPPORT_DETAILS}"
MSG_SETUP_PACKAGE_FIREWALL_RESULT = "configured and secured. Safety will analyze package installations for security risks before installation, and warn you if you install vulnerable packages.\n"
MSG_SETUP_PACKAGE_FIREWALL_NOTE_STATUS = "To see your firewall status, usage and to configure your firewall security settings visit [link]https://platform.safetycli.com/firewall/[/link]"
MSG_SETUP_CONTINUE_PROMPT = "[bold][Press Enter to continue][/bold]"
MSG_SETUP_CODEBASE_TITLE = " Secure Your First Codebase"
MSG_SETUP_CODEBASE_DESCRIPTION = "Safety monitors your codebase for open source dependency vulnerabilities and risk, surfacing reachable vulnerabilities that pose actual risk, and gives you advice on what to fix and how.\n"
MSG_SETUP_CODEBASE_PROMPT = (
f"[bold]Would you like to secure this codebase with Safety? {ASK_HINT}[/bold]"
)
MSG_SETUP_CODEBASE_NO_PROJECT = "We didn't find any dependency specification files in the current directory. Please navigate to a Python codebase directory and run:\n\n`safety init`"
MSG_ANALYZE_CODEBASE_TITLE = " Analyze {project_name} for Python Vulnerabilities"
MSG_NO_VULNERABILITIES_FOUND = " No vulnerabilities found :party_popper:\n\n"
MSG_CODEBASE_URL_DESCRIPTION = (
":mag_right: View detailed results in your Safety dashboard:\n"
)
MSG_NO_VULNS_CODEBASE_URL_DESCRIPTION = (
":mag_right: Any future vulnerabilities will appear here:\n"
)
MSG_OPEN_DASHBOARD_PROMPT = (
f":light_bulb: Open this in a new browser window now? {ASK_HINT}"
)
MSG_COMMAND_TO_RUN = "`source ~/.safety/.safety_profile`"
MSG_LAST_MANUAL_STEP = (
":yellow_circle: IMPORTANT: At the end, restart the terminal to activate your Safety configuration."
if sys.platform == "win32"
else f":yellow_circle: IMPORTANT: Run {MSG_COMMAND_TO_RUN} to activate your Safety configuration."
)
MSG_SETUP_COMPLETE_TITLE = " Wrap Up"
MSG_SETUP_COMPLETE_SUBTITLE = "Almost done! Final step:"
MSG_TOOLS_NOT_CONFIGURED = "[bold red]x[/bold red] No package managers configured"
MSG_CODEBASE_NOT_CONFIGURED = "[bold red]x[/bold red] No codebase configured"
MSG_CODEBASE_FAILED_TO_SCAN = (
"[bold red]x[/bold red] Failed to complete the codebase scan, reason: {reason}. Reach out to "
+ SUPPORT_DETAILS
)
MSG_COMPLETE_TOOL_SECURED = ":white_heavy_check_mark: {tools} secured - Safety is automatically analyzing all package installations for risk. To configure or audit your installations visit [link]{firewall_url}[/link]"
MSG_COMPLETE_SECURED = ":white_heavy_check_mark: Codebase secured - to see your vulnerable packages, visit [link]{codebase_url}[/link]"
MSG_SETUP_NEXT_STEPS_SUBTITLE = " Next steps:"
MSG_HELP = f":speech_balloon: Need help or want to give feedback? {SUPPORT_DETAILS}"
MSG_DOCS = ":books: Read the docs: [link]https://docs.safetycli.com[/link]"
MSG_TEAM = ":busts_in_silhouette: Invite your team: [link]https://platform.safetycli.com/organization/team[/link]"
MSG_SETUP_NEXT_STEPS = (
MSG_TEAM,
":floppy_disk: Commit `.safety-project.ini` to your Github repository so that your team-members use the same codebase.",
":heavy_plus_sign: Add another codebase: `safety init` (run this in the codebase directory)",
MSG_DOCS,
MSG_HELP,
)
MSG_SETUP_NEXT_STEPS_ERROR = (MSG_HELP, MSG_DOCS)
MSG_SETUP_NEXT_STEPS_NO_PROJECT = (
":heavy_plus_sign: Add a codebase with `safety init` (run this in the codebase directory)",
MSG_TEAM,
MSG_DOCS,
MSG_HELP,
)
MSG_SETUP_NEXT_STEPS_NO_VULNS = (MSG_TEAM, MSG_DOCS, MSG_HELP)
MSG_SETUP_NEXT_STEPS_MANUAL_STEP = (
"(Don't forget to restart the terminal now!)"
if sys.platform == "win32"
else f"(Don't forget to run {MSG_COMMAND_TO_RUN} now!)"
)

View File

@@ -0,0 +1,490 @@
import logging
import sys
import uuid
from rich.prompt import Prompt
import typer
from rich.console import Console
from safety.events.utils.emission import emit_firewall_setup_completed
from safety.init.render import progressive_print
from safety.util import clean_project_id
from ..tool import configure_system, configure_alias
from ..codebase_utils import load_unverified_project_from_config, save_project_info
from .constants import (
MSG_AUTH_PROMPT,
MSG_NEED_AUTHENTICATION,
MSG_SETUP_INCOMPLETE,
MSG_SETUP_PACKAGE_FIREWALL_NOTE_STATUS,
MSG_SETUP_PACKAGE_FIREWALL_RESULT,
)
from pathlib import Path
from safety_schemas.models import ProjectModel
from safety_schemas.models.events.types import ToolType
from safety.scan.util import GIT
from ..auth.utils import SafetyAuthSession
from typing import TYPE_CHECKING, Any, Literal, Optional, Tuple
from safety.scan.render import (
print_wait_project_verification,
prompt_project_id,
prompt_link_project,
)
if TYPE_CHECKING:
from ..codebase_utils import UnverifiedProjectModel
from ..models import SafetyCLI
from .types import FirewallConfigStatus
logger = logging.getLogger(__name__)
def check_project(
ctx: typer.Context,
session: SafetyAuthSession,
console: Console,
unverified_project: "UnverifiedProjectModel",
git_origin: Optional[str],
ask_project_id: bool = False,
) -> dict:
"""
Check the project against the session and stage, verifying the project if necessary.
Args:
console: The console for output.
ctx (typer.Context): The context of the Typer command.
session (SafetyAuthSession): The authentication session.
unverified_project (UnverifiedProjectModel): The unverified project model.
stage (Stage): The current stage.
git_origin (Optional[str]): The Git origin URL.
ask_project_id (bool): Whether to prompt for the project ID.
Returns:
dict: The result of the project check.
"""
stage = ctx.obj.auth.stage
source = ctx.obj.telemetry.safety_source if ctx.obj.telemetry else None
data = {"scan_stage": stage, "safety_source": source}
PRJ_SLUG_KEY = "project_slug"
PRJ_SLUG_SOURCE_KEY = "project_slug_source"
PRJ_GIT_ORIGIN_KEY = "git_origin"
if git_origin:
data[PRJ_GIT_ORIGIN_KEY] = git_origin
if unverified_project.id:
data[PRJ_SLUG_KEY] = unverified_project.id
if unverified_project.created:
data[PRJ_SLUG_SOURCE_KEY] = ".safety-project.ini"
else:
data[PRJ_SLUG_SOURCE_KEY] = "user"
elif not git_origin or ask_project_id:
fallback_id = unverified_project.project_path.parent.name
if not fallback_id:
# Sometimes the parent directory is empty, so we generate
# a random ID
fallback_id = str(uuid.uuid4())[:10]
fallback_id = clean_project_id(fallback_id)
if ask_project_id:
id = prompt_project_id(console, fallback_id)
else:
id = fallback_id
unverified_project.id = id
data[PRJ_SLUG_KEY] = unverified_project.id
data[PRJ_SLUG_SOURCE_KEY] = "user"
status = print_wait_project_verification(
console,
data[PRJ_SLUG_KEY] if data.get(PRJ_SLUG_KEY, None) else "-",
(session.check_project, data),
on_error_delay=1,
)
return status
def verify_project(
console: Console,
ctx: typer.Context,
session: SafetyAuthSession,
unverified_project: "UnverifiedProjectModel",
git_origin: Optional[str],
create_if_missing: bool = True,
link_behavior: Literal["always", "prompt", "never"] = "prompt",
prompt_for_name: bool = False,
) -> Tuple[bool, Optional[str]]:
"""
Verify the project, linking it if necessary and saving the verified project information.
Args:
console: The console for output.
ctx (typer.Context): The context of the Typer command.
session (SafetyAuthSession): The authentication session.
unverified_project (UnverifiedProjectModel): The unverified project model.
git_origin (Optional[str]): The Git origin URL.
create_if_missing (bool): Whether to create codebase if it doesn't exist. Defaults to True.
link_behavior (Literal["always", "prompt", "never"]): How to handle codebase linking.
- "always": Link without prompting
- "prompt": Ask user before linking (default)
- "never": Don't link, return early if codebase exists
Returns:
Tuple[bool, Optional[str]]: (success, status_message)
- (True, "created"): New codebase was created and verified
- (True, "linked"): Existing codebase was linked
- (True, "found"): Codebase found but not linked (link_behavior="never")
- (False, "not_found"): Codebase not found and create_if_missing=False
- (False, None): Verification failed
"""
# Track if we need to ask for project ID (when user declines linking)
ask_for_project_id = False
asked_codebase_asked_before = False
while True:
result = check_project(
ctx,
session,
console,
unverified_project,
git_origin,
# Ask for project ID when:
# 1. User previously declined linking and we need a new ID
# 2. We're in prompt mode and don't have a project ID yet
ask_project_id=ask_for_project_id,
)
unverified_slug = result.get("slug")
project = result.get("project", None)
# Handle case where project doesn't exist
if not project:
if not create_if_missing:
return (False, "not_found")
# Project will be created - continue to verification
project_status = (True, "created")
if prompt_for_name and not asked_codebase_asked_before:
asked_codebase_asked_before = True
ask_for_project_id = True
continue
else:
# Project exists - handle based on link_behavior
if link_behavior == "never":
unverified_project.id = project.get("slug")
return (True, "found")
elif link_behavior == "always":
project_status = (True, "linked")
elif link_behavior == "prompt":
# Prompt user for confirmation
prj_name = project.get("name", None)
prj_admin_email = project.get("admin", None)
should_link = prompt_link_project(
prj_name=prj_name, prj_admin_email=prj_admin_email, console=console
)
if should_link:
project_status = (True, "linked")
else:
# User declined linking, ask for new project ID and retry
unverified_project.id = None
ask_for_project_id = True
continue
# Proceed with project verification
verified_prj = print_wait_project_verification(
console,
unverified_slug, # type: ignore
(session.project, {"project_id": unverified_slug}),
on_error_delay=1,
)
if (
verified_prj
and isinstance(verified_prj, dict)
and verified_prj.get("slug", None)
):
save_verified_project(
ctx,
verified_prj["slug"],
verified_prj.get("name", None),
unverified_project.project_path,
verified_prj.get("url", None),
verified_prj.get("organization", None),
)
return project_status
else:
# Verification failed
return (False, None)
def save_verified_project(
ctx: typer.Context,
slug: str,
name: Optional[str],
project_path: Path,
url_path: Optional[str],
organization: Optional[dict],
):
"""
Save the verified project information to the context and project info file.
Args:
ctx (typer.Context): The context of the Typer command.
slug (str): The project slug.
name (Optional[str]): The project name.
project_path (Path): The project path.
url_path (Optional[str]): The project URL path.
organization (Optional[str]): The project organization.
"""
ctx.obj.project = ProjectModel(
id=slug, name=name, project_path=project_path, url_path=url_path
)
save_project_info(project=ctx.obj.project, project_path=project_path)
ctx.obj.org = {}
if organization:
ctx.obj.org = {
"name": organization.get("name"),
"slug": organization.get("slug"),
}
def create_project(
ctx: typer.Context,
console: Console,
target: Path,
unverified_project: Optional["UnverifiedProjectModel"] = None,
create_if_missing: bool = True,
link_behavior: Literal["always", "prompt", "never"] = "prompt",
) -> Tuple[bool, Optional[str]]:
"""
Loads existing project from the specified target locations or creates a new project.
Args:
ctx: The CLI context
console: The console object
target (Path): The target location
unverified_project (UnverifiedProjectModel): The unverified project model
create_if_missing (bool): Whether to create codebase if it doesn't exist
link_behavior (Literal["always", "prompt", "never"]): How to handle codebase linking
"""
# Load .safety-project.ini
if not unverified_project:
unverified_project = load_unverified_project_from_config(project_root=target)
session = ctx.obj.auth.client
git_data = GIT(root=target).build_git_data()
origin = None
if git_data:
origin = git_data.origin
if ctx.obj.platform_enabled:
result = verify_project(
console,
ctx,
session,
unverified_project,
origin,
create_if_missing,
link_behavior,
)
if ctx.obj.project:
ctx.obj.project.git = git_data
return result
else:
console.print("Project creation is not supported for your account.")
return (False, None)
def launch_auth_if_needed(ctx: typer.Context, console: Console) -> Optional[str]:
"""
Launch the authentication flow if needed.
Args:
ctx: The CLI context
Returns:
Optional[str]: The organization slug if authentication is successful
"""
obj: "SafetyCLI" = ctx.obj
org_slug = None
if (
not obj.auth
or not obj.auth.client
or not obj.auth.client.is_using_auth_credentials()
):
console.print(MSG_NEED_AUTHENTICATION)
if not console.is_interactive:
sys.exit(0)
auth_choice = Prompt.ask(
MSG_AUTH_PROMPT,
choices=["r", "l", "R", "L"],
default="L",
show_choices=False,
show_default=True,
console=console,
).lower()
from safety.auth.cli import auth_app
from safety.cli_util import get_command_for
login_command = get_command_for(name="login", typer_instance=auth_app)
register_command = get_command_for(name="register", typer_instance=auth_app)
ctx.obj.only_auth_msg = True
if auth_choice == "r":
ctx.invoke(register_command)
else:
ctx.invoke(login_command)
try:
data = ctx.obj.auth.client.initialize()
org_slug = data.get("organization-data", {}).get("slug")
except Exception:
logger.exception("Unable to load data on the init command")
return org_slug
def setup_firewall(
ctx: Any, status: "FirewallConfigStatus", org_slug: Optional[str], console: Console
) -> Tuple[str, bool, bool, "FirewallConfigStatus"]:
"""
Setup the firewall, this function also handles the output.
Args:
ctx: The CLI context
status: The current status of the firewall
org_slug: The organization slug
console: The console object
Returns:
Tuple[bool, bool, FirewallConfigStatus]: A tuple containing the following:
- bool: True if all tools are configured, False otherwise
- bool: True if all tools are missing, False otherwise
- FirewallConfigStatus: The current status of the firewall
"""
emoji_check = "[green]:icon_check:[/green]"
configured_index = configure_system(org_slug)
configured_alias = configure_alias()
if configured_alias is None:
configured_alias = []
console.line()
configured = {}
if configured_index:
configured["index"] = configured_index
if configured_alias:
configured["alias"] = configured_alias
if any([item[1] for item in configured_index]) or any(
[item[1] for item in configured_alias]
):
for config_type, results in configured.items():
for tool_type, path in results:
tool_name = tool_type.value
index_type = "global"
tool_config = status[tool_type]
is_configured = False
if path:
if config_type == "index":
msg = f"Configured {tool_name}s {index_type} index"
else:
msg = f"Aliased {tool_name} to safety"
is_configured = True
configured_msg = f"{emoji_check} {msg}"
path = path.resolve()
if len(path.parts) > 1:
progressive_print([f"{configured_msg} (`{path}`)"])
else:
progressive_print([configured_msg])
else:
if config_type == "index":
msg = f"{tool_name}s {index_type} index"
else:
msg = f"{tool_name} alias"
prefix_msg = "Failed to configure"
emoji = "[red bold]x[/red bold]"
# If there is a non-compatible global index
if tool_type in [ToolType.POETRY]:
prefix_msg = "Skipped"
msg += " - not supported by poetry"
emoji = "[gray bold]-[/gray bold]"
# TODO: Set None for now, to avoid mixing
# no configured error with skipped.
tool_config[config_type] = None
else:
is_configured = False
progressive_print([f"{emoji} {prefix_msg} {msg}"])
if config_obj := tool_config[config_type]:
config_obj.is_configured = is_configured
console.line()
else:
progressive_print(["[red bold]x[/red bold] Failed to configure system"])
completed = []
missing = []
for tool_type, tool_status in status.items():
for config_type, config_obj in tool_status.items():
if config_obj:
if config_obj.is_configured:
completed.append(config_obj)
else:
missing.append(config_obj)
all_completed = not missing
all_missing = not completed
tools = [tool_type.value.title() for tool_type in status]
completed_tools = (
", ".join(tools[:-1]) + " and " + tools[-1] if len(tools) > 1 else tools[0]
)
if all_completed:
console.print(
f"{emoji_check} {completed_tools} {MSG_SETUP_PACKAGE_FIREWALL_RESULT}"
)
console.print(MSG_SETUP_PACKAGE_FIREWALL_NOTE_STATUS)
else:
progressive_print([MSG_SETUP_INCOMPLETE])
console.line()
emit_firewall_setup_completed(
event_bus=ctx.obj.event_bus,
ctx=ctx,
status=status,
)
return completed_tools, all_completed, all_missing, status

View File

@@ -0,0 +1,6 @@
from safety_schemas.models.events.payloads import InitExitStep
class StepTracker:
def __init__(self):
self.current_step: InitExitStep = InitExitStep.UNKNOWN

View File

@@ -0,0 +1,164 @@
import time
from typing import List, Union
from rich.console import RenderableType
from rich.prompt import Prompt
import typer
from safety.console import main_console as console
from safety.events.utils.emission import (
emit_codebase_setup_response_created,
emit_firewall_setup_response_created,
)
from safety.init.constants import (
MSG_SETUP_CODEBASE_PROMPT,
MSG_SETUP_CONTINUE_PROMPT,
MSG_SETUP_PACKAGE_FIREWALL_PROMPT,
)
def typed_print(
text: str, delay: float = 0.02, console=console, style="bold", end_line=True
):
rich_text = console.render_str(text)
text = rich_text.plain
for char in text:
console.print(char, end="", style=style)
if char != "\n":
time.sleep(delay)
if end_line:
console.line()
def progressive_print(
sections: Union[List[str], List[RenderableType]],
pause_between: float = 0.7,
console=console,
):
for section in sections:
obj = section
if isinstance(section, str):
obj = console.render_str(section)
console.print(obj)
time.sleep(pause_between)
def render_header(
title, emoji=":shield:", margin_left=0, margin_right=2, console=console
):
"""
Create a modern header with emoji that works cross-platform
"""
content = f"{' ' * margin_left}{emoji}{title}{' ' * margin_right}"
rendered_content = console.render_str(content)
plain_text = rendered_content.plain
underline = console.render_str(f"[blue]{'' * len(plain_text)}[/blue]")
console.print()
typed_print(plain_text, style="bold white", delay=0.01, console=console)
console.print(underline)
console.print()
def ask_firewall_setup(ctx: typer.Context, prompt_user: bool = True) -> bool:
"""
Ask the user if they want to set up Safety Firewall.
As a side effect, this function emits an event with the response.
Args:
ctx: The CLI context
prompt_user: Whether to prompt the user for input
Returns:
bool: True if the user wants to set up Safety Firewall, False otherwise
"""
firewall_choice = "y"
if prompt_user:
firewall_choice = Prompt.ask(
MSG_SETUP_PACKAGE_FIREWALL_PROMPT,
choices=["y", "n", "Y", "N"],
default="y",
show_default=False,
show_choices=False,
console=console,
).lower()
should_setup_firewall = firewall_choice == "y"
emit_firewall_setup_response_created(
event_bus=ctx.obj.event_bus,
ctx=ctx,
user_consent_requested=prompt_user,
user_consent=should_setup_firewall if prompt_user else None,
)
return should_setup_firewall
def ask_codebase_setup(ctx: typer.Context, prompt_user: bool = True) -> bool:
"""
Ask the user if they want to set up a codebase.
As a side effect, this function emits an event with the response.
Args:
ctx: The CLI context
prompt_user: Whether to prompt the user for input
Returns:
bool: True if the user wants to set up a codebase, False otherwise
"""
codebase_response = "y"
if prompt_user:
codebase_response = Prompt.ask(
MSG_SETUP_CODEBASE_PROMPT,
choices=["y", "n", "Y", "N"],
default="y",
show_default=False,
show_choices=False,
console=console,
).lower()
should_setup_codebase = codebase_response == "y"
emit_codebase_setup_response_created(
event_bus=ctx.obj.event_bus,
ctx=ctx,
user_consent_requested=prompt_user,
user_consent=should_setup_codebase if prompt_user else None,
)
return should_setup_codebase
def ask_continue(ctx: typer.Context, prompt_user: bool = True) -> bool:
"""
Ask the user if they want to continue by typing enter
Args:
ctx: The CLI context
prompt_user: Whether to prompt the user for input
Returns:
bool: True if the user wants to continue, False otherwise
"""
if prompt_user:
return (
Prompt.ask(
MSG_SETUP_CONTINUE_PROMPT,
choices=["y", "Y"],
default="y",
show_default=False,
show_choices=False,
console=console,
).lower()
== "y"
)
return True

View File

@@ -0,0 +1,13 @@
from typing import TYPE_CHECKING, Dict, Optional, Union
if TYPE_CHECKING:
from safety_schemas.models.events.types import ToolType
from safety_schemas.models.events.payloads import (
AliasConfig,
IndexConfig,
)
FirewallConfigStatus = Dict[
ToolType, Dict[str, Optional[Union[AliasConfig, IndexConfig]]]
]