diff --git a/docs/.ipynb_checkpoints/Safety-CLI-Quickstart-checkpoint.ipynb b/docs/.ipynb_checkpoints/Safety-CLI-Quickstart-checkpoint.ipynb index 363fcab7..4ea4f7b1 100644 --- a/docs/.ipynb_checkpoints/Safety-CLI-Quickstart-checkpoint.ipynb +++ b/docs/.ipynb_checkpoints/Safety-CLI-Quickstart-checkpoint.ipynb @@ -1,5 +1,12 @@ { - "cells": [], + "cells": [ + { + "metadata": {}, + "cell_type": "raw", + "source": "", + "id": "e4a30302820cf149" + } + ], "metadata": {}, "nbformat": 4, "nbformat_minor": 5 diff --git a/pyproject.toml b/pyproject.toml index 3c7351c1..e6f3bbc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "setuptools>=65.5.1", "typer>=0.12.1", "typing-extensions>=4.7.1", + "python-levenshtein>=0.25.1", ] license = "MIT" license-files = ["LICENSES/*"] diff --git a/safety/cli.py b/safety/cli.py index 36099f3b..c4055a42 100644 --- a/safety/cli.py +++ b/safety/cli.py @@ -2,7 +2,7 @@ from __future__ import absolute_import import configparser from dataclasses import asdict -from datetime import date, datetime +from datetime import date, datetime, timedelta from enum import Enum import requests import time @@ -14,45 +14,56 @@ import platform import sys from functools import wraps -from typing import Dict, Optional from packaging import version as packaging_version from packaging.version import InvalidVersion import click import typer +from safety_schemas.models.config import VulnerabilityDefinition from safety import safety from safety.console import main_console as console from safety.alerts import alert -from safety.auth import auth, inject_session, proxy_options, auth_options +from safety.auth import inject_session, proxy_options, auth_options from safety.auth.models import Organization -from safety.scan.constants import CLI_LICENSES_COMMAND_HELP, CLI_MAIN_INTRODUCTION, CLI_DEBUG_HELP, CLI_DISABLE_OPTIONAL_TELEMETRY_DATA_HELP, \ - DEFAULT_EPILOG, DEFAULT_SPINNER, CLI_CHECK_COMMAND_HELP, CLI_CHECK_UPDATES_HELP, CLI_CONFIGURE_HELP, CLI_GENERATE_HELP, \ - CLI_CONFIGURE_PROXY_TIMEOUT, CLI_CONFIGURE_PROXY_REQUIRED, CLI_CONFIGURE_ORGANIZATION_ID, CLI_CONFIGURE_ORGANIZATION_NAME, \ - CLI_CONFIGURE_SAVE_TO_SYSTEM, CLI_CONFIGURE_PROXY_HOST_HELP, CLI_CONFIGURE_PROXY_PORT_HELP, CLI_CONFIGURE_PROXY_PROTOCOL_HELP, \ +from safety.pip.command import pip_app +from safety.init.command import init_app +from safety.scan.constants import CLI_LICENSES_COMMAND_HELP, CLI_MAIN_INTRODUCTION, CLI_DEBUG_HELP, \ + CLI_DISABLE_OPTIONAL_TELEMETRY_DATA_HELP, \ + DEFAULT_EPILOG, DEFAULT_SPINNER, CLI_CHECK_COMMAND_HELP, CLI_CHECK_UPDATES_HELP, CLI_CONFIGURE_HELP, \ + CLI_GENERATE_HELP, CLI_GENERATE_MINIMUM_CVSS_SEVERITY, \ + CLI_CONFIGURE_PROXY_TIMEOUT, CLI_CONFIGURE_PROXY_REQUIRED, CLI_CONFIGURE_ORGANIZATION_ID, \ + CLI_CONFIGURE_ORGANIZATION_NAME, \ + CLI_CONFIGURE_SAVE_TO_SYSTEM, CLI_CONFIGURE_PROXY_HOST_HELP, CLI_CONFIGURE_PROXY_PORT_HELP, \ + CLI_CONFIGURE_PROXY_PROTOCOL_HELP, \ CLI_GENERATE_PATH -from .cli_util import SafetyCLICommand, SafetyCLILegacyGroup, SafetyCLILegacyCommand, SafetyCLISubGroup, SafetyCLIUtilityCommand, handle_cmd_exception -from safety.constants import BAR_LINE, CONFIG_FILE_USER, CONFIG_FILE_SYSTEM, EXIT_CODE_VULNERABILITIES_FOUND, EXIT_CODE_OK, EXIT_CODE_FAILURE +from .cli_util import SafetyCLILegacyGroup, SafetyCLILegacyCommand, SafetyCLISubGroup, SafetyCLIUtilityCommand, \ + handle_cmd_exception +from safety.constants import BAR_LINE, CONFIG_FILE_USER, CONFIG_FILE_SYSTEM, EXIT_CODE_VULNERABILITIES_FOUND, \ + EXIT_CODE_OK, EXIT_CODE_FAILURE from safety.errors import InvalidCredentialError, SafetyException, SafetyError from safety.formatter import SafetyFormatter -from safety.models import SafetyCLI from safety.output_utils import should_add_nl -from safety.safety import get_packages, read_vulnerabilities, process_fixes +from safety.safety import get_packages, process_fixes +from safety.scan.finder import FileFinder +from safety.scan.main import process_files from safety.util import get_packages_licenses, initializate_config_dirs, output_exception, \ MutuallyExclusiveOption, DependentOption, transform_ignore, SafetyPolicyFile, active_color_if_needed, \ get_processed_options, get_safety_version, json_alias, bare_alias, html_alias, SafetyContext, is_a_remote_mirror, \ filter_announcements, get_fix_options from safety.scan.command import scan_project_app, scan_system_app from safety.auth.cli import auth_app -from safety_schemas.models import ConfigModel, Stage +from safety_schemas.config.schemas.v3_0 import main as v3_0 +from safety_schemas.models import ConfigModel, Stage, Ecosystem, VulnerabilitySeverityLabels try: - from typing import Annotated + from typing import Annotated, Optional except ImportError: from typing_extensions import Annotated LOG = logging.getLogger(__name__) + def get_network_telemetry(): import psutil import socket @@ -78,10 +89,10 @@ def get_network_telemetry(): network_info['download_speed'] = None network_info['error'] = str(e) - # Get network addresses net_if_addrs = psutil.net_if_addrs() - network_info['interfaces'] = {iface: [addr.address for addr in addrs if addr.family == socket.AF_INET] for iface, addrs in net_if_addrs.items()} + network_info['interfaces'] = {iface: [addr.address for addr in addrs if addr.family == socket.AF_INET] for + iface, addrs in net_if_addrs.items()} # Get network connections net_connections = psutil.net_connections(kind='inet') @@ -113,6 +124,7 @@ def get_network_telemetry(): return network_info + def preprocess_args(f): if '--debug' in sys.argv: index = sys.argv.index('--debug') @@ -122,6 +134,7 @@ def preprocess_args(f): sys.argv.pop(index + 1) # Remove the next argument (1 or true) return f + def configure_logger(ctx, param, debug): level = logging.CRITICAL @@ -148,10 +161,12 @@ def configure_logger(ctx, param, debug): network_telemetry = get_network_telemetry() LOG.debug('Network telemetry: %s', network_telemetry) + @click.group(cls=SafetyCLILegacyGroup, help=CLI_MAIN_INTRODUCTION, epilog=DEFAULT_EPILOG) @auth_options() @proxy_options -@click.option('--disable-optional-telemetry', default=False, is_flag=True, show_default=True, help=CLI_DISABLE_OPTIONAL_TELEMETRY_DATA_HELP) +@click.option('--disable-optional-telemetry', default=False, is_flag=True, show_default=True, + help=CLI_DISABLE_OPTIONAL_TELEMETRY_DATA_HELP) @click.option('--debug', is_flag=True, help=CLI_DEBUG_HELP, callback=configure_logger) @click.version_option(version=get_safety_version()) @click.pass_context @@ -180,6 +195,7 @@ def clean_check_command(f): """ Main entry point for validation. """ + @wraps(f) def inner(ctx, *args, **kwargs): @@ -200,7 +216,7 @@ def inner(ctx, *args, **kwargs): kwargs.pop('proxy_port', None) if ctx.get_parameter_source("json_version") != click.core.ParameterSource.DEFAULT and not ( - save_json or json or output == 'json'): + save_json or json or output == 'json'): raise click.UsageError( "Illegal usage: `--json-version` only works with JSON related outputs." ) @@ -209,18 +225,20 @@ def inner(ctx, *args, **kwargs): if ctx.get_parameter_source("apply_remediations") != click.core.ParameterSource.DEFAULT: if not authenticated: - raise InvalidCredentialError(message="The --apply-security-updates option needs authentication. See {link}.") + raise InvalidCredentialError( + message="The --apply-security-updates option needs authentication. See {link}.") if not files: raise SafetyError(message='--apply-security-updates only works with files; use the "-r" option to ' 'specify files to remediate.') auto_remediation_limit = get_fix_options(policy_file, auto_remediation_limit) - policy_file, server_audit_and_monitor = safety.get_server_policies(ctx.obj.auth.client, policy_file=policy_file, + policy_file, server_audit_and_monitor = safety.get_server_policies(ctx.obj.auth.client, + policy_file=policy_file, proxy_dictionary=None) audit_and_monitor = (audit_and_monitor and server_audit_and_monitor) kwargs.update({"auto_remediation_limit": auto_remediation_limit, - "policy_file":policy_file, + "policy_file": policy_file, "audit_and_monitor": audit_and_monitor}) except SafetyError as e: @@ -235,9 +253,10 @@ def inner(ctx, *args, **kwargs): return inner + def print_deprecation_message( - old_command: str, - deprecation_date: datetime, + old_command: str, + deprecation_date: datetime, new_command: Optional[str] = None ) -> None: """ @@ -262,20 +281,23 @@ def print_deprecation_message( click.echo(click.style(BAR_LINE, fg="yellow", bold=True)) click.echo("\n") click.echo(click.style("DEPRECATED: ", fg="red", bold=True) + - click.style(f"this command (`{old_command}`) has been DEPRECATED, and will be unsupported beyond {deprecation_date.strftime('%d %B %Y')}.", fg="yellow", bold=True)) - + click.style( + f"this command (`{old_command}`) has been DEPRECATED, and will be unsupported beyond {deprecation_date.strftime('%d %B %Y')}.", + fg="yellow", bold=True)) + if new_command: click.echo("\n") click.echo(click.style("We highly encourage switching to the new ", fg="green") + click.style(f"`{new_command}`", fg="green", bold=True) + - click.style(" command which is easier to use, more powerful, and can be set up to mimic the deprecated command if required.", fg="green")) - + click.style( + " command which is easier to use, more powerful, and can be set up to mimic the deprecated command if required.", + fg="green")) + click.echo("\n") click.echo(click.style(BAR_LINE, fg="yellow", bold=True)) click.echo("\n") - @cli.command(cls=SafetyCLILegacyCommand, utility_command=True, help=CLI_CHECK_COMMAND_HELP) @proxy_options @auth_options(stage=False) @@ -283,7 +305,8 @@ def print_deprecation_message( help="Path to a local or remote vulnerability database. Default: empty") @click.option("--full-report/--short-report", default=False, cls=MutuallyExclusiveOption, mutually_exclusive=["output", "json", "bare"], - with_values={"output": ['json', 'bare'], "json": [True, False], "html": [True, False], "bare": [True, False]}, + with_values={"output": ['json', 'bare'], "json": [True, False], "html": [True, False], + "bare": [True, False]}, help='Full reports include a security advisory (if available). Default: --short-report') @click.option("--cache", is_flag=False, flag_value=60, default=0, help="Cache requests to the vulnerability database locally. Default: 0 seconds", @@ -298,10 +321,12 @@ def print_deprecation_message( @click.option("ignore_unpinned_requirements", "--ignore-unpinned-requirements/--check-unpinned-requirements", "-iur", default=None, help="Check or ignore unpinned requirements found.") @click.option('--json', default=False, cls=MutuallyExclusiveOption, mutually_exclusive=["output", "bare"], - with_values={"output": ['screen', 'text', 'bare', 'json', 'html'], "bare": [True, False]}, callback=json_alias, + with_values={"output": ['screen', 'text', 'bare', 'json', 'html'], "bare": [True, False]}, + callback=json_alias, hidden=True, is_flag=True, show_default=True) @click.option('--html', default=False, cls=MutuallyExclusiveOption, mutually_exclusive=["output", "bare"], - with_values={"output": ['screen', 'text', 'bare', 'json', 'html'], "bare": [True, False]}, callback=html_alias, + with_values={"output": ['screen', 'text', 'bare', 'json', 'html'], "bare": [True, False]}, + callback=html_alias, hidden=True, is_flag=True, show_default=True) @click.option('--bare', default=False, cls=MutuallyExclusiveOption, mutually_exclusive=["output", "json"], with_values={"output": ['screen', 'text', 'bare', 'json'], "json": [True, False]}, callback=bare_alias, @@ -368,9 +393,11 @@ def check(ctx, db, full_report, stdin, files, cache, ignore, ignore_unpinned_req 'ignore_unpinned_requirements': ignore_unpinned_requirements} LOG.info('Calling the check function') - vulns, db_full = safety.check(session=ctx.obj.auth.client, packages=packages, db_mirror=db, cached=cache, ignore_vulns=ignore, + vulns, db_full = safety.check(session=ctx.obj.auth.client, packages=packages, db_mirror=db, cached=cache, + ignore_vulns=ignore, ignore_severity_rules=ignore_severity_rules, proxy=None, - include_ignored=True, is_env_scan=is_env_scan, telemetry=ctx.obj.config.telemetry_enabled, + include_ignored=True, is_env_scan=is_env_scan, + telemetry=ctx.obj.config.telemetry_enabled, params=params) LOG.debug('Vulnerabilities returned: %s', vulns) LOG.debug('full database returned is None: %s', db_full is None) @@ -452,6 +479,7 @@ def clean_license_command(f): """ Main entry point for validation. """ + @wraps(f) def inner(ctx, *args, **kwargs): # TODO: Remove this soon, for now it keeps a legacy behavior @@ -505,7 +533,8 @@ def license(ctx, db, output, cache, files): announcements = [] if not db: - announcements = safety.get_announcements(session=ctx.obj.auth.client, telemetry=ctx.obj.config.telemetry_enabled) + announcements = safety.get_announcements(session=ctx.obj.auth.client, + telemetry=ctx.obj.config.telemetry_enabled) output_report = SafetyFormatter(output=output).render_licenses(announcements, filtered_packages_licenses) @@ -515,35 +544,109 @@ def license(ctx, db, output, cache, files): @cli.command(cls=SafetyCLILegacyCommand, utility_command=True, help=CLI_GENERATE_HELP) @click.option("--path", default=".", help=CLI_GENERATE_PATH) +@click.option("--minimum-cvss-severity", default="critical", help=CLI_GENERATE_MINIMUM_CVSS_SEVERITY) @click.argument('name', required=True) @click.pass_context -def generate(ctx, name, path): +def generate(ctx, name, path, minimum_cvss_severity): """Create a boilerplate Safety CLI policy file NAME is the name of the file type to generate. Valid values are: policy_file """ - if name != 'policy_file': + if name != 'policy_file' and name != 'installation_policy': click.secho(f'This Safety version only supports "policy_file" generation. "{name}" is not supported.', fg='red', file=sys.stderr) sys.exit(EXIT_CODE_FAILURE) LOG.info('Running generate %s', name) + if name == 'policy_file': + generate_policy_file(name, path) + elif name == 'installation_policy': + generate_installation_policy(ctx, name, path, minimum_cvss_severity) + + +def generate_installation_policy(ctx, name, path, minimum_cvss_severity): + all_severities = [severity.name.lower() for severity in VulnerabilitySeverityLabels] + policy_severities = all_severities[all_severities.index(minimum_cvss_severity.lower()):] + policy_severities_set = set(policy_severities[:]) + + target = path + + ecosystems = [Ecosystem.PYTHON] + to_include = {file_type: paths for file_type, paths in ctx.obj.config.scan.include_files.items() if + file_type.ecosystem in ecosystems} + + # Initialize file finder + file_finder = FileFinder(target=target, ecosystems=ecosystems, + max_level=ctx.obj.config.scan.max_depth, + exclude=ctx.obj.config.scan.ignore, + include_files=to_include, + console=console) + + for handler in file_finder.handlers: + if handler.ecosystem: + wait_msg = "Fetching Safety's vulnerability database..." + with console.status(wait_msg, spinner=DEFAULT_SPINNER): + handler.download_required_assets(ctx.obj.auth.client) + + wait_msg = "Scanning project directory" + with console.status(wait_msg, spinner=DEFAULT_SPINNER): + path, file_paths = file_finder.search() + + target_ecosystems = ", ".join([member.value for member in ecosystems]) + wait_msg = f"Analyzing {target_ecosystems} files and environments for security findings" + + config = ctx.obj.config + + vulnerabilities = [] + with console.status(wait_msg, spinner=DEFAULT_SPINNER) as status: + for path, analyzed_file in process_files(paths=file_paths, + config=config): + affected_specifications = analyzed_file.dependency_results.get_affected_specifications() + if any(affected_specifications): + for spec in affected_specifications: + for vuln in spec.vulnerabilities: + if (vuln.severity + and vuln.severity.cvssv3 + and vuln.severity.cvssv3.get("base_severity", "none").lower() in policy_severities_set): + vulnerabilities.append(vuln) + + policy = v3_0.Config( + installation=v3_0.Installation( + default_action=v3_0.InstallationAction.ALLOW, + allow=v3_0.AllowedInstallation( + packages = None, + vulnerabilities={ + vuln.vulnerability_id: v3_0.IgnoredVulnerability( + reason=f"Autogenerated policy for {vuln.package_name} package.", + expires=date.today() + timedelta(days=90)) + for vuln in vulnerabilities + }), + deny=v3_0.DeniedInstallation( + packages=None, + vulnerabilities=v3_0.DeniedVulnerability( + block_on_any_of=v3_0.DeniedVulnerabilityCriteria(cvss_severity=policy_severities) + ) + ) + ) + ) + + click.secho(policy.json(by_alias=True, exclude_none=True, indent=4)) + + +def generate_policy_file(name, path): path = Path(path) if not path.exists(): click.secho(f'The path "{path}" does not exist.', fg='red', file=sys.stderr) sys.exit(EXIT_CODE_FAILURE) - policy = path / '.safety-policy.yml' - default_config = ConfigModel() - try: default_config.save_policy_file(policy) LOG.debug('Safety created the policy file.') msg = f'A default Safety policy file has been generated! Review the file contents in the path {path} in the ' \ - 'file: .safety-policy.yml' + 'file: .safety-policy.yml' click.secho(msg, fg='green') except Exception as exc: if isinstance(exc, OSError): @@ -555,7 +658,8 @@ def generate(ctx, name, path): @cli.command(cls=SafetyCLILegacyCommand, utility_command=True) -@click.option("--path", default=".safety-policy.yml", help="Path where the generated file will be saved. Default: current directory") +@click.option("--path", default=".safety-policy.yml", + help="Path where the generated file will be saved. Default: current directory") @click.argument('name') @click.argument('version', required=False) @click.pass_context @@ -574,7 +678,9 @@ def validate(ctx, name, version, path): sys.exit(EXIT_CODE_FAILURE) if version not in ["3.0", "2.0", None]: - click.secho(f'Version "{version}" is not a valid value, allowed values are 3.0 and 2.0. Use --path to specify the target file.', fg='red', file=sys.stderr) + click.secho( + f'Version "{version}" is not a valid value, allowed values are 3.0 and 2.0. Use --path to specify the target file.', + fg='red', file=sys.stderr) sys.exit(EXIT_CODE_FAILURE) def fail_validation(e): @@ -711,7 +817,8 @@ def configure(ctx, proxy_protocol, proxy_host, proxy_port, proxy_timeout, config.write(configfile) except Exception as e: if (isinstance(e, OSError) and e.errno == 2 or e is PermissionError) and save_to_system: - click.secho("Unable to save the configuration: writing to system-wide Safety configuration file requires admin privileges") + click.secho( + "Unable to save the configuration: writing to system-wide Safety configuration file requires admin privileges") else: click.secho(f"Unable to save the configuration, error: {e}") sys.exit(1) @@ -720,32 +827,35 @@ def configure(ctx, proxy_protocol, proxy_host, proxy_port, proxy_timeout, cli_app = typer.Typer(rich_markup_mode="rich", cls=SafetyCLISubGroup) typer.rich_utils.STYLE_HELPTEXT = "" + def print_check_updates_header(console): VERSION = get_safety_version() console.print( f"Safety {VERSION} checking for Safety version and configuration updates:") + class Output(str, Enum): SCREEN = "screen" JSON = "json" + @cli_app.command( - cls=SafetyCLIUtilityCommand, - help=CLI_CHECK_UPDATES_HELP, - name="check-updates", epilog=DEFAULT_EPILOG, - context_settings={"allow_extra_args": True, - "ignore_unknown_options": True}, - ) + cls=SafetyCLIUtilityCommand, + help=CLI_CHECK_UPDATES_HELP, + name="check-updates", epilog=DEFAULT_EPILOG, + context_settings={"allow_extra_args": True, + "ignore_unknown_options": True}, +) @handle_cmd_exception def check_updates(ctx: typer.Context, - version: Annotated[ - int, - typer.Option(min=1), - ] = 1, - output: Annotated[Output, - typer.Option( - help="The main output generated by Safety CLI.") - ] = Output.SCREEN): + version: Annotated[ + int, + typer.Option(min=1), + ] = 1, + output: Annotated[Output, + typer.Option( + help="The main output generated by Safety CLI.") + ] = Output.SCREEN): """ Check for Safety CLI version updates """ @@ -792,7 +902,8 @@ def check_updates(ctx: typer.Context, console.print() console.print("[red]Safety is not authenticated, please first authenticate and try again.[/red]") console.print() - console.print("To authenticate, use the `auth` command: `safety auth login` Or for more help: `safety auth —help`") + console.print( + "To authenticate, use the `auth` command: `safety auth login` Or for more help: `safety auth —help`") sys.exit(1) if not data: @@ -827,15 +938,18 @@ def check_updates(ctx: typer.Context, f"If Safety was installed from a requirements file, update Safety to version {latest_available_version} in that requirements file." ) console.print() - console.print(f"Pip: To install the updated version of Safety directly via pip, run: pip install safety=={latest_available_version}") + console.print( + f"Pip: To install the updated version of Safety directly via pip, run: pip install safety=={latest_available_version}") elif packaging_version.parse(latest_available_version) < packaging_version.parse(VERSION): # Notify user about downgrading - console.print(f"Latest stable version is {latest_available_version}. If you want to downgrade to this version, you can run: pip install safety=={latest_available_version}") + console.print( + f"Latest stable version is {latest_available_version}. If you want to downgrade to this version, you can run: pip install safety=={latest_available_version}") else: console.print("You are already using the latest stable version of Safety.") except InvalidVersion as invalid_version: LOG.exception(f'Invalid version format encountered: {invalid_version}') - console.print(f"Error: Invalid version format encountered for the latest available version: {latest_available_version}") + console.print( + f"Error: Invalid version format encountered for the latest available version: {latest_available_version}") console.print("Please report this issue or try again later.") if console.quiet: @@ -849,8 +963,10 @@ def check_updates(ctx: typer.Context, cli.add_command(typer.main.get_command(cli_app), "check-updates") +cli.add_command(typer.main.get_command(init_app), "init") cli.add_command(typer.main.get_command(scan_project_app), "scan") cli.add_command(typer.main.get_command(scan_system_app), "system-scan") +cli.add_command(typer.main.get_command(pip_app), "pip") cli.add_command(typer.main.get_command(auth_app), "auth") diff --git a/safety/constants.py b/safety/constants.py index 9e25d335..b3aa8bdb 100644 --- a/safety/constants.py +++ b/safety/constants.py @@ -55,6 +55,8 @@ def get_user_dir() -> Path: CACHE_FILE_DIR = USER_CONFIG_DIR / f"{JSON_SCHEMA_VERSION.replace('.', '')}" DB_CACHE_FILE = CACHE_FILE_DIR / "cache.json" +PIP_LOCK = USER_CONFIG_DIR / "pip.lock" + CONFIG_FILE_NAME = "config.ini" CONFIG_FILE_SYSTEM = SYSTEM_CONFIG_DIR / CONFIG_FILE_NAME if SYSTEM_CONFIG_DIR else None CONFIG_FILE_USER = USER_CONFIG_DIR / CONFIG_FILE_NAME diff --git a/safety/init/__init__.py b/safety/init/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/safety/init/command.py b/safety/init/command.py new file mode 100644 index 00000000..21cc22fa --- /dev/null +++ b/safety/init/command.py @@ -0,0 +1,96 @@ +from pathlib import Path + +from rich.prompt import Prompt +from ..cli_util import SafetyCLICommand, SafetyCLISubGroup +import typer +import os + +from safety.scan.decorators import initialize_scan +from safety.init.constants import PROJECT_INIT_CMD_NAME, PROJECT_INIT_HELP, PROJECT_INIT_DIRECTORY_HELP +from safety.init.main import create_project +from safety.console import main_console as console +from ..scan.command import scan +from ..scan.models import ScanOutput +from ..tool.main import configure_system, configure_local_directory, has_local_tool_files, configure_alias + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +init_app = typer.Typer(rich_markup_mode= "rich", cls=SafetyCLISubGroup) + +@init_app.command( + cls=SafetyCLICommand, + help=PROJECT_INIT_HELP, + name=PROJECT_INIT_CMD_NAME, + options_metavar="[OPTIONS]", + context_settings={ + "allow_extra_args": True, + "ignore_unknown_options": True + }, +) +def init(ctx: typer.Context, + directory: Annotated[ + Path, + typer.Argument( + exists=True, + file_okay=False, + dir_okay=True, + writable=False, + readable=True, + resolve_path=True, + show_default=False, + help=PROJECT_INIT_DIRECTORY_HELP + ), + ] = Path(".")): + + do_init(ctx, directory, False) + + +def do_init(ctx: typer.Context, directory: Path, prompt_user: bool = True): + project_dir = directory if os.path.isabs(directory) else os.path.join(os.getcwd(), directory) + initialize_scan(ctx, console) + create_project(ctx, console, Path(project_dir)) + + answer = 'y' if not prompt_user else None + if prompt_user: + console.print( + "Safety prevents vulnerable or malicious packages from being installed on your computer. We do this by wrapping your package manager.") + prompt = "Do you want to enable proactive malicious package prevention?" + answer = Prompt.ask(prompt=prompt, choices=["y", "n"], + default="y", show_default=True, console=console).lower() + + if answer == 'y': + configure_system() + + if prompt_user: + prompt = "Do you want to alias pip to Safety?" + answer = Prompt.ask(prompt=prompt, choices=["y", "n"], + default="y", show_default=True, console=console).lower() + + if answer == 'y': + configure_alias() + + if has_local_tool_files(project_dir): + if prompt_user: + prompt = "Do you want to enable proactive malicious package prevention for any project in working directory?" + answer = Prompt.ask(prompt=prompt, choices=["y", "n"], + default="y", show_default=True, console=console).lower() + + if answer == 'y': + configure_local_directory(project_dir) + + if prompt_user: + prompt = "It looks like your current directory contains a requirements.txt file. Would you like Safety to scan it?" + answer = Prompt.ask(prompt=prompt, choices=["y", "n"], + default="y", show_default=True, console=console).lower() + + if answer == 'y': + ctx.command.name = "scan" + ctx.params = { + "target": directory, + "output": ScanOutput.SCREEN, + "policy_file_path": None + } + scan(ctx=ctx, target=directory, output=ScanOutput.SCREEN, policy_file_path=None) diff --git a/safety/init/constants.py b/safety/init/constants.py new file mode 100644 index 00000000..89337962 --- /dev/null +++ b/safety/init/constants.py @@ -0,0 +1,6 @@ +# Project options +PROJECT_INIT_CMD_NAME = "init" +PROJECT_INIT_HELP = "Creates new Safety CLI project in the current working directory."\ +"\nExample: safety project init" +PROJECT_INIT_DIRECTORY_HELP = "Defines a directory for creating a new project. (default: current directory)\n\n" \ + "[bold]Example: safety project init /path/to/project[/bold]" diff --git a/safety/init/main.py b/safety/init/main.py new file mode 100644 index 00000000..6328c655 --- /dev/null +++ b/safety/init/main.py @@ -0,0 +1,258 @@ +import typer +from rich.console import Console + +from .models import UnverifiedProjectModel + +import configparser +from pathlib import Path +from safety_schemas.models import ProjectModel, Stage +from safety.scan.util import GIT +from ..auth.utils import SafetyAuthSession + +from typing import Optional +from safety.scan.render import ( + print_wait_project_verification, + prompt_project_id, + prompt_link_project, +) + +PROJECT_CONFIG = ".safety-project.ini" +PROJECT_CONFIG_SECTION = "project" +PROJECT_CONFIG_ID = "id" +PROJECT_CONFIG_URL = "url" +PROJECT_CONFIG_NAME = "name" + + +def check_project( + ctx: typer.Context, + session: SafetyAuthSession, + console: Console, + unverified_project: UnverifiedProjectModel, + stage: Stage, + 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 + data[PRJ_SLUG_SOURCE_KEY] = ".safety-project.ini" + elif not git_origin or ask_project_id: + # Set a project id for this scan (no spaces). If empty Safety will use: pyupio: + parent_root_name = None + if unverified_project.project_path.parent.name: + parent_root_name = unverified_project.project_path.parent.name + + unverified_project.id = prompt_project_id(console, stage, parent_root_name) + 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, + stage: Stage, + git_origin: 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. + stage (Stage): The current stage. + git_origin (Optional[str]): The Git origin URL. + """ + + verified_prj = False + + link_prj = True + + while not verified_prj: + result = check_project( + ctx, + session, + console, + unverified_project, + stage, + git_origin, + ask_project_id=not link_prj, + ) + + unverified_slug = result.get("slug") + + project = result.get("project", None) + user_confirm = result.get("user_confirm", False) + + if user_confirm: + if project and link_prj: + prj_name = project.get("name", None) + prj_admin_email = project.get("admin", None) + + link_prj = prompt_link_project( + prj_name=prj_name, prj_admin_email=prj_admin_email, console=console + ) + + if not link_prj: + continue + + verified_prj = print_wait_project_verification( + console, + unverified_slug, + (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), + ) + else: + verified_prj = False + + +def load_unverified_project_from_config(project_root: Path) -> UnverifiedProjectModel: + """ + Loads an unverified project from the configuration file located at the project root. + + Args: + project_root (Path): The root directory of the project. + + Returns: + UnverifiedProjectModel: An instance of UnverifiedProjectModel. + """ + config = configparser.ConfigParser() + project_path = project_root / PROJECT_CONFIG + config.read(project_path) + id = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_ID, fallback=None) + url = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_URL, fallback=None) + name = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_NAME, fallback=None) + created = True + if id: + created = False + + return UnverifiedProjectModel( + id=id, url_path=url, name=name, project_path=project_path, created=created + ) + + +def save_verified_project( + ctx: typer.Context, + slug: str, + name: Optional[str], + project_path: Path, + url_path: Optional[str], +): + """ + 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. + """ + ctx.obj.project = ProjectModel( + id=slug, name=name, project_path=project_path, url_path=url_path + ) + if ctx.obj.auth.stage is Stage.development: + save_project_info(project=ctx.obj.project, project_path=project_path) + + +def save_project_info(project: ProjectModel, project_path: Path) -> None: + """ + Saves the project information to the configuration file. + + Args: + project (ProjectModel): The ProjectModel object containing project information. + project_path (Path): The path to the configuration file. + """ + config = configparser.ConfigParser() + config.read(project_path) + + if PROJECT_CONFIG_SECTION not in config.sections(): + config[PROJECT_CONFIG_SECTION] = {} + + config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_ID] = project.id + if project.url_path: + config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_URL] = project.url_path + if project.name: + config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_NAME] = project.name + + with open(project_path, "w") as configfile: + config.write(configfile) + + +def create_project( + ctx: typer.Context, console: Console, target: Path +): + """ + Loads existing project from the specified target locations or creates a new project. + + Args: + ctx: The CLI context + session: The authentication session + console: The console object + target (Path): The target location + """ + # Load .safety-project.ini + unverified_project = load_unverified_project_from_config(project_root=target) + + stage = ctx.obj.auth.stage + 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: + verify_project(console, ctx, session, unverified_project, stage, origin) + else: + console.print("Project creation is not supported for your account.") diff --git a/safety/init/models.py b/safety/init/models.py new file mode 100644 index 00000000..8e334933 --- /dev/null +++ b/safety/init/models.py @@ -0,0 +1,17 @@ +from pathlib import Path +from typing import Optional + +from pydantic.dataclasses import dataclass + + +@dataclass +class UnverifiedProjectModel: + """ + Data class representing an unverified project model. + """ + + id: Optional[str] + project_path: Path + created: bool + name: Optional[str] = None + url_path: Optional[str] = None diff --git a/safety/pip/__init__.py b/safety/pip/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/safety/pip/command.py b/safety/pip/command.py new file mode 100644 index 00000000..14247a9b --- /dev/null +++ b/safety/pip/command.py @@ -0,0 +1,43 @@ +from pathlib import Path + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import typer +from typer import Option + +from .constants import PIP_COMMAND_NAME, PIP_COMMAND_HELP +from .decorators import optional_project_command +from ..cli_util import SafetyCLICommand, SafetyCLISubGroup +from ..tool.utils import PipCommand + +pip_app = typer.Typer(rich_markup_mode="rich", cls=SafetyCLISubGroup) + + +@pip_app.command( + cls=SafetyCLICommand, + help=PIP_COMMAND_HELP, + name=PIP_COMMAND_NAME, + options_metavar="[OPTIONS]", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +@optional_project_command +def init( + ctx: typer.Context, + target: Annotated[ + Path, + Option( + exists=True, + file_okay=False, + dir_okay=True, + writable=False, + readable=True, + resolve_path=True, + show_default=False, + ), + ] = Path("."), +): + command = PipCommand.from_args(ctx.args) + command.execute(ctx) diff --git a/safety/pip/constants.py b/safety/pip/constants.py new file mode 100644 index 00000000..521abf9c --- /dev/null +++ b/safety/pip/constants.py @@ -0,0 +1,3 @@ +# PIP options +PIP_COMMAND_NAME = "pip" +PIP_COMMAND_HELP = "Commands for managing Safety project.\nExample: safety pip list" diff --git a/safety/pip/decorators.py b/safety/pip/decorators.py new file mode 100644 index 00000000..7739877f --- /dev/null +++ b/safety/pip/decorators.py @@ -0,0 +1,57 @@ +from functools import wraps +from pathlib import Path + +from safety_schemas.models import ProjectModel + +from ..cli_util import process_auth_status_not_ready +from safety.console import main_console +from ..init.main import load_unverified_project_from_config, verify_project +from ..scan.decorators import initialize_scan +from ..scan.util import GIT + + +def optional_project_command(func): + @wraps(func) + def inner(ctx, target: Path, *args, **kwargs): + ctx.obj.console = main_console + ctx.params.pop("console", None) + + if not ctx.obj.auth.is_valid(): + process_auth_status_not_ready( + console=main_console, auth=ctx.obj.auth, ctx=ctx + ) + + upload_request_id = kwargs.pop("upload_request_id", None) + + if not upload_request_id: + initialize_scan(ctx, main_console) + + # Load .safety-project.ini + unverified_project = load_unverified_project_from_config(project_root=target) + + if ctx.obj.platform_enabled and not unverified_project.created: + stage = ctx.obj.auth.stage + session = ctx.obj.auth.client + git_data = GIT(root=target).build_git_data() + origin = None + + if git_data: + origin = git_data.origin + + verify_project( + main_console, ctx, session, unverified_project, stage, origin + ) + + ctx.obj.project.git = git_data + else: + ctx.obj.project = ProjectModel( + id="", + name="Undefined project", + project_path=unverified_project.project_path, + ) + + ctx.obj.project.upload_request_id = upload_request_id + + return func(ctx, target=target, *args, **kwargs) + + return inner diff --git a/safety/scan/command.py b/safety/scan/command.py index 4bbba379..79c904f5 100644 --- a/safety/scan/command.py +++ b/safety/scan/command.py @@ -5,7 +5,6 @@ import json import sys from typing import Any, Dict, List, Optional, Set, Tuple, Callable -from typing_extensions import Annotated from safety.constants import EXIT_CODE_VULNERABILITIES_FOUND from safety.safety import process_fixes_scan @@ -27,13 +26,20 @@ SYSTEM_SCAN_TARGET_HELP, SCAN_APPLY_FIXES, SCAN_DETAILED_OUTPUT, CLI_SCAN_COMMAND_HELP, CLI_SYSTEM_SCAN_COMMAND_HELP from safety.scan.decorators import inject_metadata, scan_project_command_init, scan_system_command_init from safety.scan.finder.file_finder import should_exclude -from safety.scan.main import load_policy_file, load_unverified_project_from_config, process_files, save_report_as +from safety.init.main import load_unverified_project_from_config +from safety.scan.main import load_policy_file, process_files, save_report_as from safety.scan.models import ScanExport, ScanOutput, SystemScanExport, SystemScanOutput from safety.scan.render import print_detected_ecosystems_section, print_fixes_section, print_summary, render_scan_html, render_scan_spdx, render_to_console from safety.scan.util import Stage from safety_schemas.models import Ecosystem, FileModel, FileType, ProjectModel, \ ReportModel, ScanType, VulnerabilitySeverityLabels, SecurityUpdates, Vulnerability from safety.scan.fun_mode.easter_eggs import run_easter_egg + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + LOG = logging.getLogger(__name__) diff --git a/safety/scan/constants.py b/safety/scan/constants.py index 7a9cf13b..032b253d 100644 --- a/safety/scan/constants.py +++ b/safety/scan/constants.py @@ -53,6 +53,8 @@ CLI_VALIDATE_HELP = "Check if your local Safety CLI policy file is valid."\ "\nExample: Example: safety validate --path /path/to/policy.yml" +CLI_GATEWAY_CONFIGURE_COMMAND_HELP = "Configures the project in the working directory to use Gateway." + # Global options help _CLI_PROXY_TIP_HELP = f"[nhc]Note: proxy details can be set globally in a config file.[/nhc]\n\nSee [bold]safety configure --help[/bold]\n\n" @@ -144,6 +146,8 @@ # Generate options CLI_GENERATE_PATH = "The path where the generated file will be saved (default: current directory).\n\n" \ "[bold]Example: safety generate policy_file --path .my-project-safety-policy.yml[/bold]" +CLI_GENERATE_MINIMUM_CVSS_SEVERITY = "The minimum CVSS severity to generate the installation policy for.\n\n" \ +"[bold]Example: safety generate installation_policy --minimum-cvss-severity high[/bold]" # Command default settings CMD_PROJECT_NAME = "scan" diff --git a/safety/scan/decorators.py b/safety/scan/decorators.py index 72d7e4e1..3f08c220 100644 --- a/safety/scan/decorators.py +++ b/safety/scan/decorators.py @@ -2,8 +2,6 @@ import logging import os from pathlib import Path -from random import randint -import sys from typing import Any, List, Optional from rich.padding import Padding @@ -12,16 +10,17 @@ from safety.auth.cli import render_email_note from safety.cli_util import process_auth_status_not_ready from safety.console import main_console -from safety.constants import SAFETY_POLICY_FILE_NAME, SYSTEM_CONFIG_DIR, SYSTEM_POLICY_FILE, USER_POLICY_FILE -from safety.errors import SafetyError, SafetyException, ServerError +from safety.constants import SYSTEM_POLICY_FILE, USER_POLICY_FILE +from safety.errors import SafetyError, SafetyException from safety.scan.constants import DEFAULT_SPINNER -from safety.scan.main import PROJECT_CONFIG, download_policy, load_policy_file, \ - load_unverified_project_from_config, resolve_policy +from safety.scan.main import download_policy, load_policy_file, resolve_policy from safety.scan.models import ScanOutput, SystemScanOutput -from safety.scan.render import print_announcements, print_header, print_project_info, print_wait_policy_download +from safety.scan.render import print_announcements, print_header, print_wait_policy_download from safety.scan.util import GIT +from ..init.main import load_unverified_project_from_config, verify_project + +from safety.scan.validators import fail_if_not_allowed_stage -from safety.scan.validators import verify_project from safety.util import build_telemetry_data, pluralize from safety_schemas.models import MetadataModel, ScanType, ReportSchemaVersion, \ PolicySource @@ -98,8 +97,8 @@ def inner(ctx, policy_file_path: Optional[Path], target: Path, project_path=unverified_project.project_path ) - ctx.obj.project.upload_request_id = upload_request_id ctx.obj.project.git = git_data + ctx.obj.project.upload_request_id = upload_request_id if not policy_file_path: policy_file_path = target / Path(".safety-policy.yml") diff --git a/safety/scan/main.py b/safety/scan/main.py index 8df72f33..13159bfd 100644 --- a/safety/scan/main.py +++ b/safety/scan/main.py @@ -1,36 +1,31 @@ -import configparser import logging from pathlib import Path + import re import requests import os from urllib.parse import urljoin import platform + import time from typing import Any, Dict, Generator, Optional, Set, Tuple, Union from pydantic import ValidationError -import typer from ..auth.utils import SafetyAuthSession from ..errors import SafetyError from .ecosystems.base import InspectableFile from .ecosystems.target import InspectableFileContext -from .models import ScanExport, UnverifiedProjectModel + +from .models import ScanExport from safety.scan.util import GIT -from safety_schemas.models import FileType, PolicyFileModel, PolicySource, \ - ConfigModel, Stage, ProjectModel, ScanType -from safety.util import get_safety_version + +from safety_schemas.models import FileType, PolicyFileModel, PolicySource, ConfigModel, Stage, ScanType + from safety.constants import PLATFORM_API_BASE_URL LOG = logging.getLogger(__name__) -PROJECT_CONFIG = ".safety-project.ini" -PROJECT_CONFIG_SECTION = "project" -PROJECT_CONFIG_ID = "id" -PROJECT_CONFIG_URL = "url" -PROJECT_CONFIG_NAME = "name" - def download_policy(session: SafetyAuthSession, project_id: str, stage: Stage, branch: Optional[str]) -> Optional[PolicyFileModel]: """ @@ -82,56 +77,6 @@ def download_policy(session: SafetyAuthSession, project_id: str, stage: Stage, b return None -def load_unverified_project_from_config(project_root: Path) -> UnverifiedProjectModel: - """ - Loads an unverified project from the configuration file located at the project root. - - Args: - project_root (Path): The root directory of the project. - - Returns: - UnverifiedProjectModel: An instance of UnverifiedProjectModel. - """ - config = configparser.ConfigParser() - project_path = project_root / PROJECT_CONFIG - config.read(project_path) - id = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_ID, fallback=None) - id = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_ID, fallback=None) - url = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_URL, fallback=None) - name = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_NAME, fallback=None) - created = True - if id: - created = False - - return UnverifiedProjectModel(id=id, url_path=url, - name=name, project_path=project_path, - created=created) - - -def save_project_info(project: ProjectModel, project_path: Path) -> None: - """ - Saves the project information to the configuration file. - - Args: - project (ProjectModel): The ProjectModel object containing project information. - project_path (Path): The path to the configuration file. - """ - config = configparser.ConfigParser() - config.read(project_path) - - if PROJECT_CONFIG_SECTION not in config.sections(): - config[PROJECT_CONFIG_SECTION] = {} - - config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_ID] = project.id - if project.url_path: - config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_URL] = project.url_path - if project.name: - config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_NAME] = project.name - - with open(project_path, 'w') as configfile: - config.write(configfile) - - def load_policy_file(path: Path) -> Optional[PolicyFileModel]: """ Loads a policy file from the specified path. diff --git a/safety/scan/models.py b/safety/scan/models.py index 86ffc21a..82174605 100644 --- a/safety/scan/models.py +++ b/safety/scan/models.py @@ -1,9 +1,6 @@ from enum import Enum -from pathlib import Path from typing import Optional -from pydantic.dataclasses import dataclass - class FormatMixin: """ Mixin class providing format-related utilities for Enum classes. @@ -120,14 +117,3 @@ class SystemScanExport(str, Enum): Enum representing different system scan export formats. """ JSON = "json" - -@dataclass -class UnverifiedProjectModel(): - """ - Data class representing an unverified project model. - """ - id: Optional[str] - project_path: Path - created: bool - name: Optional[str] = None - url_path: Optional[str] = None diff --git a/safety/scan/render.py b/safety/scan/render.py index a44a51ef..3a2cd967 100644 --- a/safety/scan/render.py +++ b/safety/scan/render.py @@ -381,7 +381,7 @@ def prompt_project_id(console: Console, stage: Stage, prj_root_name: Optional[st hint = "" if default_prj_id: hint = f" If empty Safety will use [bold]{default_prj_id}[/bold]" - prompt_text = f"Set a project id for this scan (no spaces).{hint}" + prompt_text = f"Set a project id (no spaces).{hint}" def ask(): prj_id = None @@ -421,7 +421,7 @@ def prompt_link_project(console: Console, prj_name: str, prj_admin_email: str) - f"[bold]Project admin:[/bold] {prj_admin_email}"): console.print(Padding(detail, (0, 0, 0, 2)), emoji=True) - prompt_question = "Do you want to link this scan with this existing project?" + prompt_question = "Do you want to link it with this existing project?" answer = Prompt.ask(prompt=prompt_question, choices=["y", "n"], default="y", show_default=True, console=console).lower() diff --git a/safety/scan/validators.py b/safety/scan/validators.py index a118b47e..923c8d8b 100644 --- a/safety/scan/validators.py +++ b/safety/scan/validators.py @@ -3,12 +3,9 @@ from pathlib import Path from typing import Optional, Tuple import typer -from safety.scan.main import save_project_info -from safety.scan.models import ScanExport, ScanOutput, UnverifiedProjectModel -from safety.scan.render import print_wait_project_verification, prompt_project_id, prompt_link_project +from safety.scan.models import ScanExport, ScanOutput -from safety_schemas.models import AuthenticationType, ProjectModel, Stage -from safety.auth.utils import SafetyAuthSession +from safety_schemas.models import AuthenticationType MISSING_SPDX_EXTENSION_MSG = "spdx extra is not installed, please install it with: pip install safety[spdx]" @@ -57,122 +54,22 @@ def output_callback(output: ScanOutput) -> str: return output.value -def save_verified_project(ctx: typer.Context, slug: str, name: Optional[str], project_path: Path, url_path: Optional[str]): +def fail_if_not_allowed_stage(ctx: typer.Context): """ - Save the verified project information to the context and project info file. + Fail the command if the authentication type is not allowed in the current stage. 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. """ - ctx.obj.project = ProjectModel( - id=slug, - name=name, - project_path=project_path, - url_path=url_path - ) - if ctx.obj.auth.stage is Stage.development: - save_project_info(project=ctx.obj.project, - project_path=project_path) - - -def check_project(console, ctx: typer.Context, session: SafetyAuthSession, - unverified_project: UnverifiedProjectModel, stage: Stage, - 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. + if ctx.resilient_parsing: + return - 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 - data[PRJ_SLUG_SOURCE_KEY] = ".safety-project.ini" - elif not git_origin or ask_project_id: - # Set a project id for this scan (no spaces). If empty Safety will use: pyupio: - parent_root_name = None - if unverified_project.project_path.parent.name: - parent_root_name = unverified_project.project_path.parent.name - - unverified_project.id = prompt_project_id(console, stage, parent_root_name) - 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, ctx: typer.Context, session: SafetyAuthSession, - unverified_project: UnverifiedProjectModel, stage: Stage, - git_origin: 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. - stage (Stage): The current stage. - git_origin (Optional[str]): The Git origin URL. - """ - - verified_prj = False - - link_prj = True - - while not verified_prj: - result = check_project(console, ctx, session, unverified_project, stage, git_origin, ask_project_id=not link_prj) - - unverified_slug = result.get("slug") - - project = result.get("project", None) - user_confirm = result.get("user_confirm", False) - - if user_confirm: - if project and link_prj: - prj_name = project.get("name", None) - prj_admin_email = project.get("admin", None) - - link_prj = prompt_link_project(prj_name=prj_name, - prj_admin_email=prj_admin_email, - console=console) - - if not link_prj: - continue + auth_type: AuthenticationType = ctx.obj.auth.client.get_authentication_type() - verified_prj = print_wait_project_verification( - console, unverified_slug, (session.project, - {"project_id": unverified_slug}), - on_error_delay=1) + if os.getenv("SAFETY_DB_DIR"): + return - 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)) - else: - verified_prj = False + if not auth_type.is_allowed_in(stage): + raise typer.BadParameter(f"'{auth_type.value}' auth type isn't allowed with " \ + f"the '{stage}' stage.") diff --git a/safety/tool/constants.py b/safety/tool/constants.py new file mode 100644 index 00000000..dd9b9956 --- /dev/null +++ b/safety/tool/constants.py @@ -0,0 +1,1006 @@ +REPOSITORY_URL = "https://pkgs.safetycli.com/repository/public/pypi/simple/" +PROJECT_CONFIG = ".safety-project.ini" + +MOST_FREQUENTLY_DOWNLOADED_PYPI_PACKAGES = [ + "boto3", + "urllib3", + "botocore", + "requests", + "setuptools", + "certifi", + "idna", + "charset-normalizer", + "aiobotocore", + "typing-extensions", + "python-dateutil", + "s3transfer", + "packaging", + "grpcio-status", + "s3fs", + "six", + "fsspec", + "pyyaml", + "numpy", + "importlib-metadata", + "cryptography", + "zipp", + "cffi", + "pip", + "pandas", + "google-api-core", + "pycparser", + "pydantic", + "protobuf", + "wheel", + "jmespath", + "attrs", + "rsa", + "pyasn1", + "click", + "platformdirs", + "pytz", + "colorama", + "jinja2", + "awscli", + "markupsafe", + "tomli", + "pyjwt", + "googleapis-common-protos", + "filelock", + "virtualenv", + "cachetools", + "wrapt", + "google-auth", + "pluggy", + "pytest", + "pydantic-core", + "pyparsing", + "docutils", + "pyarrow", + "pyasn1-modules", + "requests-oauthlib", + "aiohttp", + "scipy", + "jsonschema", + "oauthlib", + "sqlalchemy", + "iniconfig", + "exceptiongroup", + "yarl", + "decorator", + "multidict", + "psutil", + "soupsieve", + "greenlet", + "tzdata", + "pillow", + "isodate", + "pygments", + "beautifulsoup4", + "annotated-types", + "requests-toolbelt", + "frozenlist", + "tomlkit", + "pyopenssl", + "aiosignal", + "distlib", + "async-timeout", + "more-itertools", + "openpyxl", + "tqdm", + "et-xmlfile", + "grpcio", + "deprecated", + "cloudpickle", + "lxml", + "pynacl", + "werkzeug", + "proto-plus", + "azure-core", + "google-cloud-storage", + "asn1crypto", + "coverage", + "websocket-client", + "msgpack", + "h11", + "rich", + "dill", + "pexpect", + "sniffio", + "anyio", + "mypy-extensions", + "ptyprocess", + "importlib-resources", + "sortedcontainers", + "matplotlib", + "chardet", + "rpds-py", + "grpcio-tools", + "aiohappyeyeballs", + "flask", + "httpx", + "referencing", + "scikit-learn", + "jsonschema-specifications", + "httpcore", + "pyzmq", + "poetry-core", + "keyring", + "google-cloud-core", + "python-dotenv", + "pathspec", + "markdown-it-py", + "pkginfo", + "msal", + "networkx", + "bcrypt", + "mdurl", + "gitpython", + "psycopg2-binary", + "poetry-plugin-export", + "google-resumable-media", + "paramiko", + "kiwisolver", + "smmap", + "gitdb", + "xmltodict", + "snowflake-connector-python", + "tabulate", + "cycler", + "typedload", + "jaraco-classes", + "jeepney", + "secretstorage", + "ruamel-yaml", + "tenacity", + "wcwidth", + "build", + "backoff", + "shellingham", + "threadpoolctl", + "regex", + "itsdangerous", + "portalocker", + "py", + "google-crc32c", + "rapidfuzz", + "pyproject-hooks", + "py4j", + "google-cloud-bigquery", + "fastjsonschema", + "sqlparse", + "mccabe", + "pytest-cov", + "awswrangler", + "trove-classifiers", + "msal-extensions", + "azure-storage-blob", + "google-api-python-client", + "pycodestyle", + "joblib", + "google-auth-oauthlib", + "ruamel-yaml-clib", + "tzlocal", + "docker", + "alembic", + "fonttools", + "prompt-toolkit", + "cachecontrol", + "azure-identity", + "distro", + "marshmallow", + "uritemplate", + "isort", + "cython", + "ply", + "httplib2", + "redis", + "pymysql", + "pyrsistent", + "gym-notices", + "google-auth-httplib2", + "poetry", + "blinker", + "defusedxml", + "dnspython", + "dulwich", + "toml", + "gunicorn", + "crashtest", + "markdown", + "nest-asyncio", + "babel", + "cleo", + "sentry-sdk", + "opentelemetry-api", + "scramp", + "multiprocess", + "installer", + "termcolor", + "black", + "huggingface-hub", + "mock", + "msrest", + "pendulum", + "requests-aws4auth", + "ipython", + "pyflakes", + "pycryptodomex", + "grpc-google-iam-v1", + "types-requests", + "azure-common", + "traitlets", + "fastapi", + "setuptools-scm", + "tornado", + "flake8", + "contourpy", + "prometheus-client", + "future", + "openai", + "mako", + "pycryptodome", + "imageio", + "jedi", + "webencodings", + "pygithub", + "parso", + "transformers", + "typing-inspect", + "kubernetes", + "jsonpointer", + "matplotlib-inline", + "starlette", + "loguru", + "opentelemetry-sdk", + "retry", + "argcomplete", + "pkgutil-resolve-name", + "redshift-connector", + "elasticsearch", + "pymongo", + "opentelemetry-semantic-conventions", + "pytzdata", + "pytest-runner", + "asgiref", + "pg8000", + "bs4", + "datadog", + "debugpy", + "python-json-logger", + "jsonpath-ng", + "uvicorn", + "executing", + "smart-open", + "zope-interface", + "asttokens", + "typer", + "aioitertools", + "apache-airflow", + "sagemaker", + "arrow", + "google-pasta", + "pyspark", + "humanfriendly", + "websockets", + "stack-data", + "shapely", + "pure-eval", + "torch", + "oscrypto", + "tokenizers", + "pysocks", + "sphinx", + "typeguard", + "tox", + "scikit-image", + "requests-file", + "google-cloud-pubsub", + "pytest-mock", + "google-cloud-secret-manager", + "snowflake-sqlalchemy", + "mysql-connector-python", + "pylint", + "jupyter-core", + "jupyter-client", + "astroid", + "jsonpatch", + "setproctitle", + "adal", + "types-python-dateutil", + "ipykernel", + "xgboost", + "orjson", + "schema", + "tb-nightly", + "nbconvert", + "xlrd", + "toolz", + "appdirs", + "aiofiles", + "sympy", + "opensearch-py", + "nodeenv", + "pywavelets", + "jaraco-functools", + "jupyter-server", + "nbformat", + "jupyterlab", + "progressbar2", + "comm", + "identify", + "bleach", + "mypy", + "pathos", + "pyodbc", + "pre-commit", + "xlsxwriter", + "rfc3339-validator", + "aws-requests-auth", + "gym", + "pox", + "ppft", + "mistune", + "aenum", + "jaraco-context", + "tinycss2", + "pbr", + "google-cloud-appengine-logging", + "notebook", + "db-dtypes", + "mpmath", + "sentencepiece", + "responses", + "cfgv", + "cattrs", + "python-utils", + "slack-sdk", + "jupyterlab-server", + "nbclient", + "lz4", + "ipywidgets", + "sshtunnel", + "absl-py", + "widgetsnbextension", + "watchdog", + "asynctest", + "semver", + "rfc3986", + "google-cloud-aiplatform", + "jupyterlab-widgets", + "altair", + "pandas-gbq", + "click-man", + "tensorboard", + "smdebug-rulesconfig", + "simplejson", + "text-unidecode", + "argon2-cffi", + "apache-airflow-providers-common-sql", + "snowballstemmer", + "azure-mgmt-core", + "docker-pycreds", + "nltk", + "python-slugify", + "croniter", + "structlog", + "selenium", + "antlr4-python3-runtime", + "google-cloud-logging", + "argon2-cffi-bindings", + "azure-storage-file-datalake", + "django", + "pydeequ", + "pytest-xdist", + "h5py", + "google-cloud-resource-manager", + "dataclasses", + "execnet", + "send2trash", + "opentelemetry-proto", + "google-cloud-bigquery-storage", + "oauth2client", + "dataclasses-json", + "json5", + "tiktoken", + "wandb", + "databricks-sql-connector", + "langchain-core", + "overrides", + "prettytable", + "pandocfilters", + "semantic-version", + "jupyterlab-pygments", + "msrestazure", + "safetensors", + "hvac", + "colorlog", + "imbalanced-learn", + "monotonic", + "seaborn", + "alabaster", + "terminado", + "webcolors", + "ordered-set", + "graphql-core", + "notebook-shim", + "lazy-object-proxy", + "funcsigs", + "numba", + "llvmlite", + "gremlinpython", + "xxhash", + "great-expectations", + "flatbuffers", + "pydata-google-auth", + "fqdn", + "uri-template", + "imagesize", + "opentelemetry-exporter-otlp-proto-common", + "isoduration", + "backports-tarfile", + "wsproto", + "tensorflow", + "thrift", + "hypothesis", + "rfc3986-validator", + "trio", + "inflection", + "html5lib", + "plotly", + "entrypoints", + "sphinxcontrib-serializinghtml", + "jupyter-events", + "lockfile", + "coloredlogs", + "sphinxcontrib-htmlhelp", + "cached-property", + "sphinxcontrib-qthelp", + "sphinxcontrib-devhelp", + "sphinxcontrib-applehelp", + "gast", + "azure-cli", + "azure-datalake-store", + "opentelemetry-exporter-otlp-proto-http", + "pyproject-api", + "azure-mgmt-resource", + "async-lru", + "faker", + "sphinxcontrib-jsmath", + "nose", + "opencv-python", + "outcome", + "statsmodels", + "readme-renderer", + "jupyter-server-terminals", + "libcst", + "retrying", + "datasets", + "aniso8601", + "pybind11", + "databricks-sdk", + "pyroaring", + "azure-keyvault-secrets", + "email-validator", + "argparse", + "parameterized", + "docopt", + "google-cloud-audit-log", + "confluent-kafka", + "kafka-python", + "pymssql", + "zeep", + "gcsfs", + "click-plugins", + "jupyter-lsp", + "ruff", + "deepdiff", + "docstring-parser", + "tblib", + "time-machine", + "jiter", + "patsy", + "azure-storage-common", + "deprecation", + "azure-nspkg", + "databricks-cli", + "nh3", + "twine", + "invoke", + "delta-spark", + "watchtower", + "mlflow", + "pydantic-settings", + "azure-mgmt-storage", + "opentelemetry-exporter-otlp-proto-grpc", + "applicationinsights", + "dbt-core", + "freezegun", + "pickleshare", + "apache-airflow-providers-ssh", + "python-multipart", + "langchain", + "uv", + "unidecode", + "azure-keyvault-keys", + "azure-cosmos", + "pytest-metadata", + "pipenv", + "tensorboard-data-server", + "azure-graphrbac", + "google-cloud-kms", + "backcall", + "trio-websocket", + "azure-keyvault", + "pytest-asyncio", + "psycopg2", + "google-cloud-dataproc", + "keras", + "datetime", + "zope-event", + "apache-airflow-providers-google", + "backports-zoneinfo", + "google-cloud-monitoring", + "looker-sdk", + "azure-mgmt-containerregistry", + "makefun", + "google-cloud-vision", + "mlflow-skinny", + "hatchling", + "spacy", + "torchvision", + "apache-airflow-providers-snowflake", + "google-cloud-spanner", + "google-cloud-container", + "nvidia-nccl-cu12", + "triton", + "gevent", + "google-cloud-dlp", + "uvloop", + "simple-salesforce", + "tldextract", + "analytics-python", + "apache-airflow-providers-databricks", + "tensorflow-estimator", + "google-cloud-bigquery-datatransfer", + "azure-mgmt-keyvault", + "azure-mgmt-cosmosdb", + "azure-mgmt-compute", + "graphviz", + "google-cloud-tasks", + "ujson", + "opentelemetry-instrumentation", + "azure-mgmt-authorization", + "fastavro", + "httptools", + "pathlib2", + "azure-mgmt-network", + "google-cloud-datacatalog", + "pkce", + "google-ads", + "opt-einsum", + "sh", + "jsondiff", + "azure-mgmt-msi", + "google-cloud-firestore", + "evergreen-py", + "google-cloud-bigtable", + "astunparse", + "watchfiles", + "configparser", + "flask-appbuilder", + "fabric", + "azure-mgmt-recoveryservices", + "apache-airflow-providers-mysql", + "scp", + "db-contrib-tool", + "google-cloud-build", + "omegaconf", + "azure-mgmt-monitor", + "ecdsa", + "gspread", + "azure-mgmt-signalr", + "azure-mgmt-containerinstance", + "blis", + "thinc", + "bitarray", + "murmurhash", + "pycrypto", + "dask", + "requests-mock", + "catalogue", + "cymem", + "azure-mgmt-sql", + "preshed", + "google-cloud-workflows", + "opentelemetry-exporter-otlp", + "azure-mgmt-web", + "google-cloud-redis", + "azure-batch", + "kombu", + "pywin32", + "azure-data-tables", + "wasabi", + "azure-mgmt-containerservice", + "azure-mgmt-servicebus", + "azure-mgmt-redis", + "google-cloud-dataplex", + "srsly", + "pytimeparse", + "google-cloud-language", + "authlib", + "google-cloud-automl", + "google-cloud-videointelligence", + "google-cloud-os-login", + "azure-mgmt-rdbms", + "brotli", + "pyserial", + "azure-mgmt-dns", + "langchain-community", + "nvidia-cudnn-cu12", + "texttable", + "azure-mgmt-advisor", + "google-cloud-memcache", + "azure-mgmt-eventhub", + "tensorflow-serving-api", + "gsutil", + "lark", + "azure-cli-core", + "flask-cors", + "pysftp", + "celery", + "langcodes", + "azure-mgmt-batch", + "azure-mgmt-loganalytics", + "azure-mgmt-cdn", + "ninja", + "azure-mgmt-recoveryservicesbackup", + "azure-mgmt-iothub", + "azure-mgmt-search", + "azure-mgmt-marketplaceordering", + "azure-mgmt-trafficmanager", + "azure-mgmt-managementgroups", + "pip-tools", + "azure-mgmt-cognitiveservices", + "azure-mgmt-devtestlabs", + "azure-mgmt-eventgrid", + "python-gnupg", + "jira", + "pypdf2", + "azure-mgmt-applicationinsights", + "azure-mgmt-servicefabric", + "billiard", + "azure-mgmt-media", + "azure-mgmt-billing", + "ratelimit", + "azure-mgmt-iothubprovisioningservices", + "azure-mgmt-policyinsights", + "azure-mgmt-nspkg", + "google-cloud-orchestration-airflow", + "apache-airflow-providers-cncf-kubernetes", + "azure-mgmt-batchai", + "azure-mgmt-iotcentral", + "azure-mgmt-datamigration", + "graphql-relay", + "azure-mgmt-maps", + "graphene", + "azure-appconfiguration", + "amqp", + "google-cloud-dataproc-metastore", + "mdit-py-plugins", + "google-cloud-translate", + "ijson", + "sqlalchemy-bigquery", + "vine", + "nvidia-cublas-cu12", + "nvidia-nvjitlink-cu12", + "spacy-loggers", + "spacy-legacy", + "levenshtein", + "agate", + "azure-mgmt-datalake-nspkg", + "knack", + "yapf", + "awscrt", + "azure-mgmt-datalake-store", + "google-cloud-dataform", + "types-pyyaml", + "confection", + "propcache", + "google-cloud-speech", + "nvidia-cuda-runtime-cu12", + "opencensus", + "opencensus-context", + "nvidia-cuda-cupti-cu12", + "nvidia-cuda-nvrtc-cu12", + "parsedatetime", + "nvidia-cusparse-cu12", + "nvidia-cufft-cu12", + "nvidia-cusolver-cu12", + "grpcio-gcp", + "nvidia-curand-cu12", + "google-cloud-texttospeech", + "typing", + "humanize", + "pytest-html", + "langsmith", + "nvidia-nvtx-cu12", + "flask-sqlalchemy", + "opentelemetry-util-http", + "narwhals", + "azure-multiapi-storage", + "gcloud-aio-storage", + "pycountry", + "jsonpickle", + "zstandard", + "avro-python3", + "libclang", + "apispec", + "gcloud-aio-auth", + "azure-storage-queue", + "contextlib2", + "azure-mgmt-datalake-analytics", + "gcloud-aio-bigquery", + "azure-mgmt-reservations", + "javaproperties", + "tensorflow-io-gcs-filesystem", + "azure-loganalytics", + "djangorestframework", + "azure-mgmt-consumption", + "hpack", + "google-cloud-compute", + "click-didyoumean", + "azure-mgmt-relay", + "parsimonious", + "azure-synapse-artifacts", + "python-magic", + "azure-cli-telemetry", + "click-repl", + "moto", + "pyathena", + "pyproj", + "protobuf3-to-dict", + "durationpy", + "stevedore", + "python-daemon", + "azure-synapse-spark", + "apache-airflow-providers-http", + "mypy-boto3-s3", + "pyspnego", + "cfn-lint", + "astor", + "azure-mgmt-apimanagement", + "h2", + "hyperframe", + "azure-mgmt-hdinsight", + "azure-mgmt-privatedns", + "boto3-stubs", + "mashumaro", + "dateparser", + "ml-dtypes", + "mysqlclient", + "azure-mgmt-security", + "opencensus-ext-azure", + "azure-mgmt-synapse", + "azure-mgmt-kusto", + "azure-mgmt-netapp", + "grpcio-health-checking", + "azure-mgmt-redhatopenshift", + "iso8601", + "lightgbm", + "azure-mgmt-appconfiguration", + "azure-keyvault-administration", + "boto", + "azure-mgmt-sqlvirtualmachine", + "azure-mgmt-imagebuilder", + "azure-synapse-accesscontrol", + "enum34", + "azure-mgmt-servicelinker", + "azure-mgmt-botservice", + "azure-mgmt-servicefabricmanagedclusters", + "jpype1", + "python-jose", + "azure-mgmt-databoxedge", + "azure-synapse-managedprivateendpoints", + "azure-mgmt-extendedlocation", + "office365-rest-python-client", + "onnxruntime", + "azure-mgmt-managedservices", + "cramjam", + "urllib3-secure-extra", + "avro", + "holidays", + "psycopg", + "botocore-stubs", + "fasteners", + "resolvelib", + "partd", + "hyperlink", + "leather", + "apscheduler", + "flask-wtf", + "jupyter", + "marisa-trie", + "locket", + "jupyter-console", + "python-http-client", + "elastic-transport", + "dbt-extractor", + "tensorflow-text", + "language-data", + "inflect", + "fuzzywuzzy", + "cytoolz", + "cmake", + "parse", + "python-gitlab", + "mypy-boto3-rds", + "tifffile", + "eth-utils", + "eth-hash", + "netaddr", + "incremental", + "setuptools-rust", + "python-levenshtein", + "geopandas", + "twisted", + "langchain-text-splitters", + "types-awscrt", + "apache-airflow-providers-fab", + "yamllint", + "cligj", + "sphinx-rtd-theme", + "azure-mgmt-deploymentmanager", + "pytest-timeout", + "lazy-loader", + "wtforms", + "bytecode", + "accelerate", + "polars", + "sendgrid", + "frozendict", + "flask-login", + "opentelemetry-instrumentation-requests", + "jaydebeapi", + "eth-typing", + "dacite", + "types-pytz", + "py-cpuinfo", + "querystring-parser", + "universal-pathlib", + "dbt-semantic-interfaces", + "magicattr", + "cssselect", + "fastparquet", + "opencv-python-headless", + "automat", + "unicodecsv", + "constantly", + "kfp", + "ddtrace", + "logbook", + "envier", + "cloudpathlib", + "types-s3transfer", + "google-cloud-dataflow-client", + "sqlalchemy-utils", + "apache-beam", + "validators", + "bracex", + "apache-airflow-providers-ftp", + "phonenumbers", + "diskcache", + "mergedeep", + "slicer", + "shap", + "python-docx", + "types-urllib3", + "pytest-rerunfailures", + "types-setuptools", + "pathy", + "pytz-deprecation-shim", + "yappi", + "pydot", + "types-protobuf", + "ipython-genutils", + "pytorch-lightning", + "fire", + "apache-airflow-providers-sqlite", + "nvidia-cublas-cu11", + "azure-storage-file-share", + "mmh3", + "azure-mgmt-datafactory", + "azure-servicebus", + "nvidia-cudnn-cu11", + "inject", + "typed-ast", + "connexion", + "configargparse", + "linkify-it-py", + "aws-sam-translator", + "slackclient", + "eth-abi", + "pydash", + "timm", + "datadog-api-client", + "nvidia-cuda-runtime-cu11", + "nvidia-cuda-nvrtc-cu11", + "geographiclib", + "gradio", + "cron-descriptor", + "ansible", + "azure-kusto-data", + "django-cors-headers", + "junit-xml", + "geopy", + "uc-micro-py", + "pyee", + "xarray", + "ansible-core", + "pypdf", + "pyotp", + "starkbank-ecdsa", + "geoip2", + "multimethod", + "eth-account", + "meson", + "jellyfish", + "futures", + "cachelib", + "flask-caching", + "natsort", + "autopep8", + "torchaudio", + "torchmetrics", + "pydub", + "pandera", + "pyhcl", + "apache-airflow-providers-slack", + "oracledb", + "google-cloud-run", + "h3", + "apache-airflow-providers-amazon", + "sqlalchemy-spanner", + "events", + "google-cloud-batch", + "requests-ntlm", + "bottle", + "google-cloud-storage-transfer", + "junitparser", + "apache-airflow-providers-smtp", + "apache-airflow-providers-imap", + "emoji", + "crcmod", + "statsd", + "limits", + "apache-airflow-providers-common-io", + "methodtools", + "asyncpg", + "strictyaml", + "wcmatch", + "marshmallow-sqlalchemy", + "faiss-cpu", + "sentence-transformers", + "psycopg-binary", + "azure-keyvault-certificates", + "django-filter", + "maxminddb", + "weasel", + "gql", + "onnx", + "fiona", + "boltons", + "dbt-common", + "bidict", + "keras-applications", + "json-merge-patch", + "elasticsearch-dsl", + "ftfy", + "swagger-ui-bundle", + "tableauserverclient", + "flask-jwt-extended", + "lightning-utilities", + "meson-python", + "google-cloud", +] + diff --git a/safety/tool/main.py b/safety/tool/main.py new file mode 100644 index 00000000..402028d8 --- /dev/null +++ b/safety/tool/main.py @@ -0,0 +1,50 @@ +import os.path +from pathlib import Path + +from safety.console import main_console as console +from safety.tool.utils import PipConfigurator, PipRequirementsConfigurator, PoetryPyprojectConfigurator, is_os_supported + + +def has_local_tool_files(directory: Path) -> bool: + configurators = [PipRequirementsConfigurator(), PoetryPyprojectConfigurator()] + + for file_name in os.listdir(directory): + if os.path.isfile(file_name): + file = Path(file_name) + for configurator in configurators: + if configurator.is_supported(file): + return True + + return False + + +def configure_system(): + configurators = [PipConfigurator()] + + for configurator in configurators: + configurator.configure() + +def configure_alias(): + if not is_os_supported(): + return + + home = Path.home() + with open(home / '.profile', "a+") as f: + content = f.read() + + alias = f'alias pip="safety-beta pip"\n' + if content.find(alias) == -1: + f.seek(0) + f.write(content + '\n' + alias) + + console.print("Configured PIP alias") + +def configure_local_directory(directory: Path): + configurators = [PipRequirementsConfigurator(), PoetryPyprojectConfigurator()] + + for file_name in os.listdir(directory): + if os.path.isfile(file_name): + file = Path(file_name) + for configurator in configurators: + if configurator.is_supported(file): + configurator.configure(file) diff --git a/safety/tool/pip.py b/safety/tool/pip.py new file mode 100644 index 00000000..cdb6e0c3 --- /dev/null +++ b/safety/tool/pip.py @@ -0,0 +1,88 @@ +import base64 +import json +import shutil +import subprocess +from pathlib import Path +from typing import Optional +from urllib.parse import urlsplit, urlunsplit + +import typer +from rich.console import Console + +from safety.console import main_console + +REPOSITORY_URL = "https://pkgs.safetycli.com/repository/public/pypi/simple/" + + +class Pip: + + @classmethod + def is_installed(cls) -> bool: + """ + Checks if the PIP program is installed + + Returns: + True if PIP is installed on system, or false otherwise + """ + return shutil.which("pip") is not None + + @classmethod + def configure_requirements(cls, file: Path, console: Optional[Console] = main_console) -> None: + """ + Configures Safety index url for specified requirements file. + + Args: + file (Path): Path to requirements.txt file. + console (Console): Console instance. + """ + + with open(file, "r+") as f: + content = f.read() + + index_config = f"-i {REPOSITORY_URL}\n" + if content.find(index_config) == -1: + f.seek(0) + f.write(index_config + content) + + console.print(f"Configured {file} file") + else: + console.print(f"{file} is already configured. Skipping.") + + @classmethod + def configure_system(cls, console: Optional[Console] = main_console): + """ + Configures PIP system to use to Safety index url. + """ + subprocess.run(["pip", "config", "set", "global.index-url", REPOSITORY_URL], capture_output=True) + console.print("Configured PIP global settings") + + @classmethod + def index_credentials(cls, ctx: typer.Context): + auth_envelop = json.dumps({ + "version": "1.0", + "access_token": ctx.obj.auth.client.token["access_token"], + "api_key": ctx.obj.auth.client.api_key, + "project_id": ctx.obj.project.id if ctx.obj.project else None, + }) + return base64.urlsafe_b64encode(auth_envelop.encode("utf-8")).decode("utf-8") + + @classmethod + def default_index_url(cls) -> str: + return f"https://pypi.org/simple/" + + @classmethod + def build_index_url(cls, ctx: typer.Context, index_url: Optional[str]) -> str: + if index_url is None: + index_url = REPOSITORY_URL + + url = urlsplit(index_url) + + encoded_auth = cls.index_credentials(ctx) + netloc = f'user:{encoded_auth}@{url.netloc}' + + if type(url.netloc) == bytes: + url = url._replace(netloc=netloc.encode("utf-8")) + elif type(url.netloc) == str: + url = url._replace(netloc=netloc) + + return urlunsplit(url) diff --git a/safety/tool/poetry.py b/safety/tool/poetry.py new file mode 100644 index 00000000..a5034540 --- /dev/null +++ b/safety/tool/poetry.py @@ -0,0 +1,51 @@ +import shutil +import subprocess +from pathlib import Path +from typing import Optional +import sys + +from rich.console import Console + +from safety.console import main_console +from safety.tool.pip import REPOSITORY_URL + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +class Poetry: + + @classmethod + def is_installed(cls) -> bool: + """ + Checks if the PIP program is installed + + Returns: + True if PIP is installed on system, or false otherwise + """ + return shutil.which("poetry") is not None + + @classmethod + def is_poetry_project_file(cls, file: Path) -> bool: + try: + cfg = tomllib.loads(file.read_text()) + return cfg.get("build-system", {}).get("requires") == "poetry-core" + except (IOError, ValueError) as e: + return False + + @classmethod + def configure_pyproject(cls, file: Path, console: Optional[Console] = main_console) -> None: + """ + Configures index url for specified requirements file. + + Args: + file (Path): Path to requirements.txt file. + console (Console): Console instance. + """ + if not cls.is_installed(): + console.log("Poetry is not installed.") + + subprocess.run(["poetry", "source", "add", "safety", REPOSITORY_URL], capture_output=True) + console.print(f"Configured {file} file") + diff --git a/safety/tool/utils.py b/safety/tool/utils.py new file mode 100644 index 00000000..9c533c1e --- /dev/null +++ b/safety/tool/utils.py @@ -0,0 +1,257 @@ +import abc +import os.path +import re +import subprocess +from abc import abstractmethod +from pathlib import Path +from subprocess import CompletedProcess +from sys import platform +from tempfile import mkstemp + +import typer +from Levenshtein import distance +from filelock import FileLock +from rich.padding import Padding +from rich.prompt import Prompt + +from safety.console import main_console as console +from safety.constants import PIP_LOCK +from safety.tool.constants import MOST_FREQUENTLY_DOWNLOADED_PYPI_PACKAGES, PROJECT_CONFIG, REPOSITORY_URL +from safety.tool.pip import Pip +from safety.tool.poetry import Poetry + +from typing_extensions import List + + +def is_os_supported(): + return platform in ["linux", "linux2", "darwin"] + +class BuildFileConfigurator(abc.ABC): + + @abc.abstractmethod + def is_supported(self, file: Path) -> bool: + """ + Returns whether a specific file is supported by this class. + Args: + file (str): The file to check. + Returns: + bool: Whether the file is supported by this class. + """ + pass + + @abc.abstractmethod + def configure(self, file: Path) -> None: + """ + Configures specific file. + Args: + file (str): The file to configure. + """ + pass + + +class PipRequirementsConfigurator(BuildFileConfigurator): + __file_name_pattern = re.compile("^([a-zA-Z_-]+)?requirements([a-zA-Z_-]+)?.txt$") + + def is_supported(self, file: Path) -> bool: + return self.__file_name_pattern.match(os.path.basename(file)) is not None + + def configure(self, file: Path) -> None: + Pip.configure_requirements(file) + + +class PoetryPyprojectConfigurator(BuildFileConfigurator): + __file_name_pattern = re.compile("^pyproject.toml$") + + def is_supported(self, file: Path) -> bool: + return self.__file_name_pattern.match(os.path.basename(file)) is not None and Poetry.is_poetry_project_file( + file) + + def configure(self, file: Path) -> None: + Poetry.configure_pyproject(file) + + +class ToolConfigurator(abc.ABC): + + @abc.abstractmethod + def configure(self) -> None: + """ + Configures specific tool. + """ + pass + + +class PipConfigurator(ToolConfigurator): + + def configure(self) -> None: + Pip.configure_system() + + +class PipCommand(abc.ABC): + + def __init__(self, args: List[str], capture_output: bool = False) -> None: + self._args = args + self.__capture_output = capture_output + self.__filelock = FileLock(PIP_LOCK, 10) + + @abstractmethod + def before(self, ctx: typer.Context): + pass + + @abstractmethod + def after(self, ctx: typer.Context, result): + pass + + def execute(self, ctx: typer.Context): + with self.__filelock: + self.before(ctx) + args = ["pip"] + self.__remove_safety_args(self._args) + result = subprocess.run(args, capture_output=self.__capture_output, env=self.env(ctx)) + self.after(ctx, result) + + def env(self, ctx: typer.Context): + return os.environ.copy() + + @classmethod + def from_args(self, args): + if "install" in args: + return PipInstallCommand(args) + elif "uninstall" in args: + return PipUninstallCommand(args) + else: + return PipGenericCommand(args) + + def __remove_safety_args(self, args: List[str]): + return [arg for arg in args if not arg.startswith("--safety")] + + +class PipGenericCommand(PipCommand): + + def __init__(self, args: List[str]) -> None: + super().__init__(args) + + def before(self, ctx: typer.Context): + pass + + def after(self, ctx: typer.Context, result): + pass + + +class PipInstallCommand(PipCommand): + + def __init__(self, args: List[str]) -> None: + super().__init__(args) + self.package_names = [] + self.__index_url = None + + def before(self, ctx: typer.Context): + args = self._args + + ranges_to_delete = [] + for ind, val in enumerate(args): + if ind > 0 and (args[ind - 1].startswith("-i") or args[ind - 1].startswith("--index-url")): + if args[ind].startswith("https://pkgs.safetycli.com"): + self.__index_url = args[ind] + + ranges_to_delete.append((ind - 1, ind)) + elif ind > 0 and (args[ind - 1] == "-r" or args[ind - 1] == "--requirement"): + requirement_file = args[ind] + + if not Path(requirement_file).is_file(): + continue + + with open(requirement_file, "r") as f: + fd, tmp_requirements_path = mkstemp(suffix="safety-requirements.txt", text=True) + with os.fdopen(fd, "w") as tf: + requirements = re.sub(r"^(-i|--index-url).*$", "", f.read(), flags=re.MULTILINE) + tf.write(requirements) + + args[ind] = tmp_requirements_path + elif ind > 0 and (not args[ind - 1].startswith("-e") or not args[ind - 1].startswith("--editable")) and not args[ind].startswith("-"): + if args[ind] == '.': + continue + + package_name = args[ind] + (valid, candidate_package_name) = self.__check_typosquatting(package_name) + if not valid: + prompt = f"You are about to install {package_name} package. Did you mean to install {candidate_package_name}?" + answer = Prompt.ask(prompt=prompt, choices=["y", "n"], + default="y", show_default=True, console=console).lower() + if answer == 'y': + package_name = candidate_package_name + console.print(f"Installing {package_name} package instead.") + args[ind] = package_name + + self.__add_package_name(package_name) + + for (start, end) in ranges_to_delete: + args = args[:start] + args[end + 1:] + + self._args = args + + def after(self, ctx: typer.Context, result: CompletedProcess[str]): + if result and result.returncode == 0: + self.__run_scan() + else: + self.__render_package_details() + + def env(self, ctx: typer.Context) -> dict: + env = super().env(ctx) + env["PIP_INDEX_URL"] = Pip.build_index_url(ctx, self.__index_url) if not self.__is_check_disabled() else Pip.default_index_url() + return env + + def __is_check_disabled(self): + return "--safety-disable-check" in self._args + + def __check_typosquatting(self, package_name): + max_edit_distance = 2 if len(package_name) > 5 else 1 + + if package_name in MOST_FREQUENTLY_DOWNLOADED_PYPI_PACKAGES: + return (True, package_name) + + for pkg in MOST_FREQUENTLY_DOWNLOADED_PYPI_PACKAGES: + if (abs(len(pkg) - len(package_name)) <= max_edit_distance + and distance(pkg, package_name) <= max_edit_distance): + return (False, pkg) + + return (True, package_name) + + def __run_scan(self): + if not is_os_supported(): + return + + target = os.getcwd() + if Path(os.path.join(target, PROJECT_CONFIG)).is_file(): + try: + subprocess.Popen( + ['safety-beta', 'scan'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True + ) + except Exception: + pass + + def __add_package_name(self, package_name): + r = re.compile(r"^([a-zA-Z_-]+)(([~<>=]=)[a-zA-Z0-9._-]+)?") + match = r.match(package_name) + if match: + self.package_names.append(match.group(1)) + + def __render_package_details(self): + for package_name in self.package_names: + console.print( + Padding(f"Learn more: [link]https://data.safetycli.com/packages/pypi/{package_name}/[/link]", + (0, 0, 0, 1)), emoji=True) + + +class PipUninstallCommand(PipCommand): + + def __init__(self, args: List[str]) -> None: + super().__init__(args) + + def before(self, ctx: typer.Context): + pass + + def after(self, ctx: typer.Context, result): + pass diff --git a/tests/test-safety-project.ini b/tests/test-safety-project.ini new file mode 100644 index 00000000..0e47071b --- /dev/null +++ b/tests/test-safety-project.ini @@ -0,0 +1,4 @@ +[project] +id = safety +url = /projects/e008f386-0a5e-4967-b8b9-079239d5f93c/findings +name = safety diff --git a/tests/test_cli.py b/tests/test_cli.py index 1114afb7..8ae345ef 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -593,6 +593,35 @@ def dummy_function(): # Assert the preprocessed arguments assert preprocessed_args == ['--debug', 'scan'], f"Preprocessed args: {preprocessed_args}" + @patch('safety.auth.cli.get_auth_info', return_value={'email': 'test@test.com'}) + @patch.object(Auth, 'is_valid', return_value=True) + @patch('safety.auth.utils.SafetyAuthSession.get_authentication_type', return_value=AuthenticationType.TOKEN) + @patch('safety.auth.utils.SafetyAuthSession.initialize_scan', return_value={'platform-enabled': True}) + @patch('safety.auth.utils.SafetyAuthSession.check_project', return_value={'user_confirm': True}) + @patch('safety.auth.utils.SafetyAuthSession.project', return_value={'slug': 'slug'}) + def test_init_project(self, mock_get_auth_info, mock_is_valid, mock_get_auth_type, mock_initialize_scan, mock_check_project, mock_project): + with tempfile.TemporaryDirectory() as tempdir: + result = self.runner.invoke(cli.cli, ['project', 'init', tempdir]) + cleaned_stdout = click.unstyle(result.stdout) + assert result.exit_code == 1, f"CLI exited with code {result.exit_code}" + assert cleaned_stdout.startswith("Set a project id (no spaces). If empty Safety will use"), f"CLI exited with output: {result.output}" + + @patch('safety.auth.cli.get_auth_info', return_value={'email': 'test@test.com'}) + @patch.object(Auth, 'is_valid', return_value=True) + @patch('safety.auth.utils.SafetyAuthSession.get_authentication_type', return_value=AuthenticationType.TOKEN) + @patch('safety.auth.utils.SafetyAuthSession.initialize_scan', return_value={'platform-enabled': True}) + @patch('safety.auth.utils.SafetyAuthSession.check_project', return_value={'user_confirm': True}) + @patch('safety.auth.utils.SafetyAuthSession.project', return_value={'slug': 'slug'}) + def test_existing_project_is_linked(self, mock_get_auth_info, mock_is_valid, mock_get_auth_type, mock_initialize_scan, mock_check_project, mock_project): + dirname = os.path.dirname(__file__) + source_project_file = os.path.join(dirname, "test-safety-project.ini") + + with tempfile.TemporaryDirectory() as tempdir: + project_file = os.path.join(tempdir, '.safety-project.ini') + shutil.copy(source_project_file, project_file) + result = self.runner.invoke(cli.cli, ['project', 'init', tempdir]) + assert result.exit_code == 0, f"CLI exited with code {result.exit_code} and output: {result.output} and error: {result.stderr}" + class TestNetworkTelemetry(unittest.TestCase): @patch('psutil.net_io_counters')