Skip to content

Commit 0d44b69

Browse files
authored
CM-71120 add support for local certificate trust store (#521)
1 parent c5e1f99 commit 0d44b69

11 files changed

Lines changed: 400 additions & 25 deletions

File tree

‎README.md‎

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,20 @@ This guide walks you through both installation and usage.
1818
2. [On Windows](#on-windows)
1919
2. [Install Pre-Commit Hook](#install-pre-commit-hook)
2020
3. [Cycode CLI Commands](#cycode-cli-commands)
21-
4. [MCP Command](#mcp-command-experiment)
21+
4. [Certificates and Proxies](#certificates-and-proxies)
22+
5. [MCP Command](#mcp-command-experiment)
2223
1. [Starting the MCP Server](#starting-the-mcp-server)
2324
2. [Available Options](#available-options)
2425
3. [MCP Tools](#mcp-tools)
2526
4. [Usage Examples](#usage-examples)
2627
5. [Advanced Configuration](#advanced-configuration)
27-
5. [Platform Command](#platform-command-beta)
28+
6. [Platform Command](#platform-command-beta)
2829
1. [Discovering Commands](#discovering-commands)
2930
2. [Examples](#platform-examples)
3031
3. [Notes & Limitations](#platform-notes--limitations)
31-
6. [AI Guardrails](#ai-guardrails-beta)
32+
7. [AI Guardrails](#ai-guardrails-beta)
3233
1. [Data Collected by AI Guardrails](#data-collected-by-ai-guardrails)
33-
7. [Scan Command](#scan-command)
34+
8. [Scan Command](#scan-command)
3435
1. [Running a Scan](#running-a-scan)
3536
1. [Options](#options)
3637
1. [Severity Threshold](#severity-option)
@@ -64,11 +65,11 @@ This guide walks you through both installation and usage.
6465
4. [Ignoring a Secret, IaC, or SCA Rule](#ignoring-a-secret-iac-sca-or-sast-rule)
6566
5. [Ignoring a Package](#ignoring-a-package)
6667
6. [Ignoring via a config file](#ignoring-via-a-config-file)
67-
6. [Report command](#report-command)
68+
9. [Report command](#report-command)
6869
1. [Generating SBOM Report](#generating-sbom-report)
69-
7. [Import command](#import-command)
70-
8. [Scan logs](#scan-logs)
71-
9. [Syntax Help](#syntax-help)
70+
10. [Import command](#import-command)
71+
11. [Scan logs](#scan-logs)
72+
12. [Syntax Help](#syntax-help)
7273

7374
# Prerequisites
7475

@@ -356,6 +357,51 @@ The following are the options and commands available with the Cycode CLI applica
356357
| [report](#report-command) | Generate report. You will need to specify which report type to perform as SBOM. |
357358
| status | Show the CLI status and exit. |
358359
360+
# Certificates and Proxies
361+
362+
By default, Cycode CLI verifies HTTPS connections against the CA bundle shipped with the CLI.
363+
364+
If your organization uses a proxy that inspects HTTPS traffic, or an on-premises installation with
365+
its own CA, you have two options.
366+
367+
**Option 1 — use the certificates already installed on the machine.** If your CA is in the machine
368+
certificate store (as is usually the case on a managed device), opt in:
369+
370+
```bash
371+
export CYCODE_CLI_ENABLE_TRUSTSTORE=1
372+
```
373+
374+
The CLI then verifies against the Windows certificate store, the macOS Keychain, or the system CA
375+
directory on Linux, and no certificate paths need to be configured.
376+
377+
> [!IMPORTANT]
378+
> This is opt-in on purpose. Trusting the machine store means trusting every root certificate
379+
> present on that machine, including any an administrator or malicious software installed. Enable it
380+
> when you know your machine's certificate store is one you trust.
381+
382+
**Option 2 — point the CLI at a CA bundle file.** Works without opting in:
383+
384+
| Environment Variable | Description |
385+
|----------------------|-------------|
386+
| `REQUESTS_CA_BUNDLE` | Path to a CA bundle file (`.pem` or `.crt`) to trust. |
387+
| `CURL_CA_BUNDLE` | Alias for `REQUESTS_CA_BUNDLE`, honored when the latter is unset. |
388+
389+
The two options combine: with `CYCODE_CLI_ENABLE_TRUSTSTORE=1`, certificates from
390+
`REQUESTS_CA_BUNDLE` are trusted *in addition to* the machine store, not instead of it.
391+
392+
> [!TIP]
393+
> Run any command with `-v` to see which trust source is in use, for example `cycode -v status`.
394+
395+
Notes:
396+
397+
- `CYCODE_CLI_ENABLE_TRUSTSTORE` requires Python 3.10 or newer. On Python 3.9 the CLI logs a warning
398+
and falls back to the bundled CA bundle; use `REQUESTS_CA_BUNDLE` instead, or upgrade Python. The
399+
standalone executables and the Docker image already ship a supported Python.
400+
- On Windows, the CLI has always fallen back to the system certificate store when neither
401+
`REQUESTS_CA_BUNDLE` nor `CURL_CA_BUNDLE` is set. That behavior is unchanged.
402+
- Proxies themselves are configured with the standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`
403+
environment variables.
404+
359405
# MCP Command \[EXPERIMENT\]
360406

361407
> [!WARNING]
@@ -565,20 +611,20 @@ cycode mcp -t streamable-http -H 127.0.0.2 -p 9000 &
565611
```
566612
567613
### Advanced Configuration
568-
##### Custom Certificates and Timeouts (Proxy Environments)
614+
##### Timeouts and Custom Certificates (Proxy Environments)
569615
570-
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.
616+
If long-running scans are being cut short by your MCP client, increase the tool call timeout.
571617
572618
| Environment Variable | Description |
573619
|----------------------|-------------|
574-
| `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. |
575-
| `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. |
576620
| `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. |
577621
578-
> [!TIP]
579-
> 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.
622+
Behind a corporate proxy, set the certificate variables in the MCP server's `env` block. See
623+
[Certificates and Proxies](#certificates-and-proxies) for the options: either
624+
`CYCODE_CLI_ENABLE_TRUSTSTORE=1` to use the certificates already on the machine, or
625+
`REQUESTS_CA_BUNDLE` pointing at a CA bundle file.
580626

581-
Example `mcp.json` configuration with custom certificates and a longer timeout:
627+
Example `mcp.json` configuration with a custom CA bundle and a longer timeout:
582628

583629
```json
584630
{
@@ -588,7 +634,6 @@ Example `mcp.json` configuration with custom certificates and a longer timeout:
588634
"args": ["mcp"],
589635
"env": {
590636
"REQUESTS_CA_BUNDLE": "/path/to/your/corporate-ca-bundle.pem",
591-
"SSL_CERT_FILE": "/path/to/your/corporate-ca-bundle.pem",
592637
"MCP_TOOL_TIMEOUT": "1800"
593638
}
594639
}

‎cycode/cli/consts.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@
209209
LOGGING_LEVEL_ENV_VAR_NAME = 'LOGGING_LEVEL'
210210
VERBOSE_ENV_VAR_NAME = 'CYCODE_CLI_VERBOSE'
211211
DEBUG_ENV_VAR_NAME = 'CYCODE_CLI_DEBUG'
212+
ENABLE_TRUSTSTORE_ENV_VAR_NAME = 'CYCODE_CLI_ENABLE_TRUSTSTORE'
212213

213214
CYCODE_CONFIGURATION_DIRECTORY: str = '.cycode'
214215

‎cycode/cli/exceptions/custom_exceptions.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from requests import Response
22

3+
from cycode.cli import consts
34
from cycode.cli.models import CliError, CliErrors
5+
from cycode.cli.utils import trust_store
46

57

68
class CycodeError(Exception):
@@ -96,6 +98,17 @@ def __str__(self) -> str:
9698
return f'Error occurred while parsing terraform plan file. Path: {self.file_path}'
9799

98100

101+
_SSL_ERROR_CA_BUNDLE_HINT = (
102+
'set the REQUESTS_CA_BUNDLE (or CURL_CA_BUNDLE) environment variable to the path of a valid .pem or similar'
103+
)
104+
_SSL_ERROR_TRUST_HINT = (
105+
'If you use an on-premises installation or a proxy that intercepts SSL traffic, '
106+
f'set {consts.ENABLE_TRUSTSTORE_ENV_VAR_NAME}=1 to trust the CA certificates installed in your '
107+
f'machine certificate store, or {_SSL_ERROR_CA_BUNDLE_HINT}'
108+
if trust_store.is_supported() and not trust_store.is_enabled()
109+
else f'If you use an on-premises installation or a proxy that intercepts SSL traffic, {_SSL_ERROR_CA_BUNDLE_HINT}'
110+
)
111+
99112
KNOWN_USER_FRIENDLY_REQUEST_ERRORS: CliErrors = {
100113
RequestHttpError: CliError(
101114
soft_fail=True,
@@ -122,8 +135,6 @@ def __str__(self) -> str:
122135
RequestSslError: CliError(
123136
soft_fail=True,
124137
code='ssl_error',
125-
message='An SSL error occurred when trying to connect to the Cycode API. '
126-
'If you use an on-premises installation or a proxy that intercepts SSL traffic '
127-
'you should use the CURL_CA_BUNDLE environment variable to specify path to a valid .pem or similar',
138+
message=f'An SSL error occurred when trying to connect to the Cycode API. {_SSL_ERROR_TRUST_HINT}',
128139
),
129140
}

‎cycode/cli/utils/trust_store.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import sys
2+
3+
from cycode import config
4+
from cycode.cli import consts
5+
from cycode.logger import get_logger
6+
7+
logger = get_logger('Trust Store')
8+
9+
# truststore requires Python 3.10+, so on 3.9 the OS trust store is unavailable
10+
_MIN_PYTHON_VERSION = (3, 10)
11+
12+
_installed = False
13+
14+
15+
def is_supported() -> bool:
16+
return sys.version_info >= _MIN_PYTHON_VERSION
17+
18+
19+
def is_enabled() -> bool:
20+
return config.get_val_as_bool(consts.ENABLE_TRUSTSTORE_ENV_VAR_NAME)
21+
22+
23+
def is_installed() -> bool:
24+
"""Whether the OS trust store has been injected into the TLS stack."""
25+
return _installed
26+
27+
28+
def install() -> bool:
29+
"""Verify TLS against the machine's trust store.
30+
31+
`truststore.inject_into_ssl()` patches `ssl.SSLContext` process-wide, so this covers all HTTPS
32+
traffic (the shared session, the presigned S3 upload, the version check) without touching call
33+
sites. Certificates from `REQUESTS_CA_BUNDLE`/`CURL_CA_BUNDLE` keep working: requests still loads
34+
them, and truststore treats them as additional trust anchors on top of the OS store.
35+
"""
36+
global _installed
37+
38+
if _installed:
39+
return True
40+
41+
if not is_enabled():
42+
logger.debug(
43+
'OS trust store not enabled, using the bundled CA store (certifi). Set %s=1 to enable it',
44+
consts.ENABLE_TRUSTSTORE_ENV_VAR_NAME,
45+
)
46+
return False
47+
48+
if not is_supported():
49+
logger.warning(
50+
'OS trust store requires Python %s+, using the bundled CA store (certifi). Current version: %s',
51+
'.'.join(map(str, _MIN_PYTHON_VERSION)),
52+
'.'.join(map(str, sys.version_info[:3])),
53+
)
54+
return False
55+
56+
try:
57+
import truststore
58+
59+
truststore.inject_into_ssl()
60+
except Exception as e:
61+
logger.warning('Failed to use the OS trust store, falling back to the bundled CA store (certifi). %s', e)
62+
return False
63+
64+
logger.debug('Using the OS trust store for TLS verification')
65+
_installed = True
66+
return True

‎cycode/cyclient/cycode_client_base.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
RequestTimeoutError,
2020
SlowUploadConnectionError,
2121
)
22+
from cycode.cli.utils import trust_store
2223
from cycode.cyclient import config
2324
from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id
2425
from cycode.cyclient.logger import logger
@@ -28,6 +29,8 @@
2829

2930

3031
class SystemStorageSslContext(HTTPAdapter):
32+
"""Windows system trust store path, used when truststore is not active."""
33+
3134
def init_poolmanager(self, *args, **kwargs) -> None:
3235
default_context = ssl.create_default_context()
3336
default_context.load_default_certs()
@@ -43,11 +46,13 @@ def cert_verify(self, *args, **kwargs) -> None:
4346
@functools.cache
4447
def _get_session() -> requests.Session:
4548
"""Process-wide Session so TCP+TLS connections are reused across all API calls."""
49+
trust_store.install()
50+
4651
session = requests.Session()
47-
# On Windows without an explicit CA bundle env var, fall back to the system
48-
# trust store via a custom SSL context.
49-
if platform.system() == 'Windows' and not (
50-
os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE')
52+
if (
53+
not trust_store.is_installed()
54+
and platform.system() == 'Windows'
55+
and not (os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE'))
5156
):
5257
session.mount('https://', SystemStorageSslContext())
5358
return session

‎cycode/cyclient/scan_client.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
SlowUploadConnectionError,
1515
)
1616
from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip
17+
from cycode.cli.utils import trust_store
1718
from cycode.cyclient import models
1819
from cycode.cyclient.cycode_client_base import CycodeClientBase, UploadProgressTracker
1920
from cycode.cyclient.logger import logger
@@ -153,7 +154,9 @@ def upload_to_presigned_post(
153154
tracker = UploadProgressTracker(prepared.body, on_upload_progress)
154155

155156
try:
156-
# We are not using Cycode client, as we are calling aws S3.
157+
# We are not using Cycode client, as we are calling aws S3. That also means this call
158+
# skips the shared session, so the OS trust store has to be installed explicitly here.
159+
trust_store.install()
157160
response = requests.post(
158161
url,
159162
data=tracker,

‎poetry.lock‎

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyinstaller.spec‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os
66
import platform
77
import subprocess
8+
import sys
89

910
_INIT_FILE_PATH = os.path.join('cycode', '__init__.py')
1011
_CODESIGN_IDENTITY = os.environ.get('APPLE_CERT_NAME')
@@ -41,6 +42,11 @@ _hiddenimports = [
4142
'cycode.cli.apps.mcp',
4243
]
4344

45+
# truststore is imported lazily inside cycode/cli/utils/trust_store.py, and it picks its platform
46+
# backend behind a sys.platform branch. Only the current platform's backend actually resolves.
47+
if sys.version_info >= (3, 10):
48+
_hiddenimports += ['truststore', 'truststore._windows', 'truststore._macos', 'truststore._openssl']
49+
4450
a = Analysis(
4551
scripts=['cycode/cli/main.py'],
4652
excludes=['tests', 'setuptools', 'pkg_resources'],

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ patch-ng = "1.19.1"
4949
typer = "^0.15.3"
5050
tenacity = ">=9.1.2,<9.2.0"
5151
mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" }
52+
truststore = { version = ">=0.10.4,<0.11.0", markers = "python_version >= '3.10'" }
5253
pydantic = ">=2.11.5,<3.0.0"
5354
pathvalidate = ">=3.3.1,<4.0.0"
5455
tomli-w = ">=1.0.0,<2.0.0"

‎tests/conftest.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import responses
66

77
from cycode.cli.user_settings.credentials_manager import CredentialsManager
8+
from cycode.cli.utils import trust_store
89
from cycode.cyclient.client_creator import create_scan_client
910
from cycode.cyclient.cycode_oidc_based_client import CycodeOidcBasedClient
1011
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:
118119
},
119120
status=200,
120121
)
122+
123+
124+
@pytest.fixture(autouse=True)
125+
def _no_real_trust_store_injection(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None:
126+
"""Never let the suite patch the global `ssl` module.
127+
128+
`trust_store.install()` runs lazily from `_get_session()`, so any test that builds a session
129+
would otherwise really call `truststore.inject_into_ssl()` — process-wide, leaking across tests.
130+
It also fails noisily under pyfakefs, where truststore's platform probe cannot read the files
131+
it expects. tests/utils/test_trust_store.py opts out: it tests install() itself, with the
132+
truststore module stubbed.
133+
"""
134+
if request.module.__name__.rsplit('.', 1)[-1] == 'test_trust_store':
135+
return
136+
monkeypatch.setattr(trust_store, 'install', lambda: False)

0 commit comments

Comments
 (0)