From d145cf854a6a455fe2cea0c5594a3a4aba9b22ee Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 11:47:50 +0000 Subject: [PATCH 1/4] feat(ENG-15617): add an option to trust the operating system trust store Add --ssl-system-trust-store/--without-ssl-system-trust-store, the CLOUDSMITH_SSL_SYSTEM_TRUST_STORE environment variable and the ssl_system_trust_store config.ini key. The option is off by default. When the option is on, the requests sessions, the cloudsmith_api SDK and the MCP server verify TLS with a truststore context. That context trusts the operating system trust store in addition to the certifi bundle or REQUESTS_CA_BUNDLE. --without-ssl-verify-strict and --without-ssl-verify apply as before. The hint for a certificate from an untrusted CA now names the option. The frozen binary selftest checks that truststore is bundled. Co-Authored-By: Claude Opus 5.5 --- .../cli/commands/credential_helper/manage.py | 1 + cloudsmith_cli/cli/config.py | 11 ++ cloudsmith_cli/cli/decorators.py | 13 ++ .../test_credential_helper_maven_tools.py | 9 +- cloudsmith_cli/cli/tests/test_decorators.py | 52 ++++++ cloudsmith_cli/cli/tests/test_webserver.py | 12 ++ cloudsmith_cli/cli/webserver.py | 3 + cloudsmith_cli/core/api/init.py | 2 + cloudsmith_cli/core/certificate_errors.py | 4 +- cloudsmith_cli/core/mcp/server.py | 16 +- cloudsmith_cli/core/session.py | 35 +++- .../core/tests/test_certificate_errors.py | 8 + .../core/tests/test_mcp_http_client.py | 46 +++++ cloudsmith_cli/core/tests/test_rest.py | 165 ++++++++++++++++++ .../core/tests/test_update_check.py | 2 + cloudsmith_cli/core/update_check.py | 1 + cloudsmith_cli/data/config.ini | 6 + packaging/pyinstaller/entry.py | 21 +++ pyproject.toml | 1 + uv.lock | 2 + 20 files changed, 404 insertions(+), 6 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 60827274b..29037a25b 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 4b67868e7..22c04869c 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 24f149e42..2dd65f370 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", @@ -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") @@ -644,6 +655,7 @@ def wrapper(ctx, *args, **kwargs): 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 +900,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 a5f61dea9..8ea4b560d 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 ef3ae6723..a9a586779 100644 --- a/cloudsmith_cli/cli/tests/test_decorators.py +++ b/cloudsmith_cli/cli/tests/test_decorators.py @@ -3,6 +3,7 @@ import click import click.testing import pytest +import truststore from ...core import keyring as core_keyring from ...core.api.exceptions import ApiException @@ -157,6 +158,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 +320,26 @@ 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 + + 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 91d0cc9e1..036e034b4 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 c34a1b7d2..404a7bb5d 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/core/api/init.py b/cloudsmith_cli/core/api/init.py index b74b364f7..7e2c8bcd1 100644 --- a/cloudsmith_cli/core/api/init.py +++ b/cloudsmith_cli/core/api/init.py @@ -29,6 +29,7 @@ def initialise_api( proxy=None, ssl_verify=True, ssl_verify_strict=True, + ssl_system_trust_store=False, user_agent=None, headers=None, rate_limit=True, @@ -54,6 +55,7 @@ def initialise_api( config.error_retry_cb = error_retry_cb config.verify_ssl = ssl_verify config.ssl_verify_strict = ssl_verify_strict + config.ssl_system_trust_store = 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 e1a098d8b..c45fc8fb2 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 323ef8296..7fd2b7511 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 6b2f3d580..6324f2f09 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 @@ -156,6 +157,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 +207,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 +241,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 +280,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 ed8d05119..a2074b80b 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 a7f13d0c7..b6585ec85 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 1d57f78ad..d93416499 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,161 @@ 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.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 1620f9d92..eabe34ed1 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 b180d8d82..87f8c74f0 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 c45434dee..73374fcda 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 bab31c8ba..61339d0ae 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 4501ac097..8d85413b6 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 932ec2514..e39d71223 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"] From 7b26642c54fe844d3805f2914df6f9e092d19a6d Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 11:50:47 +0000 Subject: [PATCH 2/4] test(ENG-15617): require TLS 1.2 on the test HTTPS server Co-Authored-By: Claude Opus 5.5 --- cloudsmith_cli/core/tests/test_rest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cloudsmith_cli/core/tests/test_rest.py b/cloudsmith_cli/core/tests/test_rest.py index d93416499..5995d2c7a 100644 --- a/cloudsmith_cli/core/tests/test_rest.py +++ b/cloudsmith_cli/core/tests/test_rest.py @@ -365,6 +365,7 @@ 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) From 19f8d9a4d6d6115474179505cd75e643f09afb21 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 11:59:34 +0000 Subject: [PATCH 3/4] fix(ENG-15617): keep the TLS settings in credential helper domain lookups A credential helper initialises the SDK again for an uncached custom domain lookup. That reset --ssl-verify, --ssl-verify-strict and --ssl-system-trust-store to their defaults, so the lookup failed behind a TLS-intercepting proxy. initialise_session now sets the TLS settings as process defaults, as it does for the proxy, and initialise_api uses them when a caller leaves a setting out. Add the CHANGELOG entries for the option and the fix. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + cloudsmith_cli/cli/decorators.py | 7 +++- cloudsmith_cli/cli/tests/test_decorators.py | 42 +++++++++++++++++++++ cloudsmith_cli/conftest.py | 3 +- cloudsmith_cli/core/api/init.py | 22 ++++++++--- cloudsmith_cli/core/session.py | 28 ++++++++++++++ 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbf17a4b..79521e848 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 `REQUESTS_CA_BUNDLE` and the CA bundle that it 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/decorators.py b/cloudsmith_cli/cli/decorators.py index 2dd65f370..32e1eef8a 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -597,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) @@ -651,6 +651,11 @@ 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, diff --git a/cloudsmith_cli/cli/tests/test_decorators.py b/cloudsmith_cli/cli/tests/test_decorators.py index a9a586779..907f00373 100644 --- a/cloudsmith_cli/cli/tests/test_decorators.py +++ b/cloudsmith_cli/cli/tests/test_decorators.py @@ -2,6 +2,7 @@ import click import click.testing +import cloudsmith_api import pytest import truststore @@ -10,10 +11,12 @@ 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, @@ -340,6 +343,45 @@ def command(opts): 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/conftest.py b/cloudsmith_cli/conftest.py index 5a7075828..2d0de3d76 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 7e2c8bcd1..823d0cdb6 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,9 +28,9 @@ def initialise_api( host=None, credential=None, proxy=None, - ssl_verify=True, - ssl_verify_strict=True, - ssl_system_trust_store=False, + ssl_verify=None, + ssl_verify_strict=None, + ssl_system_trust_store=None, user_agent=None, headers=None, rate_limit=True, @@ -53,9 +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 - config.ssl_system_trust_store = ssl_system_trust_store + # 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/session.py b/cloudsmith_cli/core/session.py index 6324f2f09..cea2c9cba 100644 --- a/cloudsmith_cli/core/session.py +++ b/cloudsmith_cli/core/session.py @@ -40,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.""" From 3c24cc009d540e1adcdfb762b13bf40ce68c510a Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Fri, 6 Nov 2026 12:00:45 +0000 Subject: [PATCH 4/4] docs(ENG-15617): say which CA bundle stays trusted REQUESTS_CA_BUNDLE replaces the included bundle, so the CHANGELOG must not say that the CLI trusts both. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79521e848..6a937d571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +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 `REQUESTS_CA_BUNDLE` and the CA bundle that it 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) +- 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