├── safety ├── cli_utils.py ├── init │ ├── __init__.py │ ├── models.py │ └── types.py ├── firewall │ ├── __init__.py │ ├── events │ │ ├── __init__.py │ │ ├── handlers.py │ │ └── utils.py │ └── constants.py ├── tool │ ├── uv │ │ ├── __init__.py │ │ └── list_pkgs.py │ ├── pip │ │ └── __init__.py │ ├── npm │ │ └── __init__.py │ ├── poetry │ │ ├── __init__.py │ │ ├── constants.py │ │ └── main.py │ ├── interceptors │ │ ├── types.py │ │ ├── __init__.py │ │ └── factory.py │ ├── __init__.py │ ├── intents.py │ ├── auth.py │ ├── resolver.py │ ├── decorators.py │ ├── typosquatting.py │ └── definitions.py ├── formatters │ ├── __init__.py │ ├── schemas │ │ ├── __init__.py │ │ └── zero_five.py │ ├── html.py │ └── bare.py ├── scan │ ├── fun_mode │ │ └── __init__.py │ ├── ecosystems │ │ ├── __init__.py │ │ ├── python │ │ │ └── __init__.py │ │ ├── base.py │ │ └── target.py │ ├── finder │ │ └── __init__.py │ ├── __init__.py │ └── validators.py ├── .DS_Store ├── templates │ └── .DS_Store ├── __init__.py ├── platform │ └── __init__.py ├── events │ ├── event_bus │ │ ├── __init__.py │ │ └── utils.py │ ├── handlers │ │ ├── __init__.py │ │ └── base.py │ ├── __init__.py │ ├── types │ │ ├── base.py │ │ ├── __init__.py │ │ └── aliases.py │ └── utils │ │ ├── __init__.py │ │ ├── creation.py │ │ └── conditions.py ├── __main__.py ├── config │ ├── main.py │ ├── __init__.py │ └── log_codes.py ├── models │ ├── tools.py │ ├── requirements.py │ ├── __init__.py │ └── obj.py ├── auth │ ├── __init__.py │ ├── constants.py │ ├── oauth2.py │ └── models.py ├── encoding.py ├── codebase │ ├── render.py │ └── constants.py ├── utils │ ├── tls.py │ ├── tokens.py │ ├── auth_session.py │ └── pyapp_utils.py ├── decorators.py ├── logs_helpers.py ├── alerts │ └── templates │ │ ├── pr.jinja2 │ │ └── issue.jinja2 ├── meta.py ├── error_handlers.py ├── codebase_utils.py └── asyncio_patch.py ├── tests ├── utils │ └── __init__.py ├── formatters │ ├── __init__.py │ ├── test_bare.py │ ├── test_screen.py │ └── test_text.py ├── platform │ ├── __init__.py │ └── test_http_utils.py ├── reqs_4.txt ├── tool │ ├── __init__.py │ ├── uv │ │ ├── __init__.py │ │ └── test_uv_command.py │ ├── poetry │ │ ├── __init__.py │ │ └── test_poetry_command.py │ ├── test_installation_commands.py │ ├── interceptors │ │ └── test_factory.py │ └── base.py ├── events │ ├── __init__.py │ └── event_bus │ │ ├── __init__.py │ │ └── test_utils.py ├── firewall │ ├── __init__.py │ └── events │ │ ├── __init__.py │ │ └── test_utils.py ├── reqs_1.txt ├── reqs_pinned.txt ├── reqs_pinned_affected.txt ├── reqs_unpinned.txt ├── scan │ ├── ecosystems │ │ └── python │ │ │ └── __init__.py │ ├── test_file_handlers.py │ ├── test_command.py │ └── test_file_finder.py ├── __init__.py ├── reqs │ └── requirements.txt ├── reqs_3.txt ├── action │ ├── requirements.txt-secure │ ├── requirements.txt-insecure │ ├── docker-insecure │ │ └── Dockerfile │ ├── docker-secure │ │ └── Dockerfile │ ├── pyproject.toml-insecure │ ├── pyproject.toml-secure │ ├── poetry.lock-secure │ ├── poetry.lock-insecure │ ├── Pipfile.lock-secure │ └── Pipfile.lock-insecure ├── auth │ ├── test_assets │ │ ├── config_no_id.ini │ │ ├── config.ini │ │ └── config_empty.ini │ ├── test_cli.py │ └── test_auth_main.py ├── test_fix │ └── basic │ │ └── reqs_simple.txt ├── reqs_2.txt ├── reqs_pinned_and_unpinned.txt ├── test-safety-project.ini ├── test_db │ ├── licenses.json │ ├── insecure.json │ ├── insecure_full.json │ └── report_invalid_decode_error.json ├── .policy_with_ignores.yml ├── .policy_full.yml ├── test_models.py ├── test_policy_file │ ├── v3_0 │ │ ├── default_policy_file.yml │ │ └── default_policy_file_using_invalid_keyword.yml │ ├── default_policy_file.yml │ ├── default_policy_file_using_invalid_typo_keyword.yml │ └── default_policy_file_using_invalid_keyword.yml ├── conftest.py └── test_encoding.py ├── safety.jpg ├── .safety-project.ini ├── docs ├── images │ ├── api_key_sign_up.png │ ├── api_key_subscribe.png │ ├── api_key_account_page.png │ ├── api_key_authorization.png │ ├── api_key_permissions.png │ ├── api_key_subscriptions_page.png │ └── api_key_account_page_with_key.png ├── .ipynb_checkpoints │ └── Safety-CLI-Quickstart-checkpoint.ipynb └── api_key.md ├── entrypoint.sh ├── .github ├── scripts │ ├── build_binary.py │ ├── ci_pyproject.py │ ├── should_build.py │ └── smoke_test_binary.sh ├── workflows │ ├── pr.yml │ ├── issue_responder.yml │ ├── build.yml │ ├── cd.yml │ └── bump.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.yml │ └── bug_report.yml └── PULL_REQUEST_TEMPLATE.md ├── scripts ├── extract_version.py └── generate_contributors.py ├── Dockerfile ├── .vscode ├── tasks.json └── settings.json ├── RELEASE.md ├── LICENSES ├── MIT.txt └── NOTICE.md ├── .gitignore ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── SECURITY.md ├── release.sh ├── refresh_notice.py └── action.yml /safety/cli_utils.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /safety/init/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /safety/firewall/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /safety/tool/uv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/platform/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/reqs_4.txt: -------------------------------------------------------------------------------- 1 | django==1.11 -------------------------------------------------------------------------------- /tests/tool/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/tool/uv/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /safety/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /safety/scan/fun_mode/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/events/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/firewall/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/formatters/test_bare.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/formatters/test_screen.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/formatters/test_text.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /safety/formatters/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /safety/scan/ecosystems/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/firewall/events/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/reqs_1.txt: -------------------------------------------------------------------------------- 1 | -r reqs_2.txt 2 | -------------------------------------------------------------------------------- /tests/reqs_pinned.txt: -------------------------------------------------------------------------------- 1 | django==4.1.3 -------------------------------------------------------------------------------- /tests/tool/poetry/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/events/event_bus/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/reqs_pinned_affected.txt: -------------------------------------------------------------------------------- 1 | django==1.6 -------------------------------------------------------------------------------- /tests/reqs_unpinned.txt: -------------------------------------------------------------------------------- 1 | django 2 | numpy -------------------------------------------------------------------------------- /tests/scan/ecosystems/python/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /safety/scan/ecosystems/python/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /tests/reqs/requirements.txt: -------------------------------------------------------------------------------- 1 | secure-package==1.0.0 -------------------------------------------------------------------------------- /tests/reqs_3.txt: -------------------------------------------------------------------------------- 1 | insecure-package==0.1.1 2 | -------------------------------------------------------------------------------- /tests/action/requirements.txt-secure: -------------------------------------------------------------------------------- 1 | secure-package==0.1.0 2 | -------------------------------------------------------------------------------- /tests/action/requirements.txt-insecure: -------------------------------------------------------------------------------- 1 | insecure-package==0.1 2 | -------------------------------------------------------------------------------- /safety.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/safety.jpg -------------------------------------------------------------------------------- /safety/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/safety/.DS_Store -------------------------------------------------------------------------------- /safety/tool/pip/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import Pip 2 | 3 | __all__ = ["Pip"] 4 | -------------------------------------------------------------------------------- /tests/auth/test_assets/config_no_id.ini: -------------------------------------------------------------------------------- 1 | [organization] 2 | name = "Safety CLI Org" -------------------------------------------------------------------------------- /tests/test_fix/basic/reqs_simple.txt: -------------------------------------------------------------------------------- 1 | django==1.8 2 | safety==2.3.0 3 | flask==0.87.0 -------------------------------------------------------------------------------- /tests/reqs_2.txt: -------------------------------------------------------------------------------- 1 | insecure-package==0.1.0 2 | --requirement reqs_3.txt # with comment 3 | -------------------------------------------------------------------------------- /safety/tool/npm/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import Npm 2 | 3 | __all__ = [ 4 | "Npm", 5 | ] 6 | -------------------------------------------------------------------------------- /safety/templates/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/safety/templates/.DS_Store -------------------------------------------------------------------------------- /tests/auth/test_assets/config.ini: -------------------------------------------------------------------------------- 1 | [organization] 2 | id = "org_id23423ds" 3 | name = "Safety CLI Org" -------------------------------------------------------------------------------- /tests/reqs_pinned_and_unpinned.txt: -------------------------------------------------------------------------------- 1 | -r reqs_pinned.txt 2 | packaging==21.3 3 | pipenv 4 | flower==1.2.0 -------------------------------------------------------------------------------- /safety/tool/poetry/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import Poetry 2 | 3 | __all__ = [ 4 | "Poetry", 5 | ] 6 | -------------------------------------------------------------------------------- /tests/auth/test_assets/config_empty.ini: -------------------------------------------------------------------------------- 1 | [organization] 2 | id = "org_id23423ds" 3 | name = "Safety CLI Org" -------------------------------------------------------------------------------- /.safety-project.ini: -------------------------------------------------------------------------------- 1 | [project] 2 | id = safety 3 | url = /codebases/safety/findings 4 | name = safety 5 | 6 | -------------------------------------------------------------------------------- /docs/images/api_key_sign_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/docs/images/api_key_sign_up.png -------------------------------------------------------------------------------- /docs/images/api_key_subscribe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/docs/images/api_key_subscribe.png -------------------------------------------------------------------------------- /docs/images/api_key_account_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/docs/images/api_key_account_page.png -------------------------------------------------------------------------------- /docs/images/api_key_authorization.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/docs/images/api_key_authorization.png -------------------------------------------------------------------------------- /docs/images/api_key_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/docs/images/api_key_permissions.png -------------------------------------------------------------------------------- /tests/action/docker-insecure/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | RUN python3 -m pip install insecure-package==0.1 4 | -------------------------------------------------------------------------------- /tests/action/docker-secure/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | RUN python3 -m pip install secure-package==0.1.0 4 | -------------------------------------------------------------------------------- /safety/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = """safetycli.com""" 4 | __email__ = 'cli@safetycli.com' 5 | -------------------------------------------------------------------------------- /docs/images/api_key_subscriptions_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/docs/images/api_key_subscriptions_page.png -------------------------------------------------------------------------------- /safety/firewall/events/__init__.py: -------------------------------------------------------------------------------- 1 | from .utils import register_event_handlers 2 | 3 | 4 | __all__ = ["register_event_handlers"] 5 | -------------------------------------------------------------------------------- /safety/platform/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import SafetyPlatformClient 2 | 3 | __all__ = [ 4 | "SafetyPlatformClient", 5 | ] 6 | -------------------------------------------------------------------------------- /docs/images/api_key_account_page_with_key.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyupio/safety/HEAD/docs/images/api_key_account_page_with_key.png -------------------------------------------------------------------------------- /tests/test-safety-project.ini: -------------------------------------------------------------------------------- 1 | [project] 2 | id = safety 3 | url = /projects/e008f386-0a5e-4967-b8b9-079239d5f93c/findings 4 | name = safety 5 | -------------------------------------------------------------------------------- /safety/tool/interceptors/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class InterceptorType(Enum): 5 | UNIX_ALIAS = auto() 6 | WINDOWS_BAT = auto() 7 | -------------------------------------------------------------------------------- /safety/events/event_bus/__init__.py: -------------------------------------------------------------------------------- 1 | from .bus import EventBus 2 | from .utils import start_event_bus 3 | 4 | __all__ = [ 5 | "EventBus", 6 | "start_event_bus", 7 | ] 8 | -------------------------------------------------------------------------------- /safety/events/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import EventHandler 2 | from .common import SecurityEventsHandler 3 | 4 | 5 | __all__ = ["EventHandler", "SecurityEventsHandler"] 6 | -------------------------------------------------------------------------------- /safety/tool/interceptors/__init__.py: -------------------------------------------------------------------------------- 1 | from .types import InterceptorType 2 | from .factory import create_interceptor 3 | 4 | __all__ = ["InterceptorType", "create_interceptor"] 5 | -------------------------------------------------------------------------------- /safety/scan/finder/__init__.py: -------------------------------------------------------------------------------- 1 | from .file_finder import FileFinder 2 | from .handlers import PythonFileHandler 3 | 4 | __all__ = [ 5 | "FileFinder", 6 | "PythonFileHandler" 7 | ] -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu -o pipefail 3 | 4 | export SAFETY_OS_TYPE="docker" 5 | export SAFETY_OS_RELEASE="" 6 | export SAFETY_OS_DESCRIPTION="run" 7 | 8 | exec python -m safety $@ 9 | -------------------------------------------------------------------------------- /.github/scripts/build_binary.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.11" 3 | # dependencies = [ 4 | # "pyinstaller==6.11.1" 5 | # ] 6 | # /// 7 | 8 | # universal2 9 | # windows64 10 | # linux64 -------------------------------------------------------------------------------- /safety/__main__.py: -------------------------------------------------------------------------------- 1 | """Allow safety to be executable through `python -m safety`.""" 2 | from safety.cli import cli 3 | 4 | 5 | if __name__ == "__main__": # pragma: no cover 6 | cli(prog_name="safety") 7 | -------------------------------------------------------------------------------- /safety/config/main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from safety.constants import USER_CONFIG_DIR 4 | 5 | AUTH_CONFIG_FILE_NAME = "auth.ini" 6 | AUTH_CONFIG_USER = USER_CONFIG_DIR / Path(AUTH_CONFIG_FILE_NAME) 7 | -------------------------------------------------------------------------------- /safety/init/models.py: -------------------------------------------------------------------------------- 1 | from safety_schemas.models.events.payloads import InitExitStep 2 | 3 | 4 | class StepTracker: 5 | def __init__(self): 6 | self.current_step: InitExitStep = InitExitStep.UNKNOWN 7 | -------------------------------------------------------------------------------- /safety/models/tools.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from subprocess import CompletedProcess 3 | 4 | 5 | @dataclass 6 | class ToolResult: 7 | process: CompletedProcess 8 | duration_ms: int 9 | tool_path: str 10 | -------------------------------------------------------------------------------- /safety/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli_utils import auth_options, proxy_options, configure_auth_session 2 | from .cli import auth 3 | 4 | 5 | __all__ = [ 6 | "proxy_options", 7 | "auth_options", 8 | "configure_auth_session", 9 | "auth", 10 | ] 11 | -------------------------------------------------------------------------------- /safety/scan/__init__.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | from safety_schemas.models import Vulnerability, RemediationModel 4 | from safety.scan.render import get_render_console 5 | console = Console() 6 | 7 | Vulnerability.__render__ = get_render_console(Vulnerability) 8 | -------------------------------------------------------------------------------- /docs/.ipynb_checkpoints/Safety-CLI-Quickstart-checkpoint.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "metadata": {}, 5 | "cell_type": "raw", 6 | "source": "", 7 | "id": "e4a30302820cf149" 8 | } 9 | ], 10 | "metadata": {}, 11 | "nbformat": 4, 12 | "nbformat_minor": 5 13 | } 14 | -------------------------------------------------------------------------------- /tests/test_db/licenses.json: -------------------------------------------------------------------------------- 1 | { 2 | "licenses": { 3 | "BSD-3-Clause": 1 4 | }, 5 | "packages": { 6 | "django": [ 7 | { 8 | "start_version": "0.0", 9 | "license_id": 1 10 | } 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /safety/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import AuthConfig 2 | from .proxy import get_proxy_config 3 | from .tls import get_tls_config 4 | from .main import AUTH_CONFIG_USER 5 | 6 | __all__ = [ 7 | "AuthConfig", 8 | "get_proxy_config", 9 | "get_tls_config", 10 | "AUTH_CONFIG_USER", 11 | ] 12 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | branches: [main] 7 | 8 | jobs: 9 | build-preview: 10 | uses: ./.github/workflows/reusable-build.yml 11 | with: 12 | bump-command: "local-bump" 13 | branch-name: ${{ github.head_ref }} -------------------------------------------------------------------------------- /safety/tool/__init__.py: -------------------------------------------------------------------------------- 1 | from .tool_inspector import ToolInspector 2 | from .factory import tool_commands 3 | from .main import configure_system, configure_alias 4 | from .base import ToolResult 5 | 6 | 7 | __all__ = [ 8 | "ToolInspector", 9 | "tool_commands", 10 | "configure_system", 11 | "configure_alias", 12 | "ToolResult", 13 | ] 14 | -------------------------------------------------------------------------------- /safety/tool/poetry/constants.py: -------------------------------------------------------------------------------- 1 | MSG_SAFETY_SOURCE_NOT_ADDED = "\nError: Safety Firewall could not be added as a source in your pyproject.toml file. You will not be protected from malicious or insecure packages. Please run `safety init` to fix this." 2 | MSG_SAFETY_SOURCE_ADDED = ( 3 | "\nSafety Firewall has been added as a source to protect this codebase" 4 | ) 5 | -------------------------------------------------------------------------------- /tests/.policy_with_ignores.yml: -------------------------------------------------------------------------------- 1 | security: 2 | ignore-vulnerabilities: 3 | 44423: 4 | reason: we don't use the vulnerable function 5 | expires: '2022-01-21' 6 | 44741: 7 | reason: This is a low severity vuln, we can ignore 8 | expires: '2022-02-22 14:00:00' 9 | 35797: 10 | 23231: 11 | -------------------------------------------------------------------------------- /tests/action/pyproject.toml-insecure: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "insecure" 3 | version = "0.1.0" 4 | description = "" 5 | authors = [] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.10" 9 | insecure-package = "^0.1.0" 10 | 11 | [tool.poetry.dev-dependencies] 12 | 13 | [build-system] 14 | requires = ["setuptools>=42"] 15 | build-backend = "setuptools.build_meta" 16 | -------------------------------------------------------------------------------- /safety/init/types.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Dict, Optional, Union 2 | 3 | if TYPE_CHECKING: 4 | from safety_schemas.models.events.types import ToolType 5 | from safety_schemas.models.events.payloads import ( 6 | AliasConfig, 7 | IndexConfig, 8 | ) 9 | 10 | 11 | FirewallConfigStatus = Dict[ 12 | ToolType, Dict[str, Optional[Union[AliasConfig, IndexConfig]]] 13 | ] 14 | -------------------------------------------------------------------------------- /tests/test_db/insecure.json: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "advisory": "PyUp.io metadata", 4 | "timestamp": 1675459691, 5 | "last_updated": "2023-02-03 21:28:11", 6 | "base_domain": "https://pyup.io", 7 | "schema_version": "2.0.0", 8 | "attribution": "Licensed under CC-BY-4.0 by pyup.io" 9 | }, 10 | "vulnerable_packages": { 11 | "django": [ 12 | "<1.9", 13 | "<1.8.3" 14 | ] 15 | } 16 | } -------------------------------------------------------------------------------- /tests/action/pyproject.toml-secure: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "poetry-demo" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Yeison Vargas "] 6 | readme = "README.md" 7 | packages = [{include = "poetry_demo"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | secure-package = "0.1.0" 12 | 13 | 14 | [build-system] 15 | requires = ["poetry-core"] 16 | build-backend = "poetry.core.masonry.api" 17 | -------------------------------------------------------------------------------- /tests/.policy_full.yml: -------------------------------------------------------------------------------- 1 | 2 | security: 3 | ignore-cvss-severity-below: 7 4 | ignore-cvss-unknown-severity: False 5 | ignore-vulnerabilities: 6 | 44423: 7 | reason: we don't use the vulnerable function 8 | expires: '2022-01-21' 9 | 44741: 10 | reason: This is a low severity vuln, we can ignore 11 | expires: '2022-02-22 14:00:00' 12 | 35797: 13 | continue-on-vulnerability-error: False 14 | -------------------------------------------------------------------------------- /scripts/extract_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | from packaging.version import Version 3 | 4 | raw_version = os.environ.get('SAFETY_VERSION', None) 5 | if not raw_version: 6 | raise ValueError("Missing SAFETY_VERSION environment variable") 7 | 8 | v = Version(raw_version) 9 | major, minor = v.major, v.minor 10 | 11 | with open(os.getenv('GITHUB_ENV'), "a") as env: 12 | print(f"SAFETY_MAJOR_VERSION={major}", file=env) 13 | print(f"SAFETY_MINOR_VERSION={minor}", file=env) 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | ARG SAFETY_VERSION 3 | 4 | # Don't use WORKDIR here as per Github's docs 5 | RUN mkdir /app 6 | 7 | RUN apt-get update && apt-get -y install docker.io jq git && apt-get clean && rm -rf /var/lib/apt/lists/* 8 | 9 | COPY entrypoint.sh /app/entrypoint.sh 10 | 11 | RUN cd /app && python3 -m pip install safety==$SAFETY_VERSION 12 | 13 | ENV LC_ALL=C.UTF-8 14 | ENV LANG=C.UTF-8 15 | ENV PYTHONPATH="/app" 16 | 17 | ENTRYPOINT ["/app/entrypoint.sh"] 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 📖 Safety CLI Documentation 4 | url: https://docs.safetycli.com/safety-docs 5 | about: Check the Safety CLI documentation for in-depth overview of all the available commands and options. 6 | - name: 💻 Take Our Survey! 7 | url: https://form.typeform.com/to/ttlLdSaM 8 | about: We're on a mission to make Safety CLI the best it can be, and we need YOUR help. We've put together a brief survey to understand how you use Safety CLI, what you love about it, and where you think we can do better. 9 | -------------------------------------------------------------------------------- /safety/models/requirements.py: -------------------------------------------------------------------------------- 1 | from packaging.specifiers import SpecifierSet 2 | 3 | 4 | def is_pinned_requirement(spec: SpecifierSet) -> bool: 5 | """ 6 | Check if a requirement is pinned. 7 | 8 | Args: 9 | spec (SpecifierSet): The specifier set of the requirement. 10 | 11 | Returns: 12 | bool: True if the requirement is pinned, False otherwise. 13 | """ 14 | if not spec or len(spec) != 1: 15 | return False 16 | 17 | specifier = next(iter(spec)) 18 | 19 | return (specifier.operator == '==' and '*' != specifier.version[-1]) or specifier.operator == '===' 20 | -------------------------------------------------------------------------------- /safety/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .obj import SafetyCLI 2 | from .requirements import is_pinned_requirement 3 | from .vulnerabilities import ( 4 | Vulnerability, 5 | CVE, 6 | Severity, 7 | Fix, 8 | SafetyRequirement, 9 | Package, 10 | SafetyEncoder, 11 | RequirementFile, 12 | ) 13 | from .tools import ToolResult 14 | 15 | __all__ = [ 16 | "Package", 17 | "SafetyCLI", 18 | "Vulnerability", 19 | "CVE", 20 | "Severity", 21 | "Fix", 22 | "is_pinned_requirement", 23 | "SafetyRequirement", 24 | "SafetyEncoder", 25 | "RequirementFile", 26 | "ToolResult", 27 | ] 28 | -------------------------------------------------------------------------------- /safety/config/log_codes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Log codes for configuration-related operations. 3 | """ 4 | 5 | CONFIG = "config" 6 | 7 | # Proxy Configuration 8 | PROXY = f"{CONFIG}.proxy" 9 | PROXY_RESOLVED = f"{PROXY}.resolved" 10 | PROXY_NOT_DEFINED = f"{PROXY}.not_defined" 11 | PROXY_HOST_EMPTY = f"{PROXY}.host_empty" 12 | PROXY_PROTOCOL_INVALID = f"{PROXY}.invalid_protocol" 13 | PROXY_CONFIG_MISSING_SECTION = f"{PROXY}.missing_section" 14 | 15 | # TLS Configuration 16 | TLS = f"{CONFIG}.tls" 17 | TLS_RESOLVED = f"{TLS}.resolved" 18 | TLS_RESOLUTION_FALLBACK_DEFAULT = f"{TLS}.resolution_fallback_default" 19 | TLS_CA_BUNDLE_RESOLVED = f"{TLS}.ca_bundle_resolved" 20 | -------------------------------------------------------------------------------- /tests/action/poetry.lock-secure: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "secure-package" 3 | version = "0.1.0" 4 | description = "Secure package, don't use it." 5 | category = "main" 6 | optional = false 7 | python-versions = ">=3.6" 8 | 9 | [metadata] 10 | lock-version = "1.1" 11 | python-versions = "^3.10" 12 | content-hash = "a7a19d1b76edc5b4e84dff4e17cf4b3580d5ae3ad27e9f5afaafb7a1aef2c1e4" 13 | 14 | [metadata.files] 15 | secure-package = [ 16 | {file = "secure_package-0.1.0-py2.py3-none-any.whl", hash = "sha256:35b8b4b1658f0207012877c83b04ef3a64b828e07b125f0ada8117c45a23d171"}, 17 | {file = "secure_package-0.1.0.tar.gz", hash = "sha256:3d973c0c48835ec3174b6aa0ab5649e67eb80874b3451d572ff2a7feb2b14020"}, 18 | ] 19 | -------------------------------------------------------------------------------- /tests/action/poetry.lock-insecure: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "insecure-package" 3 | version = "0.1.0" 4 | description = "Insecure Package, don't use it" 5 | category = "main" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [metadata] 10 | lock-version = "1.1" 11 | python-versions = "^3.10" 12 | content-hash = "27e25e27372dfd4e6e2068573a1e1d6fe00885d4f91a3a363e7ac29b24ebefd3" 13 | 14 | [metadata.files] 15 | insecure-package = [ 16 | {file = "insecure-package-0.1.0.tar.gz", hash = "sha256:4da7b05a052f19841c1efbd4cc8edea9a78aaaa295360923cb2b052b547b7877"}, 17 | {file = "insecure_package-0.1.0-py2.py3-none-any.whl", hash = "sha256:4d9485a53c970c892f887e10fd99e97b82848ac9010e79bc715585e1a6a74422"}, 18 | ] 19 | -------------------------------------------------------------------------------- /safety/events/__init__.py: -------------------------------------------------------------------------------- 1 | from .handlers import EventHandler 2 | from .types import ( 3 | CloseResourcesEvent, 4 | CommandErrorEvent, 5 | CommandExecutedEvent, 6 | FirewallConfiguredEvent, 7 | FirewallDisabledEvent, 8 | FirewallHeartbeatEvent, 9 | FlushSecurityTracesEvent, 10 | PackageInstalledEvent, 11 | PackageUninstalledEvent, 12 | ) 13 | 14 | __all__ = [ 15 | "EventHandler", 16 | "CloseResourcesEvent", 17 | "FlushSecurityTracesEvent", 18 | "CommandExecutedEvent", 19 | "CommandErrorEvent", 20 | "PackageInstalledEvent", 21 | "PackageUninstalledEvent", 22 | "FirewallHeartbeatEvent", 23 | "FirewallConfiguredEvent", 24 | "FirewallDisabledEvent", 25 | ] 26 | -------------------------------------------------------------------------------- /safety/events/types/base.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Any, Optional 2 | from typing_extensions import Annotated 3 | from pydantic import ConfigDict 4 | from safety_schemas.models.events import EventTypeBase, PayloadBase 5 | 6 | 7 | if TYPE_CHECKING: 8 | pass 9 | 10 | 11 | class InternalEventType(EventTypeBase): 12 | """ 13 | Internal event types. 14 | """ 15 | 16 | CLOSE_RESOURCES = "com.safetycli.close_resources" 17 | FLUSH_SECURITY_TRACES = "com.safetycli.flush_security_traces" 18 | EVENT_BUS_READY = "com.safetycli.event_bus_ready" 19 | 20 | 21 | class InternalPayload(PayloadBase): 22 | ctx: Optional[Annotated[Any, "CustomContext"]] = None 23 | 24 | model_config = ConfigDict(extra="allow") 25 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Setup Safety Development Environment", 6 | "type": "shell", 7 | "command": "bash", 8 | "args": [ 9 | "-c", 10 | "mkdir -p ~/.safety && [ -f ~/.safety/config.ini ] && cp ~/.safety/config.ini ~/.safety/config.ini.backup 2>/dev/null || true; cp ${workspaceFolder}/local_config.ini ~/.safety/config.ini 2>/dev/null && echo 'Development config installed successfully!' || echo 'Failed to install development config'" 11 | ], 12 | "presentation": { 13 | "reveal": "silent", 14 | "close": true, 15 | "revealProblems": "onProblem", 16 | "panel": "shared" 17 | }, 18 | "problemMatcher": [] 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import packaging 4 | 5 | from safety.errors import InvalidRequirementError 6 | from safety.models import SafetyRequirement 7 | 8 | 9 | class TestSafetyRequirement(unittest.TestCase): 10 | 11 | @unittest.skipIf(tuple(map(int, packaging.__version__.split("."))) < (22, 0), 12 | "not validated in these versions") 13 | def test_with_invalid_input(self): 14 | invalid_inputs = [ 15 | 'django*', 16 | 'django>=python>=3.6', 17 | 'numpy>=3.3python>=3.6', 18 | '', 19 | '\n' 20 | ] 21 | 22 | for i_input in invalid_inputs: 23 | with self.assertRaises(InvalidRequirementError): 24 | SafetyRequirement(i_input) 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestEnabled": true, 3 | "python.testing.unittestEnabled": false, 4 | // This uses the default environment which is a virtual environment 5 | // created by Hatch 6 | "python.testing.pytestPath": "${workspaceFolder}/.venv/bin/pytest", 7 | "python.testing.autoTestDiscoverOnSaveEnabled": true, 8 | "python.testing.pytestArgs": [ 9 | "-v", 10 | "-s", 11 | // Disable assertion rewriting, to prevent conflicts with 12 | // debugpy 13 | "--assert=plain", 14 | ], 15 | "cSpell.words": [ 16 | "Authlib", 17 | "dparse", 18 | "filelock", 19 | "psutil", 20 | "pydantic", 21 | "pytest", 22 | "ruamel", 23 | "setuptools", 24 | "typer" 25 | ], 26 | "python.defaultInterpreterPath": ".venv/bin/python" 27 | } -------------------------------------------------------------------------------- /docs/api_key.md: -------------------------------------------------------------------------------- 1 | # API Key 2 | 3 | This is a step by step guide on how to get an API key that can be used for safety. Using an API Key 4 | with safety gives you access to the latest vulnerabilities. The freely available database 5 | is synced only once per month. 6 | 7 | In order to get an API Key you need a subscription on [safetycli.com](https://safetycli.com). 8 | 9 | ## Step 1 - Sign Up 10 | 11 | Go to [safetycli.com](https://safetycli.com) and click on `sign up`. 12 | 13 | ## Step 2 - Start your free trial 14 | 15 | Choose the plan best suited to your team's need and start your 14-day free trial. 16 | 17 | ## Step 3 - Go back to account page 18 | 19 | Once payment is complete, you'll be redirected to your account page. 20 | 21 | ## Step 4 - Copy your API key 22 | 23 | Copy your API Key from your account homepage - and you're done! 24 | -------------------------------------------------------------------------------- /safety/events/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .aliases import ( 2 | CloseResourcesEvent, 3 | CommandErrorEvent, 4 | CommandExecutedEvent, 5 | FirewallConfiguredEvent, 6 | FirewallDisabledEvent, 7 | FirewallHeartbeatEvent, 8 | FlushSecurityTracesEvent, 9 | PackageInstalledEvent, 10 | PackageUninstalledEvent, 11 | EventBusReadyEvent, 12 | ) 13 | from .base import InternalEventType, InternalPayload 14 | 15 | __all__ = [ 16 | "CloseResourcesEvent", 17 | "FlushSecurityTracesEvent", 18 | "InternalEventType", 19 | "InternalPayload", 20 | "CommandExecutedEvent", 21 | "CommandErrorEvent", 22 | "PackageInstalledEvent", 23 | "PackageUninstalledEvent", 24 | "FirewallHeartbeatEvent", 25 | "FirewallConfiguredEvent", 26 | "FirewallDisabledEvent", 27 | "EventBusReadyEvent", 28 | ] 29 | -------------------------------------------------------------------------------- /tests/test_policy_file/v3_0/default_policy_file.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | 3 | scanning-settings: 4 | max-depth: 6 5 | exclude: [] 6 | include-files: [] 7 | system: 8 | targets: [] 9 | 10 | 11 | report: 12 | dependency-vulnerabilities: 13 | enabled: true 14 | auto-ignore-in-report: 15 | python: 16 | environment-results: true 17 | unpinned-requirements: true 18 | cvss-severity: [] 19 | 20 | 21 | fail-scan-with-exit-code: 22 | dependency-vulnerabilities: 23 | enabled: true 24 | fail-on-any-of: 25 | cvss-severity: 26 | - critical 27 | - high 28 | - medium 29 | exploitability: 30 | - critical 31 | - high 32 | - medium 33 | 34 | security-updates: 35 | dependency-vulnerabilities: 36 | auto-security-updates-limit: 37 | - patch 38 | -------------------------------------------------------------------------------- /safety/events/handlers/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Event handler definitions for the event bus system. 3 | """ 4 | 5 | from abc import ABC, abstractmethod 6 | from typing import Any, TypeVar, Generic 7 | 8 | from safety_schemas.models.events import Event 9 | 10 | # Type variable for event types 11 | EventType = TypeVar("EventType", bound=Event) 12 | 13 | 14 | class EventHandler(Generic[EventType], ABC): 15 | """ 16 | Abstract base class for event handlers. 17 | 18 | Concrete handlers should implement the handle method. 19 | """ 20 | 21 | @abstractmethod 22 | async def handle(self, event: EventType) -> Any: 23 | """ 24 | Handle an event asynchronously. 25 | 26 | Args: 27 | event: The event to handle 28 | 29 | Returns: 30 | Any result from handling the event 31 | """ 32 | pass 33 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | Safety is distributed as a binary for Windows 32/64 bit, Linux 32/64 bit and macOS 64 bit. 4 | The binary is built on appveyor (see `appveyor.py` and `appveyor.yml`) and distributed through GitHub. 5 | 6 | ## Issuing a new release 7 | 8 | First, review and update the `CHANGELOG.md` file; then the version string in `safety/VERSION` and `appveyor.yml` and push the changes to master. 9 | 10 | Make sure the release builds properly on appveyor prior to tagging it. 11 | 12 | To issue a new release, tag the release with `git tag -s -a 1.x.x -m "Small description"` and push the tag with `git push origin --tags`. 13 | Once the build is completed and all artifacts are collected, the binaries are uploaded as a GitHub release. 14 | 15 | ### Note: 16 | 17 | Use standard PEP 440 versions, verify if the version to tag matches the current AppVeyor and Travis regexes. 18 | -------------------------------------------------------------------------------- /tests/tool/poetry/test_poetry_command.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | import unittest 3 | 4 | from safety.tool.poetry.command import PoetryCommand 5 | from ..base import CommandToolCaseMixin 6 | 7 | 8 | class TestPoetryCommand(CommandToolCaseMixin, unittest.TestCase): 9 | """ 10 | Test cases for PoetryCommand functionality. 11 | """ 12 | 13 | command_class = PoetryCommand 14 | command_args = ["poetry", "add", "foobar"] 15 | parent_env = {"EXISTING_VAR": "existing_value", "PATH": "/usr/bin:/bin"} 16 | expected_env_vars = { 17 | "POETRY_HTTP_BASIC_SAFETY_USERNAME": "user", 18 | "POETRY_HTTP_BASIC_SAFETY_PASSWORD": "mock_credentials_value", 19 | } 20 | mock_configurations = [ 21 | { 22 | "target": "safety.tool.poetry.command.index_credentials", 23 | "return_value": "mock_credentials_value", 24 | } 25 | ] 26 | -------------------------------------------------------------------------------- /tests/test_policy_file/v3_0/default_policy_file_using_invalid_keyword.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | 3 | scanning-settings: 4 | max-depth: 6 5 | exclude: [] 6 | include-files: [] 7 | system: 8 | targets: [] 9 | 10 | 11 | report: 12 | dependency-vulnerabilities: 13 | enabled: true 14 | auto-ignore-in-report: 15 | python: 16 | environment-results: true 17 | unpinned-requirements: true 18 | cvss-severity: [] 19 | transitive: True 20 | 21 | 22 | fail-scan-with-exit-code: 23 | dependency-vulnerabilities: 24 | enabled: true 25 | fail-on-any-of: 26 | cvss-severity: 27 | - critical 28 | - high 29 | - medium 30 | exploitability: 31 | - critical 32 | - high 33 | - medium 34 | 35 | security-updates: 36 | dependency-vulnerabilities: 37 | auto-security-updates-limit: 38 | - patch 39 | -------------------------------------------------------------------------------- /safety/encoding.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def detect_encoding(file_path: Path) -> str: 8 | """ 9 | UTF-8 is the most common encoding standard, this is a simple 10 | way to improve the support for related Windows based files. 11 | 12 | Handles the most common cases efficiently. 13 | """ 14 | try: 15 | with open(file_path, "rb") as f: 16 | # Read first 3 bytes for BOM detection 17 | bom = f.read(3) 18 | 19 | # Check most common Windows patterns first 20 | if bom[:2] in (b"\xff\xfe", b"\xfe\xff"): 21 | return "utf-16" 22 | elif bom.startswith(b"\xef\xbb\xbf"): 23 | return "utf-8-sig" 24 | 25 | return "utf-8" 26 | except Exception: 27 | logger.exception("Error detecting encoding") 28 | return "utf-8" 29 | -------------------------------------------------------------------------------- /tests/action/Pipfile.lock-secure: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "139d26f574e8b79a4b4e1b372592d887beeb136e4a276cc4584bfda4e9bdd55c" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.11" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "secure-package": { 20 | "hashes": [ 21 | "sha256:35b8b4b1658f0207012877c83b04ef3a64b828e07b125f0ada8117c45a23d171", 22 | "sha256:3d973c0c48835ec3174b6aa0ab5649e67eb80874b3451d572ff2a7feb2b14020" 23 | ], 24 | "index": "pypi", 25 | "version": "==0.1.0" 26 | } 27 | }, 28 | "develop": {} 29 | } 30 | -------------------------------------------------------------------------------- /tests/action/Pipfile.lock-insecure: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "e6b242cfea1ce041af2fe97dba83d77249aa562133f75af886087d4b6206d852" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.10" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "insecure-package": { 20 | "hashes": [ 21 | "sha256:4d9485a53c970c892f887e10fd99e97b82848ac9010e79bc715585e1a6a74422", 22 | "sha256:4da7b05a052f19841c1efbd4cc8edea9a78aaaa295360923cb2b052b547b7877" 23 | ], 24 | "index": "pypi", 25 | "version": "==0.1.0" 26 | } 27 | }, 28 | "develop": {} 29 | } 30 | -------------------------------------------------------------------------------- /safety/events/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .emission import ( 2 | emit_command_error, 3 | emit_command_executed, 4 | emit_firewall_disabled, 5 | emit_diff_operations, 6 | emit_firewall_configured, 7 | emit_tool_command_executed, 8 | emit_firewall_heartbeat, 9 | emit_init_started, 10 | emit_auth_started, 11 | emit_auth_completed, 12 | ) 13 | 14 | from .creation import ( 15 | create_internal_event, 16 | InternalEventType, 17 | InternalPayload, 18 | ) 19 | 20 | __all__ = [ 21 | "emit_command_error", 22 | "emit_command_executed", 23 | "emit_firewall_disabled", 24 | "create_internal_event", 25 | "InternalEventType", 26 | "InternalPayload", 27 | "emit_firewall_configured", 28 | "emit_diff_operations", 29 | "emit_init_started", 30 | "emit_auth_started", 31 | "emit_auth_completed", 32 | "emit_tool_command_executed", 33 | "emit_firewall_heartbeat", 34 | ] 35 | -------------------------------------------------------------------------------- /tests/scan/test_file_handlers.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from unittest.mock import Mock, patch 4 | from safety.scan.finder.handlers import PythonFileHandler 5 | 6 | @patch('safety.safety.fetch_database') 7 | def test_download_required_assets(mock_fetch_database): 8 | handler = PythonFileHandler() 9 | session = Mock() 10 | 11 | os.environ["SAFETY_DB_DIR"] = "/path/to/db" 12 | handler.download_required_assets(session) 13 | 14 | _, kwargs = mock_fetch_database.call_args 15 | 16 | assert kwargs['db'] == "/path/to/db" 17 | 18 | @patch('safety.safety.fetch_database') 19 | def test_download_required_assets_no_db_dir(mock_fetch_database): 20 | handler = PythonFileHandler() 21 | session = Mock() 22 | 23 | if "SAFETY_DB_DIR" in os.environ: 24 | del os.environ["SAFETY_DB_DIR"] 25 | handler.download_required_assets(session) 26 | 27 | _, kwargs = mock_fetch_database.call_args 28 | 29 | assert kwargs['db'] == False 30 | -------------------------------------------------------------------------------- /safety/firewall/events/handlers.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from safety.events.handlers import EventHandler 4 | from safety.events.types import EventBusReadyEvent 5 | from safety.events.utils import emit_firewall_heartbeat 6 | 7 | from safety.tool import ToolInspector 8 | 9 | if TYPE_CHECKING: 10 | from safety.events.event_bus import EventBus 11 | 12 | 13 | class HeartbeatInspectionEventHandler(EventHandler[EventBusReadyEvent]): 14 | """ 15 | Inspect the system for installed tools and send an emit 16 | a firewall heartbeat event. 17 | """ 18 | 19 | def __init__(self, event_bus: "EventBus") -> None: 20 | super().__init__() 21 | self.event_bus = event_bus 22 | 23 | async def handle(self, event: EventBusReadyEvent): 24 | ctx = event.payload.ctx 25 | inspector = ToolInspector(timeout=1.0) 26 | tools = await inspector.inspect_all_tools() 27 | 28 | emit_firewall_heartbeat(self.event_bus, ctx, tools=tools) 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Type of Change 6 | 7 | - [ ] Bug fix 8 | - [ ] New feature 9 | - [ ] Documentation update 10 | - [ ] Refactor 11 | - [ ] Other (please describe): 12 | 13 | ## Related Issues 14 | 15 | 16 | 17 | ## Testing 18 | 19 | - [ ] Tests added or updated 20 | - [ ] No tests required 21 | 22 | 23 | 24 | ## Checklist 25 | 26 | - [ ] Code is well-documented 27 | - [ ] Changelog is updated (if needed) 28 | - [ ] No sensitive information (e.g., keys, credentials) is included in the code 29 | - [ ] All PR feedback is addressed 30 | 31 | ## Additional Notes 32 | 33 | 34 | -------------------------------------------------------------------------------- /safety/codebase/render.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional 2 | 3 | if TYPE_CHECKING: 4 | from rich.console import Console 5 | 6 | 7 | def render_initialization_result( 8 | console: "Console", 9 | codebase_init_status: Optional[str] = None, 10 | codebase_id: Optional[str] = None, 11 | ): 12 | if not codebase_init_status or not codebase_id: 13 | console.print("Error: unable to initialize codebase") 14 | return 15 | 16 | message = None 17 | 18 | if codebase_init_status == "created": 19 | from safety.codebase.constants import CODEBASE_INIT_CREATED 20 | 21 | message = CODEBASE_INIT_CREATED 22 | 23 | if codebase_init_status == "linked": 24 | from safety.codebase.constants import CODEBASE_INIT_LINKED 25 | 26 | message = CODEBASE_INIT_LINKED 27 | 28 | if codebase_init_status == "reinitialized": 29 | from safety.codebase.constants import CODEBASE_INIT_REINITIALIZED 30 | 31 | message = CODEBASE_INIT_REINITIALIZED 32 | 33 | if message: 34 | console.print(message.format(codebase_name=codebase_id)) 35 | -------------------------------------------------------------------------------- /safety/tool/interceptors/factory.py: -------------------------------------------------------------------------------- 1 | from sys import platform 2 | from typing import Optional 3 | from .types import InterceptorType 4 | from .unix import UnixAliasInterceptor 5 | from .windows import WindowsInterceptor 6 | from .base import CommandInterceptor 7 | 8 | 9 | def create_interceptor( 10 | interceptor_type: Optional[InterceptorType] = None, 11 | ) -> CommandInterceptor: 12 | """ 13 | Create appropriate interceptor based on OS and type 14 | """ 15 | interceptor_map = { 16 | InterceptorType.UNIX_ALIAS: UnixAliasInterceptor, 17 | InterceptorType.WINDOWS_BAT: WindowsInterceptor, 18 | } 19 | 20 | if interceptor_type: 21 | return interceptor_map[interceptor_type]() 22 | 23 | # Auto-select based on OS 24 | if platform == "win32": 25 | return interceptor_map[InterceptorType.WINDOWS_BAT]() 26 | 27 | if platform in ["linux", "linux2", "darwin"]: 28 | # Default to alias-based on Unix-like systems 29 | return interceptor_map[InterceptorType.UNIX_ALIAS]() 30 | 31 | raise NotImplementedError(f"Platform '{platform}' is not supported.") 32 | -------------------------------------------------------------------------------- /safety/tool/uv/list_pkgs.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata as md 2 | import json 3 | import os 4 | 5 | 6 | def get_package_location(dist): 7 | """ 8 | Get the installation location of a package distribution. 9 | """ 10 | try: 11 | if hasattr(dist, "locate_file") and callable(dist.locate_file): 12 | root = dist.locate_file("") 13 | if root: 14 | return os.path.abspath(str(root)) 15 | except (AttributeError, OSError, TypeError): 16 | pass 17 | 18 | return "" 19 | 20 | 21 | def main() -> int: 22 | """ 23 | List all installed packages with their versions and locations. 24 | """ 25 | packages = [] 26 | for dist in md.distributions(): 27 | packages.append( 28 | { 29 | "name": dist.metadata.get("Name", ""), 30 | "version": dist.version, 31 | "location": get_package_location(dist), 32 | } 33 | ) 34 | 35 | print(json.dumps(packages, separators=(",", ":"))) 36 | return 0 37 | 38 | 39 | if __name__ == "__main__": 40 | raise SystemExit(main()) 41 | -------------------------------------------------------------------------------- /tests/platform/test_http_utils.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | 4 | from safety.platform.http_utils import extract_detail 5 | 6 | 7 | def test_extract_detail_valid_json_with_detail(): 8 | # Test valid JSON with detail 9 | response = Mock() 10 | response.json.return_value = {"detail": "Error message"} 11 | detail = extract_detail(response) 12 | assert detail == "Error message" 13 | 14 | 15 | def test_extract_detail_valid_json_without_detail(): 16 | # Test valid JSON without detail 17 | response = Mock() 18 | response.json.return_value = {"message": "Something else"} 19 | detail = extract_detail(response) 20 | assert detail is None 21 | 22 | 23 | def test_extract_detail_invalid_json(): 24 | # Test invalid JSON 25 | response = Mock() 26 | response.json.side_effect = ValueError() 27 | detail = extract_detail(response) 28 | assert detail is None 29 | 30 | 31 | def test_extract_detail_empty_response(): 32 | # Test empty response 33 | response = Mock() 34 | response.json.return_value = {} 35 | detail = extract_detail(response) 36 | assert detail is None 37 | -------------------------------------------------------------------------------- /.github/workflows/issue_responder.yml: -------------------------------------------------------------------------------- 1 | name: Issue Responder 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | respond: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Install GitHub CLI 17 | run: sudo apt-get install gh 18 | 19 | - name: Respond to new issues 20 | run: gh issue comment ${{ github.event.issue.number }} --body "$BODY" 21 | env: 22 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | BODY: | 24 | Hi @${{ github.event.issue.user.login }}, thank you for opening this issue! 25 | 26 | We appreciate your effort in reporting this. Our team will review it and get back to you soon. 27 | If you have any additional details or updates, feel free to add them to this issue. 28 | 29 | **Note:** If this is a serious security issue that could impact the security of Safety CLI users, please email security@safetycli.com immediately. 30 | 31 | Thank you for contributing to Safety CLI! 32 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Safety CLI Cybersecurity Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Other 2 | .direnv/ 3 | .envrc 4 | uv.lock 5 | .hatch/ 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *,cover 52 | .hypothesis/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | 61 | # Sphinx documentation 62 | docs/_build/ 63 | 64 | # PyBuilder 65 | target/ 66 | dev-release.sh 67 | 68 | # pyenv python configuration file 69 | .python-version 70 | sandbox.py 71 | 72 | # PyCharm 73 | .idea 74 | *.idea/* 75 | 76 | # Virtual Env 77 | venv/ 78 | .venv/ 79 | 80 | # Mac OS 81 | .DS_Store 82 | -------------------------------------------------------------------------------- /safety/utils/tls.py: -------------------------------------------------------------------------------- 1 | import ssl 2 | from ssl import SSLContext 3 | 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | TLS = "config.tls" 9 | TLS_SYSTEM_STORE_ATTEMPT = f"{TLS}.system_store_attempt" 10 | TLS_SYSTEM_STORE_RESOLVED = f"{TLS}.system_store_resolved" 11 | TLS_SYSTEM_STORE_UNSUPPORTED = f"{TLS}.system_store_unsupported" 12 | 13 | 14 | def get_system_tls_context() -> SSLContext: 15 | """ 16 | Get SSL context for system trust store. 17 | 18 | Attempts to use truststore if available, falls back to ssl.create_default_context(). 19 | 20 | Returns: 21 | SSLContext: The SSL context for system trust store. 22 | """ 23 | logger.debug(TLS_SYSTEM_STORE_ATTEMPT) 24 | 25 | try: 26 | import truststore # type: ignore[import-untyped] 27 | 28 | context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 29 | logger.debug(TLS_SYSTEM_STORE_RESOLVED, extra={"method": "truststore"}) 30 | return context 31 | except ImportError: 32 | logger.debug( 33 | TLS_SYSTEM_STORE_UNSUPPORTED, extra={"reason": "truststore not available"} 34 | ) 35 | context = ssl.create_default_context() 36 | logger.debug( 37 | TLS_SYSTEM_STORE_RESOLVED, extra={"method": "ssl.create_default_context"} 38 | ) 39 | return context 40 | -------------------------------------------------------------------------------- /safety/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from safety.events.utils import emit_command_executed 4 | 5 | 6 | def notify(func): 7 | """ 8 | A decorator that wraps a function to emit events. 9 | 10 | Args: 11 | func (callable): The function to be wrapped by the decorator. 12 | 13 | Returns: 14 | callable: The wrapped function with notification logic. 15 | 16 | The decorator ensures that the `emit_command_executed` function is called 17 | after the wrapped function completes, regardless of whether it exits 18 | normally or via a `SystemExit` exception. 19 | 20 | Example: 21 | @notify 22 | def my_function(ctx, *args, **kwargs): 23 | # function implementation 24 | pass 25 | """ 26 | 27 | @wraps(func) 28 | def inner(ctx, *args, **kwargs): 29 | try: 30 | result = func(ctx, *args, **kwargs) 31 | emit_command_executed(ctx.obj.event_bus, ctx, returned_code=0) 32 | return result 33 | except SystemExit as e: 34 | # Handle sys.exit() case 35 | exit_code = e.code if isinstance(e.code, int) else 1 36 | emit_command_executed(ctx.obj.event_bus, ctx, returned_code=exit_code) 37 | raise 38 | # Any other exceptions will bypass notification and propagate normally 39 | 40 | return inner 41 | -------------------------------------------------------------------------------- /safety/firewall/constants.py: -------------------------------------------------------------------------------- 1 | MSG_UNINSTALL_EXPLANATION = "Would you like to uninstall Safety Firewall on this machine? Doing so will mean you are no longer protected from malicious or vulnerable packages." 2 | MSG_UNINSTALL_SUCCESS = "Safety Firewall has been uninstalled from your machine. Note that your individual requirements files may still reference Safety Firewall. You can remove these references by removing the following line from your requirements files:" 3 | MSG_REQ_FILE_LINE = "-i https://pkgs.safetycli.com/repository/public/pypi/simple/" 4 | 5 | MSG_FEEDBACK = "We're sorry to see you go. If you have any feedback on how we can do better, we'd love to hear it. Otherwise hit enter to exit." 6 | 7 | 8 | UNINSTALL_HELP = "Uninstall Safety Firewall from your machine." 9 | 10 | 11 | FIREWALL_CMD_NAME = "firewall" 12 | UNINSTALL_CMD_NAME = "uninstall" 13 | 14 | 15 | FIREWALL_HELP = "[BETA] Manage Safety Firewall settings." 16 | 17 | MSG_UNINSTALL_CONFIG = ( 18 | "Removing global configuration for pip from: ~/.config/pip/pip.conf", 19 | "Removing global configuration for uv from: uv.toml", 20 | ) 21 | MSG_UNINSTALL_WRAPPERS = "Removing aliases to safety from config files" 22 | 23 | INIT_CMD_NAME = "init" 24 | INIT_HELP = "Initialize Safety Firewall on this machine." 25 | MSG_INIT_SUCCESS = "Safety Firewall has been initialized on your machine. The following tools are now protected: {}" 26 | -------------------------------------------------------------------------------- /tests/tool/uv/test_uv_command.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | import unittest 3 | from safety.tool.uv.command import AuditableUvCommand 4 | from ..base import CommandToolCaseMixin 5 | 6 | 7 | class TestUvCommand(CommandToolCaseMixin, unittest.TestCase): 8 | """ 9 | Test cases for UvCommand functionality. 10 | """ 11 | 12 | command_class = AuditableUvCommand 13 | command_args = ["uv", "pip", "install", "package"] 14 | parent_env = {"EXISTING_VAR": "existing_value", "PATH": "/usr/bin:/bin"} 15 | expected_env_vars = { 16 | "UV_INDEX_SAFETY_USERNAME": "user", 17 | "UV_INDEX_SAFETY_PASSWORD": "mock_credentials_value", 18 | # UV < 0.4.23 does only support UV_INDEX_URL, so we comment it out to avoid a warning in modern versions 19 | # "UV_INDEX_URL": "https://user:mock_credentials_value@pkgs.safetycli.com/repository/public/pypi/simple/", 20 | "UV_DEFAULT_INDEX": "https://user:mock_credentials_value@pkgs.safetycli.com/repository/public/pypi/simple/", 21 | } 22 | mock_configurations = [ 23 | { 24 | "target": "safety.tool.uv.command.index_credentials", 25 | "return_value": "mock_credentials_value", 26 | }, 27 | { 28 | "target": "safety.tool.uv.command.Uv.build_index_url", 29 | "return_value": "https://user:mock_credentials_value@pkgs.safetycli.com/repository/public/pypi/simple/", 30 | }, 31 | ] 32 | -------------------------------------------------------------------------------- /safety/events/utils/creation.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Optional, TypeVar 3 | 4 | from safety_schemas.models.events import Event, EventTypeBase, PayloadBase, SourceType 5 | 6 | from safety.meta import get_identifier 7 | 8 | from ..types import InternalEventType, InternalPayload 9 | 10 | PayloadBaseT = TypeVar("PayloadBaseT", bound=PayloadBase) 11 | EventTypeBaseT = TypeVar("EventTypeBaseT", bound=EventTypeBase) 12 | 13 | 14 | def create_event( 15 | payload: PayloadBaseT, 16 | event_type: EventTypeBaseT, 17 | source: SourceType = SourceType(get_identifier()), 18 | timestamp: int = int(time.time()), 19 | correlation_id: Optional[str] = None, 20 | **kwargs, 21 | ) -> Event[EventTypeBaseT, PayloadBaseT]: 22 | """ 23 | Generic factory function for creating any type of event. 24 | """ 25 | 26 | return Event( 27 | timestamp=timestamp, 28 | payload=payload, 29 | type=event_type, 30 | source=source, 31 | correlation_id=correlation_id, 32 | **kwargs, 33 | ) 34 | 35 | 36 | def create_internal_event( 37 | event_type: InternalEventType, 38 | payload: InternalPayload, 39 | ) -> Event[InternalEventType, InternalPayload]: 40 | """ 41 | Create an internal event. 42 | """ 43 | return Event( 44 | type=event_type, 45 | timestamp=int(time.time()), 46 | source=SourceType(get_identifier()), 47 | payload=payload, 48 | ) 49 | -------------------------------------------------------------------------------- /safety/auth/constants.py: -------------------------------------------------------------------------------- 1 | from safety.constants import get_required_config_setting 2 | 3 | HOST: str = "localhost" 4 | 5 | CLIENT_ID = get_required_config_setting("CLIENT_ID") 6 | AUTH_SERVER_URL = get_required_config_setting("AUTH_SERVER_URL") 7 | SAFETY_PLATFORM_URL = get_required_config_setting("SAFETY_PLATFORM_URL") 8 | OAUTH2_SCOPE = "openid email profile offline_access" 9 | 10 | 11 | OPENID_CONFIG_URL = f"{AUTH_SERVER_URL}/.well-known/openid-configuration" 12 | 13 | CLAIM_EMAIL_VERIFIED_API = "https://api.safetycli.com/email_verified" 14 | CLAIM_EMAIL_VERIFIED_AUTH_SERVER = "email_verified" 15 | 16 | CLI_AUTH = f"{SAFETY_PLATFORM_URL}/cli/auth" 17 | CLI_AUTH_SUCCESS = f"{SAFETY_PLATFORM_URL}/cli/auth/success" 18 | CLI_AUTH_LOGOUT = f"{SAFETY_PLATFORM_URL}/cli/logout" 19 | CLI_CALLBACK = f"{SAFETY_PLATFORM_URL}/cli/callback" 20 | CLI_LOGOUT_SUCCESS = f"{SAFETY_PLATFORM_URL}/cli/logout/success" 21 | 22 | MSG_NON_AUTHENTICATED = ( 23 | "Safety is not authenticated. Please run 'safety auth login' to log in." 24 | ) 25 | MSG_FAIL_LOGIN_AUTHED = """[green]You are authenticated as[/green] {email}. 26 | 27 | To log into a different account, first logout via: safety auth logout, and then login again.""" 28 | MSG_FAIL_REGISTER_AUTHED = "You are currently logged in to {email}, please logout using `safety auth logout` before registering a new account." 29 | 30 | MSG_LOGOUT_DONE = "[green]Logout done.[/green]" 31 | MSG_LOGOUT_FAILED = "[red]Logout failed. Try again.[/red]" 32 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-alpine 2 | 3 | ENV PYTHONDONTWRITEBYTECODE=1 4 | ENV PYTHONUNBUFFERED=1 5 | 6 | RUN apk add --no-cache \ 7 | git \ 8 | curl \ 9 | wget \ 10 | zsh \ 11 | jq \ 12 | sudo \ 13 | docker \ 14 | docker-compose \ 15 | bash \ 16 | grep \ 17 | sed \ 18 | nodejs \ 19 | npm \ 20 | # Build dependencies for Python packages 21 | gcc \ 22 | musl-dev \ 23 | python3-dev \ 24 | libffi-dev \ 25 | openssl-dev \ 26 | cargo \ 27 | rust \ 28 | make && npm install -g pyright 29 | 30 | RUN pip install --no-cache-dir uv \ 31 | && uv pip install --system hatch hatch-containers 32 | 33 | ARG USERNAME=developer 34 | ARG USER_UID=1000 35 | ARG USER_GID=$USER_UID 36 | 37 | RUN addgroup -g $USER_GID $USERNAME \ 38 | && adduser -u $USER_UID -G $USERNAME -s /bin/zsh -D $USERNAME \ 39 | && echo "$USERNAME ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME \ 40 | && chmod 0440 /etc/sudoers.d/$USERNAME \ 41 | && addgroup $USERNAME docker 42 | 43 | RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" 44 | 45 | RUN sed -i 's|/bin/ash|/bin/zsh|' /etc/passwd 46 | 47 | RUN cp -r /root/.oh-my-zsh /home/$USERNAME/ \ 48 | && cp /root/.zshrc /home/$USERNAME/ \ 49 | && chown -R $USERNAME:$USERNAME /home/$USERNAME/.oh-my-zsh \ 50 | && chown $USERNAME:$USERNAME /home/$USERNAME/.zshrc 51 | 52 | USER $USERNAME 53 | 54 | CMD ["zsh"] -------------------------------------------------------------------------------- /safety/models/obj.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import TYPE_CHECKING, List, Optional 3 | 4 | if TYPE_CHECKING: 5 | from rich.console import Console 6 | from safety_schemas.models import ( 7 | ConfigModel, 8 | MetadataModel, 9 | PolicyFileModel, 10 | ProjectModel, 11 | ReportSchemaVersion, 12 | TelemetryModel, 13 | ) 14 | 15 | from safety.auth.models import Auth 16 | from safety.events.handlers import SecurityEventsHandler 17 | from safety.events.event_bus import EventBus 18 | from safety_schemas.models.events import Event 19 | 20 | 21 | @dataclass 22 | class SafetyCLI: 23 | """ 24 | A class representing Safety CLI settings. 25 | """ 26 | 27 | auth: Optional["Auth"] = None 28 | telemetry: Optional["TelemetryModel"] = None 29 | metadata: Optional["MetadataModel"] = None 30 | schema: Optional["ReportSchemaVersion"] = None 31 | project: Optional["ProjectModel"] = None 32 | config: Optional["ConfigModel"] = None 33 | console: Optional["Console"] = None 34 | system_scan_policy: Optional["PolicyFileModel"] = None 35 | platform_enabled: bool = False 36 | firewall_enabled: bool = False 37 | events_enabled: bool = False 38 | event_bus: Optional["EventBus"] = None 39 | security_events_handler: Optional["SecurityEventsHandler"] = None 40 | correlation_id: Optional[str] = None 41 | pending_events: List["Event"] = field(default_factory=list) 42 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We release patches and updates to ensure the security of our software. Below is a list of supported versions: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 3.x.x | ✅ | 10 | | < 3.0 | ❌ | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | If you discover a security vulnerability in this repository, please report it to us directly. We take security issues seriously and will respond promptly to address the issue. 15 | 16 | To report a vulnerability: 17 | 18 | 1. **Email**: Please send the details to [engineers@safetycli.com](mailto:engineers@safetycli.com). Include as much information as possible to help us understand the nature of the vulnerability and how it can be reproduced. 19 | 20 | ## Security Best Practices 21 | 22 | We encourage our users to follow these best practices to ensure the security of their deployments: 23 | 24 | - Always run the latest version of the software to benefit from security updates. 25 | - Regularly review and update dependencies to avoid known vulnerabilities. 26 | - Consider using containerization and sandboxing techniques to isolate the software from other parts of your system. 27 | 28 | ## Code of Conduct 29 | 30 | Please note that all participants in our community are expected to adhere to our [Code of Conduct](./CODE_OF_CONDUCT.md). This includes those participating in our security bounty program. 31 | 32 | Thank you for helping to keep our project secure! 33 | -------------------------------------------------------------------------------- /.github/scripts/ci_pyproject.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.8" 3 | # dependencies = [] 4 | # /// 5 | 6 | def update_pyproject_toml(file_path: str) -> None: 7 | """ 8 | Updates the pyproject.toml file by replacing 'type = "container"' with 9 | 'type = "virtual"' 10 | 11 | This allows to keep using the same hatch test environment configuration for 12 | local and CI, local uses container. 13 | 14 | This won't be needed if hatch supports a way to set the type of environment 15 | via environment variables. This is a workaround until that is implemented. 16 | 17 | Args: 18 | file_path: Path to the pyproject.toml file 19 | """ 20 | try: 21 | # Read the file 22 | with open(file_path, 'r', encoding='utf-8') as f: 23 | content = f.read() 24 | 25 | # Replace the content 26 | updated_content = content.replace('type = "container"', 27 | 'type = "virtual"') 28 | 29 | # Write back to the file 30 | with open(file_path, 'w', encoding='utf-8') as f: 31 | f.write(updated_content) 32 | 33 | except Exception as e: 34 | print(f"Error updating {file_path}: {str(e)}") 35 | raise 36 | 37 | if __name__ == '__main__': 38 | import sys 39 | 40 | if len(sys.argv) != 2: 41 | print("Usage: python update_config.py ") 42 | sys.exit(1) 43 | 44 | update_pyproject_toml(sys.argv[1]) -------------------------------------------------------------------------------- /safety/utils/tokens.py: -------------------------------------------------------------------------------- 1 | """ 2 | Token validation utilities shared across the application. 3 | """ 4 | 5 | from typing import Any, Dict, Literal, Optional 6 | import logging 7 | 8 | from authlib.oidc.core import CodeIDToken 9 | from authlib.jose import jwt 10 | from authlib.jose.errors import ExpiredTokenError 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def get_token_claims( 17 | token: str, 18 | token_type: Literal["access_token", "id_token"], 19 | jwks: Dict[str, Any], 20 | silent_if_expired: bool = False, 21 | ) -> Optional[CodeIDToken]: 22 | """ 23 | Decode and validate token claims. 24 | 25 | Args: 26 | token: The token to decode 27 | token_type: Type of token (access_token or id_token) 28 | jwks: JSON Web Key Set for validation 29 | silent_if_expired: Whether to silently ignore expired tokens 30 | 31 | Returns: 32 | Decoded token claims, or None if invalid 33 | 34 | Raises: 35 | ValueError: If token_type is invalid 36 | ExpiredTokenError: If token is expired and silent_if_expired is False 37 | """ 38 | if token_type not in ("access_token", "id_token"): 39 | raise ValueError(f"Invalid token_type: {token_type}") 40 | 41 | claims = None 42 | 43 | try: 44 | claims = jwt.decode(token, jwks, claims_cls=CodeIDToken) # type: ignore 45 | claims.validate() 46 | 47 | except ExpiredTokenError as e: 48 | if not silent_if_expired: 49 | raise e 50 | 51 | return claims 52 | -------------------------------------------------------------------------------- /safety/logs_helpers.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | 4 | 5 | def log_call(*, show_args=True, show_result=False): 6 | """ 7 | Configurable logging decorator. 8 | 9 | Args: 10 | show_args: Log function arguments (default: True) 11 | show_result: Log return value (default: False) 12 | """ 13 | 14 | def decorator(func): 15 | logger = logging.getLogger(func.__module__) 16 | 17 | @functools.wraps(func) 18 | def wrapper(*args, **kwargs): 19 | if logger.isEnabledFor(logging.DEBUG): 20 | if show_args: 21 | args_repr = [repr(a) for a in args] 22 | kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()] 23 | signature = ", ".join(args_repr + kwargs_repr) 24 | logger.debug("-> %s(%s)", func.__name__, signature) 25 | else: 26 | logger.debug("-> %s", func.__name__) 27 | 28 | try: 29 | result = func(*args, **kwargs) 30 | 31 | if logger.isEnabledFor(logging.DEBUG): 32 | if show_result: 33 | logger.debug("<- %s => %r", func.__name__, result) 34 | else: 35 | logger.debug("<- %s", func.__name__) 36 | 37 | return result 38 | except Exception as e: 39 | logger.error("✗ %s failed: %s", func.__name__, e) 40 | raise 41 | 42 | return wrapper 43 | 44 | return decorator 45 | -------------------------------------------------------------------------------- /safety/utils/auth_session.py: -------------------------------------------------------------------------------- 1 | """ 2 | Authentication session management utilities. 3 | """ 4 | 5 | from enum import Enum 6 | 7 | from typing import TYPE_CHECKING 8 | from safety.config import AuthConfig 9 | 10 | from safety_schemas.models import Stage 11 | 12 | if TYPE_CHECKING: 13 | from authlib.integrations.httpx_client import OAuth2Client 14 | 15 | 16 | def discard_token(oauth2_client: "OAuth2Client") -> bool: 17 | """ 18 | Clean the authentication session. 19 | 20 | Args: 21 | oauth2_client: The authentication client. 22 | 23 | Returns: 24 | bool: Always returns True. 25 | """ 26 | 27 | AuthConfig.clear() 28 | oauth2_client.token = None 29 | 30 | return True 31 | 32 | 33 | class AuthenticationType(str, Enum): 34 | """ 35 | Enum representing authentication types. 36 | """ 37 | 38 | token = "token" 39 | api_key = "api_key" 40 | none = "unauthenticated" 41 | 42 | def is_allowed_in(self, stage: Stage = Stage.development) -> bool: 43 | """ 44 | Check if the authentication type is allowed in the given stage. 45 | 46 | Args: 47 | stage (Stage): The current stage. 48 | 49 | Returns: 50 | bool: True if the authentication type is allowed, otherwise False. 51 | """ 52 | if self is AuthenticationType.none: 53 | return False 54 | 55 | if stage == Stage.development and self is AuthenticationType.api_key: 56 | return False 57 | 58 | if (not stage == Stage.development) and self is AuthenticationType.token: 59 | return False 60 | 61 | return True 62 | -------------------------------------------------------------------------------- /safety/firewall/events/utils.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from safety_schemas.models.events import EventType 4 | from safety.events.event_bus import EventBus 5 | from safety.events.types import InternalEventType 6 | 7 | from .handlers import HeartbeatInspectionEventHandler 8 | 9 | if TYPE_CHECKING: 10 | from safety.models import SafetyCLI 11 | 12 | 13 | def register_event_handlers(event_bus: "EventBus", obj: "SafetyCLI") -> None: 14 | """ 15 | Subscribes to the firewall events that are relevant to the current context. 16 | """ 17 | handle_inspection = HeartbeatInspectionEventHandler(event_bus=event_bus) 18 | event_bus.subscribe([InternalEventType.EVENT_BUS_READY], handle_inspection) 19 | 20 | if sec_events_handler := obj.security_events_handler: 21 | event_bus.subscribe( 22 | [ 23 | EventType.FIREWALL_CONFIGURED, 24 | EventType.FIREWALL_HEARTBEAT, 25 | EventType.FIREWALL_DISABLED, 26 | EventType.PACKAGE_INSTALLED, 27 | EventType.PACKAGE_UNINSTALLED, 28 | EventType.PACKAGE_UPDATED, 29 | EventType.TOOL_COMMAND_EXECUTED, 30 | EventType.INIT_STARTED, 31 | EventType.FIREWALL_SETUP_RESPONSE_CREATED, 32 | EventType.FIREWALL_SETUP_COMPLETED, 33 | EventType.CODEBASE_DETECTION_STATUS, 34 | EventType.CODEBASE_SETUP_RESPONSE_CREATED, 35 | EventType.CODEBASE_SETUP_COMPLETED, 36 | EventType.INIT_SCAN_COMPLETED, 37 | EventType.INIT_EXITED, 38 | ], 39 | sec_events_handler, 40 | ) 41 | -------------------------------------------------------------------------------- /tests/tool/test_installation_commands.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | import pytest 4 | from unittest.mock import MagicMock, patch 5 | 6 | import typer 7 | 8 | from safety.tool.pip.command import AuditablePipCommand 9 | from safety.tool.uv.command import AuditableUvCommand 10 | from safety.tool.poetry.command import AuditablePoetryCommand 11 | 12 | 13 | class TestInstallationCommandsAudit: 14 | """ 15 | Test suite for verifying installation audit functionality in command classes. 16 | """ 17 | 18 | def setup_method(self): 19 | """ 20 | Set up test fixtures. 21 | """ 22 | self.ctx = MagicMock(spec=typer.Context) 23 | self.ctx.obj = MagicMock() 24 | self.result = MagicMock(duration_ms=100, process=MagicMock(returncode=0)) 25 | 26 | @pytest.mark.parametrize( 27 | "command_class,command_args", 28 | [ 29 | (AuditablePipCommand, ["install", "requests"]), 30 | (AuditableUvCommand, ["pip", "install", "requests"]), 31 | (AuditablePoetryCommand, ["add", "requests"]), 32 | ], 33 | ) 34 | @patch("safety.tool.base.BaseCommand._handle_command_result") 35 | def test_installation_command_calls_audit( 36 | self, mock_handle_result, command_class, command_args 37 | ): 38 | """ 39 | Test that all installation commands call handle_installation_audit in after(). 40 | """ 41 | command = command_class(command_args) 42 | 43 | with patch.object( 44 | command_class, "handle_installation_audit" 45 | ) as mock_handle_audit: 46 | command.after(self.ctx, self.result) 47 | 48 | mock_handle_audit.assert_called_once_with(self.ctx, self.result) 49 | -------------------------------------------------------------------------------- /safety/auth/oauth2.py: -------------------------------------------------------------------------------- 1 | from authlib.oauth2.rfc6749 import OAuth2Token 2 | from authlib.oidc.core import CodeIDToken 3 | from typing import Any, Dict, Literal, Optional 4 | 5 | import logging 6 | from safety.logs_helpers import log_call 7 | 8 | from safety.utils.tokens import get_token_claims 9 | from safety.config import AuthConfig 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Token: 15 | @staticmethod 16 | def get_claims_for( 17 | token: str, 18 | token_type: Literal["access_token", "id_token"], 19 | jwks: Dict[str, Any], 20 | silent_if_expired: bool = False, 21 | ) -> Optional[CodeIDToken]: 22 | """ 23 | Decode and validate the token data. 24 | 25 | Args: 26 | token (str): The token to decode. 27 | token_type: Type of token (access_token or id_token) 28 | jwks: JSON Web Key Set for validation 29 | silent_if_expired (bool): Whether to silently ignore expired tokens. 30 | 31 | Returns: 32 | Optional[CodeIDToken]: The decoded token data, or None if invalid. 33 | """ 34 | return get_token_claims(token, token_type, jwks, silent_if_expired) 35 | 36 | 37 | @log_call() 38 | def update_token(token: OAuth2Token, refresh_token: str, access_token: str): 39 | """ 40 | Saves the refreshed token to the default storage. 41 | 42 | Args: 43 | token: OAuth2Token - the NEW refreshed token 44 | refresh_token: str - the OLD refresh_token string 45 | access_token: str - the OLD access_token string 46 | """ 47 | 48 | if auth_config := AuthConfig.from_token(token=token): 49 | auth_config.save() 50 | else: 51 | raise ValueError("Invalid authentication token.") 52 | -------------------------------------------------------------------------------- /LICENSES/NOTICE.md: -------------------------------------------------------------------------------- 1 | # Package Licenses 2 | 3 | | Name | Version | License | 4 | |------|---------|----------| 5 | | annotated-types | 0.7.0 | MIT License | 6 | | anyio | 4.5.2 | MIT | 7 | | authlib | 1.3.2 | BSD-3-Clause | 8 | | certifi | 2025.1.31 | MPL-2.0 | 9 | | cffi | 1.17.1 | MIT | 10 | | charset-normalizer | 3.4.1 | MIT | 11 | | click | 8.1.8 | BSD License | 12 | | cryptography | 44.0.2 | Apache-2.0 OR BSD-3-Clause | 13 | | dparse | 0.6.4 | MIT license | 14 | | exceptiongroup | 1.2.2 | MIT License | 15 | | filelock | 3.16.1 | Unlicense | 16 | | h11 | 0.14.0 | MIT | 17 | | httpcore | 1.0.8 | BSD-3-Clause | 18 | | httpx | 0.28.1 | BSD-3-Clause | 19 | | idna | 3.10 | BSD License | 20 | | jinja2 | 3.1.6 | BSD License | 21 | | joblib | 1.4.2 | BSD 3-Clause | 22 | | markdown-it-py | 3.0.0 | MIT License | 23 | | markupsafe | 2.1.5 | BSD-3-Clause | 24 | | marshmallow | 3.22.0 | MIT License | 25 | | mdurl | 0.1.2 | MIT License | 26 | | nltk | 3.9.1 | Apache License, Version 2.0 | 27 | | packaging | 25.0 | Apache Software License | 28 | | pip | 23.0.1 | MIT | 29 | | psutil | 6.1.1 | BSD-3-Clause | 30 | | pycparser | 2.22 | BSD-3-Clause | 31 | | pydantic | 2.9.2 | MIT | 32 | | pydantic-core | 2.23.4 | MIT | 33 | | pygments | 2.19.1 | BSD-2-Clause | 34 | | regex | 2024.11.6 | Apache Software License | 35 | | requests | 2.32.3 | Apache-2.0 | 36 | | rich | 14.0.0 | MIT | 37 | | ruamel-yaml | 0.18.10 | MIT license | 38 | | ruamel-yaml-clib | 0.2.8 | MIT | 39 | | safety-schemas | 0.0.14 | MIT | 40 | | setuptools | 75.3.2 | MIT License | 41 | | shellingham | 1.5.4 | ISC License | 42 | | sniffio | 1.3.1 | MIT OR Apache-2.0 | 43 | | tenacity | 9.0.0 | Apache 2.0 | 44 | | tomli | 2.2.1 | MIT License | 45 | | tomlkit | 0.13.2 | MIT | 46 | | tqdm | 4.67.1 | MPL-2.0 AND MIT | 47 | | typer | 0.15.2 | MIT License | 48 | | typing-extensions | 4.13.2 | PSF-2.0 | 49 | | urllib3 | 2.2.3 | MIT License | 50 | -------------------------------------------------------------------------------- /tests/test_policy_file/default_policy_file.yml: -------------------------------------------------------------------------------- 1 | # Safety Security and License Configuration file 2 | # We recommend checking this file into your source control in the root of your Python project 3 | # If this file is named .safety-policy.yml and is in the same directory where you run `safety check` it will be used by default. 4 | # Otherwise, you can use the flag `safety check --policy-file ` to specify a custom location and name for the file. 5 | # To validate and review your policy file, run the validate command: `safety validate policy_file --path ` 6 | project-id: '' 7 | security: # configuration for the `safety check` command 8 | ignore-cvss-severity-below: 0 # A severity number between 0 and 10. Some helpful reference points: 9=ignore all vulnerabilities except CRITICAL severity. 7=ignore all vulnerabilities except CRITICAL & HIGH severity. 4=ignore all vulnerabilities except CRITICAL, HIGH & MEDIUM severity. 9 | ignore-cvss-unknown-severity: False # True or False. We recommend you set this to False. 10 | ignore-vulnerabilities: # Here you can list multiple specific vulnerabilities you want to ignore (optionally for a time period) 11 | # We recommend making use of the optional `reason` and `expires` keys for each vulnerability that you ignore. 12 | 25853: # Example vulnerability ID 13 | reason: we don't use the vulnerable function # optional, for internal note purposes to communicate with your team. This reason will be reported in the Safety reports 14 | expires: '2022-10-21' # datetime string - date this ignore will expire, best practice to use this variable 15 | continue-on-vulnerability-error: False # Suppress non-zero exit codes when vulnerabilities are found. Enable this in pipelines and CI/CD processes if you want to pass builds that have vulnerabilities. We recommend you set this to False. 16 | -------------------------------------------------------------------------------- /safety/scan/ecosystems/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List 3 | 4 | from safety_schemas.models import Ecosystem, FileType, ConfigModel, DependencyResultModel 5 | from typer import FileTextWrite 6 | 7 | NOT_IMPLEMENTED = "Not implemented funtion" 8 | 9 | 10 | class Inspectable(ABC): 11 | """ 12 | Abstract base class defining the interface for objects that can be inspected for dependencies. 13 | """ 14 | 15 | @abstractmethod 16 | def inspect(self, config: ConfigModel) -> DependencyResultModel: 17 | """ 18 | Inspects the object and returns the result of the dependency analysis. 19 | 20 | Args: 21 | config (ConfigModel): The configuration model for inspection. 22 | 23 | Returns: 24 | DependencyResultModel: The result of the dependency inspection. 25 | """ 26 | return NotImplementedError(NOT_IMPLEMENTED) 27 | 28 | 29 | class Remediable(ABC): 30 | """ 31 | Abstract base class defining the interface for objects that can be remediated. 32 | """ 33 | 34 | @abstractmethod 35 | def remediate(self): 36 | """ 37 | Remediates the object to address any detected issues. 38 | """ 39 | return NotImplementedError(NOT_IMPLEMENTED) 40 | 41 | 42 | class InspectableFile(Inspectable): 43 | """ 44 | Represents an inspectable file within a specific ecosystem and file type. 45 | """ 46 | 47 | def __init__(self, file: FileTextWrite): 48 | """ 49 | Initializes an InspectableFile instance. 50 | 51 | Args: 52 | file (FileTextWrite): The file to be inspected. 53 | """ 54 | self.file = file 55 | self.ecosystem: Ecosystem 56 | self.file_type: FileType 57 | self.dependency_results: DependencyResultModel = \ 58 | DependencyResultModel(dependencies=[]) 59 | -------------------------------------------------------------------------------- /tests/test_policy_file/default_policy_file_using_invalid_typo_keyword.yml: -------------------------------------------------------------------------------- 1 | # Safety Security and License Configuration file 2 | # We recommend checking this file into your source control in the root of your Python project 3 | # If this file is named .safety-policy.yml and is in the same directory where you run `safety check` it will be used by default. 4 | # Otherwise, you can use the flag `safety check --policy-file ` to specify a custom location and name for the file. 5 | # To validate and review your policy file, run the validate command: `safety validate policy_file --path ` 6 | project-id: '' 7 | security: # configuration for the `safety check` command 8 | ignore-cvss-severity-below: 0 # A severity number between 0 and 10. Some helpful reference points: 9=ignore all vulnerabilities except CRITICAL severity. 7=ignore all vulnerabilities except CRITICAL & HIGH severity. 4=ignore all vulnerabilities except CRITICAL, HIGH & MEDIUM severity. 9 | ignore-cvss-unknown-severity: False # True or False. We recommend you set this to False. 10 | ignore-vunerabilities: # Here you can list multiple specific vulnerabilities you want to ignore (optionally for a time period) 11 | # We recommend making use of the optional `reason` and `expires` keys for each vulnerability that you ignore. 12 | 25853: # Example vulnerability ID 13 | reason: we don't use the vulnerable function # optional, for internal note purposes to communicate with your team. This reason will be reported in the Safety reports 14 | expires: '2022-10-21' # datetime string - date this ignore will expire, best practice to use this variable 15 | continue-on-vulnerability-error: False # Suppress non-zero exit codes when vulnerabilities are found. Enable this in pipelines and CI/CD processes if you want to pass builds that have vulnerabilities. We recommend you set this to False. 16 | -------------------------------------------------------------------------------- /tests/test_policy_file/default_policy_file_using_invalid_keyword.yml: -------------------------------------------------------------------------------- 1 | # Safety Security and License Configuration file 2 | # We recommend checking this file into your source control in the root of your Python project 3 | # If this file is named .safety-policy.yml and is in the same directory where you run `safety check` it will be used by default. 4 | # Otherwise, you can use the flag `safety check --policy-file ` to specify a custom location and name for the file. 5 | # To validate and review your policy file, run the validate command: `safety validate policy_file --path ` 6 | project-id: '' 7 | security: # configuration for the `safety check` command 8 | ignore-cvss-severity-below: 0 # A severity number between 0 and 10. Some helpful reference points: 9=ignore all vulnerabilities except CRITICAL severity. 7=ignore all vulnerabilities except CRITICAL & HIGH severity. 4=ignore all vulnerabilities except CRITICAL, HIGH & MEDIUM severity. 9 | ignore-cvss-unknown-severity: False # True or False. We recommend you set this to False. 10 | ignore-vulnerabilities: # Here you can list multiple specific vulnerabilities you want to ignore (optionally for a time period) 11 | # We recommend making use of the optional `reason` and `expires` keys for each vulnerability that you ignore. 12 | 25853: # Example vulnerability ID 13 | reason: we don't use the vulnerable function # optional, for internal note purposes to communicate with your team. This reason will be reported in the Safety reports 14 | expires: '2022-10-21' # datetime string - date this ignore will expire, best practice to use this variable 15 | continue-on-vulnerability-error: False # Suppress non-zero exit codes when vulnerabilities are found. Enable this in pipelines and CI/CD processes if you want to pass builds that have vulnerabilities. We recommend you set this to False. 16 | transitive: True 17 | -------------------------------------------------------------------------------- /tests/test_db/insecure_full.json: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "advisory": "PyUp.io metadata", 4 | "timestamp": 1675459691, 5 | "last_updated": "2023-02-03 21:28:11", 6 | "base_domain": "https://pyup.io", 7 | "schema_version": "2.0.0", 8 | "attribution": "Licensed under CC-BY-4.0 by pyup.io", 9 | "packages": { 10 | "django": { 11 | "secure_versions": ["2.1", "1.9", "2.0"], 12 | "insecure_versions": ["1.8"], 13 | "latest_version": "2.1", 14 | "latest_secure_version": "2.1", 15 | "more_info_path": "/p/pypi/django/97c/" 16 | } 17 | } 18 | }, 19 | "vulnerable_packages": { 20 | "django": [ 21 | { 22 | "specs": [ 23 | "<1.9" 24 | ], 25 | "advisory": "Some adivsory", 26 | "transitive": false, 27 | "more_info_path": "/v/42108/97c", 28 | "ids": [ 29 | { 30 | "type": "pyup", 31 | "id": "some id" 32 | }, 33 | { 34 | "type": "cve", 35 | "id": "some cve" 36 | } 37 | ] 38 | }, 39 | { 40 | "specs": [ 41 | "<1.9" 42 | ], 43 | "advisory": "Some other adivsory", 44 | "transitive": false, 45 | "more_info_path": "/v/42108/97c", 46 | "ids": [ 47 | { 48 | "type": "pyup", 49 | "id": "some other id" 50 | }, 51 | { 52 | "type": "cve", 53 | "id": "some other cve" 54 | } 55 | ] 56 | } 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/scan/test_command.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import tempfile 3 | from importlib.metadata import version 4 | from click.testing import CliRunner 5 | from packaging.version import Version 6 | from safety.auth.models import Auth 7 | from safety.cli import cli 8 | from safety.console import main_console as console 9 | from unittest.mock import patch 10 | 11 | 12 | class TestScanCommand(unittest.TestCase): 13 | def setUp(self): 14 | # mix_stderr was removed in Click 8.2.0 15 | if Version(version("click")) >= Version("8.2.0"): 16 | self.runner = CliRunner() 17 | else: 18 | self.runner = CliRunner(mix_stderr=False) 19 | self.target = tempfile.mkdtemp() 20 | # Make sure the console is not quiet 21 | # TODO: This is a workaround, we should improve the way the console 22 | # is initialized in the CLI 23 | console.quiet = False 24 | 25 | cli.commands = cli.all_commands 26 | self.cli = cli 27 | 28 | @patch.object(Auth, "is_valid", return_value=False) 29 | @patch( 30 | "safety.platform.SafetyPlatformClient.get_authentication_type", 31 | return_value="unauthenticated", 32 | ) 33 | def test_scan(self, mock_is_valid, mock_get_auth_type): 34 | result = self.runner.invoke( 35 | self.cli, ["scan", "--target", self.target, "--output", "json"] 36 | ) 37 | self.assertEqual(result.exit_code, 1) 38 | 39 | result = self.runner.invoke( 40 | self.cli, 41 | [ 42 | "--stage", 43 | "production", 44 | "scan", 45 | "--target", 46 | self.target, 47 | "--output", 48 | "json", 49 | ], 50 | ) 51 | self.assertEqual(result.exit_code, 1) 52 | 53 | result = self.runner.invoke( 54 | self.cli, 55 | ["--stage", "cicd", "scan", "--target", self.target, "--output", "screen"], 56 | ) 57 | self.assertEqual(result.exit_code, 1) 58 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit script on error 4 | set -e 5 | 6 | # Ensure the version is passed as an argument 7 | if [ -z "$1" ]; then 8 | echo "Usage: $0 " 9 | exit 1 10 | fi 11 | 12 | NEW_VERSION=$1 13 | 14 | # Validate the version follows semantic versioning (X.Y.Z) 15 | if [[ ! "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 16 | echo "Error: Version must be in format X.Y.Z (e.g., 1.10.4)" 17 | exit 1 18 | fi 19 | 20 | # Pull the latest changes from the main branch 21 | echo "Pulling latest changes..." 22 | git pull origin main 23 | 24 | # Verify the latest commit 25 | echo "Verifying the latest commit..." 26 | git log -1 27 | 28 | # Ask for user confirmation to proceed 29 | read -p "Is this the correct commit? (y/n): " CONFIRM 30 | if [ "$CONFIRM" != "y" ]; then 31 | echo "Aborting the release process." 32 | exit 1 33 | fi 34 | 35 | # Create a new tag 36 | echo "Creating a new tag for version $NEW_VERSION..." 37 | git tag -s -a "$NEW_VERSION" -m "Version $NEW_VERSION" 38 | 39 | # Push the new tag to GitHub 40 | echo "Pushing the tag to GitHub..." 41 | git push origin "$NEW_VERSION" 42 | 43 | # Verify the tag on GitHub 44 | echo "Tag created. Verify it here: https://github.com/pyupio/safety/tags" 45 | 46 | # Prompt user to create a release manually on GitHub 47 | echo "Please create a release on GitHub from the tag here: https://github.com/pyupio/safety/releases/new" 48 | echo "Attach the relevant binaries to the release before publishing." 49 | 50 | # Remove any existing dist folder to avoid conflicts 51 | echo "Cleaning up old builds..." 52 | rm -rf dist 53 | 54 | # Install required packages if not already installed 55 | echo "Installing necessary packages for build..." 56 | pip install --upgrade build twine 57 | 58 | # Build the package 59 | echo "Building the package..." 60 | python3 -m build 61 | 62 | # Publish the package to PyPI 63 | echo "Publishing the package to PyPI..." 64 | twine upload dist/* 65 | 66 | # Verify the release on PyPI 67 | echo "Verify the new release here: https://pypi.org/project/safety/" 68 | -------------------------------------------------------------------------------- /tests/tool/interceptors/test_factory.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | 4 | from safety.tool.interceptors.types import InterceptorType 5 | from safety.tool.interceptors.unix import UnixAliasInterceptor 6 | from safety.tool.interceptors.windows import WindowsInterceptor 7 | from safety.tool.interceptors.factory import create_interceptor 8 | 9 | 10 | class TestFactory(unittest.TestCase): 11 | def test_explicit_unix_alias_interceptor(self): 12 | interceptor = create_interceptor(InterceptorType.UNIX_ALIAS) 13 | self.assertIsInstance(interceptor, UnixAliasInterceptor) 14 | 15 | def test_explicit_windows_interceptor(self): 16 | interceptor = create_interceptor(InterceptorType.WINDOWS_BAT) 17 | self.assertIsInstance(interceptor, WindowsInterceptor) 18 | 19 | @patch('safety.tool.interceptors.factory.platform', 'win32') 20 | def test_auto_select_windows(self): 21 | interceptor = create_interceptor() 22 | self.assertIsInstance(interceptor, WindowsInterceptor) 23 | 24 | def test_auto_select_unix_like(self): 25 | unix_platforms = ['linux', 'linux2', 'darwin'] 26 | 27 | for platform in unix_platforms: 28 | with self.subTest(platform=platform): 29 | with patch('safety.tool.interceptors.factory.platform', 30 | platform): 31 | interceptor = create_interceptor() 32 | self.assertIsInstance(interceptor, UnixAliasInterceptor) 33 | 34 | @patch('safety.tool.interceptors.factory.platform', 'unsupported_os') 35 | def test_unsupported_platform(self): 36 | with self.assertRaises(NotImplementedError) as context: 37 | create_interceptor() 38 | self.assertIn("Platform 'unsupported_os' is not supported", 39 | str(context.exception)) 40 | 41 | def test_invalid_interceptor_type(self): 42 | invalid_type = "INVALID_TYPE" 43 | with self.assertRaises(KeyError): 44 | create_interceptor(invalid_type) 45 | -------------------------------------------------------------------------------- /safety/tool/intents.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from enum import Enum, auto 3 | from typing import Any, Dict, List, Optional, Set 4 | 5 | 6 | class ToolIntentionType(Enum): 7 | """ 8 | High-level intentions that are common across tools 9 | """ 10 | 11 | ADD_PACKAGE = auto() 12 | REMOVE_PACKAGE = auto() 13 | UPDATE_PACKAGE = auto() 14 | DOWNLOAD_PACKAGE = auto() 15 | SEARCH_PACKAGES = auto() 16 | SYNC_PACKAGES = auto() 17 | LIST_PACKAGES = auto() 18 | INIT_PROJECT = auto() 19 | BUILD_PROJECT = auto() 20 | RUN_SCRIPT = auto() 21 | UNKNOWN = auto() 22 | 23 | 24 | @dataclass 25 | class Dependency: 26 | """ 27 | Common representation of a dependency 28 | """ 29 | 30 | name: str 31 | arg_index: int 32 | original_text: str 33 | version_constraint: Optional[str] = None 34 | extras: Set[str] = field(default_factory=set) 35 | is_dev_dependency: bool = False 36 | corrected_text: Optional[str] = None 37 | 38 | 39 | @dataclass 40 | class CommandToolIntention: 41 | """ 42 | Represents a parsed tool command with normalized intention 43 | """ 44 | 45 | tool: str 46 | command: str 47 | intention_type: ToolIntentionType 48 | command_chain: List[str] = field(default_factory=list) 49 | packages: List[Dependency] = field(default_factory=list) 50 | options: Dict[str, Any] = field(default_factory=dict) 51 | raw_args: List[str] = field(default_factory=list) 52 | 53 | def modifies_packages(self) -> bool: 54 | """ 55 | Check if this intention type modifies installed packages. 56 | """ 57 | return self.intention_type in { 58 | ToolIntentionType.ADD_PACKAGE, 59 | ToolIntentionType.REMOVE_PACKAGE, 60 | ToolIntentionType.UPDATE_PACKAGE, 61 | ToolIntentionType.SYNC_PACKAGES, 62 | } 63 | 64 | def queries_packages(self) -> bool: 65 | """ 66 | Check if this intention type queries for packages. 67 | """ 68 | return self.intention_type in { 69 | ToolIntentionType.SEARCH_PACKAGES, 70 | ToolIntentionType.LIST_PACKAGES, 71 | } 72 | -------------------------------------------------------------------------------- /safety/codebase/constants.py: -------------------------------------------------------------------------------- 1 | CMD_HELP_CODEBASE_INIT = "Initialize a Safety Codebase (like git init for security). Sets up a new codebase or connects your local project to an existing one on Safety Platform." 2 | CMD_HELP_CODEBASE = ( 3 | "[BETA] Manage your Safety Codebase integration.\nExample: safety codebase init" 4 | ) 5 | 6 | CMD_CODEBASE_GROUP_NAME = "codebase" 7 | CMD_CODEBASE_INIT_NAME = "init" 8 | 9 | 10 | # init options help 11 | CMD_HELP_CODEBASE_INIT_NAME = "Name of the codebase. Defaults to GIT origin name, parent directory name, or random string if parent directory is unnamed. The value will be normalized for use as an identifier." 12 | CMD_HELP_CODEBASE_INIT_LINK_TO = ( 13 | "Link to an existing codebase using its codebase slug (found in Safety Platform)." 14 | ) 15 | CMD_HELP_CODEBASE_INIT_DISABLE_FIREWALL = "Don't enable Firewall protection for this codebase (enabled by default when available in your organization)" 16 | CMD_HELP_CODEBASE_INIT_PATH = ( 17 | "Path to the codebase directory. Defaults to current directory." 18 | ) 19 | 20 | 21 | CODEBASE_INIT_REINITIALIZED = "Reinitialized existing codebase {codebase_name}" 22 | CODEBASE_INIT_ALREADY_EXISTS = "A codebase already exists in this directory. Please delete .safety-project.ini and run `safety codebase init` again to initialize a new codebase." 23 | CODEBASE_INIT_NOT_FOUND_LINK_TO = "\nError: codebase '{codebase_name}' specified with --link-to does not exist.\n\nTo create a new codebase instead, use one of:\n safety codebase init\n safety codebase init --name \"custom name\"\n\nTo link to an existing codebase, verify the codebase id and try again." 24 | CODEBASE_INIT_NOT_FOUND_PROJECT_FILE = "\nError: codebase '{codebase_name}' specified with the current .safety-project.ini file does not exist.\n\nTo create a new codebase instead, delete the corrupted .safety-project.ini file and then use one of:\n safety codebase init\n safety codebase init --name \"custom name\"\n\nTo link to an existing codebase, verify the codebase id and try again." 25 | CODEBASE_INIT_LINKED = "Linked to codebase {codebase_name}." 26 | CODEBASE_INIT_CREATED = "Created new codebase {codebase_name}." 27 | CODEBASE_INIT_ERROR = "Error: unable to initialize the codebase. Please try again." 28 | -------------------------------------------------------------------------------- /safety/formatters/html.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Dict, Tuple, Optional 3 | 4 | 5 | from safety.formatter import FormatterAPI 6 | from safety.formatters.json import build_json_report 7 | from safety.output_utils import get_report_brief_info, parse_html 8 | from safety.util import get_basic_announcements 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | 13 | class HTMLReport(FormatterAPI): 14 | """ 15 | HTML report formatter for when the output is input for something else. 16 | """ 17 | 18 | def render_vulnerabilities(self, announcements: List[Dict], vulnerabilities: List[Dict], remediations: Dict, 19 | full: bool, packages: List[Dict], fixes: Tuple = ()) -> Optional[str]: 20 | """ 21 | Renders vulnerabilities in HTML format. 22 | 23 | Args: 24 | announcements (List[Dict]): List of announcements. 25 | vulnerabilities (List[Dict]): List of vulnerabilities. 26 | remediations (Dict): Remediation data. 27 | full (bool): Flag indicating full output. 28 | packages (List[Dict]): List of packages. 29 | fixes (Tuple, optional): Tuple of fixes. 30 | 31 | Returns: 32 | str: Rendered HTML vulnerabilities report. 33 | """ 34 | LOG.debug( 35 | f'HTML Output, Rendering {len(vulnerabilities)} vulnerabilities, {len(remediations)} package ' 36 | f'remediations with full_report: {full}') 37 | report = build_json_report(announcements, vulnerabilities, remediations, packages) 38 | 39 | return parse_html(kwargs={"json_data": report}) 40 | 41 | def render_licenses(self, announcements: List[Dict], licenses: List[Dict]) -> None: 42 | """ 43 | Renders licenses in HTML format. 44 | 45 | Args: 46 | announcements (List[Dict]): List of announcements. 47 | licenses (List[Dict]): List of licenses. 48 | """ 49 | pass 50 | 51 | def render_announcements(self, announcements: List[Dict]) -> None: 52 | """ 53 | Renders announcements in HTML format. 54 | 55 | Args: 56 | announcements (List[Dict]): List of announcements. 57 | """ 58 | pass 59 | -------------------------------------------------------------------------------- /safety/alerts/templates/pr.jinja2: -------------------------------------------------------------------------------- 1 | Vulnerability fix: This PR updates [{{ pkg }}]({{ remediation['more_info_url'] }}) from **{% if remediation['version'] %}{{ remediation['version'] }}{% else %}{{ remediation['requirement']['specifier'] }}{% endif %}** to **{{ remediation['recommended_version'] }}** to fix {{ vulns | length }} vulnerabilit{{ "y" if vulns|length == 1 else "ies" }}{% if overall_impact %}{{ " rated " + overall_impact if vulns|length == 1 else " with the highest CVSS severity rating being " + overall_impact }}{% endif %}. 2 | 3 | To read more about the impact of {{ "this vulnerability" if vulns|length == 1 else "these vulnerabilities" }} see [PyUp’s {{ pkg }} page]({{ remediation['more_info_url'] }}). 4 | 5 | {{ hint }} 6 | 7 |
8 | Vulnerabilities Fixed 9 | {% for vuln in vulns %} 10 | * {{ vuln.advisory }} 11 | {% if vuln.severity and vuln.severity.cvssv3 and vuln.severity.cvssv3.base_severity %} 12 | * This vulnerability was rated {{ vuln.severity.cvssv3.base_severity }} ({{ vuln.severity.cvssv3.base_score }}) on CVSSv3. 13 | {% endif %} 14 | * To read more about this vulnerability, see PyUp’s [vulnerability page]({{ vuln.more_info_url }}) 15 | {% endfor %} 16 |
17 | 18 |
19 | Changelog 20 | {% if summary_changelog %} 21 | The full changelog is too long to post here. See [PyUp’s {{ pkg }} page]({{ remediation['more_info_url'] }}) for more information. 22 | {% else %} 23 | {% for version, log in changelog.items() %} 24 | ### {{ version }} 25 | 26 | ``` 27 | {{ log }} 28 | ``` 29 | {% endfor %} 30 | {% endif %} 31 |
32 | 33 |
34 | Ignoring {{ "This Vulnerability" if vulns|length == 1 else "These Vulnerabilities" }} 35 | 36 | If you wish to [ignore this vulnerability](https://docs.pyup.io/docs/safety-20-policy-file), you can add the following to `.safety-policy.yml` in this repo: 37 | 38 | ``` 39 | security: 40 | ignore-vulnerabilities:{% for vuln in vulns %} 41 | {{ vuln.vulnerability_id }}: 42 | reason: enter a reason as to why you're ignoring this vulnerability 43 | expires: 'YYYY-MM-DD' # datetime string - date this ignore will expire 44 | {% endfor %} 45 | ``` 46 | 47 |
48 | -------------------------------------------------------------------------------- /safety/events/event_bus/utils.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Optional, cast 2 | from .bus import EventBus 3 | 4 | from safety_schemas.models.events import EventType 5 | from safety.events.types import InternalEventType 6 | from safety.events.handlers import SecurityEventsHandler 7 | 8 | from safety.constants import PLATFORM_API_EVENTS_ENDPOINT 9 | 10 | if TYPE_CHECKING: 11 | from safety.models import SafetyCLI 12 | from safety.auth.models import Auth 13 | 14 | 15 | def start_event_bus(obj: "SafetyCLI", auth: "Auth"): 16 | """ 17 | Initializes the event bus with the default security events handler 18 | for authenticated users. 19 | This function creates an instance of the EventBus, starts it, 20 | and assigns it to the `event_bus` attribute of the provided `obj`. 21 | It also initializes the `security_events_handler` with the necessary 22 | parameters and subscribes it to a predefined list of events. 23 | 24 | Args: 25 | obj (SafetyCLI): The main application object. 26 | http_client: The HTTP client containing the necessary credentials and proxies. 27 | 28 | """ 29 | event_bus = EventBus() 30 | event_bus.start() 31 | obj.event_bus = event_bus 32 | 33 | token: Optional[str] = ( 34 | cast(Optional[str], auth.platform.token.get("access_token")) 35 | if auth.platform.token 36 | else None 37 | ) 38 | api_key: Optional[str] = auth.platform.api_key 39 | 40 | # TODO: Improve this on a future refactor 41 | obj.security_events_handler = SecurityEventsHandler( 42 | api_endpoint=PLATFORM_API_EVENTS_ENDPOINT, 43 | tls_config=auth.platform._tls_config.verify_context, 44 | proxy_config=auth.platform._proxy_config, 45 | auth_token=token, 46 | api_key=api_key, 47 | ) 48 | 49 | events = [ 50 | EventType.AUTH_STARTED, 51 | EventType.AUTH_COMPLETED, 52 | EventType.COMMAND_EXECUTED, 53 | EventType.COMMAND_ERROR, 54 | InternalEventType.CLOSE_RESOURCES, 55 | InternalEventType.FLUSH_SECURITY_TRACES, 56 | ] 57 | 58 | event_bus.subscribe(events, obj.security_events_handler) 59 | 60 | if obj.firewall_enabled: 61 | from safety.firewall.events.utils import register_event_handlers 62 | 63 | register_event_handlers(obj.event_bus, obj=obj) 64 | -------------------------------------------------------------------------------- /safety/utils/pyapp_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from typing import Optional, Dict 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def get_path() -> Optional[str]: 9 | """ 10 | Get the PATH environment variable with pyapp-related paths filtered out. 11 | 12 | This is necessary when running as a pyapp binary to prevent the bundled 13 | Python from interfering with system tools. 14 | 15 | Returns: 16 | str or None: The filtered PATH string (or original PATH if not in PYAPP environment), 17 | or None if PATH is not set 18 | """ 19 | # If not in PYAPP environment, return original PATH 20 | if "PYAPP" not in os.environ: 21 | return os.environ.get("PATH") 22 | 23 | # If PATH is not set, return None 24 | if "PATH" not in os.environ: 25 | return None 26 | 27 | logger.debug( 28 | "Binary environment detected, filtering internal Python path from PATH" 29 | ) 30 | 31 | original_path = os.environ["PATH"] 32 | path_parts = original_path.split(os.pathsep) 33 | 34 | filtered_paths = [] 35 | removed_paths = [] 36 | 37 | for path in path_parts: 38 | path_lower = path.lower() 39 | if "pyapp" in path_lower and "safety" in path_lower: 40 | removed_paths.append(path) 41 | logger.debug(f"Removing internal Python path from PATH: {path}") 42 | else: 43 | filtered_paths.append(path) 44 | 45 | if removed_paths: 46 | filtered_path = os.pathsep.join(filtered_paths) 47 | logger.info(f"Filtered {len(removed_paths)} internal Python path(s) from PATH") 48 | logger.debug( 49 | f"Original PATH entries: {len(path_parts)}, Filtered PATH entries: {len(filtered_paths)}" 50 | ) 51 | return filtered_path 52 | 53 | return original_path 54 | 55 | 56 | def get_env() -> Dict[str, str]: 57 | """ 58 | Get a copy of the environment with pyapp-related paths filtered from PATH. 59 | 60 | This is useful for subprocess calls to prevent the bundled Python from 61 | interfering with system tools. 62 | 63 | Returns: 64 | dict: A copy of os.environ with filtered PATH 65 | """ 66 | env = os.environ.copy() 67 | filtered_path = get_path() 68 | 69 | if filtered_path is not None: 70 | env["PATH"] = filtered_path 71 | 72 | return env 73 | -------------------------------------------------------------------------------- /safety/meta.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | import logging 3 | import os 4 | import platform 5 | from typing import Dict, Optional 6 | 7 | 8 | LOG = logging.getLogger(__name__) 9 | 10 | 11 | def get_version() -> Optional[str]: 12 | """ 13 | Get the version of the Safety package. 14 | 15 | Returns: 16 | Optional[str]: The Safety version if found, otherwise None. 17 | """ 18 | try: 19 | return version("safety") 20 | except PackageNotFoundError: 21 | LOG.exception("Unable to get Safety version.") 22 | return None 23 | 24 | 25 | def get_identifier() -> str: 26 | """ 27 | Get the identifier of the source type. 28 | 29 | Returns: 30 | str: The source type identifier. 31 | """ 32 | 33 | if source := os.environ.get("SAFETY_SOURCE_TYPE", None): 34 | return source 35 | 36 | from safety_schemas.models.events.types import SourceType 37 | 38 | return SourceType.SAFETY_CLI_PYPI.value 39 | 40 | 41 | def get_user_agent() -> str: 42 | """ 43 | Get the user agent string for HTTP requests. 44 | 45 | Returns: 46 | str: The user agent string in the format: safety-cli/{version} ({os} {arch}; Python/{python_version}) 47 | """ 48 | safety_version = get_version() or "unknown" 49 | os_name = platform.system() 50 | 51 | # Get architecture 52 | machine = platform.machine() 53 | # Normalize architecture names 54 | if machine in ("x86_64", "AMD64"): 55 | arch = "x86_64" 56 | elif machine in ("arm64", "aarch64"): 57 | arch = "arm_64" 58 | elif machine == "i386": 59 | arch = "x86" 60 | else: 61 | arch = machine or "unknown" 62 | 63 | python_version = platform.python_version() 64 | 65 | return f"safety-cli/{safety_version} ({os_name} {arch}; Python/{python_version})" 66 | 67 | 68 | def get_meta_http_headers() -> Dict[str, str]: 69 | """ 70 | Get the metadata headers for the client. 71 | 72 | Returns: 73 | Dict[str, str]: The metadata headers. 74 | """ 75 | 76 | from safety_schemas.models.events.constants import SAFETY_NAMESPACE 77 | 78 | namespace = SAFETY_NAMESPACE.title() 79 | 80 | return { 81 | f"{namespace}-Client-Version": get_version() or "", 82 | f"{namespace}-Client-Id": get_identifier(), 83 | "User-Agent": get_user_agent(), 84 | } 85 | -------------------------------------------------------------------------------- /safety/tool/auth.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | 4 | from urllib.parse import urlsplit, urlunsplit 5 | 6 | from safety.tool.constants import ( 7 | NPMJS_PUBLIC_REPOSITORY_URL, 8 | PYPI_PUBLIC_REPOSITORY_URL, 9 | ) 10 | from typing import TYPE_CHECKING, Optional, Literal 11 | 12 | if TYPE_CHECKING: 13 | from safety.auth.models import Auth 14 | from safety.cli_util import CustomContext 15 | from safety.models import SafetyCLI 16 | 17 | SafetyContext = CustomContext[SafetyCLI] 18 | 19 | 20 | def index_credentials(ctx: "SafetyContext") -> str: 21 | """ 22 | Returns the index credentials for the current context. 23 | This should be used together with user:index_credential for index 24 | basic auth. 25 | 26 | Args: 27 | ctx (SafetyContext): The context. 28 | 29 | Returns: 30 | str: The index credentials. 31 | """ 32 | api_key = None 33 | token = None 34 | 35 | auth_obj = getattr(ctx.obj, "auth", None) 36 | 37 | if auth_obj is not None: 38 | auth: "Auth" = auth_obj 39 | 40 | client = auth.platform 41 | token = client.token.get("access_token") if client.token else None 42 | api_key = client.api_key 43 | 44 | auth_envelop = json.dumps( 45 | { 46 | "version": "1.0", 47 | "access_token": token, 48 | "api_key": api_key, 49 | "project_id": ctx.obj.project.id if ctx.obj.project else None, 50 | } 51 | ) 52 | return base64.urlsafe_b64encode(auth_envelop.encode("utf-8")).decode("utf-8") 53 | 54 | 55 | def build_index_url( 56 | ctx: "SafetyContext", index_url: Optional[str], index_type: Literal["pypi", "npm"] 57 | ) -> str: 58 | """ 59 | Builds the index URL for the current context. 60 | """ 61 | if index_url is None: 62 | # TODO: Make this to select the index based on auth org or project 63 | index_url = { 64 | "pypi": PYPI_PUBLIC_REPOSITORY_URL, 65 | "npm": NPMJS_PUBLIC_REPOSITORY_URL, 66 | }[index_type] 67 | 68 | url = urlsplit(index_url) 69 | 70 | encoded_auth = index_credentials(ctx) 71 | netloc = f"user:{encoded_auth}@{url.netloc}" 72 | 73 | if type(url.netloc) is bytes: 74 | url = url._replace(netloc=netloc.encode("utf-8")) 75 | elif type(url.netloc) is str: 76 | url = url._replace(netloc=netloc) 77 | 78 | return urlunsplit(url) 79 | -------------------------------------------------------------------------------- /safety/tool/resolver.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import subprocess 3 | import shutil 4 | import logging 5 | 6 | from safety.utils.pyapp_utils import get_path, get_env 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def get_unwrapped_command(name: str) -> str: 12 | """ 13 | Find the true executable for a command, skipping wrappers/aliases/.bat files. 14 | 15 | Args: 16 | command: The command to resolve (e.g. 'pip', 'python') 17 | 18 | Returns: 19 | Path to the actual executable 20 | """ 21 | logger.debug(f"get_unwrapped_command called with name: {name}") 22 | 23 | if sys.platform in ["win32"]: 24 | for lookup_term in [f"{name}.exe", name]: 25 | logger.debug(f"Windows platform detected, looking for: {lookup_term}") 26 | 27 | where_result = subprocess.run( 28 | ["where.exe", lookup_term], 29 | capture_output=True, 30 | text=True, 31 | env=get_env(), 32 | ) 33 | 34 | logger.debug(f"where.exe returncode: {where_result.returncode}") 35 | logger.debug(f"where.exe stdout: {where_result.stdout}") 36 | logger.debug(f"where.exe stderr: {where_result.stderr}") 37 | 38 | if where_result.returncode == 0: 39 | for path in where_result.stdout.splitlines(): 40 | path = path.strip() 41 | if not path: 42 | continue 43 | 44 | logger.debug(f"Checking path: {path}") 45 | path_lower = path.lower() 46 | 47 | if not path_lower.endswith((".exe", ".bat", ".cmd")): 48 | logger.debug(f"Skipping non-executable path: {path}") 49 | continue 50 | 51 | if "\\safety\\" in path_lower and path_lower.endswith( 52 | f"{name}.bat" 53 | ): 54 | logger.debug(f"Skipping Safety wrapper: {path}") 55 | continue 56 | 57 | return path 58 | 59 | logger.debug( 60 | f"No unwrapped command found on Windows, returning bare name: {name}" 61 | ) 62 | return name 63 | 64 | fallback = shutil.which(name, path=get_path()) or name 65 | logger.debug(f"Using fallback (shutil.which or name): {fallback}") 66 | return fallback 67 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Safety CLI Development Environment", 3 | 4 | "build": { 5 | "dockerfile": "Dockerfile", 6 | "context": "." 7 | }, 8 | 9 | "remoteUser": "developer", 10 | "workspaceFolder": "${localWorkspaceFolder}", 11 | "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", 12 | 13 | 14 | "mounts": [ 15 | "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind", 16 | "source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/developer/.ssh,type=bind,consistency=cached", 17 | "source=${localEnv:HOME}/.safety,target=/home/developer/.safety,type=bind,consistency=cached" 18 | ], 19 | 20 | "remoteEnv": { 21 | "PYTHONPATH": "${localWorkspaceFolder}", 22 | "TERM": "xterm-256color" 23 | }, 24 | 25 | "customizations": { 26 | "vscode": { 27 | "settings": { 28 | "terminal.integrated.defaultProfile.linux": "zsh", 29 | "terminal.integrated.profiles.linux": { 30 | "zsh": { 31 | "path": "/bin/zsh" 32 | } 33 | }, 34 | "python.defaultInterpreterPath": "${localWorkspaceFolder}/.hatch/bin/python", 35 | "editor.rulers": [80], 36 | "files.exclude": { 37 | "**/__pycache__": true, 38 | "**/.pytest_cache": true 39 | }, 40 | "search.exclude": { 41 | "**/.hatch": true, 42 | } 43 | }, 44 | "extensions": [ 45 | "ms-python.vscode-pylance", 46 | "ms-python.python", 47 | "ms-python.debugpy", 48 | "ms-pyright.pyright", 49 | "charliermarsh.ruff", 50 | "tamasfe.even-better-toml", 51 | "GitHub.copilot", 52 | "streetsidesoftware.code-spell-checker", 53 | "VisualStudioExptTeam.vscodeintellicode", 54 | "VisualStudioExptTeam.intellicode-api-usage-examples", 55 | "mechatroner.rainbow-csv", 56 | "redhat.vscode-yaml", 57 | "eamodio.gitlens", 58 | "github.vscode-github-actions" 59 | ] 60 | } 61 | }, 62 | 63 | "postCreateCommand": "hatch env create default && git config --global core.editor nano", 64 | "postAttachCommand": "sudo chown root:developer /var/run/docker.sock && sudo chmod 660 /var/run/docker.sock && hatch env remove default && hatch env create default", 65 | 66 | "containerEnv": { 67 | "SHELL": "/bin/zsh" 68 | }, 69 | 70 | "waitFor": "postCreateCommand", 71 | "shutdownAction": "stopContainer" 72 | } -------------------------------------------------------------------------------- /tests/auth/test_cli.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, ANY 2 | from click.testing import CliRunner 3 | import unittest 4 | from packaging.version import Version 5 | 6 | from importlib.metadata import version 7 | 8 | from safety.cli import cli 9 | 10 | 11 | class TestSafetyAuthCLI(unittest.TestCase): 12 | def setUp(self): 13 | self.maxDiff = None 14 | # mix_stderr was removed in Click 8.2.0 15 | if Version(version("click")) >= Version("8.2.0"): 16 | self.runner = CliRunner() 17 | else: 18 | self.runner = CliRunner(mix_stderr=False) 19 | 20 | cli.commands = cli.all_commands 21 | self.cli = cli 22 | 23 | @unittest.skip("We are bypassing email verification for now") 24 | @patch("safety.auth.cli.fail_if_authenticated") 25 | @patch("safety.auth.cli.get_authorization_data") 26 | @patch("safety.auth.cli.process_browser_callback") 27 | def test_auth_calls_login( 28 | self, process_browser_callback, get_authorization_data, fail_if_authenticated 29 | ): 30 | auth_data = "https://safetycli.com", "initialState" 31 | get_authorization_data.return_value = auth_data 32 | process_browser_callback.return_value = { 33 | "email": "user@safetycli.com", 34 | "name": "Safety User", 35 | } 36 | result = self.runner.invoke(self.cli, ["auth"]) 37 | 38 | fail_if_authenticated.assert_called_once() 39 | get_authorization_data.assert_called_once() 40 | process_browser_callback.assert_called_once_with( 41 | auth_data[0], initial_state=auth_data[1], ctx=ANY, headless=False 42 | ) 43 | 44 | expected = [ 45 | "", 46 | "Redirecting your browser to log in; once authenticated, return here to start using Safety", 47 | "", 48 | "You're authenticated", 49 | " Account: Safety User, user@safetycli.com (email verification required)", 50 | "", 51 | "To complete your account open the “verify your email” email sent to", 52 | "user@safetycli.com", 53 | "", 54 | "Can’t find the verification email? Login at", 55 | "`https://platform.safetycli.com/login/` to resend the verification email", 56 | "", 57 | ] 58 | 59 | for res_line, exp_line in zip(result.stdout.splitlines(), expected): 60 | self.assertIn(exp_line, res_line) 61 | -------------------------------------------------------------------------------- /tests/scan/test_file_finder.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | import unittest 3 | from unittest.mock import patch 4 | from pathlib import Path 5 | 6 | from safety.scan.finder.file_finder import FileFinder, should_exclude 7 | 8 | 9 | class TestShouldExclude(unittest.TestCase): 10 | def test_should_exclude(self): 11 | excludes = {Path("/exclude"), Path("/file.py")} 12 | self.assertTrue(should_exclude(excludes, Path("/exclude/path"))) 13 | self.assertTrue(should_exclude(excludes, Path("/file.py"))) 14 | self.assertFalse(should_exclude(excludes, Path("/absolute/path"))) 15 | 16 | 17 | class TestFileFinder(unittest.TestCase): 18 | @patch.object(Path, "glob") 19 | @patch("os.walk") 20 | def test_process_directory(self, mock_os_walk, mock_glob): 21 | # Mock the os.walk function to return a fake directory structure 22 | mock_os_walk.return_value = [ 23 | ("/root", ["dir1", "dir2"], ["file1", "file2"]), 24 | ("/root/dir1", [], ["file3", "file4"]), 25 | ("/root/dir2", [], ["file5", "file6"]), 26 | ] 27 | 28 | # Mock the Path.glob method to simulate the exclusion patterns 29 | mock_glob.return_value = [Path("/root/dir1")] 30 | 31 | file_finder = FileFinder(max_level=1, ecosystems=[], target=Path("/root")) 32 | dir_path, files = file_finder.process_directory("/root") 33 | 34 | self.assertEqual(dir_path, "/root") 35 | self.assertEqual( 36 | len(files), 0 37 | ) # No files should be found as we didn't mock the handlers 38 | 39 | @patch.object(Path, "glob") 40 | @patch("os.walk") 41 | def test_search(self, mock_os_walk, mock_glob): 42 | # Mock the os.walk function to return a fake directory structure 43 | mock_os_walk.return_value = [ 44 | ("/root", ["dir1", "dir2"], ["file1", "file2"]), 45 | ("/root/dir1", [], ["file3", "file4"]), 46 | ("/root/dir2", [], ["file5", "file6"]), 47 | ] 48 | 49 | # Mock the Path.glob method to simulate the exclusion patterns 50 | mock_glob.return_value = [Path("/root/dir1")] 51 | 52 | file_finder = FileFinder(max_level=1, ecosystems=[], target=Path("/root")) 53 | dir_path, files = file_finder.search() 54 | 55 | self.assertEqual(dir_path, Path("/root")) 56 | self.assertEqual( 57 | len(files), 0 58 | ) # No files should be found as we didn't mock the handlers 59 | -------------------------------------------------------------------------------- /safety/events/utils/conditions.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import TYPE_CHECKING, Any, Callable, List, Optional, TypeVar, cast, overload 3 | 4 | 5 | if TYPE_CHECKING: 6 | from safety.events.event_bus import EventBus 7 | from safety.cli_util import CustomContext 8 | 9 | 10 | def should_emit( 11 | event_bus: Optional["EventBus"], ctx: Optional["CustomContext"] 12 | ) -> bool: 13 | """ 14 | Common conditions that apply to all event emissions. 15 | """ 16 | if event_bus is None: 17 | return False 18 | 19 | # Be aware that ctx depends on the command being parsed, if the emit func 20 | # is called from the entrypoint group command, ctx will not have 21 | # the command parsed yet. 22 | 23 | return True 24 | 25 | 26 | def should_emit_firewall_heartbeat(ctx: Optional["CustomContext"]) -> bool: 27 | """ 28 | Condition to check if the firewall is enabled. 29 | """ 30 | if ctx and ctx.obj.firewall_enabled: 31 | return True 32 | 33 | return False 34 | 35 | 36 | # Define TypeVars for better typing 37 | F = TypeVar("F", bound=Callable[..., Any]) 38 | R = TypeVar("R") 39 | 40 | 41 | @overload 42 | def conditional_emitter(emit_func: F, *, conditions: None = None) -> F: ... 43 | 44 | 45 | @overload 46 | def conditional_emitter( 47 | emit_func: None = None, 48 | *, 49 | conditions: Optional[List[Callable[[Optional["CustomContext"]], bool]]] = None, 50 | ) -> Callable[[F], F]: ... 51 | 52 | 53 | def conditional_emitter( 54 | emit_func=None, 55 | *, 56 | conditions: Optional[List[Callable[[Optional["CustomContext"]], bool]]] = None, 57 | ): 58 | """ 59 | A decorator that conditionally calls the decorated function based on conditions. 60 | Only executes the decorated function if all conditions evaluate to True. 61 | """ 62 | 63 | def decorator(func: F) -> F: 64 | @wraps(func) 65 | def wrapper(event_bus, ctx=None, *args, **kwargs): 66 | if not should_emit(event_bus, ctx): 67 | return None 68 | 69 | if conditions: 70 | if all(condition(ctx) for condition in conditions): 71 | return func(event_bus, ctx, *args, **kwargs) 72 | return None 73 | return func(event_bus, ctx, *args, **kwargs) 74 | 75 | return cast(F, wrapper) # Cast to help type checker 76 | 77 | if emit_func is None: 78 | return decorator 79 | return decorator(emit_func) 80 | -------------------------------------------------------------------------------- /safety/tool/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from pathlib import Path 3 | from typing import TYPE_CHECKING 4 | 5 | from rich.console import Console 6 | from safety_schemas.models import ProjectModel 7 | 8 | from safety.console import main_console 9 | from safety.errors import SafetyException 10 | from safety.tool.constants import ( 11 | MSG_NOT_AUTHENTICATED_TOOL, 12 | MSG_NOT_AUTHENTICATED_TOOL_NO_TTY, 13 | ) 14 | 15 | from ..codebase_utils import load_unverified_project_from_config 16 | from ..scan.util import GIT 17 | 18 | 19 | if TYPE_CHECKING: 20 | from safety.cli_util import CustomContext 21 | from safety.models import SafetyCLI 22 | 23 | SafetyContext = CustomContext[SafetyCLI] 24 | 25 | 26 | def prepare_tool_execution(func): 27 | @wraps(func) 28 | def inner(ctx: "SafetyContext", target: Path, *args, **kwargs): 29 | ctx.obj.console = main_console 30 | ctx.params.pop("console", None) 31 | 32 | if not ctx.obj.auth: 33 | raise SafetyException("Authentication object is not available") 34 | 35 | if not ctx.obj.auth.is_valid(): 36 | tool_name = ctx.command.name.title() if ctx.command.name else "Tool" 37 | if ctx.obj.console.is_interactive: 38 | ctx.obj.console.line() 39 | ctx.obj.console.print( 40 | MSG_NOT_AUTHENTICATED_TOOL.format(tool_name=tool_name) 41 | ) 42 | ctx.obj.console.line() 43 | 44 | from safety.cli_util import process_auth_status_not_ready 45 | 46 | process_auth_status_not_ready( 47 | console=main_console, auth=ctx.obj.auth, ctx=ctx 48 | ) 49 | else: 50 | stderr_console = Console(stderr=True) 51 | stderr_console.print( 52 | MSG_NOT_AUTHENTICATED_TOOL_NO_TTY.format(tool_name=tool_name) 53 | ) 54 | 55 | unverified_project = load_unverified_project_from_config(project_root=target) 56 | 57 | if prj_id := unverified_project.id: 58 | ctx.obj.project = ProjectModel( 59 | id=prj_id, 60 | name=unverified_project.name, 61 | project_path=unverified_project.project_path, 62 | ) 63 | 64 | git_data = GIT(root=target).build_git_data() 65 | ctx.obj.project.git = git_data 66 | 67 | return func(ctx, target=target, *args, **kwargs) 68 | 69 | return inner 70 | -------------------------------------------------------------------------------- /safety/alerts/templates/issue.jinja2: -------------------------------------------------------------------------------- 1 | Safety has detected a vulnerable package, [{{ pkg }}]({{ remediation['more_info_url'] }}), that should be updated from **{% if remediation['version'] %}{{ remediation['version'] }}{% else %}{{ remediation['requirement']['specifier'] }}{% endif %}** to **{{ remediation['recommended_version'] }}** to fix {{ vulns | length }} vulnerabilit{{ "y" if vulns|length == 1 else "ies" }}{% if overall_impact %}{{ " rated " + overall_impact if vulns|length == 1 else " with the highest CVSS severity rating being " + overall_impact }}{% endif %}. 2 | 3 | To read more about the impact of {{ "this vulnerability" if vulns|length == 1 else "these vulnerabilities" }} see [PyUp’s {{ pkg }} page]({{ remediation['more_info_url'] }}). 4 | 5 | {{ hint }} 6 | 7 | If you're using `pip`, you can run: 8 | 9 | ``` 10 | pip install {{ pkg }}=={{ remediation['recommended_version'] }} 11 | # Followed by a pip freeze 12 | ``` 13 | 14 |
15 | Vulnerabilities Found 16 | {% for vuln in vulns %} 17 | * {{ vuln.advisory }} 18 | {% if vuln.severity and vuln.severity.cvssv3 and vuln.severity.cvssv3.base_severity %} 19 | * This vulnerability was rated {{ vuln.severity.cvssv3.base_severity }} ({{ vuln.severity.cvssv3.base_score }}) on CVSSv3. 20 | {% endif %} 21 | * To read more about this vulnerability, see PyUp’s [vulnerability page]({{ vuln.more_info_url }}) 22 | {% endfor %} 23 |
24 | 25 |
26 | Changelog from {{ remediation['requirement']['name'] }}{{ remediation['requirement']['specifier'] }} to {{ remediation['recommended_version'] }} 27 | {% if summary_changelog %} 28 | The full changelog is too long to post here. See [PyUp’s {{ pkg }} page]({{ remediation['more_info_url'] }}) for more information. 29 | {% else %} 30 | {% for version, log in changelog.items() %} 31 | ### {{ version }} 32 | 33 | ``` 34 | {{ log }} 35 | ``` 36 | {% endfor %} 37 | {% endif %} 38 |
39 | 40 |
41 | Ignoring {{ "This Vulnerability" if vulns|length == 1 else "These Vulnerabilities" }} 42 | 43 | If you wish to [ignore this vulnerability](https://docs.pyup.io/docs/safety-20-policy-file), you can add the following to `.safety-policy.yml` in this repo: 44 | 45 | ``` 46 | security: 47 | ignore-vulnerabilities:{% for vuln in vulns %} 48 | {{ vuln.vulnerability_id }}: 49 | reason: enter a reason as to why you're ignoring this vulnerability 50 | expires: 'YYYY-MM-DD' # datetime string - date this ignore will expire 51 | {% endfor %} 52 | ``` 53 | 54 |
55 | -------------------------------------------------------------------------------- /tests/tool/base.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | from unittest.mock import patch, MagicMock 3 | from typing import List, Dict, Any 4 | 5 | import typer 6 | 7 | from safety.tool.base import BaseCommand 8 | 9 | 10 | class CommandToolCaseMixin: 11 | """ 12 | Generic test case for command implementations. 13 | """ 14 | 15 | command_class = None 16 | command_args: List[str] = None 17 | parent_env: Dict[str, str] = None 18 | expected_env_vars: Dict[str, str] = None 19 | mock_configurations: List[Dict[str, Any]] = None 20 | 21 | def setUp(self): 22 | """ 23 | Set up test environment before each test method. 24 | """ 25 | if not self.command_class or not self.command_args: 26 | raise ValueError("command_class and command_args must be set by subclasses") 27 | 28 | self.command = self.command_class(self.command_args) 29 | self.parent_env = self.parent_env or {} 30 | self.expected_env_vars = self.expected_env_vars or {} 31 | self.mock_configurations = self.mock_configurations or [] 32 | 33 | def test_env_preserves_existing_variables(self): 34 | """ 35 | Test that env() method does not replace existing environment variables. 36 | """ 37 | ctx = MagicMock(spec=typer.Context) 38 | 39 | # Setup parent environment 40 | existing_env = self.parent_env.copy() 41 | 42 | # Setup all required mocks 43 | mock_objects = {} 44 | for mock_config in self.mock_configurations: 45 | target = mock_config.get("target") 46 | return_value = mock_config.get("return_value") 47 | 48 | patcher = patch(target, return_value=return_value) 49 | mock = patcher.start() 50 | mock_objects[target] = mock 51 | self.addCleanup(patcher.stop) 52 | 53 | # Get the env from the command 54 | with patch.object( 55 | BaseCommand, "env", return_value=existing_env 56 | ) as mock_super_env: 57 | result_env = self.command.env(ctx) 58 | 59 | # Check that parent env variables are preserved 60 | for key, value in existing_env.items(): 61 | self.assertEqual(result_env[key], value) 62 | 63 | # Check that expected env variables are added 64 | for key, value in self.expected_env_vars.items(): 65 | self.assertIn(key, result_env) 66 | self.assertEqual(result_env[key], value) 67 | 68 | # Verify parent env was called 69 | mock_super_env.assert_called_once_with(ctx) 70 | -------------------------------------------------------------------------------- /tests/auth/test_auth_main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import unittest 3 | from unittest.mock import Mock, patch 4 | from safety.auth.constants import CLI_AUTH, CLI_AUTH_LOGOUT, CLI_CALLBACK 5 | 6 | from safety.auth import main 7 | from safety.auth.main import get_authorization_data, \ 8 | get_logout_url, get_organization, get_redirect_url 9 | 10 | 11 | 12 | class TestAuthMain(unittest.TestCase): 13 | 14 | def setUp(self): 15 | self.assets = Path(__file__).parent / Path("test_assets/") 16 | 17 | def tearDown(self): 18 | pass 19 | 20 | def test_get_authorization_data(self): 21 | org_id = "org_id3dasdasd" 22 | client = Mock() 23 | code_verifier = "test_code_verifier" 24 | organization = Mock(id=org_id) 25 | 26 | client.create_authorization_url = Mock() 27 | _ = get_authorization_data(client, code_verifier, organization) 28 | 29 | kwargs = { 30 | "sign_up": False, 31 | "locale": "en", 32 | "ensure_auth": False, 33 | "organization": org_id, 34 | "headless": False 35 | } 36 | 37 | client.create_authorization_url.assert_called_once_with( 38 | CLI_AUTH, code_verifier=code_verifier, **kwargs) 39 | 40 | client.create_authorization_url = Mock() 41 | _ = get_authorization_data(client, code_verifier, organization=None) 42 | 43 | kwargs = { 44 | "sign_up": False, 45 | "locale": "en", 46 | "ensure_auth":False, 47 | "headless": False 48 | } 49 | 50 | client.create_authorization_url.assert_called_once_with( 51 | CLI_AUTH, code_verifier=code_verifier, **kwargs) 52 | 53 | def get_logout_url(self, id_token): 54 | return f'{CLI_AUTH_LOGOUT}?id_token={id_token}' 55 | 56 | def test_get_logout_url(self): 57 | id_token = "test_id_token" 58 | result = get_logout_url(id_token) 59 | expected_result = f'{CLI_AUTH_LOGOUT}?id_token={id_token}' 60 | self.assertEqual(result, expected_result) 61 | 62 | def test_get_redirect_url(self): 63 | self.assertEqual(get_redirect_url(), CLI_CALLBACK) 64 | 65 | def test_get_organization(self): 66 | with patch.object(main, "CONFIG", 67 | (self.assets / Path("config.ini")).absolute()): 68 | result = get_organization() 69 | self.assertIsNotNone(result) 70 | self.assertEqual(result.id, "org_id23423ds") 71 | self.assertEqual(result.name, "Safety CLI Org") 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Feature Request 2 | description: Suggest an idea or a feature for this project 3 | labels: ["feature request"] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this feature request! 10 | 11 | - type: checkboxes 12 | id: checklist 13 | attributes: 14 | label: Checklist 15 | options: 16 | - label: I agree to the terms within the [Safety Code of Conduct](https://github.com/pyupio/safety/blob/main/CODE_OF_CONDUCT.md). 17 | required: true 18 | 19 | - type: textarea 20 | id: safety-version 21 | attributes: 22 | label: Safety version 23 | description: Specify the version of Safety you're using (if relevant to the feature request). 24 | placeholder: e.g., 3.2.5 25 | validations: 26 | required: false 27 | 28 | - type: textarea 29 | id: python-version 30 | attributes: 31 | label: Python version 32 | description: Specify the version of Python you're using (if relevant to the feature request). 33 | placeholder: e.g., 3.11.2 34 | validations: 35 | required: false 36 | 37 | - type: textarea 38 | id: os 39 | attributes: 40 | label: Operating System 41 | description: Specify the operating system you're using (if relevant to the feature request). 42 | placeholder: e.g., macOS 13, Windows 10 43 | validations: 44 | required: false 45 | 46 | - type: textarea 47 | id: feature-description 48 | attributes: 49 | label: Feature description 50 | description: | 51 | Describe the feature you'd like to see including: 52 | - What problem it would solve or use case it addresses 53 | - How you envision it working 54 | placeholder: | 55 | I'd like Safety to support scanning container images directly. This would help with CI/CD pipelines where we build Docker images and want to scan them for vulnerabilities before deployment... 56 | validations: 57 | required: true 58 | 59 | - type: textarea 60 | id: alternatives-and-workarounds 61 | attributes: 62 | label: Alternatives and current workarounds 63 | description: A clear and concise description of any alternatives you've considered or any workarounds that are currently in place. 64 | validations: 65 | required: false 66 | 67 | - type: textarea 68 | id: additional-context 69 | attributes: 70 | label: Additional context 71 | description: Add any other context or screenshots about the feature request here. 72 | validations: 73 | required: false 74 | 75 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import distributions 2 | from packaging.requirements import Requirement 3 | from packaging.utils import canonicalize_name 4 | 5 | import sys 6 | 7 | if sys.version_info >= (3, 11): 8 | import tomllib 9 | else: 10 | import tomli as tomllib 11 | 12 | import pytest 13 | 14 | import safety.meta 15 | 16 | # Store the original version function 17 | original_version = safety.meta.get_version 18 | 19 | 20 | def mock_version(): 21 | return "1.2.3" 22 | 23 | 24 | # Apply the patch immediately at import time 25 | safety.meta.get_version = mock_version 26 | 27 | 28 | # You can still keep the fixture to ensure cleanup 29 | @pytest.fixture(autouse=True, scope="session") 30 | def cleanup_importlib_patch(): 31 | yield 32 | safety.meta.get_version = original_version 33 | 34 | 35 | def get_project_dependencies(): 36 | try: 37 | with open("pyproject.toml", "rb") as f: 38 | pyproject = tomllib.load(f) 39 | deps = pyproject.get("project", {}).get("dependencies", []) 40 | return { 41 | canonicalize_name(Requirement(dep).name): dep 42 | for dep in deps 43 | if isinstance(dep, str) 44 | } 45 | except Exception as e: 46 | print(f"Error reading dependencies: {e}") 47 | return {} 48 | 49 | 50 | def pytest_configure(config): 51 | main_deps_specs = get_project_dependencies() 52 | all_dists = { 53 | canonicalize_name(dist.metadata["Name"]): (dist.metadata["Name"], dist.version) 54 | for dist in distributions() 55 | } 56 | 57 | # Main dependencies table 58 | print(f"\n[{len(main_deps_specs)}] Main Dependencies:") 59 | print("-" * 60) 60 | print("%-20s %-25s %-15s" % ("Package", "Specification", "Installed")) 61 | print("-" * 60) 62 | 63 | for pkg_norm, spec in sorted(main_deps_specs.items()): 64 | if pkg_norm in all_dists: 65 | name, version = all_dists[pkg_norm] 66 | print("%-20s %-25s %-15s" % (name, spec, version)) 67 | 68 | other_pkgs = [ 69 | f"{name} {ver}" 70 | for pkg_norm, (name, ver) in sorted(all_dists.items()) 71 | if pkg_norm not in main_deps_specs 72 | ] 73 | 74 | # Other dependencies in wrapped format 75 | print(f"\n[{len(other_pkgs)}] Other Dependencies:") 76 | print("-" * 80) 77 | 78 | # Print other dependencies with wrapping 79 | line = "" 80 | for pkg in other_pkgs: 81 | if len(line) + len(pkg) + 2 > 78: 82 | print(line.rstrip(", ")) 83 | line = pkg + ", " 84 | else: 85 | line += pkg + ", " 86 | if line: 87 | print(line.rstrip(", ")) 88 | -------------------------------------------------------------------------------- /refresh_notice.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import importlib.metadata 3 | from pathlib import Path 4 | from typing import List, Tuple 5 | 6 | def normalize_package_name(name: str) -> str: 7 | """Normalize package name to lowercase with hyphens.""" 8 | return name.lower().replace('_', '-').replace('.', '-') 9 | 10 | def get_license_from_classifier(classifiers: List[str]) -> str: 11 | """Extract license from classifier if available.""" 12 | for c in classifiers: 13 | if 'License :: OSI Approved ::' in c: 14 | return c.split('License :: OSI Approved :: ')[-1] 15 | return '' 16 | 17 | def get_license(dist) -> str: 18 | """Get license information from package metadata.""" 19 | classifiers = dist.metadata.get_all('Classifier') or [] 20 | classifier_license = get_license_from_classifier(classifiers) 21 | 22 | # Get direct license field 23 | license = dist.metadata.get('License', '') 24 | 25 | # If license is too long (probably full license text) and we have a classifier, use classifier 26 | if len(license) > 100 and classifier_license: 27 | return classifier_license 28 | 29 | # Try License field first 30 | if license: 31 | return license 32 | 33 | # Try License-Expression 34 | if dist.metadata.get('License-Expression'): 35 | return dist.metadata['License-Expression'] 36 | 37 | # Use classifier license if available 38 | if classifier_license: 39 | return classifier_license 40 | 41 | return 'License not found' 42 | 43 | def get_all_packages() -> List[Tuple[str, str, str]]: 44 | """Get all packages with their versions and licenses.""" 45 | packages = [ 46 | (normalize_package_name(dist.metadata['Name']), 47 | dist.version, 48 | get_license(dist)) 49 | for dist in importlib.metadata.distributions() 50 | ] 51 | return sorted(packages) 52 | 53 | def generate_markdown_table(packages: List[Tuple[str, str, str]], output_file: str): 54 | """Generate markdown table and save to file.""" 55 | with open(output_file, 'w') as f: 56 | # Write header 57 | f.write('# Package Licenses\n\n') 58 | f.write('| Name | Version | License |\n') 59 | f.write('|------|---------|----------|\n') 60 | 61 | # Write package rows 62 | for name, version, license in packages: 63 | # Escape any pipe characters in the license 64 | license = license.replace('|', '\\|') 65 | f.write(f'| {name} | {version} | {license} |\n') 66 | 67 | def main(): 68 | packages = get_all_packages() 69 | generate_markdown_table(packages, 'LICENSES/NOTICE.md') 70 | print(f"Generated package_licenses.md with {len(packages)} packages") 71 | 72 | if __name__ == '__main__': 73 | main() 74 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # action.yml 2 | name: 'pyupio/safety' 3 | description: 'Runs the Safety CLI dependency scanner against your project' 4 | inputs: 5 | api-key: 6 | description: 'PyUp.io API key' 7 | required: false 8 | default: '' 9 | create-pr: 10 | description: 'Create a PR if Safety finds any vulnerabilities. Only the `file` scan mode is currently supported - combine with Cron based actions to get automatic notifications of new vulnerabilities.' 11 | required: false 12 | default: '' 13 | create-issue: 14 | description: 'Create an issue if Safety finds any vulnerabilities. Only the `file` scan mode is currently supported - combine with Cron based actions to get automatic notifications of new vulnerabilities.' 15 | required: false 16 | default: '' 17 | scan: 18 | description: 'Scan mode to use. One of auto / docker / env / file (defaults to auto)' 19 | required: false 20 | default: 'auto' 21 | docker-image: 22 | description: 'Tag or hash of the Docker Image to scan (defaults to autodetecting the last built tagged image)' 23 | required: false 24 | default: '' 25 | requirements: 26 | description: 'Path of requirements file to scan (defaults to poetry.lock -> Pipfile.lock -> requirements.txt)' 27 | required: false 28 | default: '' 29 | continue-on-error: 30 | description: 'By default, Safety will exit with a non-zero exit code if it detects any vulnerabilities. Set this to yes / true to not error out.' 31 | required: false 32 | default: '' 33 | output-format: 34 | description: 'Output format for returned data. One of screen / text / json / bare (defaults to screen)' 35 | required: false 36 | default: 'screen' 37 | args: 38 | description: '[Advanced] Any additional arguments to pass to Safety' 39 | required: false 40 | default: '' 41 | repo-token: 42 | required: false 43 | default: '' 44 | 45 | outputs: 46 | cli-output: 47 | description: 'CLI output from Safety' 48 | exit-code: 49 | description: 'Exit code from Safety' 50 | 51 | runs: 52 | using: 'docker' 53 | image: 'docker://pyupio/safety-v2-beta:latest' 54 | env: 55 | SAFETY_API_KEY: ${{ inputs.api-key }} 56 | SAFETY_ACTION: true 57 | SAFETY_ACTION_CREATE_PR: ${{ inputs.create-pr }} 58 | SAFETY_ACTION_CREATE_ISSUE: ${{ inputs.create-issue }} 59 | SAFETY_ACTION_SCAN: ${{ inputs.scan }} 60 | SAFETY_ACTION_DOCKER_IMAGE: ${{ inputs.docker-image }} 61 | SAFETY_ACTION_REQUIREMENTS: ${{ inputs.requirements }} 62 | SAFETY_ACTION_CONTINUE_ON_ERROR: ${{ inputs.continue-on-error }} 63 | SAFETY_ACTION_OUTPUT_FORMAT: ${{ inputs.output-format }} 64 | SAFETY_ACTION_ARGS: ${{ inputs.args }} 65 | SAFETY_ACTION_FORMAT: true 66 | GITHUB_TOKEN: ${{ inputs.repo-token }} 67 | COLUMNS: 120 68 | 69 | branding: 70 | icon: 'lock' 71 | color: 'purple' 72 | -------------------------------------------------------------------------------- /safety/tool/typosquatting.py: -------------------------------------------------------------------------------- 1 | """ 2 | Typosquatting detection for various tools. 3 | """ 4 | 5 | import logging 6 | import nltk 7 | from typing import Tuple 8 | 9 | from safety.console import main_console as console 10 | from rich.prompt import Prompt 11 | from .intents import CommandToolIntention, ToolIntentionType 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class TyposquattingProtection: 17 | """ 18 | Base class for typosquatting detection. 19 | """ 20 | 21 | def __init__(self, popular_packages: Tuple[str]): 22 | self.popular_packages = popular_packages 23 | 24 | def check_package(self, package_name: str) -> Tuple[bool, str]: 25 | """ 26 | Check if a package name is likely to be a typosquatting attempt. 27 | 28 | Args: 29 | package_name: Name of the package to check 30 | 31 | Returns: 32 | Tuple of (is_valid, suggested_package_name) 33 | """ 34 | max_edit_distance = 2 if len(package_name) > 5 else 1 35 | 36 | if package_name in self.popular_packages: 37 | return (True, package_name) 38 | 39 | for pkg in self.popular_packages: 40 | if ( 41 | abs(len(pkg) - len(package_name)) <= max_edit_distance 42 | and nltk.edit_distance(pkg, package_name) <= max_edit_distance 43 | ): 44 | return (False, pkg) 45 | 46 | return (True, package_name) 47 | 48 | def coerce(self, intention: CommandToolIntention, dependency_name: str) -> str: 49 | """ 50 | Coerce a package name to its correct name if it is a typosquatting attempt. 51 | 52 | Args: 53 | intention: CommandToolIntention object 54 | dependency_name: Name of the package to coerce 55 | 56 | Returns: 57 | str: Coerced package name 58 | """ 59 | (valid, candidate_package_name) = self.check_package(dependency_name) 60 | 61 | if not valid: 62 | action = "install" 63 | 64 | if intention.intention_type == ToolIntentionType.DOWNLOAD_PACKAGE: 65 | action = "download" 66 | elif intention.intention_type == ToolIntentionType.BUILD_PROJECT: 67 | action = "build" 68 | elif intention.intention_type == ToolIntentionType.SEARCH_PACKAGES: 69 | action = "search" 70 | 71 | prompt = f"You are about to {action} {dependency_name} package. Did you mean to {action} {candidate_package_name}?" 72 | answer = Prompt.ask( 73 | prompt=prompt, 74 | choices=["y", "n"], 75 | default="y", 76 | show_default=True, 77 | console=console, 78 | ).lower() 79 | if answer == "y": 80 | return candidate_package_name 81 | 82 | return dependency_name 83 | -------------------------------------------------------------------------------- /safety/error_handlers.py: -------------------------------------------------------------------------------- 1 | # Standard library imports 2 | import logging 3 | import sys 4 | import traceback 5 | from functools import wraps 6 | from typing import TYPE_CHECKING, Optional 7 | 8 | # Third-party imports 9 | import click 10 | 11 | # Local imports 12 | from safety.constants import EXIT_CODE_FAILURE, EXIT_CODE_OK 13 | from safety.errors import SafetyError, SafetyException 14 | 15 | from safety.events.utils import emit_command_error 16 | 17 | if TYPE_CHECKING: 18 | from safety.scan.models import ScanOutput 19 | 20 | 21 | LOG = logging.getLogger(__name__) 22 | 23 | 24 | def output_exception(exception: Exception, exit_code_output: bool = True) -> None: 25 | """ 26 | Output an exception message to the console and exit. 27 | 28 | Args: 29 | exception (Exception): The exception to output. 30 | exit_code_output (bool): Whether to output the exit code. 31 | 32 | Exits: 33 | Exits the program with the appropriate exit code. 34 | """ 35 | click.secho(str(exception), fg="red", file=sys.stderr) 36 | 37 | if exit_code_output: 38 | exit_code = EXIT_CODE_FAILURE 39 | if hasattr(exception, "get_exit_code"): 40 | exit_code = exception.get_exit_code() 41 | else: 42 | exit_code = EXIT_CODE_OK 43 | 44 | sys.exit(exit_code) 45 | 46 | 47 | def handle_cmd_exception(func): 48 | """ 49 | Decorator to handle exceptions in command functions. 50 | 51 | Args: 52 | func: The command function to wrap. 53 | 54 | Returns: 55 | The wrapped function. 56 | """ 57 | 58 | @wraps(func) 59 | def inner(ctx, output: Optional["ScanOutput"] = None, *args, **kwargs): 60 | if output: 61 | from safety.scan.models import ScanOutput 62 | 63 | kwargs.update({"output": output}) 64 | 65 | if output is ScanOutput.NONE: 66 | return func(ctx, *args, **kwargs) 67 | 68 | try: 69 | return func(ctx, *args, **kwargs) 70 | except click.ClickException as e: 71 | emit_command_error( 72 | ctx.obj.event_bus, ctx, message=str(e), traceback=traceback.format_exc() 73 | ) 74 | raise e 75 | except SafetyError as e: 76 | LOG.exception("Expected SafetyError happened: %s", e) 77 | emit_command_error( 78 | ctx.obj.event_bus, ctx, message=str(e), traceback=traceback.format_exc() 79 | ) 80 | output_exception(e, exit_code_output=True) 81 | except Exception as e: 82 | emit_command_error( 83 | ctx.obj.event_bus, ctx, message=str(e), traceback=traceback.format_exc() 84 | ) 85 | LOG.exception("Unexpected Exception happened: %s", e) 86 | exception = e if isinstance(e, SafetyException) else SafetyException(info=e) 87 | output_exception(exception, exit_code_output=True) 88 | 89 | return inner 90 | -------------------------------------------------------------------------------- /scripts/generate_contributors.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | from collections import defaultdict 4 | 5 | # Repository details and GitHub token 6 | GITHUB_REPO = "pyupio/safety" 7 | GITHUB_TOKEN = os.getenv("YOUR_GITHUB_TOKEN") 8 | CONTRIBUTORS_FILE = "CONTRIBUTORS.md" 9 | 10 | # Tier thresholds 11 | TIERS = {"Valued Contributor": 10, "Frequent Contributor": 5, "First Contributor": 1} 12 | 13 | 14 | # API request to get merged PRs 15 | def get_merged_prs(): 16 | prs = [] 17 | page = 1 18 | while True: 19 | url = f"https://api.github.com/repos/{GITHUB_REPO}/pulls?state=closed&per_page=100&page={page}" 20 | headers = {"Authorization": f"token {GITHUB_TOKEN}"} 21 | response = requests.get(url, headers=headers) 22 | response.raise_for_status() 23 | page_prs = response.json() 24 | 25 | # Break if there are no more PRs 26 | if not page_prs: 27 | break 28 | 29 | prs.extend(page_prs) 30 | page += 1 31 | 32 | return prs 33 | 34 | 35 | # Count contributions for each user 36 | def count_contributions(prs): 37 | contributions = defaultdict(int) 38 | for pr in prs: 39 | if pr.get("merged_at"): 40 | user = pr["user"]["login"] 41 | contributions[user] += 1 42 | return contributions 43 | 44 | 45 | # Categorize contributors by tier 46 | def categorize_contributors(contributions): 47 | tiers = {tier: [] for tier in TIERS} 48 | for user, count in contributions.items(): 49 | for tier, threshold in TIERS.items(): 50 | if count >= threshold: 51 | tiers[tier].append((user, count)) 52 | break 53 | return tiers 54 | 55 | 56 | # Generate Shieldify badge 57 | def generate_badge(user, tier): 58 | badge_url = f"https://img.shields.io/badge/{user.replace('-', '--')}-{tier.replace(' ', '%20')}-brightgreen" 59 | return f"![{user} Badge]({badge_url})" 60 | 61 | 62 | # Generate CONTRIBUTORS.md content 63 | def generate_contributors_md(tiers): 64 | lines = ["# Contributors\n"] 65 | for tier, contributors in tiers.items(): 66 | if contributors: 67 | lines.append(f"## {tier}\n") 68 | for user, count in sorted(contributors, key=lambda x: x[1], reverse=True): 69 | badge = generate_badge(user, tier) 70 | lines.append(f"- {badge} ({count} merged PRs)\n") 71 | return "\n".join(lines) 72 | 73 | 74 | # Write the CONTRIBUTORS.md file 75 | def write_contributors_file(content): 76 | with open(CONTRIBUTORS_FILE, "w") as file: 77 | file.write(content) 78 | 79 | 80 | def main(): 81 | prs = get_merged_prs() 82 | contributions = count_contributions(prs) 83 | tiers = categorize_contributors(contributions) 84 | content = generate_contributors_md(tiers) 85 | write_contributors_file(content) 86 | print(f"{CONTRIBUTORS_FILE} generated successfully.") 87 | 88 | 89 | if __name__ == "__main__": 90 | main() 91 | -------------------------------------------------------------------------------- /safety/codebase_utils.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | from pathlib import Path 4 | from dataclasses import dataclass 5 | from typing import Optional 6 | from safety_schemas.models import ProjectModel 7 | 8 | 9 | PROJECT_CONFIG = ".safety-project.ini" 10 | PROJECT_CONFIG_SECTION = "project" 11 | PROJECT_CONFIG_ID = "id" 12 | PROJECT_CONFIG_URL = "url" 13 | PROJECT_CONFIG_NAME = "name" 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | @dataclass 20 | class UnverifiedProjectModel: 21 | """ 22 | Data class representing an unverified project model. 23 | """ 24 | 25 | id: Optional[str] 26 | project_path: Path 27 | created: bool 28 | name: Optional[str] = None 29 | url_path: Optional[str] = None 30 | 31 | 32 | def load_unverified_project_from_config(project_root: Path) -> UnverifiedProjectModel: 33 | """ 34 | Loads an unverified project from the configuration file located at the project root. 35 | 36 | Args: 37 | project_root (Path): The root directory of the project. 38 | 39 | Returns: 40 | UnverifiedProjectModel: An instance of UnverifiedProjectModel. 41 | """ 42 | config = configparser.ConfigParser() 43 | project_path = project_root / PROJECT_CONFIG 44 | config.read(project_path) 45 | id = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_ID, fallback=None) 46 | url = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_URL, fallback=None) 47 | name = config.get(PROJECT_CONFIG_SECTION, PROJECT_CONFIG_NAME, fallback=None) 48 | created = True 49 | if not id: 50 | created = False 51 | 52 | return UnverifiedProjectModel( 53 | id=id, url_path=url, name=name, project_path=project_path, created=created 54 | ) 55 | 56 | 57 | def save_project_info(project: ProjectModel, project_path: Path) -> bool: 58 | """ 59 | Saves the project information to the configuration file. 60 | 61 | Args: 62 | project (ProjectModel): The ProjectModel object containing project 63 | information. 64 | project_path (Path): The path to the configuration file. 65 | 66 | Returns: 67 | bool: True if the project information was saved successfully, False 68 | otherwise. 69 | """ 70 | config = configparser.ConfigParser() 71 | config.read(project_path) 72 | 73 | if PROJECT_CONFIG_SECTION not in config.sections(): 74 | config[PROJECT_CONFIG_SECTION] = {} 75 | 76 | config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_ID] = project.id 77 | if project.url_path: 78 | config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_URL] = project.url_path 79 | if project.name: 80 | config[PROJECT_CONFIG_SECTION][PROJECT_CONFIG_NAME] = project.name 81 | 82 | try: 83 | with open(project_path, "w") as configfile: 84 | config.write(configfile) 85 | except Exception: 86 | logger.exception("Error saving project info") 87 | return False 88 | 89 | return True 90 | -------------------------------------------------------------------------------- /safety/scan/validators.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Optional, Tuple, TYPE_CHECKING 4 | import typer 5 | from safety.scan.models import ScanExport, ScanOutput 6 | from safety.errors import SafetyException 7 | 8 | 9 | if TYPE_CHECKING: 10 | from safety.cli_util import CustomContext 11 | from safety.models import SafetyCLI 12 | from safety.utils.auth_session import AuthenticationType 13 | 14 | SafetyContext = CustomContext[SafetyCLI] 15 | 16 | 17 | MISSING_SPDX_EXTENSION_MSG = ( 18 | "spdx extra is not installed, please install it with: pip install safety[spdx]" 19 | ) 20 | 21 | 22 | def raise_if_not_spdx_extension_installed() -> None: 23 | """ 24 | Raises an error if the spdx extension is not installed. 25 | """ 26 | try: 27 | pass 28 | except Exception: 29 | raise typer.BadParameter(MISSING_SPDX_EXTENSION_MSG) 30 | 31 | 32 | def save_as_callback( 33 | save_as: Optional[Tuple[ScanExport, Path]], 34 | ) -> Tuple[Optional[str], Optional[Path]]: 35 | """ 36 | Callback function to handle save_as parameter and validate if spdx extension is installed. 37 | 38 | Args: 39 | save_as (Optional[Tuple[ScanExport, Path]]): The export type and path. 40 | 41 | Returns: 42 | Tuple[Optional[str], Optional[Path]]: The validated export type and path. 43 | """ 44 | export_type, export_path = save_as if save_as else (None, None) 45 | 46 | if ScanExport.is_format(export_type, ScanExport.SPDX): 47 | raise_if_not_spdx_extension_installed() 48 | 49 | return ( 50 | (export_type.value, export_path) 51 | if export_type and export_path 52 | else (export_type, export_path) 53 | ) 54 | 55 | 56 | def output_callback(output: ScanOutput) -> str: 57 | """ 58 | Callback function to handle output parameter and validate if spdx extension is installed. 59 | 60 | Args: 61 | output (ScanOutput): The output format. 62 | 63 | Returns: 64 | str: The validated output format. 65 | """ 66 | if ScanOutput.is_format(output, ScanExport.SPDX): 67 | raise_if_not_spdx_extension_installed() 68 | 69 | return output.value 70 | 71 | 72 | def fail_if_not_allowed_stage(ctx: "SafetyContext"): 73 | """ 74 | Fail the command if the authentication type is not allowed in the current stage. 75 | 76 | Args: 77 | ctx (typer.Context): The context of the Typer command. 78 | """ 79 | if ctx.resilient_parsing: 80 | return 81 | 82 | if not ctx.obj or not ctx.obj.auth: 83 | raise SafetyException("Authentication object is not available") 84 | 85 | stage = ctx.obj.auth.stage 86 | auth_type: AuthenticationType = ctx.obj.auth.platform.get_authentication_type() 87 | 88 | if os.getenv("SAFETY_DB_DIR"): 89 | return 90 | 91 | if not stage or not auth_type.is_allowed_in(stage): 92 | raise typer.BadParameter( 93 | f"'{auth_type.value}' auth type isn't allowed with the '{stage}' stage." 94 | ) 95 | -------------------------------------------------------------------------------- /safety/events/types/aliases.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from safety_schemas.models.events import Event, EventType 4 | from safety_schemas.models.events.payloads import ( 5 | AuthCompletedPayload, 6 | AuthStartedPayload, 7 | CodebaseSetupCompletedPayload, 8 | CodebaseSetupResponseCreatedPayload, 9 | FirewallConfiguredPayload, 10 | FirewallDisabledPayload, 11 | FirewallSetupCompletedPayload, 12 | FirewallSetupResponseCreatedPayload, 13 | InitScanCompletedPayload, 14 | InitStartedPayload, 15 | PackageInstalledPayload, 16 | PackageUninstalledPayload, 17 | CommandExecutedPayload, 18 | CommandErrorPayload, 19 | FirewallHeartbeatPayload, 20 | CodebaseDetectionStatusPayload, 21 | ) 22 | 23 | from .base import InternalEventType, InternalPayload 24 | 25 | CommandExecutedEvent = Event[ 26 | Literal[EventType.COMMAND_EXECUTED], CommandExecutedPayload 27 | ] 28 | CommandErrorEvent = Event[Literal[EventType.COMMAND_ERROR], CommandErrorPayload] 29 | PackageInstalledEvent = Event[ 30 | Literal[EventType.PACKAGE_INSTALLED], PackageInstalledPayload 31 | ] 32 | PackageUninstalledEvent = Event[ 33 | Literal[EventType.PACKAGE_UNINSTALLED], PackageUninstalledPayload 34 | ] 35 | FirewallHeartbeatEvent = Event[ 36 | Literal[EventType.FIREWALL_HEARTBEAT], FirewallHeartbeatPayload 37 | ] 38 | FirewallConfiguredEvent = Event[ 39 | Literal[EventType.FIREWALL_CONFIGURED], FirewallConfiguredPayload 40 | ] 41 | FirewallDisabledEvent = Event[ 42 | Literal[EventType.FIREWALL_DISABLED], FirewallDisabledPayload 43 | ] 44 | 45 | 46 | InitStartedEvent = Event[Literal[EventType.INIT_STARTED], InitStartedPayload] 47 | AuthStartedEvent = Event[Literal[EventType.AUTH_STARTED], AuthStartedPayload] 48 | AuthCompletedEvent = Event[Literal[EventType.AUTH_COMPLETED], AuthCompletedPayload] 49 | 50 | # Firewall setup events 51 | FirewallSetupResponseCreatedEvent = Event[ 52 | Literal[EventType.FIREWALL_SETUP_RESPONSE_CREATED], 53 | FirewallSetupResponseCreatedPayload, 54 | ] 55 | FirewallSetupCompletedEvent = Event[ 56 | Literal[EventType.FIREWALL_SETUP_COMPLETED], FirewallSetupCompletedPayload 57 | ] 58 | 59 | # Codebase setup events 60 | CodebaseDetectionStatusEvent = Event[ 61 | Literal[EventType.CODEBASE_DETECTION_STATUS], CodebaseDetectionStatusPayload 62 | ] 63 | CodebaseSetupResponseCreatedEvent = Event[ 64 | Literal[EventType.CODEBASE_SETUP_RESPONSE_CREATED], 65 | CodebaseSetupResponseCreatedPayload, 66 | ] 67 | CodebaseSetupCompletedEvent = Event[ 68 | Literal[EventType.CODEBASE_SETUP_COMPLETED], CodebaseSetupCompletedPayload 69 | ] 70 | 71 | # Scan events 72 | InitScanCompletedEvent = Event[ 73 | Literal[EventType.INIT_SCAN_COMPLETED], InitScanCompletedPayload 74 | ] 75 | 76 | # Internal events 77 | CloseResourcesEvent = Event[InternalEventType.CLOSE_RESOURCES, InternalPayload] 78 | FlushSecurityTracesEvent = Event[ 79 | InternalEventType.FLUSH_SECURITY_TRACES, InternalPayload 80 | ] 81 | EventBusReadyEvent = Event[Literal[InternalEventType.EVENT_BUS_READY], InternalPayload] 82 | -------------------------------------------------------------------------------- /tests/firewall/events/test_utils.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | import unittest 3 | from unittest.mock import MagicMock 4 | 5 | from safety_schemas.models.events import EventType 6 | from safety.events.types import InternalEventType 7 | from safety.firewall.events.utils import register_event_handlers 8 | from safety.firewall.events.handlers import HeartbeatInspectionEventHandler 9 | from safety.models.obj import SafetyCLI 10 | 11 | 12 | class TestFirewallEventsUtils(unittest.TestCase): 13 | """ 14 | Test cases for firewall events utils functions. 15 | """ 16 | 17 | def setUp(self): 18 | self.event_bus = MagicMock() 19 | self.safety_cli = MagicMock(spec=SafetyCLI) 20 | self.safety_cli.security_events_handler = MagicMock() 21 | 22 | def test_register_event_handlers_with_security_events_handler(self): 23 | """ 24 | Test register_event_handlers when security_events_handler is available. 25 | """ 26 | register_event_handlers(self.event_bus, self.safety_cli) 27 | 28 | self.event_bus.subscribe.assert_any_call( 29 | [InternalEventType.EVENT_BUS_READY], 30 | self.event_bus.subscribe.call_args_list[0][0][ 31 | 1 32 | ], # Get the handler from the first call 33 | ) 34 | 35 | self.event_bus.subscribe.assert_any_call( 36 | [ 37 | EventType.FIREWALL_CONFIGURED, 38 | EventType.FIREWALL_HEARTBEAT, 39 | EventType.FIREWALL_DISABLED, 40 | EventType.PACKAGE_INSTALLED, 41 | EventType.PACKAGE_UNINSTALLED, 42 | EventType.PACKAGE_UPDATED, 43 | EventType.TOOL_COMMAND_EXECUTED, 44 | EventType.INIT_STARTED, 45 | EventType.FIREWALL_SETUP_RESPONSE_CREATED, 46 | EventType.FIREWALL_SETUP_COMPLETED, 47 | EventType.CODEBASE_DETECTION_STATUS, 48 | EventType.CODEBASE_SETUP_RESPONSE_CREATED, 49 | EventType.CODEBASE_SETUP_COMPLETED, 50 | EventType.INIT_SCAN_COMPLETED, 51 | EventType.INIT_EXITED, 52 | ], 53 | self.safety_cli.security_events_handler, 54 | ) 55 | 56 | self.assertEqual(self.event_bus.subscribe.call_count, 2) 57 | 58 | def test_register_event_handlers_without_security_events_handler(self): 59 | """ 60 | Test register_event_handlers when security_events_handler is None. 61 | """ 62 | self.safety_cli.security_events_handler = None 63 | 64 | register_event_handlers(self.event_bus, self.safety_cli) 65 | 66 | self.assertEqual(self.event_bus.subscribe.call_count, 1) 67 | 68 | call_args = self.event_bus.subscribe.call_args 69 | events_arg = call_args[0][0] 70 | handler_arg = call_args[0][1] 71 | 72 | self.assertEqual(events_arg, [InternalEventType.EVENT_BUS_READY]) 73 | self.assertIsInstance(handler_arg, HeartbeatInspectionEventHandler) 74 | self.assertEqual(handler_arg.event_bus, self.event_bus) 75 | -------------------------------------------------------------------------------- /safety/scan/ecosystems/target.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import logging 3 | from safety_schemas.models import Ecosystem, FileType 4 | from typer import FileTextWrite 5 | 6 | from .python.main import PythonFile 7 | from ...encoding import detect_encoding 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class InspectableFileContext: 13 | """ 14 | Context manager for handling the lifecycle of an inspectable file. 15 | 16 | This class ensures that the file is properly opened and closed, handling any 17 | exceptions that may occur during the process. 18 | """ 19 | 20 | def __init__(self, file_path: Path, file_type: FileType) -> None: 21 | """ 22 | Initializes the InspectableFileContext. 23 | 24 | Args: 25 | file_path (Path): The path to the file. 26 | file_type (FileType): The type of the file. 27 | """ 28 | self.file_path = file_path 29 | self.inspectable_file = None 30 | self.file_type = file_type 31 | 32 | def __enter__(self): # TODO: Handle permission issue /Applications/... 33 | """ 34 | Enters the runtime context related to this object. 35 | 36 | Opens the file and creates the appropriate inspectable file object based on the file type. 37 | 38 | Returns: 39 | The inspectable file object. 40 | """ 41 | try: 42 | encoding = detect_encoding(self.file_path) 43 | file: FileTextWrite = open(self.file_path, mode="r+", encoding=encoding) # type: ignore 44 | self.inspectable_file = TargetFile.create( 45 | file_type=self.file_type, file=file 46 | ) 47 | except Exception: 48 | logger.exception("Error opening file") 49 | 50 | return self.inspectable_file 51 | 52 | def __exit__(self, exc_type, exc_value, traceback): 53 | """ 54 | Exits the runtime context related to this object. 55 | 56 | Ensures that the file is properly closed. 57 | """ 58 | if self.inspectable_file: 59 | self.inspectable_file.file.close() 60 | 61 | 62 | class TargetFile: 63 | """ 64 | Factory class for creating inspectable file objects based on the file type and ecosystem. 65 | """ 66 | 67 | @classmethod 68 | def create(cls, file_type: FileType, file: FileTextWrite): 69 | """ 70 | Creates an inspectable file object based on the file type and ecosystem. 71 | 72 | Args: 73 | file_type (FileType): The type of the file. 74 | file (FileTextWrite): The file object. 75 | 76 | Returns: 77 | An instance of the appropriate inspectable file class. 78 | 79 | Raises: 80 | ValueError: If the ecosystem or file type is unsupported. 81 | """ 82 | if file_type.ecosystem == Ecosystem.PYTHON: 83 | return PythonFile(file=file, file_type=file_type) 84 | 85 | raise ValueError( 86 | "Unsupported ecosystem or file type: " 87 | f"{file_type.ecosystem}:{file_type.value}" 88 | ) 89 | -------------------------------------------------------------------------------- /safety/formatters/bare.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from typing import List, Dict, Any, Optional, Tuple 3 | 4 | from safety.formatter import FormatterAPI 5 | from safety.util import get_basic_announcements 6 | 7 | 8 | class BareReport(FormatterAPI): 9 | """ 10 | Bare report, for command line tools. 11 | """ 12 | 13 | def render_vulnerabilities(self, announcements: List[Dict[str, Any]], vulnerabilities: List[Any], 14 | remediations: Any, full: bool, packages: List[Any], fixes: Tuple = ()) -> str: 15 | """ 16 | Renders vulnerabilities in a bare format. 17 | 18 | Args: 19 | announcements (List[Dict[str, Any]]): List of announcements. 20 | vulnerabilities (List[Any]): List of vulnerabilities. 21 | remediations (Any): Remediation data. 22 | full (bool): Flag indicating full output. 23 | packages (List[Any]): List of packages. 24 | fixes (Tuple, optional): Tuple of fixes. 25 | 26 | Returns: 27 | str: Rendered vulnerabilities. 28 | """ 29 | parsed_announcements = [] 30 | Announcement = namedtuple("Announcement", ["name"]) 31 | 32 | for announcement in get_basic_announcements(announcements, include_local=False): 33 | normalized_message = "-".join(announcement.get('message', 'none').lower().split()) 34 | parsed_announcements.append(Announcement(name=normalized_message)) 35 | 36 | announcements_to_render = [announcement.name for announcement in parsed_announcements] 37 | affected_packages = list(set([v.package_name for v in vulnerabilities if not v.ignored])) 38 | 39 | return " ".join(announcements_to_render + affected_packages) 40 | 41 | def render_licenses(self, announcements: List[Dict[str, Any]], packages_licenses: List[Dict[str, Any]]) -> str: 42 | """ 43 | Renders licenses in a bare format. 44 | 45 | Args: 46 | announcements (List[Dict[str, Any]]): List of announcements. 47 | packages_licenses (List[Dict[str, Any]]): List of package licenses. 48 | 49 | Returns: 50 | str: Rendered licenses. 51 | """ 52 | parsed_announcements = [] 53 | 54 | for announcement in get_basic_announcements(announcements): 55 | normalized_message = "-".join(announcement.get('message', 'none').lower().split()) 56 | parsed_announcements.append({'license': normalized_message}) 57 | 58 | announcements_to_render = [announcement.get('license') for announcement in parsed_announcements] 59 | 60 | licenses = list(set([pkg_li.get('license') for pkg_li in packages_licenses])) 61 | sorted_licenses = sorted(licenses) 62 | return " ".join(announcements_to_render + sorted_licenses) 63 | 64 | def render_announcements(self, announcements: List[Dict[str, Any]]) -> None: 65 | """ 66 | Renders announcements in a bare format. 67 | 68 | Args: 69 | announcements (List[Dict[str, Any]]): List of announcements. 70 | """ 71 | print('render_announcements bare') 72 | -------------------------------------------------------------------------------- /tests/test_encoding.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import os 3 | from pathlib import Path 4 | from unittest.mock import patch 5 | 6 | from safety.encoding import detect_encoding 7 | 8 | 9 | class TestDetectEncoding: 10 | """ 11 | Tests for the detect_encoding function. 12 | """ 13 | 14 | def test_utf16_le_detection(self): 15 | """ 16 | Test that UTF-16 LE (Little Endian) encoding is correctly detected. 17 | """ 18 | with tempfile.NamedTemporaryFile(delete=False) as f: 19 | # UTF-16 LE BOM: \xff\xfe 20 | f.write(b"\xff\xfe" + "Hello, world!".encode("utf-16-le")[2:]) 21 | 22 | try: 23 | encoding = detect_encoding(Path(f.name)) 24 | assert encoding == "utf-16" 25 | finally: 26 | os.unlink(f.name) 27 | 28 | def test_utf16_be_detection(self): 29 | """ 30 | Test that UTF-16 BE (Big Endian) encoding is correctly detected. 31 | """ 32 | with tempfile.NamedTemporaryFile(delete=False) as f: 33 | # UTF-16 BE BOM: \xfe\xff 34 | f.write(b"\xfe\xff" + "Hello, world!".encode("utf-16-be")[2:]) 35 | 36 | try: 37 | encoding = detect_encoding(Path(f.name)) 38 | assert encoding == "utf-16" 39 | finally: 40 | os.unlink(f.name) 41 | 42 | def test_utf8_sig_detection(self): 43 | """ 44 | Test that UTF-8 with signature (BOM) is correctly detected. 45 | """ 46 | with tempfile.NamedTemporaryFile(delete=False) as f: 47 | # UTF-8 with signature BOM: \xef\xbb\xbf 48 | f.write(b"\xef\xbb\xbf" + "Hello, world!".encode("utf-8")) 49 | 50 | try: 51 | encoding = detect_encoding(Path(f.name)) 52 | assert encoding == "utf-8-sig" 53 | finally: 54 | os.unlink(f.name) 55 | 56 | def test_utf8_detection(self): 57 | """ 58 | Test that regular UTF-8 (without BOM) is correctly detected. 59 | """ 60 | with tempfile.NamedTemporaryFile(delete=False) as f: 61 | # Regular UTF-8 (no BOM) 62 | f.write("Hello, world!".encode("utf-8")) 63 | 64 | try: 65 | encoding = detect_encoding(Path(f.name)) 66 | assert encoding == "utf-8" 67 | finally: 68 | os.unlink(f.name) 69 | 70 | def test_error_handling(self): 71 | """ 72 | Test that errors are properly handled and default encoding is returned. 73 | """ 74 | # Test with a non-existent file 75 | non_existent_file = Path("non_existent_file.txt") 76 | 77 | encoding = detect_encoding(non_existent_file) 78 | assert encoding == "utf-8" 79 | 80 | def test_exception_logging(self): 81 | """ 82 | Test that exceptions are properly logged. 83 | """ 84 | non_existent_file = Path("non_existent_file.txt") 85 | 86 | with patch("safety.encoding.logger") as mock_logger: 87 | encoding = detect_encoding(non_existent_file) 88 | 89 | assert encoding == "utf-8" 90 | mock_logger.exception.assert_called_once_with("Error detecting encoding") 91 | -------------------------------------------------------------------------------- /tests/test_db/report_invalid_decode_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "report_meta": { 3 | "scan_target": "environment" 4 | "scanned": [ 5 | "/usr/local/lib/python3.9/site-packages" 6 | ], 7 | "api_key": true, 8 | "packages_found": 50, 9 | "timestamp": "2022-04-30 08:16:30", 10 | "safety_version": "2.0b1" 11 | }, 12 | "scanned_packages": { 13 | "safety": { 14 | "name": "safety", 15 | "version": "2.0b1", 16 | "spec": "==2.0b1" 17 | }, 18 | "click": { 19 | "name": "click", 20 | "version": "8.0.4", 21 | "spec": "==8.0.4" 22 | }, 23 | "insecure-package": { 24 | "name": "insecure-package", 25 | "version": "0.1.0", 26 | "spec": "==0.1.0" 27 | } 28 | }, 29 | "affected_packages": { 30 | "insecure-package": { 31 | "name": "insecure-package", 32 | "version": "0.1.0", 33 | "spec": "==0.1.0", 34 | "found": "/usr/local/lib/python3.9/site-packages", 35 | "insecure_versions": [ 36 | "0.1.0" 37 | ], 38 | "secure_versions": [], 39 | "latest_version_without_known_vulnerabilities": "", 40 | "latest_version": "0.1.0", 41 | "more_info_url": "https://pyup.io/packages/pypi/insecure-package/" 42 | } 43 | }, 44 | "announcements": [], 45 | "vulnerabilities": [ 46 | { 47 | "vulnerability_id": "25853", 48 | "package_name": "insecure-package", 49 | "ignored": false, 50 | "ignored_reason": "", 51 | "ignored_expires": "", 52 | "vulnerable_spec": "<0.2.0", 53 | "all_vulnerable_specs": [ 54 | "<0.2.0" 55 | ], 56 | "analyzed_version": "0.1.0", 57 | "analyzed_requirement": "insecure-package==0.1.0", 58 | "advisory": "This is an insecure package with lots of exploitable security vulnerabilities.", 59 | "is_transitive": false, 60 | "published_date": "2021-Apr-14", 61 | "fixed_versions": [ 62 | "" 63 | ], 64 | "closest_versions_without_known_vulnerabilities": [], 65 | "resources": [ 66 | "https://pypi.org/project/insecure-package" 67 | ], 68 | "CVE": { 69 | "name": "PVE-2021-25853", 70 | "cvssv2": null, 71 | "cvssv3": null 72 | }, 73 | "affected_versions": [ 74 | "0.1.0" 75 | ], 76 | "more_info_url": "https://pyup.io/vulnerabilities/PVE-2021-25853/25853/" 77 | } 78 | ], 79 | "ignored_vulnerabilities": [], 80 | "remediations": { 81 | "insecure-package": { 82 | "current_version": "0.1.0", 83 | "vulnerabilities_found": 1, 84 | "recommended_version": null, 85 | "other_recommended_versions": [], 86 | "more_info_url": "https://pyup.io/packages/pypi/insecure-package/" 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /.github/scripts/should_build.py: -------------------------------------------------------------------------------- 1 | # /// script 2 | # requires-python = ">=3.11" 3 | # dependencies = [] 4 | # /// 5 | import sys 6 | import argparse 7 | from pathlib import Path 8 | import tomllib 9 | 10 | 11 | def read_toml_config(file_path: str) -> dict: 12 | """Read and parse TOML configuration file.""" 13 | with open(file_path, "rb") as f: 14 | return tomllib.load(f) 15 | 16 | 17 | def should_build_binary( 18 | os_type: str | None, python_version: str | None, target: str | None, config: dict 19 | ) -> bool: 20 | """Determine if this combination should trigger a binary build.""" 21 | try: 22 | artifacts_config = config["tool"]["project"]["build"]["artifacts"] 23 | return ( 24 | python_version == artifacts_config.get("python") 25 | and target in artifacts_config.get("targets", []) 26 | and os_type in artifacts_config.get("os_type", []) 27 | ) 28 | except KeyError: 29 | return False 30 | 31 | 32 | def should_build_package( 33 | os_type: str | None, python_version: str, rust_target: str | None, config: dict 34 | ) -> bool: 35 | """Determine if this combination should trigger a package build.""" 36 | try: 37 | artifacts_config = config["tool"]["project"]["build"]["artifacts"] 38 | return ( 39 | os_type == artifacts_config.get("package_os_type") 40 | and python_version == artifacts_config.get("python") 41 | and not rust_target 42 | ) 43 | except KeyError: 44 | return False 45 | 46 | 47 | def main(): 48 | parser = argparse.ArgumentParser( 49 | description="Determine if a combination should trigger a binary build" 50 | ) 51 | parser.add_argument( 52 | "build_type", 53 | choices=["binary", "package"], 54 | help="Type of build to check (binary or package)", 55 | ) 56 | parser.add_argument("toml_path", help="Path to pyproject.toml file") 57 | parser.add_argument("--os-type", help="OS type to check") 58 | parser.add_argument("--python-version", help="Python version to check") 59 | parser.add_argument("--target", help="Target to check") 60 | parser.add_argument("--rust-target", help="Rust target to check") 61 | 62 | args = parser.parse_args() 63 | 64 | toml_path = Path(args.toml_path) 65 | if not toml_path.exists(): 66 | print(f"Error: File {toml_path} not found") 67 | sys.exit(1) 68 | 69 | try: 70 | config = read_toml_config(str(toml_path)) 71 | should_build = False 72 | 73 | if args.build_type == "binary": 74 | should_build = should_build_binary( 75 | args.os_type, args.python_version, args.target, config 76 | ) 77 | elif args.build_type == "package": 78 | should_build = should_build_package( 79 | args.os_type, args.python_version, args.rust_target, config 80 | ) 81 | # Print true/false for direct use in GitHub Actions 82 | print(str(should_build).lower()) 83 | except Exception as e: 84 | print(f"Error processing TOML file: {e}", file=sys.stderr) 85 | sys.exit(1) 86 | 87 | 88 | if __name__ == "__main__": 89 | main() 90 | -------------------------------------------------------------------------------- /safety/asyncio_patch.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def apply_asyncio_patch(): 9 | """ 10 | Apply a patch to asyncio's exception handling for subprocesses. 11 | 12 | There are some issues with asyncio's exception handling for subprocesses, 13 | which causes a RuntimeError to be raised when the event loop was already closed. 14 | 15 | This patch catches the RuntimeError and ignores it, which allows the event loop 16 | to be closed properly. 17 | 18 | Similar issues: 19 | - https://bugs.python.org/issue39232 20 | - https://github.com/python/cpython/issues/92841 21 | """ 22 | 23 | import asyncio.base_subprocess 24 | 25 | original_subprocess_del = asyncio.base_subprocess.BaseSubprocessTransport.__del__ 26 | 27 | def patched_subprocess_del(self): 28 | try: 29 | original_subprocess_del(self) 30 | except (RuntimeError, ValueError, OSError) as e: 31 | if isinstance(e, RuntimeError) and str(e) != "Event loop is closed": 32 | raise 33 | if isinstance(e, ValueError) and str(e) != "I/O operation on closed pipe": 34 | raise 35 | if isinstance(e, OSError) and "[WinError 6]" not in str(e): 36 | raise 37 | logger.debug(f"Patched {original_subprocess_del}") 38 | 39 | asyncio.base_subprocess.BaseSubprocessTransport.__del__ = patched_subprocess_del 40 | 41 | if sys.platform == "win32": 42 | import asyncio.proactor_events as proactor_events 43 | 44 | original_pipe_del = proactor_events._ProactorBasePipeTransport.__del__ 45 | 46 | def patched_pipe_del(self): 47 | try: 48 | original_pipe_del(self) 49 | except (RuntimeError, ValueError) as e: 50 | if isinstance(e, RuntimeError) and str(e) != "Event loop is closed": 51 | raise 52 | if ( 53 | isinstance(e, ValueError) 54 | and str(e) != "I/O operation on closed pipe" 55 | ): 56 | raise 57 | logger.debug(f"Patched {original_pipe_del}") 58 | 59 | original_repr = proactor_events._ProactorBasePipeTransport.__repr__ 60 | 61 | def patched_repr(self): 62 | try: 63 | return original_repr(self) 64 | except ValueError as e: 65 | if str(e) != "I/O operation on closed pipe": 66 | raise 67 | logger.debug(f"Patched {original_repr}") 68 | return f"<{self.__class__} [closed]>" 69 | 70 | proactor_events._ProactorBasePipeTransport.__del__ = patched_pipe_del 71 | proactor_events._ProactorBasePipeTransport.__repr__ = patched_repr 72 | 73 | import subprocess 74 | 75 | original_popen_del = subprocess.Popen.__del__ 76 | 77 | def patched_popen_del(self): 78 | try: 79 | original_popen_del(self) 80 | except OSError as e: 81 | if "[WinError 6]" not in str(e): 82 | raise 83 | logger.debug(f"Patched {original_popen_del}") 84 | 85 | subprocess.Popen.__del__ = patched_popen_del 86 | 87 | 88 | apply_asyncio_patch() 89 | -------------------------------------------------------------------------------- /safety/formatters/schemas/zero_five.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | 3 | from marshmallow import Schema, fields as fields_, post_dump 4 | 5 | 6 | from importlib.metadata import version as dep_version 7 | from packaging import version 8 | 9 | marshmallow_version = version.parse(dep_version("marshmallow")) 10 | 11 | if marshmallow_version >= version.parse("4.0.0"): 12 | POST_DUMP_KWARGS = {"pass_collection": True} 13 | else: 14 | POST_DUMP_KWARGS = {"pass_many": True} 15 | 16 | post_dump_with_kwargs = lambda fn: post_dump(fn, **POST_DUMP_KWARGS) # noqa: E731 17 | 18 | 19 | class CVSSv2(Schema): 20 | """ 21 | Schema for CVSSv2 data. 22 | 23 | Attributes: 24 | base_score (fields_.Int): Base score of the CVSSv2. 25 | impact_score (fields_.Int): Impact score of the CVSSv2. 26 | vector_string (fields_.Str): Vector string of the CVSSv2. 27 | """ 28 | 29 | base_score = fields_.Int() 30 | impact_score = fields_.Int() 31 | vector_string = fields_.Str() 32 | 33 | class Meta(Schema.Meta): 34 | ordered = True 35 | 36 | 37 | class CVSSv3(Schema): 38 | """ 39 | Schema for CVSSv3 data. 40 | 41 | Attributes: 42 | base_score (fields_.Int): Base score of the CVSSv3. 43 | base_severity (fields_.Str): Base severity of the CVSSv3. 44 | impact_score (fields_.Int): Impact score of the CVSSv3. 45 | vector_string (fields_.Str): Vector string of the CVSSv3. 46 | """ 47 | 48 | base_score = fields_.Int() 49 | base_severity = fields_.Str() 50 | impact_score = fields_.Int() 51 | vector_string = fields_.Str() 52 | 53 | class Meta(Schema.Meta): 54 | ordered = True 55 | 56 | 57 | class VulnerabilitySchemaV05(Schema): 58 | """ 59 | Legacy JSON report schema used in Safety 1.10.3. 60 | 61 | Attributes: 62 | package_name (fields_.Str): Name of the vulnerable package. 63 | vulnerable_spec (fields_.Str): Vulnerable specification of the package. 64 | version (fields_.Str): Version of the package. 65 | advisory (fields_.Str): Advisory details for the vulnerability. 66 | vulnerability_id (fields_.Str): ID of the vulnerability. 67 | cvssv2 (Optional[CVSSv2]): CVSSv2 details of the vulnerability. 68 | cvssv3 (Optional[CVSSv3]): CVSSv3 details of the vulnerability. 69 | """ 70 | 71 | package_name = fields_.Str() 72 | vulnerable_spec = fields_.Str() 73 | version = fields_.Str(attribute="pkg.version") 74 | advisory = fields_.Str() 75 | vulnerability_id = fields_.Str() 76 | cvssv2 = fields_.Nested(CVSSv2, attribute="severity.cvssv2") 77 | cvssv3 = fields_.Nested(CVSSv3, attribute="severity.cvssv3") 78 | 79 | class Meta(Schema.Meta): 80 | ordered = True 81 | 82 | @post_dump_with_kwargs 83 | def wrap_with_envelope(self, data, many, **kwargs) -> List[Tuple]: 84 | """ 85 | Wraps the dumped data with an envelope. 86 | 87 | Args: 88 | data (List[Dict[str, Any]]): The data to be wrapped. 89 | many (bool): Indicates if multiple objects are being dumped. 90 | **kwargs (Any): Additional keyword arguments. 91 | 92 | Returns: 93 | List[Tuple]: The wrapped data. 94 | """ 95 | return [tuple(d.values()) for d in data] 96 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | update_latest: 7 | description: 'Update the "latest" tag (only applies to stable versions)' 8 | required: false 9 | default: true 10 | type: boolean 11 | 12 | jobs: 13 | build-and-push: 14 | runs-on: ubuntu-24.04 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.13" 22 | cache: "pip" 23 | 24 | - name: Safety Version 25 | run: | 26 | pip install packaging 27 | echo "SAFETY_VERSION=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" >> $GITHUB_ENV 28 | 29 | - name: Extract Major and Minor Version 30 | run: | 31 | python scripts/extract_version.py 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | with: 36 | driver-opts: | 37 | image=moby/buildkit:v0.10.6 38 | 39 | - name: Log into registry 40 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin 41 | 42 | - name: Check for Beta Version 43 | id: beta_check 44 | run: | 45 | if [[ ${{ env.SAFETY_VERSION }} =~ .*b[0-9]+$ ]]; then 46 | echo "is_beta=true" >> $GITHUB_ENV 47 | else 48 | echo "is_beta=false" >> $GITHUB_ENV 49 | fi 50 | 51 | - name: Determine Latest Tag Update 52 | run: | 53 | if [[ "${{ env.is_beta }}" == "true" ]]; then 54 | echo "update_latest=false" >> $GITHUB_ENV 55 | else 56 | echo "update_latest=${{ github.event.inputs.update_latest }}" >> $GITHUB_ENV 57 | fi 58 | 59 | - name: Docker meta 60 | id: meta 61 | uses: docker/metadata-action@v5 62 | with: 63 | images: | 64 | ghcr.io/pyupio/safety 65 | tags: | 66 | type=raw,value=${{ env.SAFETY_VERSION }},suffix=-{{ sha }} 67 | type=raw,value=${{ env.SAFETY_VERSION }} 68 | ${{ env.is_beta != 'true' && format('type=raw,value={0}.{1}', env.SAFETY_MAJOR_VERSION, env.SAFETY_MINOR_VERSION) || '' }} 69 | ${{ env.is_beta != 'true' && format('type=raw,value={0}', env.SAFETY_MAJOR_VERSION) || '' }} 70 | ${{ env.update_latest == 'true' && 'type=raw,value=latest' || '' }} 71 | ${{ env.is_beta == 'true' && 'type=raw,value=beta' || '' }} 72 | labels: | 73 | org.opencontainers.image.title=Safety CLI 74 | org.opencontainers.image.description=Safety CLI is a Python dependency vulnerability scanner that enhances software supply chain security at every stage of development. 75 | org.opencontainers.image.vendor=Safety Cybersecurity 76 | org.opencontainers.image.licenses=MIT 77 | 78 | - name: Build and Push image 79 | uses: docker/build-push-action@v4 80 | with: 81 | context: . 82 | push: true 83 | cache-from: type=local,src=/tmp/.buildx-cache 84 | cache-to: type=local,dest=/tmp/.buildx-cache-new 85 | tags: ${{ steps.meta.outputs.tags }} 86 | labels: ${{ steps.meta.outputs.labels }} 87 | build-args: SAFETY_VERSION=${{ env.SAFETY_VERSION }} 88 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | 3 | on: 4 | push: 5 | tags: 6 | # Stable releases (1.2.3) 7 | - 'v?[0-9]+.[0-9]+.[0-9]+' 8 | # Beta releases (1.2.3b0) 9 | - 'v?[0-9]+.[0-9]+.[0-9]+b[0-9]+' 10 | 11 | jobs: 12 | ci: 13 | uses: ./.github/workflows/ci.yml 14 | 15 | build: 16 | needs: [ci] 17 | uses: ./.github/workflows/reusable-build.yml 18 | permissions: 19 | id-token: write 20 | contents: read 21 | secrets: inherit 22 | with: 23 | is-release: true 24 | 25 | release: 26 | needs: [ci, build] 27 | runs-on: ubuntu-24.04 28 | environment: production 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | 36 | - name: Download binaries 37 | uses: actions/download-artifact@v4 38 | with: 39 | pattern: safety-* 40 | path: release/ 41 | merge-multiple: true 42 | 43 | - name: Download dist 44 | uses: actions/download-artifact@v4 45 | with: 46 | name: dist 47 | path: release/dist/ 48 | 49 | - name: Download checksums 50 | uses: actions/download-artifact@v4 51 | with: 52 | name: checksums 53 | path: release/ 54 | 55 | - name: Get version from tag 56 | id: get_version 57 | run: | 58 | VERSION=${GITHUB_REF#refs/tags/} 59 | echo "version=$VERSION" >> $GITHUB_OUTPUT 60 | echo "Tag version: $VERSION" 61 | 62 | - name: Generate release notes 63 | id: release_notes 64 | run: | 65 | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^) 66 | NOTES=$(git log ${PREV_TAG}..HEAD --pretty=format:"* %s (%h)") 67 | echo "RELEASE_NOTES<> $GITHUB_OUTPUT 68 | echo "## What's Changed" >> $GITHUB_OUTPUT 69 | echo "$NOTES" >> $GITHUB_OUTPUT 70 | echo "EOF" >> $GITHUB_OUTPUT 71 | 72 | - name: Create GitHub Release 73 | id: create_release 74 | uses: softprops/action-gh-release@v2.0.1 75 | with: 76 | name: Version ${{ steps.get_version.outputs.version }} 77 | body: ${{ steps.release_notes.outputs.RELEASE_NOTES }} 78 | files: | 79 | release/SHASUMS256.txt 80 | release/SHASUMS256.txt.cosign.bundle 81 | release/dist/* 82 | release/safety-* 83 | prerelease: ${{ contains(steps.get_version.outputs.version, 'b') }} 84 | token: ${{ secrets.SAFETY_BOT_TOKEN }} 85 | 86 | publish: 87 | needs: [release] 88 | runs-on: ubuntu-24.04 89 | environment: 90 | name: pypi 91 | url: https://pypi.org/p/safety 92 | permissions: 93 | id-token: write 94 | steps: 95 | - name: Download dist artifact 96 | uses: actions/download-artifact@v4 97 | with: 98 | name: dist 99 | path: dist 100 | 101 | - name: Publish to PyPI 102 | uses: pypa/gh-action-pypi-publish@release/v1 103 | with: 104 | packages-dir: dist/ 105 | verbose: true 106 | print-hash: true 107 | -------------------------------------------------------------------------------- /tests/events/event_bus/test_utils.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | import unittest 3 | from unittest.mock import MagicMock, patch 4 | 5 | from safety_schemas.models.events import EventType 6 | from safety.events.types import InternalEventType 7 | from safety.events.event_bus.utils import start_event_bus 8 | from safety.models.obj import SafetyCLI 9 | 10 | 11 | class TestEventBusUtils(unittest.TestCase): 12 | """ 13 | Test cases for event bus utility functions. 14 | """ 15 | 16 | def setUp(self): 17 | self.safety_cli = MagicMock(spec=SafetyCLI) 18 | self.safety_cli.firewall_enabled = False 19 | self.session = MagicMock() 20 | self.session.token = {"access_token": "test_token"} 21 | self.session.proxies = {"http": "proxy_url"} 22 | self.session.api_key = "test_api_key" 23 | 24 | self.mock_event_bus = MagicMock() 25 | 26 | self.event_bus_patcher = patch("safety.events.event_bus.utils.EventBus") 27 | self.mock_event_bus_class = self.event_bus_patcher.start() 28 | self.mock_event_bus_class.return_value = self.mock_event_bus 29 | 30 | def tearDown(self): 31 | self.event_bus_patcher.stop() 32 | 33 | def test_start_event_bus_without_firewall(self): 34 | """ 35 | Test start_event_bus when firewall is disabled. 36 | """ 37 | self.safety_cli.firewall_enabled = False 38 | 39 | start_event_bus(self.safety_cli, self.session) 40 | 41 | self.mock_event_bus.start.assert_called_once() 42 | self.assertEqual(self.safety_cli.event_bus, self.mock_event_bus) 43 | self.assertIsNotNone(self.safety_cli.security_events_handler) 44 | self.mock_event_bus.subscribe.assert_called_once() 45 | 46 | events = self.mock_event_bus.subscribe.call_args[0][0] 47 | self.assertIn(EventType.COMMAND_EXECUTED, events) 48 | self.assertIn(EventType.COMMAND_ERROR, events) 49 | self.assertIn(InternalEventType.CLOSE_RESOURCES, events) 50 | self.assertIn(InternalEventType.FLUSH_SECURITY_TRACES, events) 51 | 52 | with patch( 53 | "safety.firewall.events.utils.register_event_handlers" 54 | ) as mock_register: 55 | mock_register.assert_not_called() 56 | 57 | @patch("safety.firewall.events.utils.register_event_handlers") 58 | def test_start_event_bus_with_firewall(self, mock_register_handlers): 59 | """ 60 | Test start_event_bus when firewall is enabled. 61 | """ 62 | self.safety_cli.firewall_enabled = True 63 | 64 | start_event_bus(self.safety_cli, self.session) 65 | 66 | self.mock_event_bus.start.assert_called_once() 67 | self.assertEqual(self.safety_cli.event_bus, self.mock_event_bus) 68 | self.assertIsNotNone(self.safety_cli.security_events_handler) 69 | mock_register_handlers.assert_called_once_with( 70 | self.safety_cli.event_bus, obj=self.safety_cli 71 | ) 72 | 73 | def test_start_event_bus_without_token(self): 74 | """ 75 | Test start_event_bus when token is not available. 76 | """ 77 | self.session.token = None 78 | start_event_bus(self.safety_cli, self.session) 79 | self.assertIsNotNone(self.safety_cli.security_events_handler) 80 | self.mock_event_bus.subscribe.assert_called_once() 81 | -------------------------------------------------------------------------------- /safety/auth/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import os 3 | from typing import TYPE_CHECKING, Any, List, Optional, Dict, Union 4 | 5 | from authlib.integrations.base_client import BaseOAuth 6 | 7 | from safety_schemas.models import Stage 8 | 9 | if TYPE_CHECKING: 10 | from authlib.integrations.httpx_client import OAuth2Client 11 | from safety.platform import SafetyPlatformClient 12 | import httpx 13 | 14 | 15 | @dataclass 16 | class Organization: 17 | id: str 18 | name: str 19 | 20 | def to_dict(self) -> Dict: 21 | """ 22 | Convert the Organization instance to a dictionary. 23 | 24 | Returns: 25 | dict: The dictionary representation of the organization. 26 | """ 27 | return {"id": self.id, "name": self.name} 28 | 29 | 30 | @dataclass 31 | class Auth: 32 | org: Optional[Organization] 33 | jwks: Dict[str, List[Dict[str, Any]]] 34 | http_client: Union["OAuth2Client", "httpx.Client"] 35 | platform: "SafetyPlatformClient" 36 | code_verifier: str 37 | client_id: str 38 | stage: Optional[Stage] = Stage.development 39 | email: Optional[str] = None 40 | name: Optional[str] = None 41 | email_verified: bool = False 42 | 43 | def is_valid(self) -> bool: 44 | """ 45 | Check if the authentication information is valid. 46 | 47 | Returns: 48 | bool: True if valid, False otherwise. 49 | """ 50 | if os.getenv("SAFETY_DB_DIR"): 51 | return True 52 | 53 | if not self.platform: 54 | return False 55 | 56 | if self.platform.api_key: 57 | return True 58 | 59 | return bool(self.platform.token and self.email_verified) 60 | 61 | def refresh_from(self, info: Dict) -> None: 62 | """ 63 | Refresh the authentication information from the provided info. 64 | 65 | Args: 66 | info (dict): The information to refresh from. 67 | """ 68 | from safety.auth.utils import is_email_verified 69 | 70 | self.name = info.get("name") 71 | self.email = info.get("email") 72 | self.email_verified = is_email_verified(info) # type: ignore 73 | 74 | def get_auth_method(self) -> str: 75 | """ 76 | Get the authentication method. 77 | 78 | Returns: 79 | str: The authentication method. 80 | """ 81 | if self.platform.api_key: 82 | return "API Key" 83 | 84 | if self.platform.token: 85 | return "Token" 86 | 87 | return "None" 88 | 89 | 90 | class XAPIKeyAuth(BaseOAuth): 91 | def __init__(self, api_key: str) -> None: 92 | """ 93 | Initialize the XAPIKeyAuth instance. 94 | 95 | Args: 96 | api_key (str): The API key to use for authentication. 97 | """ 98 | self.api_key = api_key 99 | 100 | def __call__(self, r: Any) -> Any: 101 | """ 102 | Add the API key to the request headers. 103 | 104 | Args: 105 | r (Any): The request object. 106 | 107 | Returns: 108 | Any: The modified request object. 109 | """ 110 | r.headers["X-API-Key"] = self.api_key 111 | return r 112 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Report a bug or unexpected behavior 3 | labels: ["bug"] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thanks for taking the time to fill out this bug report! 10 | 11 | - type: checkboxes 12 | id: checklist 13 | attributes: 14 | label: Checklist 15 | options: 16 | - label: I agree to the terms within the [Safety Code of Conduct](https://github.com/pyupio/safety/blob/main/CODE_OF_CONDUCT.md). 17 | required: true 18 | - label: I have searched existing issues to ensure this bug hasn't been reported before. 19 | required: true 20 | 21 | - type: textarea 22 | id: safety-version 23 | attributes: 24 | label: Safety version 25 | description: Specify the version of Safety you're using. 26 | placeholder: e.g., 3.2.5 27 | validations: 28 | required: true 29 | 30 | - type: textarea 31 | id: python-version 32 | attributes: 33 | label: Python version 34 | description: Specify the version of Python you're using. 35 | placeholder: e.g., 3.11.2 36 | validations: 37 | required: true 38 | 39 | - type: textarea 40 | id: os 41 | attributes: 42 | label: Operating System 43 | description: Specify the operating system you're using. 44 | placeholder: e.g., macOS 13, Windows 10 45 | validations: 46 | required: true 47 | 48 | - type: textarea 49 | id: bug-description 50 | attributes: 51 | label: Bug description 52 | description: | 53 | Describe the bug including: 54 | - What you were trying to do 55 | - What you expected to happen 56 | - What actually happened 57 | placeholder: | 58 | When I run `safety scan`, I expected it to output rich formatted results, but instead it crashes with a traceback... 59 | validations: 60 | required: true 61 | 62 | - type: textarea 63 | id: reproduction-steps 64 | attributes: 65 | label: Steps to reproduce 66 | description: Provide clear steps to reproduce the behavior. 67 | placeholder: | 68 | 1. Run command: `safety scan` 69 | 2. With these options: `--output report.json` 70 | 3. Using this requirements file: `requirements.txt` 71 | 4. See error output 72 | validations: 73 | required: true 74 | 75 | - type: textarea 76 | id: command-output 77 | attributes: 78 | label: Command and output 79 | description: | 80 | Paste the command(s) you ran and the full output. If there was a crash, please include the complete traceback. 81 | 82 | ⚠️ **Important**: Please remove any sensitive information such as: 83 | - API keys, tokens, or passwords 84 | - File paths that might contain usernames or sensitive directory names 85 | - Private package names or internal URLs 86 | - Any other personally identifiable or confidential information 87 | render: shell 88 | validations: 89 | required: false 90 | 91 | - type: textarea 92 | id: additional-context 93 | attributes: 94 | label: Additional context 95 | description: Add any other context about the problem here, including screenshots if applicable. 96 | validations: 97 | required: false -------------------------------------------------------------------------------- /.github/scripts/smoke_test_binary.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | BINARY_PATH="$1" 5 | VERSION="$2" 6 | 7 | echo "========================================" 8 | echo "Binary Smoke Tests" 9 | echo "========================================" 10 | echo "Binary: $BINARY_PATH" 11 | echo "" 12 | 13 | # Verify binary exists 14 | if [ ! -f "$BINARY_PATH" ]; then 15 | echo "❌ ERROR: Binary not found at $BINARY_PATH" 16 | exit 1 17 | fi 18 | 19 | # Make executable on Unix 20 | if [[ "$OSTYPE" != "msys" && "$OSTYPE" != "win32" ]]; then 21 | chmod +x "$BINARY_PATH" 22 | fi 23 | 24 | echo "Setting up Safety config for CI testing..." 25 | SAFETY_CONFIG_DIR="$HOME/.safety" 26 | SAFETY_CONFIG_FILE="$SAFETY_CONFIG_DIR/config.ini" 27 | 28 | mkdir -p "$SAFETY_CONFIG_DIR" 29 | 30 | cat > "$SAFETY_CONFIG_FILE" << 'EOF' 31 | [settings] 32 | firewall = True 33 | platform = True 34 | events = True 35 | EOF 36 | 37 | echo "✓ Created Safety config at $SAFETY_CONFIG_FILE" 38 | 39 | # Test 1: Version 40 | echo "Test 1: Version check" 41 | "$BINARY_PATH" --version || exit 1 42 | echo "✓ Version works" 43 | 44 | # Test 2: Help 45 | echo "" 46 | echo "Test 2: Help command" 47 | "$BINARY_PATH" --help > /dev/null || exit 1 48 | echo "✓ Help works" 49 | 50 | # Test command availability checks 51 | test_commands() { 52 | local title="$1" 53 | local help_flag="$2" 54 | shift 2 55 | local cmds=("$@") 56 | 57 | echo "" 58 | echo "$title" 59 | for cmd in "${cmds[@]}"; do 60 | if "$BINARY_PATH" "$cmd" "$help_flag" > /dev/null 2>&1; then 61 | echo " ✓ $cmd" 62 | else 63 | echo " ✗ $cmd FAILED" 64 | exit 1 65 | fi 66 | done 67 | } 68 | 69 | # Test firewall commands - handle case where package managers may not be installed 70 | test_firewall_commands() { 71 | local title="$1" 72 | local help_flag="$2" 73 | shift 2 74 | local cmds=("$@") 75 | 76 | echo "" 77 | echo "$title" 78 | for cmd in "${cmds[@]}"; do 79 | set +e 80 | output=$("$BINARY_PATH" "$cmd" "$help_flag" 2>&1) 81 | exit_code=$? 82 | set -e 83 | 84 | if [ $exit_code -eq 0 ]; then 85 | echo " ✓ $cmd (package manager found)" 86 | elif echo "$output" | grep -q "Tool $cmd is not installed"; then 87 | echo " ⚠ $cmd (package manager not installed - expected behavior)" 88 | else 89 | echo " ✗ $cmd FAILED with unexpected error" 90 | echo " Output: $output" 91 | exit 1 92 | fi 93 | done 94 | } 95 | 96 | test_commands "Test 3: Commands available" "--help" \ 97 | check scan validate auth configure 98 | 99 | test_firewall_commands "Test 4: Firewall commands available" "--safety-help" \ 100 | pip poetry uv npm 101 | 102 | # Test 5: Auth subcommands 103 | echo "" 104 | echo "Test 5: Auth subcommands" 105 | for subcmd in "login" "logout" "status"; do 106 | "$BINARY_PATH" auth "$subcmd" --help > /dev/null 2>&1 && echo " ✓ auth $subcmd" 107 | done 108 | 109 | echo "" 110 | echo "========================================" 111 | echo "✅ All smoke tests passed!" 112 | echo "========================================" 113 | 114 | # Clean up the config file (optional - comment out if you want to keep it) 115 | if [ -f "$SAFETY_CONFIG_FILE" ]; then 116 | rm "$SAFETY_CONFIG_FILE" 117 | echo "✓ Cleaned up Safety config" 118 | fi -------------------------------------------------------------------------------- /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: Bump 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | bump_type: 7 | description: 'Type of bump to perform' 8 | required: true 9 | default: 'beta' 10 | type: choice 11 | options: 12 | - beta 13 | - stable 14 | 15 | jobs: 16 | check-and-bump: 17 | environment: production 18 | runs-on: ubuntu-24.04 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | fetch-tags: true 24 | token: ${{ secrets.SAFETY_BOT_TOKEN }} 25 | 26 | - name: Check current commit 27 | run: | 28 | COMMIT_MSG=$(git log --format=%B -n 1) 29 | echo "Checking commit message: $COMMIT_MSG" 30 | if [[ $COMMIT_MSG == bump:* ]]; then 31 | echo "Current commit is a bump, skipping" 32 | exit 0 33 | fi 34 | 35 | - name: Determine bump type 36 | id: bump-type 37 | run: | 38 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 39 | echo "type=${{ inputs.bump_type }}" >> $GITHUB_OUTPUT 40 | else 41 | echo "type=beta" >> $GITHUB_OUTPUT 42 | fi 43 | 44 | - name: Set up Python 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: "3.12" 48 | 49 | - name: Install hatch 50 | run: pip install hatch "click<8.3.0" 51 | 52 | - name: Configure Git 53 | run: | 54 | git config --global user.name 'safety-bot' 55 | git config --global user.email 'safety-bot@users.noreply.github.com' 56 | 57 | - name: Import GPG key 58 | uses: crazy-max/ghaction-import-gpg@v6 59 | with: 60 | gpg_private_key: ${{ secrets.SAFETY_BOT_GPG_KEY }} 61 | passphrase: ${{ secrets.SAFETY_BOT_GPG_PASSPHRASE }} 62 | git_config_global: true 63 | git_user_signingkey: true 64 | git_commit_gpgsign: true 65 | git_tag_gpgsign: true 66 | 67 | - name: Get current version 68 | id: current-version 69 | run: | 70 | CURRENT_VERSION=$(hatch version) 71 | echo "version -> $CURRENT_VERSION" 72 | echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT 73 | 74 | if [[ $CURRENT_VERSION =~ .*b[0-9]+$ ]]; then 75 | echo "is_beta=true" >> $GITHUB_OUTPUT 76 | else 77 | echo "is_beta=false" >> $GITHUB_OUTPUT 78 | fi 79 | 80 | - name: Perform version bump 81 | id: version-bump 82 | run: | 83 | if [ "${{ steps.bump-type.outputs.type }}" = "stable" ]; then 84 | COMMAND="hatch run bump" 85 | else 86 | # For beta, we should always run the bump 87 | COMMAND="hatch run beta-bump" 88 | fi 89 | 90 | # Execute the command 91 | if $COMMAND; then 92 | echo "bumped=true" >> $GITHUB_OUTPUT 93 | else 94 | echo "bumped=false" >> $GITHUB_OUTPUT 95 | fi 96 | 97 | - name: Push changes 98 | if: steps.version-bump.outputs.bumped == 'true' 99 | run: | 100 | git push --follow-tags 101 | -------------------------------------------------------------------------------- /safety/tool/definitions.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, Any 2 | from pydantic import BaseModel, Field 3 | 4 | from safety.cli_util import CommandType, FeatureType 5 | from safety.constants import CONTEXT_COMMAND_TYPE, CONTEXT_FEATURE_TYPE 6 | 7 | 8 | class ContextSettingsModel(BaseModel): 9 | """ 10 | Model for command context settings. 11 | """ 12 | 13 | allow_extra_args: bool = Field(default=True) 14 | ignore_unknown_options: bool = Field(default=True) 15 | command_type: CommandType = Field(default=CommandType.BETA) 16 | feature_type: FeatureType = Field(default=FeatureType.FIREWALL) 17 | help_option_names: List[str] = Field(default=["--safety-help"]) 18 | 19 | def as_dict(self) -> Dict[str, Any]: 20 | """ 21 | Convert to dictionary format expected by Typer. 22 | 23 | Returns: 24 | Dict[str, Any]: Dictionary representation of the context settings 25 | """ 26 | result = { 27 | "allow_extra_args": self.allow_extra_args, 28 | "ignore_unknown_options": self.ignore_unknown_options, 29 | CONTEXT_COMMAND_TYPE: self.command_type, 30 | CONTEXT_FEATURE_TYPE: self.feature_type, 31 | "help_option_names": self.help_option_names, 32 | } 33 | return result 34 | 35 | 36 | class CommandSettingsModel(BaseModel): 37 | """ 38 | Model for command settings used in the Typer decorator. 39 | """ 40 | 41 | help: str 42 | name: str 43 | options_metavar: str = Field(default="[OPTIONS]") 44 | context_settings: ContextSettingsModel = Field(default_factory=ContextSettingsModel) 45 | 46 | 47 | class ToolCommandModel(BaseModel): 48 | """ 49 | Model for a tool command definition. 50 | """ 51 | 52 | name: str 53 | display_name: str 54 | help: str 55 | # Path to custom Typer app if available 56 | custom_app: Optional[str] = None 57 | # Custom command settings for the tool 58 | command_settings: Optional[CommandSettingsModel] = None 59 | 60 | def get_command_settings(self) -> CommandSettingsModel: 61 | """ 62 | Get command settings, using defaults if not specified. 63 | 64 | Returns: 65 | CommandSettingsModel: Command settings with defaults 66 | """ 67 | if self.command_settings: 68 | return self.command_settings 69 | 70 | return CommandSettingsModel( 71 | help=self.help, 72 | name=self.display_name, 73 | ) 74 | 75 | 76 | # Tool definitions 77 | TOOLS = [ 78 | ToolCommandModel( 79 | name="poetry", 80 | display_name="poetry", 81 | help="[BETA] Run poetry commands protected by Safety firewall.\nExample: safety poetry add httpx", 82 | ), 83 | ToolCommandModel( 84 | name="pip", 85 | display_name="pip", 86 | help="[BETA] Run pip commands protected by Safety firewall.\nExample: safety pip list", 87 | ), 88 | ToolCommandModel( 89 | name="uv", 90 | display_name="uv", 91 | help="[BETA] Run uv commands protected by Safety firewall.\nExample: safety uv pip list", 92 | ), 93 | ToolCommandModel( 94 | name="npm", 95 | display_name="npm", 96 | help="[BETA] Run npm commands protected by Safety firewall.\nExample: safety npm list", 97 | ), 98 | ] 99 | -------------------------------------------------------------------------------- /safety/tool/poetry/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | import subprocess 4 | from pathlib import Path 5 | import sys 6 | from typing import Optional 7 | 8 | from rich.console import Console 9 | 10 | from safety.console import main_console 11 | from safety.tool.constants import ( 12 | PYPI_PUBLIC_REPOSITORY_URL, 13 | PYPI_ORGANIZATION_REPOSITORY_URL, 14 | PYPI_PROJECT_REPOSITORY_URL, 15 | ) 16 | from safety.tool.resolver import get_unwrapped_command 17 | from safety.utils.pyapp_utils import get_path, get_env 18 | 19 | if sys.version_info >= (3, 11): 20 | import tomllib 21 | else: 22 | import tomli as tomllib 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | class Poetry: 28 | @classmethod 29 | def is_installed(cls) -> bool: 30 | """ 31 | Checks if the Poetry program is installed 32 | 33 | Returns: 34 | True if Poetry is installed on system, or false otherwise 35 | """ 36 | return shutil.which("poetry", path=get_path()) is not None 37 | 38 | @classmethod 39 | def is_poetry_project_file(cls, file: Path) -> bool: 40 | try: 41 | cfg = tomllib.loads(file.read_text()) 42 | 43 | # First check: tool.poetry section (most definitive) 44 | if "tool" in cfg and "poetry" in cfg.get("tool", {}): 45 | return True 46 | 47 | # Extra check on build-system section 48 | build_backend = cfg.get("build-system", {}).get("build-backend", "") 49 | if build_backend and "poetry.core" in build_backend: 50 | return True 51 | 52 | return False 53 | except (IOError, ValueError): 54 | return False 55 | 56 | @classmethod 57 | def configure_pyproject( 58 | cls, 59 | file: Path, 60 | org_slug: Optional[str], 61 | project_id: Optional[str] = None, 62 | console: Console = main_console, 63 | ) -> Optional[Path]: 64 | """ 65 | Configures index url for specified requirements file. 66 | 67 | Args: 68 | file (Path): Path to requirements.txt file. 69 | org_slug (Optional[str]): Organization slug. 70 | project_id (Optional[str]): Project ID. 71 | console (Console): Console instance. 72 | """ 73 | if not cls.is_installed(): 74 | logger.error("Poetry is not installed.") 75 | return None 76 | 77 | repository_url = ( 78 | PYPI_PROJECT_REPOSITORY_URL.format(org_slug, project_id) 79 | if project_id and org_slug 80 | else ( 81 | PYPI_ORGANIZATION_REPOSITORY_URL.format(org_slug) 82 | if org_slug 83 | else PYPI_PUBLIC_REPOSITORY_URL 84 | ) 85 | ) 86 | 87 | result = subprocess.run( 88 | [ 89 | get_unwrapped_command(name="poetry"), 90 | "source", 91 | "add", 92 | "safety", 93 | repository_url, 94 | ], 95 | capture_output=True, 96 | env=get_env(), 97 | ) 98 | 99 | if result.returncode != 0: 100 | logger.error(f"Failed to configure {file} file") 101 | return None 102 | 103 | return file 104 | --------------------------------------------------------------------------------