1354 lines
41 KiB
Python
1354 lines
41 KiB
Python
# -*- coding: utf-8 -*-
|
|
# type: ignore
|
|
from __future__ import absolute_import
|
|
|
|
import configparser
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import sys
|
|
from dataclasses import asdict
|
|
from datetime import date, datetime, timedelta
|
|
from enum import Enum
|
|
from functools import wraps
|
|
from pathlib import Path
|
|
|
|
import click
|
|
import typer
|
|
import typer.rich_utils
|
|
from packaging import version as packaging_version
|
|
from packaging.version import InvalidVersion
|
|
from safety_schemas.config.schemas.v3_0 import main as v3_0
|
|
from safety_schemas.models import (
|
|
ConfigModel,
|
|
Ecosystem,
|
|
Stage,
|
|
VulnerabilitySeverityLabels,
|
|
)
|
|
|
|
from safety.alerts import alert
|
|
from safety.auth import auth_options, proxy_options
|
|
from safety.auth.cli import auth_app
|
|
from safety.auth.models import Organization
|
|
from safety.decorators import notify
|
|
from safety.codebase.command import codebase_app
|
|
from safety.console import main_console as console
|
|
from safety.constants import (
|
|
BAR_LINE,
|
|
CONFIG_FILE_SYSTEM,
|
|
CONFIG_FILE_USER,
|
|
CONTEXT_COMMAND_TYPE,
|
|
EXIT_CODE_FAILURE,
|
|
EXIT_CODE_OK,
|
|
EXIT_CODE_VULNERABILITIES_FOUND,
|
|
CLI_MAIN_INTRODUCTION,
|
|
DEFAULT_EPILOG,
|
|
)
|
|
from safety.error_handlers import handle_cmd_exception, output_exception
|
|
from safety.errors import InvalidCredentialError, SafetyError, SafetyException
|
|
from safety.firewall.command import firewall_app
|
|
from safety.formatter import SafetyFormatter
|
|
from safety.init.command import init_app
|
|
from safety.meta import get_version
|
|
from safety.output_utils import should_add_nl
|
|
from safety.tool import tool_commands
|
|
from safety.scan.command import scan_project_app, scan_system_app
|
|
from safety.scan.constants import (
|
|
CLI_CHECK_COMMAND_HELP,
|
|
CLI_CHECK_UPDATES_HELP,
|
|
CLI_CONFIGURE_HELP,
|
|
CLI_CONFIGURE_ORGANIZATION_ID,
|
|
CLI_CONFIGURE_ORGANIZATION_NAME,
|
|
CLI_CONFIGURE_PROXY_HOST_HELP,
|
|
CLI_CONFIGURE_PROXY_PORT_HELP,
|
|
CLI_CONFIGURE_PROXY_PROTOCOL_HELP,
|
|
CLI_CONFIGURE_PROXY_REQUIRED,
|
|
CLI_CONFIGURE_PROXY_TIMEOUT,
|
|
CLI_CONFIGURE_SAVE_TO_SYSTEM,
|
|
CLI_DEBUG_HELP,
|
|
CLI_DISABLE_OPTIONAL_TELEMETRY_DATA_HELP,
|
|
CLI_GENERATE_HELP,
|
|
CLI_GENERATE_MINIMUM_CVSS_SEVERITY,
|
|
CLI_GENERATE_PATH,
|
|
CLI_LICENSES_COMMAND_HELP,
|
|
DEFAULT_SPINNER,
|
|
)
|
|
from safety.scan.finder import FileFinder
|
|
from safety.scan.main import process_files
|
|
from safety.util import (
|
|
DependentOption,
|
|
MutuallyExclusiveOption,
|
|
SafetyContext,
|
|
SafetyPolicyFile,
|
|
active_color_if_needed,
|
|
bare_alias,
|
|
filter_announcements,
|
|
get_fix_options,
|
|
get_packages_licenses,
|
|
get_processed_options,
|
|
html_alias,
|
|
initialize_config_dirs,
|
|
initialize_event_bus,
|
|
is_a_remote_mirror,
|
|
json_alias,
|
|
transform_ignore,
|
|
)
|
|
|
|
from .cli_util import (
|
|
CommandType,
|
|
SafetyCLICommand,
|
|
SafetyCLILegacyCommand,
|
|
SafetyCLILegacyGroup,
|
|
SafetyCLISubGroup,
|
|
)
|
|
|
|
from safety import safety as safety_core
|
|
|
|
try:
|
|
from typing import Annotated, Optional
|
|
except ImportError:
|
|
from typing_extensions import Annotated, Optional
|
|
|
|
|
|
import safety.asyncio_patch # noqa: F401
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
def preprocess_args(f):
|
|
if "--debug" in sys.argv:
|
|
index = sys.argv.index("--debug")
|
|
if len(sys.argv) > index + 1:
|
|
next_arg = sys.argv[index + 1]
|
|
if next_arg in ("1", "true"):
|
|
sys.argv.pop(index + 1) # Remove the next argument (1 or true)
|
|
return f
|
|
|
|
|
|
def configure_logger(ctx, param, debug):
|
|
level = logging.CRITICAL
|
|
|
|
if debug:
|
|
level = logging.DEBUG
|
|
|
|
logging.basicConfig(format="%(asctime)s %(name)s => %(message)s", level=level)
|
|
|
|
if debug:
|
|
# Log the contents of the config.ini file
|
|
config = configparser.ConfigParser()
|
|
config.read(CONFIG_FILE_USER)
|
|
LOG.debug("Config file contents:")
|
|
for section in config.sections():
|
|
LOG.debug("[%s]", section)
|
|
for key, value in config.items(section):
|
|
LOG.debug("%s = %s", key, value)
|
|
|
|
# Log the proxy settings if they were attempted
|
|
if "proxy" in config:
|
|
LOG.debug(
|
|
"Proxy configuration attempted with settings: %s", dict(config["proxy"])
|
|
)
|
|
|
|
|
|
@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("--debug", is_flag=True, help=CLI_DEBUG_HELP, callback=configure_logger)
|
|
@click.version_option(version=get_version())
|
|
@click.pass_context
|
|
@preprocess_args
|
|
def cli(ctx, debug, disable_optional_telemetry):
|
|
"""
|
|
Scan and secure Python projects against package vulnerabilities. To get started navigate to a Python project and run `safety scan`.
|
|
"""
|
|
SafetyContext().safety_source = "cli"
|
|
telemetry = not disable_optional_telemetry
|
|
ctx.obj.config = ConfigModel(telemetry_enabled=telemetry)
|
|
level = logging.CRITICAL
|
|
if debug:
|
|
level = logging.DEBUG
|
|
|
|
logging.basicConfig(format="%(asctime)s %(name)s => %(message)s", level=level)
|
|
|
|
LOG.info(f"Telemetry enabled: {ctx.obj.config.telemetry_enabled}")
|
|
|
|
# Before any command make sure that the parent dirs for Safety config are present.
|
|
initialize_config_dirs()
|
|
|
|
initialize_event_bus(ctx=ctx)
|
|
|
|
|
|
def clean_check_command(f):
|
|
"""
|
|
Main entry point for validation.
|
|
"""
|
|
|
|
@wraps(f)
|
|
def inner(ctx, *args, **kwargs):
|
|
save_json = kwargs["save_json"]
|
|
output = kwargs["output"]
|
|
authenticated: bool = ctx.obj.auth.client.is_using_auth_credentials()
|
|
files = kwargs["files"]
|
|
policy_file = kwargs["policy_file"]
|
|
auto_remediation_limit = kwargs["auto_remediation_limit"]
|
|
audit_and_monitor = kwargs["audit_and_monitor"]
|
|
exit_code = kwargs["exit_code"]
|
|
|
|
# This is handled in the custom subgroup Click class
|
|
# TODO: Remove this soon, for now it keeps a legacy behavior
|
|
kwargs.pop("key", None)
|
|
kwargs.pop("proxy_protocol", None)
|
|
kwargs.pop("proxy_host", None)
|
|
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"
|
|
):
|
|
raise click.UsageError(
|
|
"Illegal usage: `--json-version` only works with JSON related outputs."
|
|
)
|
|
|
|
try:
|
|
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}."
|
|
)
|
|
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_core.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,
|
|
"audit_and_monitor": audit_and_monitor,
|
|
}
|
|
)
|
|
|
|
except SafetyError as e:
|
|
LOG.exception("Expected SafetyError happened: %s", e)
|
|
output_exception(e, exit_code_output=exit_code)
|
|
except Exception as e:
|
|
LOG.exception("Unexpected Exception happened: %s", e)
|
|
exception = e if isinstance(e, SafetyException) else SafetyException(info=e)
|
|
output_exception(exception, exit_code_output=exit_code)
|
|
|
|
return f(ctx, *args, **kwargs)
|
|
|
|
return inner
|
|
|
|
|
|
def print_deprecation_message(
|
|
old_command: str, deprecation_date: datetime, new_command: Optional[str] = None
|
|
) -> None:
|
|
"""
|
|
Print a formatted deprecation message for a command.
|
|
|
|
This function uses the click library to output a visually distinct
|
|
message in the console, warning users about the deprecation of a
|
|
specified command. It includes information about the deprecation date
|
|
and suggests an alternative command to use, if provided.
|
|
|
|
The message is formatted with colors and styles for emphasis:
|
|
- Yellow for the border and general information
|
|
- Red for the 'DEPRECATED' label
|
|
- Green for the suggestion of the new command (if provided)
|
|
|
|
Parameters:
|
|
- old_command (str): The name of the deprecated command.
|
|
- deprecation_date (datetime): The date when the command will no longer be supported.
|
|
- new_command (str, optional): The name of the alternative command to suggest. Default is None.
|
|
"""
|
|
click.echo("\n")
|
|
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,
|
|
)
|
|
)
|
|
|
|
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.echo("\n")
|
|
click.echo(click.style(BAR_LINE, fg="yellow", bold=True))
|
|
click.echo("\n")
|
|
|
|
|
|
@cli.command(
|
|
cls=SafetyCLILegacyCommand,
|
|
context_settings={CONTEXT_COMMAND_TYPE: CommandType.UTILITY},
|
|
help=CLI_CHECK_COMMAND_HELP,
|
|
)
|
|
@proxy_options
|
|
@auth_options(stage=False)
|
|
@click.option(
|
|
"--db",
|
|
default="",
|
|
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],
|
|
},
|
|
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",
|
|
hidden=True,
|
|
)
|
|
@click.option(
|
|
"--stdin",
|
|
default=False,
|
|
cls=MutuallyExclusiveOption,
|
|
mutually_exclusive=["files"],
|
|
help="Read input from stdin.",
|
|
is_flag=True,
|
|
show_default=True,
|
|
)
|
|
@click.option(
|
|
"files",
|
|
"--file",
|
|
"-r",
|
|
multiple=True,
|
|
type=click.File(),
|
|
cls=MutuallyExclusiveOption,
|
|
mutually_exclusive=["stdin"],
|
|
help="Read input from one (or multiple) requirement files. Default: empty",
|
|
)
|
|
@click.option(
|
|
"--ignore",
|
|
"-i",
|
|
multiple=True,
|
|
type=str,
|
|
default=[],
|
|
callback=transform_ignore,
|
|
help="Ignore one (or multiple) vulnerabilities by ID (coma separated). Default: empty",
|
|
)
|
|
@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,
|
|
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,
|
|
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,
|
|
hidden=True,
|
|
is_flag=True,
|
|
show_default=True,
|
|
)
|
|
@click.option(
|
|
"--output",
|
|
"-o",
|
|
type=click.Choice(["screen", "text", "json", "bare", "html"], case_sensitive=False),
|
|
default="screen",
|
|
callback=active_color_if_needed,
|
|
envvar="SAFETY_OUTPUT",
|
|
)
|
|
@click.option(
|
|
"--exit-code/--continue-on-error",
|
|
default=True,
|
|
help="Output standard exit codes. Default: --exit-code",
|
|
)
|
|
@click.option(
|
|
"--policy-file",
|
|
type=SafetyPolicyFile(),
|
|
default=".safety-policy.yml",
|
|
help="Define the policy file to be used",
|
|
)
|
|
@click.option(
|
|
"--audit-and-monitor/--disable-audit-and-monitor",
|
|
default=True,
|
|
help="Send results back to safetycli.com for viewing on your dashboard. Requires an API key.",
|
|
)
|
|
@click.option(
|
|
"project",
|
|
"--project-id",
|
|
"--project",
|
|
default=None,
|
|
help="Project to associate this scan with on safetycli.com. "
|
|
"Defaults to a canonicalized github style name if available, otherwise unknown",
|
|
)
|
|
@click.option(
|
|
"--save-json",
|
|
default="",
|
|
help="Path to where the output file will be placed; if the path is a"
|
|
" directory, Safety will use safety-report.json as filename."
|
|
" Default: empty",
|
|
)
|
|
@click.option(
|
|
"--save-html",
|
|
default="",
|
|
help="Path to where the output file will be placed; if the path is a"
|
|
" directory, Safety will use safety-report.html as the main file. "
|
|
"Default: empty",
|
|
)
|
|
@click.option(
|
|
"apply_remediations",
|
|
"--apply-security-updates",
|
|
"-asu",
|
|
default=False,
|
|
is_flag=True,
|
|
help="Apply security updates in your requirement files.",
|
|
)
|
|
@click.option(
|
|
"auto_remediation_limit",
|
|
"--auto-security-updates-limit",
|
|
"-asul",
|
|
multiple=True,
|
|
type=click.Choice(["patch", "minor", "major"]),
|
|
default=["patch"],
|
|
help="Define the limit to be used for automatic security updates in your requirement files."
|
|
" Default: patch",
|
|
)
|
|
@click.option(
|
|
"no_prompt",
|
|
"--no-prompt",
|
|
"-np",
|
|
default=False,
|
|
help="Safety won't ask for remediations outside of the remediation limit.",
|
|
is_flag=True,
|
|
show_default=True,
|
|
)
|
|
@click.option(
|
|
"json_version",
|
|
"--json-output-format",
|
|
type=click.Choice(["0.5", "1.1"]),
|
|
default="1.1",
|
|
help="Select the JSON version to be used in the output",
|
|
show_default=True,
|
|
)
|
|
@click.pass_context
|
|
@clean_check_command
|
|
@handle_cmd_exception
|
|
@notify
|
|
def check(
|
|
ctx,
|
|
db,
|
|
full_report,
|
|
stdin,
|
|
files,
|
|
cache,
|
|
ignore,
|
|
ignore_unpinned_requirements,
|
|
output,
|
|
json,
|
|
html,
|
|
bare,
|
|
exit_code,
|
|
policy_file,
|
|
audit_and_monitor,
|
|
project,
|
|
save_json,
|
|
save_html,
|
|
apply_remediations,
|
|
auto_remediation_limit,
|
|
no_prompt,
|
|
json_version,
|
|
):
|
|
"""
|
|
[underline][DEPRECATED][/underline] `check` has been replaced by the `scan` command, and will be unsupported beyond 1 June 2024.Find vulnerabilities at a target file or enviroment.
|
|
"""
|
|
LOG.info("Running check command")
|
|
|
|
non_interactive = (
|
|
not sys.stdout.isatty()
|
|
and os.environ.get("SAFETY_OS_DESCRIPTION", None) != "run"
|
|
)
|
|
silent_outputs = ["json", "bare", "html"]
|
|
is_silent_output = output in silent_outputs
|
|
prompt_mode = (
|
|
bool(not non_interactive and not stdin and not is_silent_output)
|
|
and not no_prompt
|
|
)
|
|
kwargs = {"version": json_version} if output == "json" else {}
|
|
print_deprecation_message("check", date(2024, 6, 1), new_command="scan")
|
|
# try:
|
|
packages = safety_core.get_packages(files, stdin)
|
|
|
|
ignore_severity_rules = None
|
|
ignore, ignore_severity_rules, exit_code, ignore_unpinned_requirements, project = (
|
|
get_processed_options(
|
|
policy_file,
|
|
ignore,
|
|
ignore_severity_rules,
|
|
exit_code,
|
|
ignore_unpinned_requirements,
|
|
project,
|
|
)
|
|
)
|
|
is_env_scan = not stdin and not files
|
|
|
|
params = {
|
|
"stdin": stdin,
|
|
"files": files,
|
|
"policy_file": policy_file,
|
|
"continue_on_error": not exit_code,
|
|
"ignore_severity_rules": ignore_severity_rules,
|
|
"project": project,
|
|
"audit_and_monitor": audit_and_monitor,
|
|
"prompt_mode": prompt_mode,
|
|
"auto_remediation_limit": auto_remediation_limit,
|
|
"apply_remediations": apply_remediations,
|
|
"ignore_unpinned_requirements": ignore_unpinned_requirements,
|
|
}
|
|
|
|
LOG.info("Calling the check function")
|
|
vulns, db_full = safety_core.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,
|
|
params=params,
|
|
)
|
|
LOG.debug("Vulnerabilities returned: %s", vulns)
|
|
LOG.debug("full database returned is None: %s", db_full is None)
|
|
|
|
LOG.info("Safety is going to calculate remediations")
|
|
|
|
remediations = safety_core.calculate_remediations(vulns, db_full)
|
|
|
|
announcements = []
|
|
if not db or is_a_remote_mirror(db):
|
|
LOG.info("Not local DB used, Getting announcements")
|
|
announcements = safety_core.get_announcements(
|
|
ctx.obj.auth.client, telemetry=ctx.obj.config.telemetry_enabled
|
|
)
|
|
|
|
announcements.extend(
|
|
safety_core.add_local_notifications(packages, ignore_unpinned_requirements)
|
|
)
|
|
|
|
LOG.info(
|
|
"Safety is going to render the vulnerabilities report using %s output", output
|
|
)
|
|
|
|
fixes = []
|
|
|
|
if apply_remediations and is_silent_output:
|
|
# it runs and apply only automatic fixes.
|
|
fixes = safety_core.process_fixes(
|
|
files,
|
|
remediations,
|
|
auto_remediation_limit,
|
|
output,
|
|
no_output=True,
|
|
prompt=False,
|
|
)
|
|
|
|
output_report = SafetyFormatter(output, **kwargs).render_vulnerabilities(
|
|
announcements, vulns, remediations, full_report, packages, fixes
|
|
)
|
|
|
|
# Announcements are send to stderr if not terminal, it doesn't depend on "exit_code" value
|
|
stderr_announcements = filter_announcements(
|
|
announcements=announcements, by_type="error"
|
|
)
|
|
if stderr_announcements and non_interactive:
|
|
LOG.info(
|
|
"sys.stdout is not a tty, error announcements are going to be send to stderr"
|
|
)
|
|
click.secho(
|
|
SafetyFormatter(output="text").render_announcements(stderr_announcements),
|
|
fg="red",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
found_vulns = list(filter(lambda v: not v.ignored, vulns))
|
|
LOG.info("Vulnerabilities found (Not ignored): %s", len(found_vulns))
|
|
LOG.info("All vulnerabilities found (ignored and Not ignored): %s", len(vulns))
|
|
|
|
click.secho(output_report, nl=should_add_nl(output, found_vulns), file=sys.stdout)
|
|
|
|
post_processing_report = save_json or audit_and_monitor or apply_remediations
|
|
|
|
if post_processing_report:
|
|
if apply_remediations and not is_silent_output:
|
|
# prompt_mode fixing after main check output if prompt is enabled.
|
|
fixes = safety_core.process_fixes(
|
|
files,
|
|
remediations,
|
|
auto_remediation_limit,
|
|
output,
|
|
no_output=False,
|
|
prompt=prompt_mode,
|
|
)
|
|
|
|
# Render fixes
|
|
json_report = (
|
|
output_report
|
|
if output == "json"
|
|
else SafetyFormatter(
|
|
output="json", version=json_version
|
|
).render_vulnerabilities(
|
|
announcements, vulns, remediations, full_report, packages, fixes
|
|
)
|
|
)
|
|
|
|
safety_core.save_report(save_json, "safety-report.json", json_report)
|
|
|
|
if save_html:
|
|
html_report = (
|
|
output_report
|
|
if output == "html"
|
|
else SafetyFormatter(output="html").render_vulnerabilities(
|
|
announcements, vulns, remediations, full_report, packages, fixes
|
|
)
|
|
)
|
|
|
|
safety_core.save_report(save_html, "safety-report.html", html_report)
|
|
print_deprecation_message("check", date(2024, 6, 1), new_command="scan")
|
|
if exit_code and found_vulns:
|
|
LOG.info("Exiting with default code for vulnerabilities found")
|
|
sys.exit(EXIT_CODE_VULNERABILITIES_FOUND)
|
|
|
|
sys.exit(EXIT_CODE_OK)
|
|
|
|
|
|
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
|
|
kwargs.pop("key", None)
|
|
kwargs.pop("proxy_protocol", None)
|
|
kwargs.pop("proxy_host", None)
|
|
kwargs.pop("proxy_port", None)
|
|
|
|
return f(ctx, *args, **kwargs)
|
|
|
|
return inner
|
|
|
|
|
|
@cli.command(
|
|
cls=SafetyCLILegacyCommand,
|
|
context_settings={CONTEXT_COMMAND_TYPE: CommandType.UTILITY},
|
|
help=CLI_LICENSES_COMMAND_HELP,
|
|
)
|
|
@proxy_options
|
|
@auth_options(stage=False)
|
|
@click.option(
|
|
"--db", default="", help="Path to a local license database. Default: empty"
|
|
)
|
|
@click.option(
|
|
"--output",
|
|
"-o",
|
|
type=click.Choice(["screen", "text", "json", "bare"], case_sensitive=False),
|
|
default="screen",
|
|
)
|
|
@click.option(
|
|
"--cache",
|
|
default=0,
|
|
help="Whether license database file should be cached.Default: 0 seconds",
|
|
)
|
|
@click.option(
|
|
"files",
|
|
"--file",
|
|
"-r",
|
|
multiple=True,
|
|
type=click.File(),
|
|
help="Read input from one (or multiple) requirement files. Default: empty",
|
|
)
|
|
@click.pass_context
|
|
@clean_license_command
|
|
@handle_cmd_exception
|
|
@notify
|
|
def license(ctx, db, output, cache, files):
|
|
"""
|
|
Find the open source licenses used by your Python dependencies.
|
|
"""
|
|
print_deprecation_message("license", date(2024, 6, 1), new_command=None)
|
|
LOG.info("Running license command")
|
|
packages = safety_core.get_packages(files, False)
|
|
licenses_db = {}
|
|
|
|
SafetyContext().params = ctx.params
|
|
|
|
licenses_db = safety_core.get_licenses(
|
|
session=ctx.obj.auth.client,
|
|
db_mirror=db,
|
|
cached=cache,
|
|
telemetry=ctx.obj.config.telemetry_enabled,
|
|
)
|
|
|
|
filtered_packages_licenses = get_packages_licenses(
|
|
packages=packages, licenses_db=licenses_db
|
|
)
|
|
|
|
announcements = []
|
|
if not db:
|
|
announcements = safety_core.get_announcements(
|
|
session=ctx.obj.auth.client, telemetry=ctx.obj.config.telemetry_enabled
|
|
)
|
|
|
|
output_report = SafetyFormatter(output=output).render_licenses(
|
|
announcements, filtered_packages_licenses
|
|
)
|
|
|
|
click.secho(output_report, nl=True)
|
|
print_deprecation_message("license", date(2024, 6, 1), new_command=None)
|
|
|
|
|
|
@cli.command(
|
|
cls=SafetyCLILegacyCommand,
|
|
context_settings={CONTEXT_COMMAND_TYPE: CommandType.UTILITY},
|
|
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
|
|
@handle_cmd_exception
|
|
@notify
|
|
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" 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,
|
|
)
|
|
|
|
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):
|
|
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"
|
|
)
|
|
click.secho(msg, fg="green")
|
|
except Exception as exc:
|
|
if isinstance(exc, OSError):
|
|
LOG.debug("Unable to generate %s because: %s", name, exc.errno)
|
|
|
|
click.secho(f"{str(exc)} error.", fg="red", file=sys.stderr)
|
|
sys.exit(EXIT_CODE_FAILURE)
|
|
|
|
|
|
@cli.command(
|
|
cls=SafetyCLILegacyCommand,
|
|
context_settings={CONTEXT_COMMAND_TYPE: CommandType.UTILITY},
|
|
)
|
|
@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
|
|
@handle_cmd_exception
|
|
@notify
|
|
def validate(ctx, name, version, path):
|
|
"""Verify that a local policy file is valid. NAME is the name of the file type to validate. Valid values are: policy_file"""
|
|
if name != "policy_file":
|
|
click.secho(
|
|
f'This Safety version only supports "policy_file" validation. "{name}" is not supported.',
|
|
fg="red",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(EXIT_CODE_FAILURE)
|
|
|
|
LOG.info("Running validate %s", name)
|
|
|
|
if not os.path.exists(path):
|
|
click.secho(f'The path "{path}" does not exist.', fg="red", file=sys.stderr)
|
|
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,
|
|
)
|
|
sys.exit(EXIT_CODE_FAILURE)
|
|
|
|
def fail_validation(e):
|
|
click.secho(str(e).lstrip(), fg="red", file=sys.stderr)
|
|
sys.exit(EXIT_CODE_FAILURE)
|
|
|
|
if not version:
|
|
version = "3.0"
|
|
|
|
result = ""
|
|
|
|
if version == "3.0":
|
|
policy = None
|
|
|
|
try:
|
|
from .scan.main import load_policy_file
|
|
|
|
policy = load_policy_file(Path(path))
|
|
except Exception as e:
|
|
fail_validation(e)
|
|
|
|
click.secho(
|
|
f"The Safety policy ({version}) file "
|
|
"(Used for scan and system-scan commands) "
|
|
"was successfully parsed "
|
|
"with the following values:",
|
|
fg="green",
|
|
)
|
|
if policy and policy.config:
|
|
result = policy.config.as_v30().json()
|
|
else:
|
|
try:
|
|
values = SafetyPolicyFile().convert(path, None, None)
|
|
except Exception as e:
|
|
click.secho(str(e).lstrip(), fg="red", file=sys.stderr)
|
|
sys.exit(EXIT_CODE_FAILURE)
|
|
|
|
del values["raw"]
|
|
|
|
result = json.dumps(values, indent=4, default=str)
|
|
|
|
click.secho(
|
|
"The Safety policy file "
|
|
"(Valid only for the check command) "
|
|
"was successfully parsed with the "
|
|
"following values:",
|
|
fg="green",
|
|
)
|
|
|
|
console.print_json(result)
|
|
|
|
|
|
@cli.command(
|
|
cls=SafetyCLILegacyCommand,
|
|
help=CLI_CONFIGURE_HELP,
|
|
context_settings={CONTEXT_COMMAND_TYPE: CommandType.UTILITY},
|
|
)
|
|
@click.option(
|
|
"--proxy-protocol",
|
|
"-pr",
|
|
type=click.Choice(["http", "https"]),
|
|
default="https",
|
|
cls=DependentOption,
|
|
required_options=["proxy_host"],
|
|
help=CLI_CONFIGURE_PROXY_PROTOCOL_HELP,
|
|
)
|
|
@click.option(
|
|
"--proxy-host",
|
|
"-ph",
|
|
multiple=False,
|
|
type=str,
|
|
default=None,
|
|
help=CLI_CONFIGURE_PROXY_HOST_HELP,
|
|
)
|
|
@click.option(
|
|
"--proxy-port",
|
|
"-pp",
|
|
multiple=False,
|
|
type=int,
|
|
default=80,
|
|
cls=DependentOption,
|
|
required_options=["proxy_host"],
|
|
help=CLI_CONFIGURE_PROXY_PORT_HELP,
|
|
)
|
|
@click.option(
|
|
"--proxy-timeout",
|
|
"-pt",
|
|
multiple=False,
|
|
type=int,
|
|
default=None,
|
|
help=CLI_CONFIGURE_PROXY_TIMEOUT,
|
|
)
|
|
@click.option("--proxy-required", default=False, help=CLI_CONFIGURE_PROXY_REQUIRED)
|
|
@click.option(
|
|
"--organization-id",
|
|
"-org-id",
|
|
multiple=False,
|
|
default=None,
|
|
cls=DependentOption,
|
|
required_options=["organization_name"],
|
|
help=CLI_CONFIGURE_ORGANIZATION_ID,
|
|
)
|
|
@click.option(
|
|
"--organization-name",
|
|
"-org-name",
|
|
multiple=False,
|
|
default=None,
|
|
cls=DependentOption,
|
|
required_options=["organization_id"],
|
|
help=CLI_CONFIGURE_ORGANIZATION_NAME,
|
|
)
|
|
@click.option(
|
|
"--stage",
|
|
"-stg",
|
|
multiple=False,
|
|
default=Stage.development.value,
|
|
type=click.Choice([stage.value for stage in Stage]),
|
|
help="The project development stage to be tied to the current device.",
|
|
)
|
|
@click.option(
|
|
"--save-to-system/--save-to-user",
|
|
default=False,
|
|
is_flag=True,
|
|
help=CLI_CONFIGURE_SAVE_TO_SYSTEM,
|
|
)
|
|
@click.pass_context
|
|
@handle_cmd_exception
|
|
@notify
|
|
def configure(
|
|
ctx,
|
|
proxy_protocol,
|
|
proxy_host,
|
|
proxy_port,
|
|
proxy_timeout,
|
|
proxy_required,
|
|
organization_id,
|
|
organization_name,
|
|
stage,
|
|
save_to_system,
|
|
):
|
|
"""
|
|
Configure global settings, like proxy settings and organization details
|
|
"""
|
|
|
|
config = configparser.ConfigParser()
|
|
if save_to_system:
|
|
if not CONFIG_FILE_SYSTEM:
|
|
click.secho(
|
|
"Unable to determine the system wide config path. You can set the SAFETY_SYSTEM_CONFIG_PATH env var"
|
|
)
|
|
sys.exit(1)
|
|
|
|
CONFIG_FILE = CONFIG_FILE_SYSTEM
|
|
else:
|
|
CONFIG_FILE = CONFIG_FILE_USER
|
|
|
|
config.read(CONFIG_FILE)
|
|
|
|
PROXY_SECTION_NAME: str = "proxy"
|
|
PROXY_TIMEOUT_KEY: str = "timeout"
|
|
PROXY_REQUIRED_KEY: str = "required"
|
|
|
|
if organization_id:
|
|
config["organization"] = asdict(
|
|
Organization(id=organization_id, name=organization_name)
|
|
)
|
|
|
|
DEFAULT_PROXY_TIMEOUT: int = 500
|
|
|
|
if not proxy_timeout:
|
|
try:
|
|
proxy_timeout = int(config["proxy"]["timeout"])
|
|
except Exception:
|
|
proxy_timeout = DEFAULT_PROXY_TIMEOUT
|
|
|
|
new_proxy_config = {}
|
|
new_proxy_config.setdefault(PROXY_TIMEOUT_KEY, str(proxy_timeout))
|
|
new_proxy_config.setdefault(PROXY_REQUIRED_KEY, str(proxy_required))
|
|
|
|
if proxy_host:
|
|
new_proxy_config.update(
|
|
{"protocol": proxy_protocol, "host": proxy_host, "port": str(proxy_port)}
|
|
)
|
|
|
|
if not config.has_section(PROXY_SECTION_NAME):
|
|
config.add_section(PROXY_SECTION_NAME)
|
|
|
|
proxy_config = dict(config.items(PROXY_SECTION_NAME))
|
|
proxy_config.update(new_proxy_config)
|
|
|
|
for key, value in proxy_config.items():
|
|
config.set(PROXY_SECTION_NAME, key, value)
|
|
|
|
if stage:
|
|
config["host"] = {"stage": "development" if stage == "dev" else stage}
|
|
|
|
try:
|
|
with open(CONFIG_FILE, "w") as configfile:
|
|
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"
|
|
)
|
|
else:
|
|
click.secho(f"Unable to save the configuration, error: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
cli_app = typer.Typer(rich_markup_mode="rich", cls=SafetyCLISubGroup)
|
|
typer.rich_utils.STYLE_HELPTEXT = ""
|
|
|
|
|
|
def print_check_updates_header(console):
|
|
VERSION = get_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=SafetyCLICommand,
|
|
help=CLI_CHECK_UPDATES_HELP,
|
|
name="check-updates",
|
|
epilog=DEFAULT_EPILOG,
|
|
context_settings={
|
|
"allow_extra_args": True,
|
|
"ignore_unknown_options": True,
|
|
CONTEXT_COMMAND_TYPE: CommandType.UTILITY,
|
|
},
|
|
)
|
|
@handle_cmd_exception
|
|
@notify
|
|
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,
|
|
):
|
|
"""
|
|
Check for Safety CLI version updates
|
|
"""
|
|
|
|
if output is Output.JSON:
|
|
console.quiet = True
|
|
|
|
print_check_updates_header(console)
|
|
|
|
wait_msg = "Authenticating and checking for Safety CLI updates"
|
|
|
|
VERSION = get_version()
|
|
PYTHON_VERSION = platform.python_version()
|
|
OS_TYPE = platform.system()
|
|
|
|
authenticated = ctx.obj.auth.client.is_using_auth_credentials()
|
|
data = None
|
|
|
|
console.print()
|
|
with console.status(wait_msg, spinner=DEFAULT_SPINNER):
|
|
try:
|
|
data = ctx.obj.auth.client.check_updates(
|
|
version=1,
|
|
safety_version=VERSION,
|
|
python_version=PYTHON_VERSION,
|
|
os_type=OS_TYPE,
|
|
os_release=platform.release(),
|
|
os_description=platform.platform(),
|
|
)
|
|
except InvalidCredentialError:
|
|
authenticated = False
|
|
except Exception as e:
|
|
LOG.exception(f"Failed to check updates, reason: {e}")
|
|
raise e
|
|
|
|
if not authenticated:
|
|
if console.quiet:
|
|
console.quiet = False
|
|
response = {
|
|
"status": 401,
|
|
"message": "Authenticated failed, please authenticate Safety and try again",
|
|
"data": {},
|
|
}
|
|
console.print_json(json.dumps(response))
|
|
else:
|
|
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`"
|
|
)
|
|
sys.exit(1)
|
|
|
|
if not data:
|
|
raise SafetyException("No data found.")
|
|
|
|
console.print("[green]Safety CLI is authenticated:[/green]")
|
|
|
|
from rich.padding import Padding
|
|
|
|
organization = data.get("organization", "-")
|
|
account = data.get("user_email", "-")
|
|
current_version = (
|
|
f"Current version: {VERSION} (Python {PYTHON_VERSION} on {OS_TYPE})"
|
|
)
|
|
latest_available_version = data.get("safety_updates", {}).get("stable_version", "-")
|
|
|
|
details = [
|
|
f"Organization: {organization}",
|
|
f"Account: {account}",
|
|
current_version,
|
|
f"Latest stable available version: {latest_available_version}",
|
|
]
|
|
|
|
for msg in details:
|
|
console.print(Padding(msg, (0, 0, 0, 1)), emoji=True)
|
|
|
|
console.print()
|
|
|
|
if latest_available_version:
|
|
try:
|
|
# Compare the current version and the latest available version using packaging.version
|
|
if packaging_version.parse(
|
|
latest_available_version
|
|
) > packaging_version.parse(VERSION):
|
|
console.print(
|
|
f"Update available: Safety version {latest_available_version}"
|
|
)
|
|
console.print()
|
|
console.print(
|
|
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}"
|
|
)
|
|
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}"
|
|
)
|
|
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("Please report this issue or try again later.")
|
|
|
|
if console.quiet:
|
|
console.quiet = False
|
|
response = {"status": 200, "message": "", "data": data}
|
|
console.print_json(json.dumps(response))
|
|
|
|
|
|
cli.add_command(typer.main.get_command(cli_app), name="check-updates")
|
|
cli.add_command(typer.main.get_command(init_app), name="init")
|
|
cli.add_command(typer.main.get_command(scan_project_app), name="scan")
|
|
cli.add_command(typer.main.get_command(scan_system_app), name="system-scan")
|
|
cli.add_command(typer.main.get_command(codebase_app), name="codebase")
|
|
|
|
tool_commands.auto_register_tools(group=cli)
|
|
|
|
cli.add_command(typer.main.get_command(auth_app), name="auth")
|
|
cli.add_command(typer.main.get_command(firewall_app), name="firewall")
|
|
|
|
cli.add_command(alert)
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|