diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbf17a4..6a937d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Added `skills` to `cloudsmith upstream`. Use `cloudsmith upstream skills list`, `create`, `update`, and `delete` to manage AI Skills upstreams. An AI Skills upstream caches skills and plugins from a GitHub repository. - Added `--changelog` to `cloudsmith push skills`. Use it to add release notes to an AI Skills package. The option is available only for the `skills` format. - Added `--compress` to `cloudsmith push skills`. With this option, you can pass a skill folder instead of a `.zip` archive. The CLI compresses the contents of the folder into a `.zip` archive, then uploads it. If you pass a folder without `--compress`, the command fails with a usage error. The option is available only for the `skills` format. +- Added `--ssl-system-trust-store`, the `CLOUDSMITH_SSL_SYSTEM_TRUST_STORE` environment variable, and the `ssl_system_trust_store` key in `config.ini`. With this setting, the CLI also trusts the trust store of the operating system, for example the macOS Keychain or the Windows certificate store. It still trusts the CA bundle that it trusts without the setting: `REQUESTS_CA_BUNDLE` when it is set, else the CA bundle that the CLI includes. Use it behind a TLS-intercepting proxy whose CA is in the trust store of the operating system. The setting is off by default. (#603) ### Changed @@ -93,6 +94,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith mcp start` now stops without an error when the MCP client closes the connection while the server writes a reply. Previously, the command stopped with `BrokenPipeError` and exit status 1. - The CLI now exits with status 1 when you interrupt it with Ctrl+C after the reader of its stderr has closed the pipe. Before, the CLI sent an error report and exited with status 120. - `cloudsmith check rates` no longer stops with `TypeError` when the API does not give a value for an interval, limit, remaining count, reset time or throttled status. The command now shows `None` for that value. +- The credential helpers now use `--ssl-verify`, `--ssl-verify-strict`, and their environment variables and `config.ini` keys when they look up the custom domains of a Workspace. Previously, the lookup always verified certificates with the strict X.509 checks, so it failed behind a TLS-intercepting proxy even with `--without-ssl-verify-strict`. (#603) ## [1.30.0] - 2026-10-22 diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 60827274..29037a25 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -123,6 +123,7 @@ def _download_session(opts): proxy=opts.proxy, ssl_verify=opts.ssl_verify, ssl_verify_strict=opts.ssl_verify_strict, + ssl_system_trust_store=opts.ssl_system_trust_store, user_agent=opts.api_user_agent, ) diff --git a/cloudsmith_cli/cli/config.py b/cloudsmith_cli/cli/config.py index 4b67868e..22c04869 100644 --- a/cloudsmith_cli/cli/config.py +++ b/cloudsmith_cli/cli/config.py @@ -70,6 +70,7 @@ class Default(SectionSchema): ssl_verify_strict = ConfigParam( name="ssl_verify_strict", type=bool, default=True ) + ssl_system_trust_store = ConfigParam(name="ssl_system_trust_store", type=bool) api_user_agent = ConfigParam(name="api_user_agent", type=str) mcp_allowed_tools = ConfigParam(name="mcp_allowed_tools", type=str) mcp_allowed_tool_groups = ConfigParam(name="mcp_allowed_tool_groups", type=str) @@ -576,6 +577,16 @@ def ssl_verify_strict(self, value): """Set value for strict X.509 SSL verification.""" self._set_option("ssl_verify_strict", value, allow_clear=False) + @property + def ssl_system_trust_store(self): + """Get value for SSL verification against the system trust store.""" + return self._get_option("ssl_system_trust_store", default=False) + + @ssl_system_trust_store.setter + def ssl_system_trust_store(self, value): + """Set value for SSL verification against the system trust store.""" + self._set_option("ssl_system_trust_store", value, allow_clear=False) + @property def api_user_agent(self): """Get value for API user agent.""" diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index 24f149e4..32e1eef8 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -558,6 +558,16 @@ def initialise_session(f): "by default, which rejects some certificates issued by TLS-intercepting " "corporate proxies. Certificates are still verified when disabled.", ) + @click.option( + "--ssl-system-trust-store/--without-ssl-system-trust-store", + default=None, + is_flag=True, + envvar="CLOUDSMITH_SSL_SYSTEM_TRUST_STORE", + help="Also trust the CA certificates in the operating system trust store " + "for HTTPS connections (disabled by default). Use this if a " + "TLS-intercepting proxy uses a CA that the operating system trusts. " + "The certifi bundle and REQUESTS_CA_BUNDLE stay trusted.", + ) @click.option( "--api-user-agent", envvar="CLOUDSMITH_API_USER_AGENT", @@ -587,7 +597,7 @@ def initialise_session(f): def wrapper(ctx, *args, **kwargs): # pylint: disable=missing-docstring from ..core.session import create_requests_session as _create_session - from ..core.session import set_default_proxy + from ..core.session import set_default_proxy, set_default_tls opts = config.get_or_create_options(ctx) _warn_deprecated_config_keys(ctx, opts) @@ -632,6 +642,7 @@ def wrapper(ctx, *args, **kwargs): ctx.params["ssl_verify"] = ssl_verify opts.ssl_verify = ssl_verify opts.ssl_verify_strict = kwargs.pop("ssl_verify_strict") + opts.ssl_system_trust_store = kwargs.pop("ssl_system_trust_store") opts.api_user_agent = kwargs.pop("api_user_agent") opts.api_headers = kwargs.pop("api_headers") @@ -640,10 +651,16 @@ def wrapper(ctx, *args, **kwargs): ) set_default_proxy(opts.proxy) + set_default_tls( + ssl_verify=opts.ssl_verify, + ssl_verify_strict=opts.ssl_verify_strict, + ssl_system_trust_store=opts.ssl_system_trust_store, + ) opts.session = _create_session( proxy=opts.proxy, ssl_verify=opts.ssl_verify, ssl_verify_strict=opts.ssl_verify_strict, + ssl_system_trust_store=opts.ssl_system_trust_store, user_agent=opts.api_user_agent, headers=opts.api_headers, ) @@ -888,6 +905,7 @@ def call_print_rate_limit_info_with_opts(rate_info): proxy=opts.proxy, ssl_verify=opts.ssl_verify, ssl_verify_strict=opts.ssl_verify_strict, + ssl_system_trust_store=opts.ssl_system_trust_store, user_agent=opts.api_user_agent, headers=opts.api_headers, rate_limit=opts.rate_limit, diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py index a5f61dea..8ea4b560 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_maven_tools.py @@ -1335,11 +1335,18 @@ def record_download(artifact_path, destination, session=None): ) as create_session, ): result = _invoke( - "install", [helper, "--without-ssl-verify-strict", "--no-discover"] + "install", + [ + helper, + "--without-ssl-verify-strict", + "--ssl-system-trust-store", + "--no-discover", + ], ) assert result.exit_code == 0, result.output assert create_session.call_args.kwargs["ssl_verify_strict"] is False + assert create_session.call_args.kwargs["ssl_system_trust_store"] is True # The session for the public repository gets no --api-headers. assert "headers" not in create_session.call_args.kwargs assert sessions diff --git a/cloudsmith_cli/cli/tests/test_decorators.py b/cloudsmith_cli/cli/tests/test_decorators.py index ef3ae672..907f0037 100644 --- a/cloudsmith_cli/cli/tests/test_decorators.py +++ b/cloudsmith_cli/cli/tests/test_decorators.py @@ -2,17 +2,21 @@ import click import click.testing +import cloudsmith_api import pytest +import truststore from ...core import keyring as core_keyring from ...core.api.exceptions import ApiException from ...core.mcp.server import create_ssl_context from ...core.session import create_requests_session from ...core.tests.test_certificate_errors import certificate_error +from ...credential_helpers.domains import get_workspace_domains from ...core.utils import get_data_path, read_file from ..commands.main import main from ..config import ConfigParam, ConfigSchema, Options from ..decorators import ( + common_api_auth_options, common_cli_output_options, common_package_action_options, initialise_api, @@ -157,6 +161,37 @@ def command(opts): assert create_session.call_args.kwargs["ssl_verify_strict"] is expected +@pytest.mark.parametrize( + "args,env,config_value,expected", + [ + ([], {}, None, False), + (["--ssl-system-trust-store"], {}, None, True), + ([], {"CLOUDSMITH_SSL_SYSTEM_TRUST_STORE": "1"}, None, True), + ([], {}, "true", True), + (["--without-ssl-system-trust-store"], {}, "true", False), + ], +) +def test_ssl_system_trust_store( + args, env, config_value, expected, telemetry_config_dir +): + @click.command() + @initialise_session + def command(opts): + pass + + opts = Options() + if config_value is not None: + config_file = telemetry_config_dir / "config.ini" + config_file.write_text(f"[default]\nssl_system_trust_store={config_value}\n") + opts.load_config_file(str(config_file)) + + with patch("cloudsmith_cli.core.session.create_requests_session") as create_session: + result = click.testing.CliRunner().invoke(command, args, env=env, obj=opts) + + assert result.exit_code == 0, result.output + assert create_session.call_args.kwargs["ssl_system_trust_store"] is expected + + @pytest.mark.parametrize( "args,env,configured,expected", [ @@ -288,6 +323,65 @@ def command(opts): assert result.exit_code == 0, result.output +def test_ssl_system_trust_store_env_var_reaches_all_transports(): + @click.command() + @initialise_api + def command(opts): + assert opts.api_config.ssl_system_trust_store is True + with create_requests_session() as session: + adapter = session.get_adapter("https://api.example.com/") + assert isinstance(adapter.ssl_context_factory(), truststore.SSLContext) + assert isinstance(create_ssl_context(opts.api_config), truststore.SSLContext) + + with patch( + "cloudsmith_cli.cli.decorators.CredentialProviderChain.resolve", + return_value=None, + ): + result = click.testing.CliRunner().invoke( + command, env={"CLOUDSMITH_SSL_SYSTEM_TRUST_STORE": "1"} + ) + assert result.exit_code == 0, result.output + + +@pytest.mark.parametrize( + "env,attribute,expected", + [ + ({"CLOUDSMITH_SSL_SYSTEM_TRUST_STORE": "1"}, "ssl_system_trust_store", True), + ({"CLOUDSMITH_SSL_VERIFY_STRICT": "0"}, "ssl_verify_strict", False), + ({"CLOUDSMITH_SSL_VERIFY": "0"}, "verify_ssl", False), + ], +) +def test_tls_settings_reach_an_uncached_credential_helper_domain_lookup( + env, attribute, expected, tmp_path, monkeypatch +): + seen = {} + + def list_domains(workspace, credential=None): + seen[attribute] = getattr(cloudsmith_api.Configuration(), attribute) + return [] + + monkeypatch.setattr("cloudsmith_cli.core.api.workspaces.list_domains", list_domains) + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.domains.get_default_config_path", + lambda: str(tmp_path), + ) + + @click.command() + @common_api_auth_options + @resolve_credentials + def command(opts): + get_workspace_domains("acme", credential=opts.credential, refresh=True) + + with patch( + "cloudsmith_cli.cli.decorators.CredentialProviderChain.resolve", + return_value=None, + ): + result = click.testing.CliRunner().invoke(command, env=env) + + assert result.exit_code == 0, result.output + assert seen == {attribute: expected} + + PROXY = "http://proxy.example.com:3128" diff --git a/cloudsmith_cli/cli/tests/test_webserver.py b/cloudsmith_cli/cli/tests/test_webserver.py index 91d0cc9e..036e034b 100644 --- a/cloudsmith_cli/cli/tests/test_webserver.py +++ b/cloudsmith_cli/cli/tests/test_webserver.py @@ -63,6 +63,18 @@ def test_refresh_api_config_keeps_ssl_verify(self): assert mock_init_api.call_args.kwargs["ssl_verify"] is False + def test_refresh_api_config_keeps_ssl_system_trust_store(self): + with patch.object(AuthenticationWebServer, "__init__", lambda *a, **kw: None): + server = AuthenticationWebServer.__new__(AuthenticationWebServer) + server.sso_access_token = None + server.api_opts = cloudsmith_api.Configuration() + server.api_opts.ssl_system_trust_store = True + + with patch("cloudsmith_cli.cli.webserver.initialise_api") as mock_init_api: + server.refresh_api_config_after_auth() + + assert mock_init_api.call_args.kwargs["ssl_system_trust_store"] is True + class TestAuthenticationWebRequestHandlerKeyring: """Tests for AuthenticationWebRequestHandler keyring behavior.""" diff --git a/cloudsmith_cli/cli/webserver.py b/cloudsmith_cli/cli/webserver.py index c34a1b7d..404a7bb5 100644 --- a/cloudsmith_cli/cli/webserver.py +++ b/cloudsmith_cli/cli/webserver.py @@ -61,6 +61,9 @@ def refresh_api_config_after_auth(self): proxy=getattr(self.api_opts, "proxy", None), ssl_verify=getattr(self.api_opts, "verify_ssl", True), ssl_verify_strict=getattr(self.api_opts, "ssl_verify_strict", True), + ssl_system_trust_store=getattr( + self.api_opts, "ssl_system_trust_store", False + ), user_agent=getattr(self.api_opts, "user_agent", None), headers=getattr(self.api_opts, "headers", None), rate_limit=getattr(self.api_opts, "rate_limit", True), diff --git a/cloudsmith_cli/conftest.py b/cloudsmith_cli/conftest.py index 5a707582..2d0de3d7 100644 --- a/cloudsmith_cli/conftest.py +++ b/cloudsmith_cli/conftest.py @@ -85,6 +85,7 @@ def restore_api_configuration_default(): @pytest.fixture(autouse=True) def restore_default_proxy(): - """Clear the process-wide proxy that ``initialise_session`` sets.""" + """Clear the process-wide proxy and TLS settings that ``initialise_session`` sets.""" yield session.set_default_proxy(None) + session.set_default_tls() diff --git a/cloudsmith_cli/core/api/init.py b/cloudsmith_cli/core/api/init.py index b74b364f..823d0cdb 100644 --- a/cloudsmith_cli/core/api/init.py +++ b/cloudsmith_cli/core/api/init.py @@ -7,6 +7,7 @@ import cloudsmith_api from ..rest import RestClient +from ..session import get_default_tls def _basic_auth_credentials(encoded: str) -> tuple[str, str]: @@ -27,8 +28,9 @@ def initialise_api( host=None, credential=None, proxy=None, - ssl_verify=True, - ssl_verify_strict=True, + ssl_verify=None, + ssl_verify_strict=None, + ssl_system_trust_store=None, user_agent=None, headers=None, rate_limit=True, @@ -52,8 +54,18 @@ def initialise_api( config.error_retry_backoff = error_retry_backoff config.error_retry_codes = error_retry_codes config.error_retry_cb = error_retry_cb - config.verify_ssl = ssl_verify - config.ssl_verify_strict = ssl_verify_strict + # A caller that leaves out a TLS setting, such as a domain lookup, keeps the + # setting of the session. + tls = get_default_tls() + config.verify_ssl = tls["ssl_verify"] if ssl_verify is None else ssl_verify + config.ssl_verify_strict = ( + tls["ssl_verify_strict"] if ssl_verify_strict is None else ssl_verify_strict + ) + config.ssl_system_trust_store = ( + tls["ssl_system_trust_store"] + if ssl_system_trust_store is None + else ssl_system_trust_store + ) config.client_side_validation = False if credential: diff --git a/cloudsmith_cli/core/certificate_errors.py b/cloudsmith_cli/core/certificate_errors.py index e1a098d8..c45fc8fb 100644 --- a/cloudsmith_cli/core/certificate_errors.py +++ b/cloudsmith_cli/core/certificate_errors.py @@ -64,7 +64,9 @@ def classify_certificate_error(exc): return CertificateFailure( "E_TLS_UNTRUSTED_CA", f"The TLS certificate is not from a trusted CA: {reason}.", - "If your network uses a TLS-intercepting proxy, set REQUESTS_CA_BUNDLE " + "If your network uses a TLS-intercepting proxy, use " + "--ssl-system-trust-store or set CLOUDSMITH_SSL_SYSTEM_TRUST_STORE=1 to " + "trust the CAs of the operating system. Or set REQUESTS_CA_BUNDLE " "to a CA bundle that contains the proxy CA.", ) return CertificateFailure( diff --git a/cloudsmith_cli/core/mcp/server.py b/cloudsmith_cli/core/mcp/server.py index 323ef829..7fd2b751 100644 --- a/cloudsmith_cli/core/mcp/server.py +++ b/cloudsmith_cli/core/mcp/server.py @@ -16,7 +16,7 @@ from mcp.shared._httpx_utils import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT from ..api.exceptions import ApiException -from ..session import disable_x509_strict +from ..session import create_system_trust_store_context, disable_x509_strict from ..version import get_version as get_cli_version from .data import OpenAPITool @@ -111,6 +111,16 @@ def _resolve_ca_location(api_config: cloudsmith_api.Configuration) -> str: return certifi.where() +def _create_system_trust_store_context(ca_location: str) -> ssl.SSLContext: + """Return a context that trusts the operating system trust store and ``ca_location``.""" + ctx = create_system_trust_store_context() + if os.path.isdir(ca_location): + ctx.load_verify_locations(capath=ca_location) + else: + ctx.load_verify_locations(cafile=ca_location) + return ctx + + def create_ssl_context( api_config: cloudsmith_api.Configuration, ) -> ssl.SSLContext | bool: @@ -124,7 +134,9 @@ def create_ssl_context( return False ca_location = _resolve_ca_location(api_config) - if os.path.isdir(ca_location): + if getattr(api_config, "ssl_system_trust_store", False): + ctx = _create_system_trust_store_context(ca_location) + elif os.path.isdir(ca_location): ctx = ssl.create_default_context(capath=ca_location) else: ctx = ssl.create_default_context(cafile=ca_location) diff --git a/cloudsmith_cli/core/session.py b/cloudsmith_cli/core/session.py index 6b2f3d58..cea2c9cb 100644 --- a/cloudsmith_cli/core/session.py +++ b/cloudsmith_cli/core/session.py @@ -1,5 +1,6 @@ """HTTP session creation with retry support.""" +import functools import socket import ssl import sys @@ -39,6 +40,34 @@ def get_default_proxy(): return _default_proxy["url"] +_default_tls = {} + + +def set_default_tls( + ssl_verify=None, ssl_verify_strict=None, ssl_system_trust_store=None +): + """Set the TLS settings for every SDK configuration that is not given them.""" + settings = { + "ssl_verify": ssl_verify, + "ssl_verify_strict": ssl_verify_strict, + "ssl_system_trust_store": ssl_system_trust_store, + } + _default_tls.clear() + _default_tls.update( + {name: value for name, value in settings.items() if value is not None} + ) + + +def get_default_tls(): + """Get the TLS settings that set_default_tls() set, else the CLI defaults.""" + return { + "ssl_verify": True, + "ssl_verify_strict": True, + "ssl_system_trust_store": False, + **_default_tls, + } + + class ApiKeySafeSession(requests.Session): """A session that drops the API key on a redirect to another host.""" @@ -156,6 +185,32 @@ def _create_non_strict_ssl_context(): return disable_x509_strict(create_urllib3_context()) +def create_system_trust_store_context(verify_strict=True): + """Return an SSL context that verifies against the operating system trust store. + + The context also trusts the CA certificates that are loaded into it, for + example the certifi bundle or REQUESTS_CA_BUNDLE. + """ + import truststore + + context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.verify_flags = create_urllib3_context().verify_flags + if not verify_strict: + disable_x509_strict(context) + return context + + +def _ssl_context_factory(ssl_verify_strict, ssl_system_trust_store): + """Return the factory for custom SSL contexts, or None for the default context.""" + if ssl_system_trust_store: + return functools.partial( + create_system_trust_store_context, verify_strict=ssl_verify_strict + ) + if not ssl_verify_strict: + return _create_non_strict_ssl_context + return None + + def _sdk_configuration(): """Return the SDK configuration defaults, or None when the SDK is not loaded. @@ -180,6 +235,7 @@ def create_requests_session( maxsize=4, ssl_verify=None, ssl_verify_strict=None, + ssl_system_trust_store=None, ssl_cert=None, proxy=None, session=None, @@ -213,6 +269,9 @@ def create_requests_session( verify_strict = getattr(config, "ssl_verify_strict", None) ssl_verify_strict = verify_strict if verify_strict is not None else True + if ssl_system_trust_store is None: + ssl_system_trust_store = bool(getattr(config, "ssl_system_trust_store", False)) + if ssl_cert is None and config is not None: if config.cert_file and config.key_file: ssl_cert = (config.cert_file, config.key_file) @@ -249,8 +308,8 @@ def create_requests_session( pool_connections=pools_size, pool_maxsize=maxsize, pool_block=True, - ssl_context_factory=( - None if ssl_verify_strict else _create_non_strict_ssl_context + ssl_context_factory=_ssl_context_factory( + ssl_verify_strict, ssl_system_trust_store ), ) diff --git a/cloudsmith_cli/core/tests/test_certificate_errors.py b/cloudsmith_cli/core/tests/test_certificate_errors.py index ed8d0511..a2074b80 100644 --- a/cloudsmith_cli/core/tests/test_certificate_errors.py +++ b/cloudsmith_cli/core/tests/test_certificate_errors.py @@ -48,6 +48,14 @@ def test_untrusted_ca_names_the_ca_bundle_variable(self): ) assert "REQUESTS_CA_BUNDLE" in failure.fix + def test_untrusted_ca_names_the_system_trust_store_setting(self): + failure = classify_certificate_error( + certificate_error(20, "unable to get local issuer certificate") + ) + + assert "--ssl-system-trust-store" in failure.fix + assert "CLOUDSMITH_SSL_SYSTEM_TRUST_STORE=1" in failure.fix + def test_other_failures_give_only_the_reason(self): exc = certificate_error(10, "certificate has expired") diff --git a/cloudsmith_cli/core/tests/test_mcp_http_client.py b/cloudsmith_cli/core/tests/test_mcp_http_client.py index a7f13d0c..b6585ec8 100644 --- a/cloudsmith_cli/core/tests/test_mcp_http_client.py +++ b/cloudsmith_cli/core/tests/test_mcp_http_client.py @@ -13,6 +13,7 @@ import cloudsmith_api import httpx2 import pytest +import truststore from cloudsmith_cli.core.mcp import server from cloudsmith_cli.core.mcp.server import ( @@ -106,6 +107,51 @@ def test_verification_disabled(self, api_config): assert create_ssl_context(api_config) is False + def test_system_trust_store_also_trusts_the_ca_bundle(self, api_config): + api_config.ssl_system_trust_store = True + + with patch.object( + truststore.SSLContext, + "load_verify_locations", + autospec=True, + side_effect=truststore.SSLContext.load_verify_locations, + ) as load_verify_locations: + ctx = create_ssl_context(api_config) + + assert isinstance(ctx, truststore.SSLContext) + load_verify_locations.assert_called_once_with(ctx, cafile=certifi.where()) + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname + assert ctx.verify_flags == ssl.create_default_context().verify_flags + + def test_system_trust_store_with_a_ca_directory( + self, api_config, monkeypatch, tmp_path + ): + api_config.ssl_system_trust_store = True + monkeypatch.setenv("SSL_CERT_DIR", str(tmp_path)) + + with patch.object( + truststore.SSLContext, "load_verify_locations", autospec=True + ) as load_verify_locations: + ctx = create_ssl_context(api_config) + + load_verify_locations.assert_called_once_with(ctx, capath=str(tmp_path)) + + def test_system_trust_store_without_strict_verification(self, api_config): + api_config.ssl_system_trust_store = True + api_config.ssl_verify_strict = False + + ctx = create_ssl_context(api_config) + + assert isinstance(ctx, truststore.SSLContext) + assert not ctx.verify_flags & ssl.VERIFY_X509_STRICT + + def test_system_trust_store_with_verification_disabled(self, api_config): + api_config.ssl_system_trust_store = True + api_config.verify_ssl = False + + assert create_ssl_context(api_config) is False + def test_client_certificate_is_loaded(self, api_config): api_config.cert_file = "/path/client.pem" api_config.key_file = "/path/client.key" diff --git a/cloudsmith_cli/core/tests/test_rest.py b/cloudsmith_cli/core/tests/test_rest.py index 1d57f78a..5995d2c7 100644 --- a/cloudsmith_cli/core/tests/test_rest.py +++ b/cloudsmith_cli/core/tests/test_rest.py @@ -1,11 +1,21 @@ +import datetime +import http.server +import ipaddress import socket import ssl +import threading import httpretty import pytest import requests +import truststore from cloudsmith_api.rest import ApiException +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID from urllib3.exceptions import MaxRetryError, NameResolutionError, ProxyError, SSLError +from urllib3.util.ssl_ import create_urllib3_context from ..api.init import initialise_api from ..rest import READ_TIMEOUT_SECONDS, REQUEST_TIMEOUT_SECONDS, RestClient @@ -210,6 +220,162 @@ def test_falls_back_to_the_sdk_configuration(self): assert "ssl_context" in self._pool_kwargs(session) +class TestSSLSystemTrustStore: + _pool_kwargs = staticmethod(TestSSLVerifyStrict._pool_kwargs) + + def test_uses_the_system_trust_store(self): + session = create_requests_session(ssl_system_trust_store=True) + + ctx = self._pool_kwargs(session)["ssl_context"] + assert isinstance(ctx, truststore.SSLContext) + assert ctx.verify_mode == ssl.CERT_REQUIRED + assert ctx.check_hostname + assert ctx.verify_flags == create_urllib3_context().verify_flags + + def test_without_strict_clears_the_strict_flag(self): + session = create_requests_session( + ssl_system_trust_store=True, ssl_verify_strict=False + ) + + ctx = self._pool_kwargs(session)["ssl_context"] + assert isinstance(ctx, truststore.SSLContext) + assert not ctx.verify_flags & ssl.VERIFY_X509_STRICT + + def test_context_not_used_when_verification_disabled(self): + session = create_requests_session(ssl_system_trust_store=True) + + assert "ssl_context" not in self._pool_kwargs(session, verify=False) + + def test_falls_back_to_the_sdk_configuration(self): + initialise_api(ssl_system_trust_store=True) + + session = create_requests_session() + + assert isinstance( + self._pool_kwargs(session)["ssl_context"], truststore.SSLContext + ) + + def test_trusts_the_ca_bundle_in_addition(self, tls_server): + url, ca_bundle = tls_server + session = create_requests_session(ssl_system_trust_store=True, retries=0) + + assert session.get(url, verify=ca_bundle, timeout=10).status_code == 200 + + def test_rejects_a_ca_that_no_store_trusts(self, tls_server): + url, _ = tls_server + session = create_requests_session(ssl_system_trust_store=True, retries=0) + + with pytest.raises(requests.exceptions.SSLError): + session.get(url, timeout=10) + + +def _certificate(subject, issuer, public_key, signing_key, extensions): + now = datetime.datetime.now(datetime.UTC) + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(public_key) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=5)) + .not_valid_after(now + datetime.timedelta(days=1)) + ) + for extension, critical in extensions: + builder = builder.add_extension(extension, critical=critical) + return builder.sign(signing_key, hashes.SHA256()) + + +def _write_tls_files(directory): + """Write a test CA bundle and a server certificate chain for 127.0.0.1.""" + ca_key = ec.generate_private_key(ec.SECP256R1()) + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "CLI Test CA")]) + ca_cert = _certificate( + ca_name, + ca_name, + ca_key.public_key(), + ca_key, + [ + (x509.BasicConstraints(ca=True, path_length=None), True), + ( + x509.KeyUsage( + digital_signature=False, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + True, + ), + (x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), False), + ], + ) + server_key = ec.generate_private_key(ec.SECP256R1()) + server_cert = _certificate( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")]), + ca_name, + server_key.public_key(), + ca_key, + [ + (x509.BasicConstraints(ca=False, path_length=None), True), + ( + x509.SubjectAlternativeName( + [x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] + ), + False, + ), + (x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), False), + ( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + False, + ), + ], + ) + ca_bundle = directory / "ca.pem" + ca_bundle.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM)) + chain = directory / "server.pem" + chain.write_bytes( + server_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + + server_cert.public_bytes(serialization.Encoding.PEM) + ) + return ca_bundle, chain + + +class _OkHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + +class _QuietHTTPServer(http.server.HTTPServer): + def handle_error(self, request, client_address): + pass + + +@pytest.fixture +def tls_server(tmp_path): + """Serve HTTPS on 127.0.0.1 with a certificate from a test CA.""" + ca_bundle, chain = _write_tls_files(tmp_path) + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.minimum_version = ssl.TLSVersion.TLSv1_2 + server_context.load_cert_chain(chain) + server = _QuietHTTPServer(("127.0.0.1", 0), _OkHandler) + server.socket = server_context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"https://127.0.0.1:{server.server_port}/", str(ca_bundle) + server.shutdown() + server.server_close() + + @pytest.fixture def retry(): session = create_requests_session(retries=6) diff --git a/cloudsmith_cli/core/tests/test_update_check.py b/cloudsmith_cli/core/tests/test_update_check.py index 1620f9d9..eabe34ed 100644 --- a/cloudsmith_cli/core/tests/test_update_check.py +++ b/cloudsmith_cli/core/tests/test_update_check.py @@ -571,12 +571,14 @@ def test_fetch_waits_for_the_command_connection_settings(monkeypatch): opts.proxy = "http://proxy.example.com:3128" opts.ssl_verify = False opts.ssl_verify_strict = False + opts.ssl_system_trust_store = True with patch("cloudsmith_cli.core.session.create_requests_session") as create_session: update_check.start(ctx, opts) start_check.assert_called_once_with(create_session.return_value) assert create_session.call_args.kwargs["ssl_verify"] is False assert create_session.call_args.kwargs["ssl_verify_strict"] is False + assert create_session.call_args.kwargs["ssl_system_trust_store"] is True assert create_session.call_args.kwargs["proxy"] == opts.proxy diff --git a/cloudsmith_cli/core/update_check.py b/cloudsmith_cli/core/update_check.py index b180d8d8..87f8c74f 100644 --- a/cloudsmith_cli/core/update_check.py +++ b/cloudsmith_cli/core/update_check.py @@ -488,6 +488,7 @@ def start(ctx: "click.Context", opts: "Options") -> None: proxy=opts.proxy, ssl_verify=opts.ssl_verify, ssl_verify_strict=opts.ssl_verify_strict, + ssl_system_trust_store=opts.ssl_system_trust_store, user_agent=opts.api_user_agent, ) check["thread"] = _start_background_check(session) diff --git a/cloudsmith_cli/data/config.ini b/cloudsmith_cli/data/config.ini index c45434de..73374fcd 100644 --- a/cloudsmith_cli/data/config.ini +++ b/cloudsmith_cli/data/config.ini @@ -16,6 +16,12 @@ ssl_verify= # proxy issues certificates that fail strict checks (default: True) ssl_verify_strict=true +# Whether to also trust the CA certificates in the operating system trust store +# for HTTPS connections. The certifi bundle and REQUESTS_CA_BUNDLE stay trusted. +# Set to true if a TLS-intercepting proxy uses a CA that the operating system +# trusts (default: False) +ssl_system_trust_store= + # The user agent to use for requests (default: calculated). api_user_agent= diff --git a/packaging/pyinstaller/entry.py b/packaging/pyinstaller/entry.py index bab31c8b..61339d0a 100644 --- a/packaging/pyinstaller/entry.py +++ b/packaging/pyinstaller/entry.py @@ -80,6 +80,26 @@ def _check_mcp_tls_trust(failed: list) -> None: failed.append("mcp TLS trust: CA bundle loaded no certificates") +def _check_system_trust_store(failed: list) -> None: + """Verify that truststore is bundled and gives a verifying context. + + The CLI imports truststore only when --ssl-system-trust-store is on, so the + import sweep does not load it. + """ + import ssl + + from cloudsmith_cli.core.session import create_system_trust_store_context + + try: + ctx = create_system_trust_store_context() + except ImportError as exc: + failed.append(f"system trust store: {exc!r}") + return + + if ctx.verify_mode != ssl.CERT_REQUIRED: + failed.append(f"system trust store: unexpected verify mode {ctx.verify_mode!r}") + + def _selftest() -> int: """Import every bundled ``cloudsmith_cli`` module; fail on any ImportError. @@ -115,6 +135,7 @@ def _onerror(name): _check_extra_keyring_backends(failed) _check_mcp_tls_trust(failed) + _check_system_trust_store(failed) for line in failed: print(f"SELFTEST missing: {line}") diff --git a/pyproject.toml b/pyproject.toml index 4501ac09..8d85413b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ "starlette>=1.3.1", "urllib3>=2.5", "tomlkit>=0.15.0", + "truststore>=0.10.4", "sentry-sdk>=2,<3", ] diff --git a/uv.lock b/uv.lock index 932ec251..e39d7122 100644 --- a/uv.lock +++ b/uv.lock @@ -493,6 +493,7 @@ dependencies = [ { name = "sentry-sdk" }, { name = "starlette" }, { name = "tomlkit" }, + { name = "truststore" }, { name = "urllib3" }, ] @@ -562,6 +563,7 @@ requires-dist = [ { name = "sentry-sdk", specifier = ">=2,<3" }, { name = "starlette", specifier = ">=1.3.1" }, { name = "tomlkit", specifier = ">=0.15.0" }, + { name = "truststore", specifier = ">=0.10.4" }, { name = "urllib3", specifier = ">=2.5" }, ] provides-extras = ["aws", "gcp", "all"]