diff --git a/README.md b/README.md index 7ea39e36..627f2c31 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,51 @@ 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 + +By default, Cycode CLI verifies HTTPS connections against the CA bundle shipped with the CLI. + +If your organization uses a proxy that inspects HTTPS traffic, or an on-premises installation with +its own CA, you have two options. + +**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. | +| `CURL_CA_BUNDLE` | Alias for `REQUESTS_CA_BUNDLE`, honored when the latter is unset. | + +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`. + +Notes: + +- `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\] > [!WARNING] @@ -565,20 +611,20 @@ 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. +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 custom certificates and a longer timeout: +Example `mcp.json` configuration with a custom CA bundle and a longer timeout: ```json { @@ -588,7 +634,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/consts.py b/cycode/cli/consts.py index 9007fda9..7272dae3 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' +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 4a874c1f..a9a1505f 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): @@ -96,6 +98,17 @@ 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( soft_fail=True, @@ -122,8 +135,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. ' - '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', + 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 new file mode 100644 index 00000000..55bcaff3 --- /dev/null +++ b/cycode/cli/utils/trust_store.py @@ -0,0 +1,66 @@ +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 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: + """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. + """ + global _installed + + if _installed: + return True + + 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 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])), + ) + return False + + try: + import truststore + + truststore.inject_into_ssl() + except Exception as 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') + _installed = True + return True diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index bde0e880..4e7eebe4 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 path, used when truststore is not active.""" + def init_poolmanager(self, *args, **kwargs) -> None: default_context = ssl.create_default_context() default_context.load_default_certs() @@ -43,11 +46,13 @@ 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() - # 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') + 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/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/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/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 new file mode 100644 index 00000000..11f63c38 --- /dev/null +++ b/tests/utils/test_trust_store.py @@ -0,0 +1,208 @@ +import inspect +import sys +from collections.abc import Iterator +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest + +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]: + """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 _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.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, +# 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, 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, 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, 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_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: + _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() + + +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, opted_in: None +) -> 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() + + +@requires_truststore +def test_install_swallows_import_error(monkeypatch: pytest.MonkeyPatch, opted_in: None) -> 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 + + +@requires_truststore +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 + 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() + + +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)) + + _get_fresh_session() + + assert called, '_get_session() must install the OS trust store before the first handshake' + + +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 + + 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 + + +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 _mounts_legacy_adapter(session) is True