From 4ec15f09caf9593ba1b37909f5ddb8b9bd72ee50 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 17 Aug 2026 16:52:49 +0300 Subject: [PATCH 1/4] CM-71120 add support for local certificate trust store --- README.md | 63 +++++++--- cycode/cli/app.py | 3 + cycode/cli/consts.py | 1 + cycode/cli/exceptions/custom_exceptions.py | 6 +- cycode/cli/utils/trust_store.py | 57 +++++++++ cycode/cyclient/cycode_client_base.py | 13 +- poetry.lock | 15 ++- pyinstaller.spec | 6 + pyproject.toml | 1 + tests/utils/test_trust_store.py | 134 +++++++++++++++++++++ 10 files changed, 276 insertions(+), 23 deletions(-) create mode 100644 cycode/cli/utils/trust_store.py create mode 100644 tests/utils/test_trust_store.py diff --git a/README.md b/README.md index 7ea39e36..cb249506 100644 --- a/README.md +++ b/README.md @@ -18,19 +18,20 @@ This guide walks you through both installation and usage. 2. [On Windows](#on-windows) 2. [Install Pre-Commit Hook](#install-pre-commit-hook) 3. [Cycode CLI Commands](#cycode-cli-commands) -4. [MCP Command](#mcp-command-experiment) +4. [Certificates and Proxies](#certificates-and-proxies) +5. [MCP Command](#mcp-command-experiment) 1. [Starting the MCP Server](#starting-the-mcp-server) 2. [Available Options](#available-options) 3. [MCP Tools](#mcp-tools) 4. [Usage Examples](#usage-examples) 5. [Advanced Configuration](#advanced-configuration) -5. [Platform Command](#platform-command-beta) +6. [Platform Command](#platform-command-beta) 1. [Discovering Commands](#discovering-commands) 2. [Examples](#platform-examples) 3. [Notes & Limitations](#platform-notes--limitations) -6. [AI Guardrails](#ai-guardrails-beta) +7. [AI Guardrails](#ai-guardrails-beta) 1. [Data Collected by AI Guardrails](#data-collected-by-ai-guardrails) -7. [Scan Command](#scan-command) +8. [Scan Command](#scan-command) 1. [Running a Scan](#running-a-scan) 1. [Options](#options) 1. [Severity Threshold](#severity-option) @@ -64,11 +65,11 @@ This guide walks you through both installation and usage. 4. [Ignoring a Secret, IaC, or SCA Rule](#ignoring-a-secret-iac-sca-or-sast-rule) 5. [Ignoring a Package](#ignoring-a-package) 6. [Ignoring via a config file](#ignoring-via-a-config-file) -6. [Report command](#report-command) +9. [Report command](#report-command) 1. [Generating SBOM Report](#generating-sbom-report) -7. [Import command](#import-command) -8. [Scan logs](#scan-logs) -9. [Syntax Help](#syntax-help) +10. [Import command](#import-command) +11. [Scan logs](#scan-logs) +12. [Syntax Help](#syntax-help) # Prerequisites @@ -356,6 +357,38 @@ The following are the options and commands available with the Cycode CLI applica | [report](#report-command) | Generate report. You will need to specify which report type to perform as SBOM. | | status | Show the CLI status and exit. | +# Certificates and Proxies + +Cycode CLI verifies HTTPS connections against **your machine's certificate store** — the Windows +certificate store, the macOS Keychain, or the system CA directory on Linux. This is the default on +Python 3.10 and above, including the standalone executables and the Docker image. + +In practice this means that if your organization uses a proxy that inspects HTTPS traffic, and its CA +certificate is already installed on the machine (as is usually the case on a managed device), the CLI +works with no configuration at all. + +If the CA is *not* installed system-wide, point the CLI at a bundle file instead: + +| Environment Variable | Description | +|----------------------|-------------| +| `REQUESTS_CA_BUNDLE` | Path to a CA bundle file (`.pem` or `.crt`) to trust in addition to the machine's certificate store. | +| `CURL_CA_BUNDLE` | Alias for `REQUESTS_CA_BUNDLE`, honored when the latter is unset. | +| `CYCODE_CLI_DISABLE_TRUSTSTORE` | Set to `1` to ignore the machine's certificate store and verify only against the CA bundle shipped with the CLI. Use this if the machine store itself is misconfigured. | + +> [!NOTE] +> `REQUESTS_CA_BUNDLE` is *additive* — certificates from the bundle are trusted alongside the ones in +> the machine store, so setting it never costs you the system trust. + +> [!TIP] +> Run any command with `-v` to see which trust source is in use, for example `cycode -v status`. + +On Python 3.9, the machine certificate store is used on Windows only; other platforms fall back to the +CA bundle shipped with the CLI, so `REQUESTS_CA_BUNDLE` may be required. Upgrade to Python 3.10 or +newer, or use the standalone executable, to get the machine store everywhere. + +Proxies themselves are configured with the standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` +environment variables. + # MCP Command \[EXPERIMENT\] > [!WARNING] @@ -565,20 +598,19 @@ cycode mcp -t streamable-http -H 127.0.0.2 -p 9000 & ``` ### Advanced Configuration -##### Custom Certificates and Timeouts (Proxy Environments) +##### Timeouts and Custom Certificates (Proxy Environments) -If your organization uses a corporate proxy or a custom CA bundle for HTTPS inspection, you need to tell Cycode CLI (and the underlying Python TLS stack) where to find the trusted certificate bundle. You can also increase the MCP tool call timeout if scans are being cut short. +If long-running scans are being cut short by your MCP client, increase the tool call timeout. | Environment Variable | Description | |----------------------|-------------| -| `REQUESTS_CA_BUNDLE` | Path to a custom CA bundle file (`.pem` or `.crt`). Used by the `requests` library for all HTTPS calls made by Cycode CLI. | -| `SSL_CERT_FILE` | Path to a custom CA bundle file. Used by Python's low-level `ssl` module. Set this alongside `REQUESTS_CA_BUNDLE` for full coverage. | | `MCP_TOOL_TIMEOUT` | Timeout (in seconds) that MCP clients such as Claude and GitHub Copilot wait for a tool call to complete. Increase this if long-running scans are being cut off before they finish. | -> [!TIP] -> Set both `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` to the same CA bundle path. `REQUESTS_CA_BUNDLE` covers the HTTP layer; `SSL_CERT_FILE` covers the lower-level TLS layer. Using only one may still cause certificate errors in some environments. +Certificates behind a corporate proxy usually need no configuration: the CLI trusts your machine's +certificate store. See [Certificates and Proxies](#certificates-and-proxies) for the details, and set +`REQUESTS_CA_BUNDLE` in the MCP server's `env` block if your CA is not installed system-wide. -Example `mcp.json` configuration with custom certificates and a longer timeout: +Example `mcp.json` configuration with a custom CA bundle and a longer timeout: ```json { @@ -588,7 +620,6 @@ Example `mcp.json` configuration with custom certificates and a longer timeout: "args": ["mcp"], "env": { "REQUESTS_CA_BUNDLE": "/path/to/your/corporate-ca-bundle.pem", - "SSL_CERT_FILE": "/path/to/your/corporate-ca-bundle.pem", "MCP_TOOL_TIMEOUT": "1800" } } diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 82f7f41b..1efc2dde 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -16,6 +16,7 @@ from cycode.cli.consts import CLI_CONTEXT_SETTINGS from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.configuration_manager import ConfigurationManager +from cycode.cli.utils import trust_store from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar from cycode.cli.utils.version_checker import version_checker from cycode.cyclient.cycode_client_base import CycodeClientBase @@ -246,6 +247,8 @@ def app_callback( if verbose: set_logging_level(logging.DEBUG) + trust_store.install() + ctx.obj['output'] = output if output == OutputTypeOption.JSON: no_progress_meter = True diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 9007fda9..882998aa 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -209,6 +209,7 @@ LOGGING_LEVEL_ENV_VAR_NAME = 'LOGGING_LEVEL' VERBOSE_ENV_VAR_NAME = 'CYCODE_CLI_VERBOSE' DEBUG_ENV_VAR_NAME = 'CYCODE_CLI_DEBUG' +DISABLE_TRUSTSTORE_ENV_VAR_NAME = 'CYCODE_CLI_DISABLE_TRUSTSTORE' CYCODE_CONFIGURATION_DIRECTORY: str = '.cycode' diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 4a874c1f..1e79163c 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -123,7 +123,9 @@ def __str__(self) -> str: soft_fail=True, code='ssl_error', message='An SSL error occurred when trying to connect to the Cycode API. ' - 'If you use an on-premises installation or a proxy that intercepts SSL traffic ' - 'you should use the CURL_CA_BUNDLE environment variable to specify path to a valid .pem or similar', + 'The CLI trusts the certificates installed in your machine certificate store. ' + 'If you use an on-premises installation or a proxy that intercepts SSL traffic, ' + 'install its CA certificate in the machine store, or set the REQUESTS_CA_BUNDLE ' + '(or CURL_CA_BUNDLE) environment variable to the path of a valid .pem or similar', ), } diff --git a/cycode/cli/utils/trust_store.py b/cycode/cli/utils/trust_store.py new file mode 100644 index 00000000..66430393 --- /dev/null +++ b/cycode/cli/utils/trust_store.py @@ -0,0 +1,57 @@ +import sys + +from cycode import config +from cycode.cli import consts +from cycode.logger import get_logger + +logger = get_logger('Trust Store') + +# truststore requires Python 3.10+, so on 3.9 we keep the certifi-based verification +_MIN_PYTHON_VERSION = (3, 10) + +_installed = False + + +def is_installed() -> bool: + """Whether the OS trust store has been injected into the TLS stack.""" + return _installed + + +def install() -> bool: + """Make every TLS connection verify against the machine's trust store. + + `truststore.inject_into_ssl()` patches `ssl.SSLContext` process-wide, so this covers all HTTPS + traffic (the shared session, the presigned S3 upload, the version check) without touching call + sites. Certificates from `REQUESTS_CA_BUNDLE`/`CURL_CA_BUNDLE` keep working: requests still loads + them, and truststore treats them as additional trust anchors on top of the OS store. + + Idempotent. Never raises: on any failure we fall back to certifi rather than breaking the CLI. + """ + global _installed + + if _installed: + return True + + if config.get_val_as_bool(consts.DISABLE_TRUSTSTORE_ENV_VAR_NAME): + logger.debug('OS trust store disabled by env var, using the bundled CA store (certifi)') + return False + + if sys.version_info < _MIN_PYTHON_VERSION: + logger.debug( + 'OS trust store requires Python %s+, using the bundled CA store (certifi). Current version: %s', + '.'.join(map(str, _MIN_PYTHON_VERSION)), + '.'.join(map(str, sys.version_info[:3])), + ) + return False + + try: + import truststore + + truststore.inject_into_ssl() + except Exception as e: + logger.debug('Failed to use the OS trust store, using the bundled CA store (certifi). %s', e) + return False + + logger.debug('Using the OS trust store for TLS verification') + _installed = True + return True diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index bde0e880..428bd920 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -19,6 +19,7 @@ RequestTimeoutError, SlowUploadConnectionError, ) +from cycode.cli.utils import trust_store from cycode.cyclient import config from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id from cycode.cyclient.logger import logger @@ -28,6 +29,8 @@ class SystemStorageSslContext(HTTPAdapter): + """Windows system trust store fallback for Python 3.9, where truststore is unavailable.""" + def init_poolmanager(self, *args, **kwargs) -> None: default_context = ssl.create_default_context() default_context.load_default_certs() @@ -44,10 +47,12 @@ def cert_verify(self, *args, **kwargs) -> None: def _get_session() -> requests.Session: """Process-wide Session so TCP+TLS connections are reused across all API calls.""" session = requests.Session() - # On Windows without an explicit CA bundle env var, fall back to the system - # trust store via a custom SSL context. - if platform.system() == 'Windows' and not ( - os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE') + # Python 3.9 only: truststore already covers every platform on 3.10+. Without it, on Windows and + # without an explicit CA bundle env var, fall back to the system trust store via a custom SSL context. + if ( + not trust_store.is_installed() + and platform.system() == 'Windows' + and not (os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE')) ): session.mount('https://', SystemStorageSslContext()) return session diff --git a/poetry.lock b/poetry.lock index 44fa8fb6..04f9b5f3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2186,6 +2186,19 @@ files = [ {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, ] +[[package]] +name = "truststore" +version = "0.10.4" +description = "Verify certificates using native system trust stores" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.10\"" +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + [[package]] name = "typer" version = "0.15.4" @@ -2327,4 +2340,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "ba9807509e16982bc1c02848ce47c5a149542dc6613dd8518192e79ea4acb351" +content-hash = "44c50d903c873422a0ae93f723ba70d0682ad1b67d90878f9cea707aabbfb473" diff --git a/pyinstaller.spec b/pyinstaller.spec index e5be2bc2..cf909167 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -5,6 +5,7 @@ import os import platform import subprocess +import sys _INIT_FILE_PATH = os.path.join('cycode', '__init__.py') _CODESIGN_IDENTITY = os.environ.get('APPLE_CERT_NAME') @@ -41,6 +42,11 @@ _hiddenimports = [ 'cycode.cli.apps.mcp', ] +# truststore is imported lazily inside cycode/cli/utils/trust_store.py, and it picks its platform +# backend behind a sys.platform branch. Only the current platform's backend actually resolves. +if sys.version_info >= (3, 10): + _hiddenimports += ['truststore', 'truststore._windows', 'truststore._macos', 'truststore._openssl'] + a = Analysis( scripts=['cycode/cli/main.py'], excludes=['tests', 'setuptools', 'pkg_resources'], diff --git a/pyproject.toml b/pyproject.toml index 2d2beccc..b4f384f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ patch-ng = "1.19.1" typer = "^0.15.3" tenacity = ">=9.1.2,<9.2.0" mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" } +truststore = { version = ">=0.10.4,<0.11.0", markers = "python_version >= '3.10'" } pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" tomli-w = ">=1.0.0,<2.0.0" diff --git a/tests/utils/test_trust_store.py b/tests/utils/test_trust_store.py new file mode 100644 index 00000000..93e0519b --- /dev/null +++ b/tests/utils/test_trust_store.py @@ -0,0 +1,134 @@ +import platform +import sys +from collections.abc import Iterator +from unittest.mock import MagicMock + +import pytest + +from cycode.cli import consts +from cycode.cli.utils import trust_store + + +@pytest.fixture(autouse=True) +def _reset_trust_store() -> Iterator[None]: + """Keep the module-level install flag from leaking between tests.""" + trust_store._installed = False + yield + trust_store._installed = False + + +@pytest.fixture +def mocked_truststore(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Stub out the real truststore module, so we never patch `ssl` in the test process.""" + module = MagicMock() + monkeypatch.setitem(sys.modules, 'truststore', module) + return module + + +def _disable_via_env(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + # cycode.config snapshots os.environ at import time, so monkeypatch.setenv alone isn't visible + monkeypatch.setitem(trust_store.config.configuration, consts.DISABLE_TRUSTSTORE_ENV_VAR_NAME, value) + + +def test_install_injects_os_trust_store(mocked_truststore: MagicMock) -> None: + assert trust_store.install() is True + assert trust_store.is_installed() is True + mocked_truststore.inject_into_ssl.assert_called_once_with() + + +def test_install_is_idempotent(mocked_truststore: MagicMock) -> None: + assert trust_store.install() is True + assert trust_store.install() is True + mocked_truststore.inject_into_ssl.assert_called_once_with() + + +@pytest.mark.parametrize('value', ['1', 'true', 'TRUE', 'yes', 'on', 'enabled']) +def test_install_skipped_when_disabled_by_env_var( + value: str, mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_via_env(monkeypatch, value) + + assert trust_store.install() is False + assert trust_store.is_installed() is False + mocked_truststore.inject_into_ssl.assert_not_called() + + +@pytest.mark.parametrize('value', ['0', 'false', 'no', '']) +def test_install_not_skipped_for_falsy_env_var( + value: str, mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch +) -> None: + _disable_via_env(monkeypatch, value) + + assert trust_store.install() is True + mocked_truststore.inject_into_ssl.assert_called_once_with() + + +def test_install_skipped_on_unsupported_python(mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(trust_store.sys, 'version_info', (3, 9, 21)) + + assert trust_store.install() is False + assert trust_store.is_installed() is False + mocked_truststore.inject_into_ssl.assert_not_called() + + +def test_install_swallows_import_error(monkeypatch: pytest.MonkeyPatch) -> None: + import builtins + + original_import = builtins.__import__ + + def _raising_import(name: str, *args, **kwargs): # noqa: ANN202 + if name == 'truststore': + raise ImportError('truststore is not installed') + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, '__import__', _raising_import) + + assert trust_store.install() is False + assert trust_store.is_installed() is False + + +def test_install_swallows_injection_error(mocked_truststore: MagicMock) -> None: + mocked_truststore.inject_into_ssl.side_effect = RuntimeError('no trust store on this machine') + + assert trust_store.install() is False + assert trust_store.is_installed() is False + + +# --- session integration --- + + +def _get_fresh_session(): # noqa: ANN202 + from cycode.cyclient.cycode_client_base import _get_session + + _get_session.cache_clear() + try: + return _get_session() + finally: + _get_session.cache_clear() + + +@pytest.mark.skipif(platform.system() != 'Windows', reason='the legacy fallback is Windows-only') +def test_session_skips_legacy_windows_adapter_when_trust_store_installed( + mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch +) -> None: + from cycode.cyclient.cycode_client_base import SystemStorageSslContext + + monkeypatch.delenv('REQUESTS_CA_BUNDLE', raising=False) + monkeypatch.delenv('CURL_CA_BUNDLE', raising=False) + trust_store.install() + + session = _get_fresh_session() + + assert not isinstance(session.get_adapter('https://cycode.com'), SystemStorageSslContext) + + +@pytest.mark.skipif(platform.system() != 'Windows', reason='the legacy fallback is Windows-only') +def test_session_uses_legacy_windows_adapter_without_trust_store(monkeypatch: pytest.MonkeyPatch) -> None: + from cycode.cyclient.cycode_client_base import SystemStorageSslContext + + monkeypatch.delenv('REQUESTS_CA_BUNDLE', raising=False) + monkeypatch.delenv('CURL_CA_BUNDLE', raising=False) + + session = _get_fresh_session() + + assert isinstance(session.get_adapter('https://cycode.com'), SystemStorageSslContext) From ce05cee5c8116918690b1c1f0431efa297a8d213 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 17 Aug 2026 19:57:23 +0300 Subject: [PATCH 2/4] CM-71120 fix tests on python 3.9 --- tests/utils/test_trust_store.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/utils/test_trust_store.py b/tests/utils/test_trust_store.py index 93e0519b..7b4cf8b2 100644 --- a/tests/utils/test_trust_store.py +++ b/tests/utils/test_trust_store.py @@ -30,12 +30,32 @@ def _disable_via_env(monkeypatch: pytest.MonkeyPatch, value: str) -> None: monkeypatch.setitem(trust_store.config.configuration, consts.DISABLE_TRUSTSTORE_ENV_VAR_NAME, value) +# On Python 3.9 truststore is neither installed nor importable, and install() refuses by design, +# so the tests that assert a successful injection cannot run there. +requires_truststore = pytest.mark.skipif( + sys.version_info < trust_store._MIN_PYTHON_VERSION, + reason='truststore requires Python 3.10+', +) + + +@pytest.mark.skipif( + sys.version_info >= trust_store._MIN_PYTHON_VERSION, + reason='covers the Python 3.9 fallback only', +) +def test_install_declines_on_python_39(mocked_truststore: MagicMock) -> None: + assert trust_store.install() is False + assert trust_store.is_installed() is False + mocked_truststore.inject_into_ssl.assert_not_called() + + +@requires_truststore def test_install_injects_os_trust_store(mocked_truststore: MagicMock) -> None: assert trust_store.install() is True assert trust_store.is_installed() is True mocked_truststore.inject_into_ssl.assert_called_once_with() +@requires_truststore def test_install_is_idempotent(mocked_truststore: MagicMock) -> None: assert trust_store.install() is True assert trust_store.install() is True @@ -53,6 +73,7 @@ def test_install_skipped_when_disabled_by_env_var( mocked_truststore.inject_into_ssl.assert_not_called() +@requires_truststore @pytest.mark.parametrize('value', ['0', 'false', 'no', '']) def test_install_not_skipped_for_falsy_env_var( value: str, mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch @@ -71,6 +92,7 @@ def test_install_skipped_on_unsupported_python(mocked_truststore: MagicMock, mon mocked_truststore.inject_into_ssl.assert_not_called() +@requires_truststore def test_install_swallows_import_error(monkeypatch: pytest.MonkeyPatch) -> None: import builtins @@ -87,6 +109,7 @@ def _raising_import(name: str, *args, **kwargs): # noqa: ANN202 assert trust_store.is_installed() is False +@requires_truststore def test_install_swallows_injection_error(mocked_truststore: MagicMock) -> None: mocked_truststore.inject_into_ssl.side_effect = RuntimeError('no trust store on this machine') @@ -107,6 +130,7 @@ def _get_fresh_session(): # noqa: ANN202 _get_session.cache_clear() +@requires_truststore @pytest.mark.skipif(platform.system() != 'Windows', reason='the legacy fallback is Windows-only') def test_session_skips_legacy_windows_adapter_when_trust_store_installed( mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch From 054706acb0f8bc9938fc9e67913216f51012c9d8 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Tue, 18 Aug 2026 10:11:23 +0300 Subject: [PATCH 3/4] CM-71120 flip trust to be opt-in and fixed performance degredation when installing it while not needed --- README.md | 54 +++++---- cycode/cli/app.py | 3 - cycode/cli/consts.py | 2 +- cycode/cli/exceptions/custom_exceptions.py | 18 ++- cycode/cli/utils/trust_store.py | 27 +++-- cycode/cyclient/cycode_client_base.py | 6 +- cycode/cyclient/scan_client.py | 5 +- tests/conftest.py | 16 +++ tests/utils/test_trust_store.py | 122 +++++++++++++++------ 9 files changed, 175 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index cb249506..627f2c31 100644 --- a/README.md +++ b/README.md @@ -359,35 +359,48 @@ The following are the options and commands available with the Cycode CLI applica # Certificates and Proxies -Cycode CLI verifies HTTPS connections against **your machine's certificate store** — the Windows -certificate store, the macOS Keychain, or the system CA directory on Linux. This is the default on -Python 3.10 and above, including the standalone executables and the Docker image. +By default, Cycode CLI verifies HTTPS connections against the CA bundle shipped with the CLI. -In practice this means that if your organization uses a proxy that inspects HTTPS traffic, and its CA -certificate is already installed on the machine (as is usually the case on a managed device), the CLI -works with no configuration at all. +If your organization uses a proxy that inspects HTTPS traffic, or an on-premises installation with +its own CA, you have two options. -If the CA is *not* installed system-wide, point the CLI at a bundle file instead: +**Option 1 — use the certificates already installed on the machine.** If your CA is in the machine +certificate store (as is usually the case on a managed device), opt in: + +```bash +export CYCODE_CLI_ENABLE_TRUSTSTORE=1 +``` + +The CLI then verifies against the Windows certificate store, the macOS Keychain, or the system CA +directory on Linux, and no certificate paths need to be configured. + +> [!IMPORTANT] +> This is opt-in on purpose. Trusting the machine store means trusting every root certificate +> present on that machine, including any an administrator or malicious software installed. Enable it +> when you know your machine's certificate store is one you trust. + +**Option 2 — point the CLI at a CA bundle file.** Works without opting in: | Environment Variable | Description | |----------------------|-------------| -| `REQUESTS_CA_BUNDLE` | Path to a CA bundle file (`.pem` or `.crt`) to trust in addition to the machine's certificate store. | +| `REQUESTS_CA_BUNDLE` | Path to a CA bundle file (`.pem` or `.crt`) to trust. | | `CURL_CA_BUNDLE` | Alias for `REQUESTS_CA_BUNDLE`, honored when the latter is unset. | -| `CYCODE_CLI_DISABLE_TRUSTSTORE` | Set to `1` to ignore the machine's certificate store and verify only against the CA bundle shipped with the CLI. Use this if the machine store itself is misconfigured. | -> [!NOTE] -> `REQUESTS_CA_BUNDLE` is *additive* — certificates from the bundle are trusted alongside the ones in -> the machine store, so setting it never costs you the system trust. +The two options combine: with `CYCODE_CLI_ENABLE_TRUSTSTORE=1`, certificates from +`REQUESTS_CA_BUNDLE` are trusted *in addition to* the machine store, not instead of it. > [!TIP] > Run any command with `-v` to see which trust source is in use, for example `cycode -v status`. -On Python 3.9, the machine certificate store is used on Windows only; other platforms fall back to the -CA bundle shipped with the CLI, so `REQUESTS_CA_BUNDLE` may be required. Upgrade to Python 3.10 or -newer, or use the standalone executable, to get the machine store everywhere. +Notes: -Proxies themselves are configured with the standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` -environment variables. +- `CYCODE_CLI_ENABLE_TRUSTSTORE` requires Python 3.10 or newer. On Python 3.9 the CLI logs a warning + and falls back to the bundled CA bundle; use `REQUESTS_CA_BUNDLE` instead, or upgrade Python. The + standalone executables and the Docker image already ship a supported Python. +- On Windows, the CLI has always fallen back to the system certificate store when neither + `REQUESTS_CA_BUNDLE` nor `CURL_CA_BUNDLE` is set. That behavior is unchanged. +- Proxies themselves are configured with the standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` + environment variables. # MCP Command \[EXPERIMENT\] @@ -606,9 +619,10 @@ If long-running scans are being cut short by your MCP client, increase the tool |----------------------|-------------| | `MCP_TOOL_TIMEOUT` | Timeout (in seconds) that MCP clients such as Claude and GitHub Copilot wait for a tool call to complete. Increase this if long-running scans are being cut off before they finish. | -Certificates behind a corporate proxy usually need no configuration: the CLI trusts your machine's -certificate store. See [Certificates and Proxies](#certificates-and-proxies) for the details, and set -`REQUESTS_CA_BUNDLE` in the MCP server's `env` block if your CA is not installed system-wide. +Behind a corporate proxy, set the certificate variables in the MCP server's `env` block. See +[Certificates and Proxies](#certificates-and-proxies) for the options: either +`CYCODE_CLI_ENABLE_TRUSTSTORE=1` to use the certificates already on the machine, or +`REQUESTS_CA_BUNDLE` pointing at a CA bundle file. Example `mcp.json` configuration with a custom CA bundle and a longer timeout: diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 1efc2dde..82f7f41b 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -16,7 +16,6 @@ from cycode.cli.consts import CLI_CONTEXT_SETTINGS from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.configuration_manager import ConfigurationManager -from cycode.cli.utils import trust_store from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar from cycode.cli.utils.version_checker import version_checker from cycode.cyclient.cycode_client_base import CycodeClientBase @@ -247,8 +246,6 @@ def app_callback( if verbose: set_logging_level(logging.DEBUG) - trust_store.install() - ctx.obj['output'] = output if output == OutputTypeOption.JSON: no_progress_meter = True diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 882998aa..7272dae3 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -209,7 +209,7 @@ LOGGING_LEVEL_ENV_VAR_NAME = 'LOGGING_LEVEL' VERBOSE_ENV_VAR_NAME = 'CYCODE_CLI_VERBOSE' DEBUG_ENV_VAR_NAME = 'CYCODE_CLI_DEBUG' -DISABLE_TRUSTSTORE_ENV_VAR_NAME = 'CYCODE_CLI_DISABLE_TRUSTSTORE' +ENABLE_TRUSTSTORE_ENV_VAR_NAME = 'CYCODE_CLI_ENABLE_TRUSTSTORE' CYCODE_CONFIGURATION_DIRECTORY: str = '.cycode' diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 1e79163c..5a26673b 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -1,6 +1,8 @@ from requests import Response +from cycode.cli import consts from cycode.cli.models import CliError, CliErrors +from cycode.cli.utils import trust_store class CycodeError(Exception): @@ -95,6 +97,16 @@ def __init__(self, file_path: str) -> None: def __str__(self) -> str: return f'Error occurred while parsing terraform plan file. Path: {self.file_path}' +_SSL_ERROR_CA_BUNDLE_HINT = ( + 'set the REQUESTS_CA_BUNDLE (or CURL_CA_BUNDLE) environment variable to the path of a valid .pem or similar' +) +_SSL_ERROR_TRUST_HINT = ( + 'If you use an on-premises installation or a proxy that intercepts SSL traffic, ' + f'set {consts.ENABLE_TRUSTSTORE_ENV_VAR_NAME}=1 to trust the CA certificates installed in your ' + f'machine certificate store, or {_SSL_ERROR_CA_BUNDLE_HINT}' + if trust_store.is_supported() and not trust_store.is_enabled() + else f'If you use an on-premises installation or a proxy that intercepts SSL traffic, {_SSL_ERROR_CA_BUNDLE_HINT}' +) KNOWN_USER_FRIENDLY_REQUEST_ERRORS: CliErrors = { RequestHttpError: CliError( @@ -122,10 +134,6 @@ def __str__(self) -> str: RequestSslError: CliError( soft_fail=True, code='ssl_error', - message='An SSL error occurred when trying to connect to the Cycode API. ' - 'The CLI trusts the certificates installed in your machine certificate store. ' - 'If you use an on-premises installation or a proxy that intercepts SSL traffic, ' - 'install its CA certificate in the machine store, or set the REQUESTS_CA_BUNDLE ' - '(or CURL_CA_BUNDLE) environment variable to the path of a valid .pem or similar', + message=f'An SSL error occurred when trying to connect to the Cycode API. {_SSL_ERROR_TRUST_HINT}', ), } diff --git a/cycode/cli/utils/trust_store.py b/cycode/cli/utils/trust_store.py index 66430393..55bcaff3 100644 --- a/cycode/cli/utils/trust_store.py +++ b/cycode/cli/utils/trust_store.py @@ -6,38 +6,47 @@ logger = get_logger('Trust Store') -# truststore requires Python 3.10+, so on 3.9 we keep the certifi-based verification +# truststore requires Python 3.10+, so on 3.9 the OS trust store is unavailable _MIN_PYTHON_VERSION = (3, 10) _installed = False +def is_supported() -> bool: + return sys.version_info >= _MIN_PYTHON_VERSION + + +def is_enabled() -> bool: + return config.get_val_as_bool(consts.ENABLE_TRUSTSTORE_ENV_VAR_NAME) + + def is_installed() -> bool: """Whether the OS trust store has been injected into the TLS stack.""" return _installed def install() -> bool: - """Make every TLS connection verify against the machine's trust store. + """Verify TLS against the machine's trust store. `truststore.inject_into_ssl()` patches `ssl.SSLContext` process-wide, so this covers all HTTPS traffic (the shared session, the presigned S3 upload, the version check) without touching call sites. Certificates from `REQUESTS_CA_BUNDLE`/`CURL_CA_BUNDLE` keep working: requests still loads them, and truststore treats them as additional trust anchors on top of the OS store. - - Idempotent. Never raises: on any failure we fall back to certifi rather than breaking the CLI. """ global _installed if _installed: return True - if config.get_val_as_bool(consts.DISABLE_TRUSTSTORE_ENV_VAR_NAME): - logger.debug('OS trust store disabled by env var, using the bundled CA store (certifi)') + if not is_enabled(): + logger.debug( + 'OS trust store not enabled, using the bundled CA store (certifi). Set %s=1 to enable it', + consts.ENABLE_TRUSTSTORE_ENV_VAR_NAME, + ) return False - if sys.version_info < _MIN_PYTHON_VERSION: - logger.debug( + if not is_supported(): + logger.warning( 'OS trust store requires Python %s+, using the bundled CA store (certifi). Current version: %s', '.'.join(map(str, _MIN_PYTHON_VERSION)), '.'.join(map(str, sys.version_info[:3])), @@ -49,7 +58,7 @@ def install() -> bool: truststore.inject_into_ssl() except Exception as e: - logger.debug('Failed to use the OS trust store, using the bundled CA store (certifi). %s', e) + logger.warning('Failed to use the OS trust store, falling back to the bundled CA store (certifi). %s', e) return False logger.debug('Using the OS trust store for TLS verification') diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 428bd920..4e7eebe4 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -29,7 +29,7 @@ class SystemStorageSslContext(HTTPAdapter): - """Windows system trust store fallback for Python 3.9, where truststore is unavailable.""" + """Windows system trust store path, used when truststore is not active.""" def init_poolmanager(self, *args, **kwargs) -> None: default_context = ssl.create_default_context() @@ -46,9 +46,9 @@ def cert_verify(self, *args, **kwargs) -> None: @functools.cache def _get_session() -> requests.Session: """Process-wide Session so TCP+TLS connections are reused across all API calls.""" + trust_store.install() + session = requests.Session() - # Python 3.9 only: truststore already covers every platform on 3.10+. Without it, on Windows and - # without an explicit CA bundle env var, fall back to the system trust store via a custom SSL context. if ( not trust_store.is_installed() and platform.system() == 'Windows' diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 18f400ac..19e79ddb 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -14,6 +14,7 @@ SlowUploadConnectionError, ) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip +from cycode.cli.utils import trust_store from cycode.cyclient import models from cycode.cyclient.cycode_client_base import CycodeClientBase, UploadProgressTracker from cycode.cyclient.logger import logger @@ -153,7 +154,9 @@ def upload_to_presigned_post( tracker = UploadProgressTracker(prepared.body, on_upload_progress) try: - # We are not using Cycode client, as we are calling aws S3. + # We are not using Cycode client, as we are calling aws S3. That also means this call + # skips the shared session, so the OS trust store has to be installed explicitly here. + trust_store.install() response = requests.post( url, data=tracker, diff --git a/tests/conftest.py b/tests/conftest.py index f1df29dd..4e86168e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ import responses from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cli.utils import trust_store from cycode.cyclient.client_creator import create_scan_client from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient @@ -118,3 +119,18 @@ def oidc_api_token_response(oidc_api_token_url: str) -> responses.Response: }, status=200, ) + + +@pytest.fixture(autouse=True) +def _no_real_trust_store_injection(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: + """Never let the suite patch the global `ssl` module. + + `trust_store.install()` runs lazily from `_get_session()`, so any test that builds a session + would otherwise really call `truststore.inject_into_ssl()` — process-wide, leaking across tests. + It also fails noisily under pyfakefs, where truststore's platform probe cannot read the files + it expects. tests/utils/test_trust_store.py opts out: it tests install() itself, with the + truststore module stubbed. + """ + if request.module.__name__.rsplit('.', 1)[-1] == 'test_trust_store': + return + monkeypatch.setattr(trust_store, 'install', lambda: False) diff --git a/tests/utils/test_trust_store.py b/tests/utils/test_trust_store.py index 7b4cf8b2..11f63c38 100644 --- a/tests/utils/test_trust_store.py +++ b/tests/utils/test_trust_store.py @@ -1,6 +1,7 @@ -import platform +import inspect import sys from collections.abc import Iterator +from typing import TYPE_CHECKING from unittest.mock import MagicMock import pytest @@ -8,6 +9,9 @@ from cycode.cli import consts from cycode.cli.utils import trust_store +if TYPE_CHECKING: + import requests + @pytest.fixture(autouse=True) def _reset_trust_store() -> Iterator[None]: @@ -25,9 +29,15 @@ def mocked_truststore(monkeypatch: pytest.MonkeyPatch) -> MagicMock: return module -def _disable_via_env(monkeypatch: pytest.MonkeyPatch, value: str) -> None: +def _set_enable_env(monkeypatch: pytest.MonkeyPatch, value: str) -> None: # cycode.config snapshots os.environ at import time, so monkeypatch.setenv alone isn't visible - monkeypatch.setitem(trust_store.config.configuration, consts.DISABLE_TRUSTSTORE_ENV_VAR_NAME, value) + monkeypatch.setitem(trust_store.config.configuration, consts.ENABLE_TRUSTSTORE_ENV_VAR_NAME, value) + + +@pytest.fixture +def opted_in(monkeypatch: pytest.MonkeyPatch) -> None: + """The OS trust store is opt-in, so most tests must turn it on explicitly.""" + _set_enable_env(monkeypatch, '1') # On Python 3.9 truststore is neither installed nor importable, and install() refuses by design, @@ -42,49 +52,58 @@ def _disable_via_env(monkeypatch: pytest.MonkeyPatch, value: str) -> None: sys.version_info >= trust_store._MIN_PYTHON_VERSION, reason='covers the Python 3.9 fallback only', ) -def test_install_declines_on_python_39(mocked_truststore: MagicMock) -> None: +def test_install_declines_on_python_39(mocked_truststore: MagicMock, opted_in: None) -> None: assert trust_store.install() is False assert trust_store.is_installed() is False mocked_truststore.inject_into_ssl.assert_not_called() @requires_truststore -def test_install_injects_os_trust_store(mocked_truststore: MagicMock) -> None: +def test_install_injects_os_trust_store(mocked_truststore: MagicMock, opted_in: None) -> None: assert trust_store.install() is True assert trust_store.is_installed() is True mocked_truststore.inject_into_ssl.assert_called_once_with() @requires_truststore -def test_install_is_idempotent(mocked_truststore: MagicMock) -> None: +def test_install_is_idempotent(mocked_truststore: MagicMock, opted_in: None) -> None: assert trust_store.install() is True assert trust_store.install() is True mocked_truststore.inject_into_ssl.assert_called_once_with() @pytest.mark.parametrize('value', ['1', 'true', 'TRUE', 'yes', 'on', 'enabled']) -def test_install_skipped_when_disabled_by_env_var( +def test_install_runs_when_opted_in(value: str, mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + _set_enable_env(monkeypatch, value) + + assert trust_store.is_enabled() is True + if trust_store.is_supported(): + assert trust_store.install() is True + mocked_truststore.inject_into_ssl.assert_called_once_with() + + +@pytest.mark.parametrize('value', ['0', 'false', 'no', '']) +def test_install_skipped_when_not_opted_in( value: str, mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch ) -> None: - _disable_via_env(monkeypatch, value) + _set_enable_env(monkeypatch, value) + assert trust_store.is_enabled() is False assert trust_store.install() is False assert trust_store.is_installed() is False mocked_truststore.inject_into_ssl.assert_not_called() -@requires_truststore -@pytest.mark.parametrize('value', ['0', 'false', 'no', '']) -def test_install_not_skipped_for_falsy_env_var( - value: str, mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch -) -> None: - _disable_via_env(monkeypatch, value) - - assert trust_store.install() is True - mocked_truststore.inject_into_ssl.assert_called_once_with() +def test_install_skipped_when_env_var_absent(mocked_truststore: MagicMock) -> None: + """The default with no configuration at all: certifi, exactly as before this feature.""" + assert trust_store.is_enabled() is False + assert trust_store.install() is False + mocked_truststore.inject_into_ssl.assert_not_called() -def test_install_skipped_on_unsupported_python(mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: +def test_install_skipped_on_unsupported_python( + mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch, opted_in: None +) -> None: monkeypatch.setattr(trust_store.sys, 'version_info', (3, 9, 21)) assert trust_store.install() is False @@ -93,7 +112,7 @@ def test_install_skipped_on_unsupported_python(mocked_truststore: MagicMock, mon @requires_truststore -def test_install_swallows_import_error(monkeypatch: pytest.MonkeyPatch) -> None: +def test_install_swallows_import_error(monkeypatch: pytest.MonkeyPatch, opted_in: None) -> None: import builtins original_import = builtins.__import__ @@ -110,7 +129,7 @@ def _raising_import(name: str, *args, **kwargs): # noqa: ANN202 @requires_truststore -def test_install_swallows_injection_error(mocked_truststore: MagicMock) -> None: +def test_install_swallows_injection_error(mocked_truststore: MagicMock, opted_in: None) -> None: mocked_truststore.inject_into_ssl.side_effect = RuntimeError('no trust store on this machine') assert trust_store.install() is False @@ -130,29 +149,60 @@ def _get_fresh_session(): # noqa: ANN202 _get_session.cache_clear() -@requires_truststore -@pytest.mark.skipif(platform.system() != 'Windows', reason='the legacy fallback is Windows-only') -def test_session_skips_legacy_windows_adapter_when_trust_store_installed( - mocked_truststore: MagicMock, monkeypatch: pytest.MonkeyPatch -) -> None: - from cycode.cyclient.cycode_client_base import SystemStorageSslContext +def test_session_installs_the_trust_store(monkeypatch: pytest.MonkeyPatch) -> None: + """Guards the wiring: without this, deleting the install() call leaves every other test green.""" + called = [] + monkeypatch.setattr(trust_store, 'install', lambda: called.append(True)) - monkeypatch.delenv('REQUESTS_CA_BUNDLE', raising=False) - monkeypatch.delenv('CURL_CA_BUNDLE', raising=False) - trust_store.install() + _get_fresh_session() - session = _get_fresh_session() + assert called, '_get_session() must install the OS trust store before the first handshake' - assert not isinstance(session.get_adapter('https://cycode.com'), SystemStorageSslContext) +def test_presigned_upload_installs_the_trust_store() -> None: + """The S3 presigned upload bypasses the shared session, so it installs the trust store itself.""" + import cycode.cyclient.scan_client as scan_client_module -@pytest.mark.skipif(platform.system() != 'Windows', reason='the legacy fallback is Windows-only') -def test_session_uses_legacy_windows_adapter_without_trust_store(monkeypatch: pytest.MonkeyPatch) -> None: - from cycode.cyclient.cycode_client_base import SystemStorageSslContext + source = inspect.getsource(scan_client_module.ScanClient.upload_to_presigned_post) + assert 'trust_store.install()' in source + assert source.index('trust_store.install()') < source.index('requests.post('), ( + 'the trust store must be installed before the request is issued' + ) + + +def _windows_session(monkeypatch: pytest.MonkeyPatch, *, installed: bool) -> 'requests.Session': + """Build a session as if we were on Windows, with the trust-store state fully pinned. + + platform.system() is faked so these run on every OS, and install() is stubbed so no test + ever patches `ssl` for real. + """ + import cycode.cyclient.cycode_client_base as base + monkeypatch.setattr(base.platform, 'system', lambda: 'Windows') + monkeypatch.setattr(base.trust_store, 'install', lambda: None) + monkeypatch.setattr(base.trust_store, 'is_installed', lambda: installed) monkeypatch.delenv('REQUESTS_CA_BUNDLE', raising=False) monkeypatch.delenv('CURL_CA_BUNDLE', raising=False) + return _get_fresh_session() + + +def _mounts_legacy_adapter(session: 'requests.Session') -> bool: + from cycode.cyclient.cycode_client_base import SystemStorageSslContext + + return isinstance(session.get_adapter('https://cycode.com'), SystemStorageSslContext) + + +def test_windows_legacy_adapter_skipped_when_trust_store_installed(monkeypatch: pytest.MonkeyPatch) -> None: + """truststore already covers Windows, so the legacy adapter must not be mounted on top.""" + session = _windows_session(monkeypatch, installed=True) + + assert _mounts_legacy_adapter(session) is False + - session = _get_fresh_session() +def test_windows_legacy_adapter_kept_when_trust_store_inactive(monkeypatch: pytest.MonkeyPatch) -> None: + """Whether the user simply did not opt in, or is on Python 3.9 where it is unavailable, Windows + must keep behaving exactly as it did before this feature. + """ + session = _windows_session(monkeypatch, installed=False) - assert isinstance(session.get_adapter('https://cycode.com'), SystemStorageSslContext) + assert _mounts_legacy_adapter(session) is True From f84b2a1a635decad882f398b400e4b9f644a2e86 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Tue, 18 Aug 2026 10:13:03 +0300 Subject: [PATCH 4/4] CM-71120 ruff format --- cycode/cli/exceptions/custom_exceptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 5a26673b..a9a1505f 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -97,6 +97,7 @@ def __init__(self, file_path: str) -> None: def __str__(self) -> str: return f'Error occurred while parsing terraform plan file. Path: {self.file_path}' + _SSL_ERROR_CA_BUNDLE_HINT = ( 'set the REQUESTS_CA_BUNDLE (or CURL_CA_BUNDLE) environment variable to the path of a valid .pem or similar' )