Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build/apm.spec
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ hiddenimports = [
'pathlib',
'frontmatter',
'requests',
'certifi', # CA certificate bundle for SSL verification in frozen binary
'truststore', # System trust store support for enterprise TLS roots
'certifi', # Fallback CA bundle for frozen binaries
# Rich modules (lazily imported, must be explicitly included)
'rich',
'rich.console',
Expand Down
49 changes: 37 additions & 12 deletions build/hooks/runtime_hook_ssl_certs.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,55 @@
# PyInstaller runtime hook -- configures SSL certificate paths for the
# frozen binary so that HTTPS connections work on every platform without
# requiring the user to install Python or set environment variables.
# PyInstaller runtime hook -- configures TLS trust for the frozen binary so
# HTTPS connections work on every platform without requiring the user to
# install Python or set environment variables.
#
# Problem: PyInstaller bundles OpenSSL, but the compiled-in certificate
# search path points at the *build machine's* Python framework directory
# (e.g. /Library/Frameworks/Python.framework/...). On end-user machines
# that path rarely exists, causing SSL verification failures.
# Problem: requests defaults to certifi, while enterprise machines typically
# install corporate or internal PKI roots into the OS trust store used by git,
# curl, and browsers. Frozen binaries also have brittle OpenSSL default paths.
#
# Solution: Point ``SSL_CERT_FILE`` at the certifi CA bundle shipped
# inside the frozen binary. ``requests``, ``urllib3``, and the stdlib
# ``ssl`` module all honour this variable.
# Solution: Use truststore first so urllib3/requests/stdlib HTTPS share the OS
# trust store. If truststore is unavailable, fall back to the bundled certifi
# CA bundle so frozen binaries still have a working public-web baseline.
#
# This hook executes before any application code so the variables are
# visible to every subsequent import.

import os
import sys

_CA_OVERRIDE_ENV_VARS = (
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
)


def _has_explicit_ca_override() -> bool:
"""Return True when the user explicitly selected a CA bundle/path."""
return any((os.environ.get(name) or "").strip() for name in _CA_OVERRIDE_ENV_VARS)


def _inject_system_trust_store() -> bool:
"""Inject truststore into stdlib SSL if available."""
try:
import truststore

truststore.inject_into_ssl()
return True
except Exception:
return False


def _configure_ssl_certs() -> None:
"""Set SSL_CERT_FILE to the bundled certifi CA bundle when frozen."""
"""Configure TLS trust for frozen binaries."""
if not getattr(sys, "frozen", False):
return

# Honour explicit user overrides -- never clobber them.
if os.environ.get("SSL_CERT_FILE") or os.environ.get("REQUESTS_CA_BUNDLE"):
if _has_explicit_ca_override():
return

if _inject_system_trust_store():
return

try:
Expand Down
9 changes: 5 additions & 4 deletions docs/src/content/docs/troubleshooting/common-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,15 @@ See also: [./install-failures/](./install-failures/)
### `TLS verification failed`

```
TLS verification failed -- if you're behind a corporate proxy or
firewall, set the REQUESTS_CA_BUNDLE environment variable to the
path of your organisation's CA bundle (a PEM file) and retry.
TLS verification failed -- APM uses the system trust store by default.
If you're behind a corporate proxy or firewall, make sure your
organisation's CA is installed in the OS trust store, or set
REQUESTS_CA_BUNDLE to a readable PEM bundle and retry.
```

Cause: Python's TLS stack rejected the server certificate. Almost always a corporate proxy doing TLS interception with a CA that is not in the system trust store.

Fix: export `REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem` and retry. Do not disable TLS verification.
Fix: install the corporate CA into the OS trust store and retry. For a per-shell override, export `REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem`; `SSL_CERT_FILE` alone is not a reliable requests override. Do not disable TLS verification.

See also: [./ssl-issues/](./ssl-issues/)

Expand Down
16 changes: 9 additions & 7 deletions docs/src/content/docs/troubleshooting/ssl-issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ sidebar:
order: 4
---

`apm install` and `apm audit` reach out to GitHub, GHES, GitLab, Azure DevOps, and package archives over HTTPS. When the system can't verify the server certificate, the operation fails. This page maps the failure modes to fixes.
`apm install` and `apm audit` reach out to GitHub, GHES, GitLab, Azure DevOps, and package archives over HTTPS. APM uses the operating system trust store by default for Python HTTPS calls, matching the trust roots your browser, curl, and git usually use. When the system can't verify the server certificate, the operation fails. This page maps the failure modes to fixes.

Related: [environment variables](../reference/environment-variables/), [install failures](./install-failures/), [security model](../enterprise/security/), [authentication](../getting-started/authentication/).

Expand All @@ -14,8 +14,10 @@ Related: [environment variables](../reference/environment-variables/), [install
Typical errors APM surfaces or passes through from the underlying HTTP/git stack:

```text
[!] TLS verification failed -- if you're behind a corporate proxy or
firewall, set REQUESTS_CA_BUNDLE to your organisation's CA bundle.
[!] TLS verification failed -- APM uses the system trust store by default.
If you're behind a corporate proxy or firewall, make sure your
organisation's CA is installed in the OS trust store, or set
REQUESTS_CA_BUNDLE to a readable PEM bundle.
```

```text
Expand Down Expand Up @@ -54,7 +56,7 @@ apm install --verbose

## Configure trust

APM uses `requests` for HTTP and shells out to `git` for repository operations. Both honour standard environment variables. Set them at the shell or in your profile (`~/.zshrc`, `~/.bashrc`, or the Windows user environment).
APM uses `truststore` so Python HTTP clients use the OS trust store by default, and it shells out to `git` for repository operations. The best long-term fix is to install the corporate or internal CA into the OS trust store. Use the environment variables below only when you need a per-shell or per-host override.

### Python HTTP layer

Expand All @@ -65,7 +67,7 @@ export SSL_CERT_FILE=/path/to/ca-bundle.pem
export SSL_CERT_DIR=/etc/ssl/certs
```

`REQUESTS_CA_BUNDLE` wins for `requests`. `SSL_CERT_FILE` / `SSL_CERT_DIR` cover the rest of the Python TLS stack.
`REQUESTS_CA_BUNDLE` wins for `requests` and is the right override for APM's Python HTTP layer. `SSL_CERT_FILE` / `SSL_CERT_DIR` cover the rest of the Python TLS stack, but by themselves they are not a reliable requests override; set `REQUESTS_CA_BUNDLE` as well when you need a custom PEM bundle.

### Git operations

Expand Down Expand Up @@ -128,7 +130,7 @@ export HTTP_PROXY=http://proxy.example.com:8080
export NO_PROXY=localhost,127.0.0.1,.internal.example.com
```

If the proxy performs TLS interception, you also need the proxy's signing CA in the trust store - see [Configure trust](#configure-trust). Importing the CA into the OS trust store (Keychain on macOS, `update-ca-certificates` on Debian/Ubuntu, `update-ca-trust` on RHEL, the Trusted Root store on Windows) is the most durable fix; consult your OS documentation rather than copying steps from here.
If the proxy performs TLS interception, you also need the proxy's signing CA in the trust store - see [Configure trust](#configure-trust). Importing the CA into the OS trust store (Keychain on macOS, `update-ca-certificates` on Debian/Ubuntu, `update-ca-trust` on RHEL, the Trusted Root store on Windows) is the most durable fix and is the path APM uses by default; consult your OS documentation rather than copying steps from here.

## Verify the fix

Expand Down Expand Up @@ -170,6 +172,6 @@ unset GIT_SSL_NO_VERIFY PYTHONHTTPSVERIFY

[>] Re-run with `--verbose` and capture the full exception chain.
[>] Check `curl -v https://<host>` from the same shell - if it fails, the problem is the system trust store, not APM.
[>] Confirm `REQUESTS_CA_BUNDLE` and `GIT_SSL_CAINFO` point at a readable PEM file (`openssl x509 -in $REQUESTS_CA_BUNDLE -noout -subject` should print a subject line).
[>] If using overrides, confirm `REQUESTS_CA_BUNDLE` and `GIT_SSL_CAINFO` point at a readable PEM file (`openssl x509 -in $REQUESTS_CA_BUNDLE -noout -subject` should print a subject line).
[>] If only one host fails, see [GHES and GitLab self-managed](#ghes-and-gitlab-self-managed) and the per-host `git config` recipe above.
[>] If the install proceeds past TLS but then fails, continue at [install failures](./install-failures/).
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies = [
"colorama>=0.4.6",
"pyyaml>=6.0.0",
"requests>=2.31.0",
"truststore>=0.10.0",
"python-frontmatter>=1.0.0",
"llm>=0.28",
"llm-github-models>=0.18.0",
Expand Down
6 changes: 6 additions & 0 deletions src/apm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Thin wiring layer -- all command logic lives in ``apm_cli.commands.*`` modules.
"""

# ruff: noqa: E402

import ctypes
import logging
import os
Expand All @@ -11,6 +13,10 @@

import click

from apm_cli.core.tls import configure_system_trust_store

configure_system_trust_store()

from apm_cli.commands._helpers import (
ERROR,
RESET,
Expand Down
45 changes: 45 additions & 0 deletions src/apm_cli/core/tls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""TLS trust-store bootstrap helpers for the APM CLI."""

from __future__ import annotations

import logging
import os
from collections.abc import Mapping

_CA_OVERRIDE_ENV_VARS = (
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
)


def has_explicit_ca_override(env: Mapping[str, str] | None = None) -> bool:
"""Return True when the user explicitly selected a CA bundle/path."""
environ = os.environ if env is None else env
return any((environ.get(name) or "").strip() for name in _CA_OVERRIDE_ENV_VARS)


def configure_system_trust_store(env: Mapping[str, str] | None = None) -> bool:
"""Make Python HTTPS clients use the OS trust store by default.

APM is an application, so using truststore's stdlib injection is appropriate
as long as it happens before network libraries construct SSL contexts.
Explicit CA environment variables continue to win for edge cases.
"""
if has_explicit_ca_override(env):
return False

try:
import truststore
except ImportError as exc:
logging.getLogger(__name__).debug("truststore unavailable: %s", exc)
return False

try:
truststore.inject_into_ssl()
except Exception as exc: # pragma: no cover - defensive against platform APIs
logging.getLogger(__name__).debug("truststore injection failed: %s", exc)
return False

return True
7 changes: 4 additions & 3 deletions src/apm_cli/install/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ def _log_tls_failure(host_display: str, exc: BaseException, verbose_log, logger)
Verbose: also include the host name and the underlying exception text.
"""
logger.warning(
"TLS verification failed -- if you're behind a corporate proxy or "
"firewall, set the REQUESTS_CA_BUNDLE environment variable to the "
"path of your organisation's CA bundle (a PEM file) and retry. "
"TLS verification failed -- APM uses the system trust store by default. "
"If you're behind a corporate proxy or firewall, make sure your "
"organisation's CA is installed in the OS trust store, or set "
"REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. "
"See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/"
)
if verbose_log:
Expand Down
68 changes: 58 additions & 10 deletions tests/unit/test_ssl_cert_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,35 +100,81 @@ def test_respects_existing_requests_ca_bundle(self, monkeypatch):

assert "SSL_CERT_FILE" not in os.environ

# -- Happy path: frozen + certifi available ------------------------------
def test_respects_existing_curl_ca_bundle(self, monkeypatch):
"""If the user already set CURL_CA_BUNDLE, do not set SSL_CERT_FILE."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.setenv("CURL_CA_BUNDLE", "/custom/curl-bundle.pem")

fn = _get_configure_fn()
fn()

assert "SSL_CERT_FILE" not in os.environ

def test_respects_existing_ssl_cert_dir(self, monkeypatch):
"""If the user already set SSL_CERT_DIR, do not set SSL_CERT_FILE."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
monkeypatch.setenv("SSL_CERT_DIR", "/custom/certs")

fn = _get_configure_fn()
fn()

def test_sets_ssl_cert_file_when_frozen(self, monkeypatch, tmp_path):
"""In a frozen binary with certifi, SSL_CERT_FILE is set automatically."""
assert "SSL_CERT_FILE" not in os.environ

# -- Happy path: frozen + truststore available ----------------------------

def test_injects_truststore_when_frozen(self, monkeypatch):
"""In a frozen binary, truststore is used before certifi fallback."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)

mock_truststore = MagicMock()

with patch.dict("sys.modules", {"truststore": mock_truststore}):
fn = _get_configure_fn()
fn()

assert mock_truststore.inject_into_ssl.called
assert "SSL_CERT_FILE" not in os.environ

# -- Fallback: truststore/certifi unavailable -----------------------------

def test_sets_ssl_cert_file_when_truststore_missing(self, monkeypatch, tmp_path):
"""If truststore is unavailable, frozen binaries fall back to certifi."""
ca_file = tmp_path / "cacert.pem"
ca_file.write_text("--- dummy CA bundle ---")

monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)

mock_certifi = MagicMock()
mock_certifi.where.return_value = str(ca_file)

with patch.dict("sys.modules", {"certifi": mock_certifi}):
with patch.dict("sys.modules", {"truststore": None, "certifi": mock_certifi}):
fn = _get_configure_fn()
fn()

assert os.environ.get("SSL_CERT_FILE") == str(ca_file)

# -- Fallback: certifi missing -------------------------------------------

def test_graceful_when_certifi_missing(self, monkeypatch):
"""If certifi is not importable, the hook silently continues."""
def test_graceful_when_truststore_and_certifi_missing(self, monkeypatch):
"""If both truststore and certifi are unavailable, continue silently."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)

with patch.dict("sys.modules", {"certifi": None}):
with patch.dict("sys.modules", {"truststore": None, "certifi": None}):
fn = _get_configure_fn()
fn() # must not raise

Expand All @@ -141,11 +187,13 @@ def test_skips_when_ca_file_missing(self, monkeypatch, tmp_path):
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.delenv("SSL_CERT_FILE", raising=False)
monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False)
monkeypatch.delenv("CURL_CA_BUNDLE", raising=False)
monkeypatch.delenv("SSL_CERT_DIR", raising=False)

mock_certifi = MagicMock()
mock_certifi.where.return_value = str(tmp_path / "does_not_exist.pem")

with patch.dict("sys.modules", {"certifi": mock_certifi}):
with patch.dict("sys.modules", {"truststore": None, "certifi": mock_certifi}):
fn = _get_configure_fn()
fn()

Expand Down
Loading