diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c1a995f25..85d967db5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `APM_EXTRA_CA_BUNDLE` adds corporate PEM certificates to APM package-management HTTPS while retaining default trust roots and explicit Requests/curl overrides. (#2034) — by @TameTheGame (#2741) + ## [0.33.0] - 2026-10-02 ### Added diff --git a/docs/src/content/docs/enterprise/registry-proxy.md b/docs/src/content/docs/enterprise/registry-proxy.md index 220b108bb5..fe0c51aac5 100644 --- a/docs/src/content/docs/enterprise/registry-proxy.md +++ b/docs/src/content/docs/enterprise/registry-proxy.md @@ -278,7 +278,7 @@ and `apm cache clean`. | `ERROR: ... locked to direct VCS hosts` | Lockfile predates the proxy | `apm install --update` | | HTTP 401/403 from the proxy | Missing or invalid `PROXY_REGISTRY_TOKEN` | Verify the token has read on the upstream repo path | | `git clone` hangs through the proxy | `HTTPS_PROXY` not set in the env that runs `git` | Export it in the shell that invokes `apm install`; CI secrets often miss this | -| `TLS verification failed` | Corporate proxy CA is not trusted by the OS store | Install the CA into the OS trust store, or set `REQUESTS_CA_BUNDLE`; see [SSL / TLS issues](../../troubleshooting/ssl-issues/) | +| `TLS verification failed` | Corporate proxy CA is not trusted by the OS store | Install the CA into the OS trust store, or set additive `APM_EXTRA_CA_BUNDLE` to retain public trust. Use `REQUESTS_CA_BUNDLE` only for intentional full replacement; see [SSL / TLS issues](../../troubleshooting/ssl-issues/) | | `DeprecationWarning: ARTIFACTORY_BASE_URL is deprecated` | Legacy env names | Rename to `PROXY_REGISTRY_*` | | Plaintext-token warning on proxy startup | Token sent over `http://` | Use `https://`, or set `PROXY_REGISTRY_ALLOW_HTTP=1` if the link is internal-only | | `Invalid zip archive` with a body that starts `` and is ~17KB | Upstream returned a sign-in page; proxy cached the HTML | Configure upstream credentials on the registry remote, purge the cache, then refetch | diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index 3f06cd2b1e..5ef1e88dba 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -55,12 +55,13 @@ inherited `PATH`. APM keeps certificate verification enabled for every HTTPS request. Python-based paths verify against the operating-system trust store by default through `truststore`, so corporate roots trusted by `git` and `curl` are also trusted by `apm install`. -- `REQUESTS_CA_BUNDLE` and `CURL_CA_BUNDLE` replace the OS store with an explicitly selected PEM bundle for APM's HTTP layer. -- `APM_DISABLE_TRUSTSTORE=1` restores the previous bundled-`certifi` behavior. -- If `truststore` is unavailable or injection fails, APM falls back to `certifi`; it does not disable verification. -- The Python-based `llm` runtime receives a shipped, self-contained `.pth` bootstrap in its managed virtual environment. The bootstrap imports only `truststore`; it does not execute dependency-provided package content. +- `REQUESTS_CA_BUNDLE` takes precedence over `CURL_CA_BUNDLE`; either explicitly replaces normal Requests trust and suppresses OS/additive injection. +- `APM_DISABLE_TRUSTSTORE=1` disables OS/additive trust without unsetting a separately configured replacement bundle. +- `APM_EXTRA_CA_BUNDLE` adds certificate-only PEM certificates to APM's package-management HTTPS. Truststore-backed contexts retain native OS roots; the Requests fallback retains bundled `certifi` roots plus the extra certificates. Certificate and hostname verification remain enabled. +- A selected bundle that is missing, unreadable, empty, non-regular, over 8 MiB, non-ASCII, malformed, or contains a private key fails closed with a configuration error. +- The existing Python `llm` runtime still receives a self-contained `.pth` OS-trust bootstrap at venv setup. `APM_EXTRA_CA_BUNDLE` does not extend that bootstrap or derive Python/Node child settings. -Node-based (Copilot) and Rust-based (Codex) child runtimes retain their own trust configuration for now. See [SSL / TLS issues](../../troubleshooting/ssl-issues/) for scope, overrides, and recovery steps. +Git, Node-based Copilot, and Rust-based Codex retain their own trust configuration. See [SSL / TLS issues](../../troubleshooting/ssl-issues/) for scope, overrides, and recovery steps. ## Dependency provenance diff --git a/docs/src/content/docs/reference/environment-variables.md b/docs/src/content/docs/reference/environment-variables.md index fe5056bb9b..98b2b9a2be 100644 --- a/docs/src/content/docs/reference/environment-variables.md +++ b/docs/src/content/docs/reference/environment-variables.md @@ -44,13 +44,18 @@ Controls how APM clones packages and enumerates refs on Git hosts. These setting ## TLS trust -APM verifies HTTPS against the operating-system trust store by default. For the full troubleshooting flow, see [SSL / TLS issues](../../troubleshooting/ssl-issues/). +APM verifies package-management HTTPS against the operating-system trust store by default, with bundled `certifi` as the Requests fallback. See [SSL / TLS issues](../../troubleshooting/ssl-issues/) for the full troubleshooting flow. | Variable | Purpose | Default | Notes | |---|---|---|---| -| `REQUESTS_CA_BUNDLE` | PEM bundle for APM's Python HTTP requests. | unset | Explicit override; wins over OS trust-store injection. Use for a per-shell corporate CA bundle. | -| `CURL_CA_BUNDLE` | PEM bundle fallback honoured by `requests`. | unset | Explicit override; wins over OS trust-store injection when `REQUESTS_CA_BUNDLE` is unset. | -| `APM_DISABLE_TRUSTSTORE` | Set to `1` (or `true`/`yes`/`on`) to disable OS trust-store injection. | unset | Escape hatch that restores the legacy bundled-`certifi` verification path. | +| `REQUESTS_CA_BUNDLE` | PEM bundle replacing APM's normal Requests trust. | unset | Wins over `CURL_CA_BUNDLE`, additive trust, and OS injection. | +| `CURL_CA_BUNDLE` | Replacement PEM bundle honored by Requests. | unset | Used when `REQUESTS_CA_BUNDLE` is unset; suppresses additive trust and OS injection. | +| `APM_DISABLE_TRUSTSTORE` | Set to `1` (or `true`/`yes`/`on`) to disable OS/additive trust. | unset | Restores bundled `certifi` unless an explicit Requests/curl replacement is set. | +| `APM_EXTRA_CA_BUNDLE` | Certificate-only PEM bundle added to APM package-management HTTPS. | unset | Retains OS roots, or `certifi` roots on Requests fallback. Invalid selected input fails closed: it must be a readable, non-empty regular file, no larger than 8 MiB, containing ASCII PEM certificates and no private keys. | + +Trust resolution is ordered: `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `APM_DISABLE_TRUSTSTORE`, `APM_EXTRA_CA_BUNDLE`, then the normal OS/`certifi` defaults. Higher-precedence controls suppress additive-bundle validation. Unset or blank `APM_EXTRA_CA_BUNDLE` preserves existing behavior. + +The additive setting does not derive trust settings for `apm run` children or configure Git, Node, or Rust. Those retain their existing settings; see [runtime coverage](../../troubleshooting/ssl-issues/#runtime-coverage). ## Registry (MCP and proxy) diff --git a/docs/src/content/docs/troubleshooting/common-errors.md b/docs/src/content/docs/troubleshooting/common-errors.md index 15df5433ea..a9cc895439 100644 --- a/docs/src/content/docs/troubleshooting/common-errors.md +++ b/docs/src/content/docs/troubleshooting/common-errors.md @@ -264,12 +264,14 @@ See also: [Install failures](../install-failures/) 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. +APM_EXTRA_CA_BUNDLE to a readable PEM bundle to add it while retaining +public trust. Use REQUESTS_CA_BUNDLE only to replace the complete +Requests trust set. ``` 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: 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. +Fix: install the corporate CA into the OS trust store and retry. For a per-shell additive setting that retains public trust, export `APM_EXTRA_CA_BUNDLE=/path/to/corporate-ca.pem`. Use `REQUESTS_CA_BUNDLE` only when you intend to replace the complete Requests trust set; `SSL_CERT_FILE` alone is not a reliable requests override. Do not disable TLS verification. See also: [SSL issues](../ssl-issues/) diff --git a/docs/src/content/docs/troubleshooting/install-failures.md b/docs/src/content/docs/troubleshooting/install-failures.md index e9c3a76c11..d074ed0a28 100644 --- a/docs/src/content/docs/troubleshooting/install-failures.md +++ b/docs/src/content/docs/troubleshooting/install-failures.md @@ -139,7 +139,7 @@ For end-to-end auth setup see [Authentication](../../getting-started/authenticat [!] TLS verification failed ``` -APM verifies HTTPS against the OS trust store by default. Behind a corporate proxy, install your org's CA into the OS trust store; for a per-shell override, set `REQUESTS_CA_BUNDLE` to a readable PEM bundle. Full walkthrough: [SSL / TLS issues](../ssl-issues/). +APM verifies HTTPS against the OS trust store by default. Behind a corporate proxy, install your org's CA into the OS trust store; for a per-shell additive setting that retains public trust, set `APM_EXTRA_CA_BUNDLE` to a readable PEM bundle. Use `REQUESTS_CA_BUNDLE` only when you intend to replace the complete Requests trust set. Full walkthrough: [SSL / TLS issues](../ssl-issues/). ### Timeouts and proxies diff --git a/docs/src/content/docs/troubleshooting/ssl-issues.md b/docs/src/content/docs/troubleshooting/ssl-issues.md index ef8203bba7..d2065782a5 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -17,7 +17,7 @@ Typical errors APM surfaces or passes through from the underlying HTTP/git stack [!] 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. + APM_EXTRA_CA_BUNDLE to a readable PEM bundle and retry. ``` ```text @@ -56,40 +56,70 @@ apm install --verbose ## Default behaviour: the OS trust store -**Fastest fix:** install your corporate CA in the OS trust store and retry. APM picks it up automatically on the covered Python paths. - -:::note[Planned] -**Scope caveat:** only the Python-based paths are covered. The Node-based (Copilot) and Rust-based (Codex) child runtimes are **not yet covered** by OS-store propagation (tracked in #2034). Behind a TLS-proxy today, export `NODE_EXTRA_CA_CERTS=/path/to/org-ca-bundle.pem` for the Node runtime and configure the Codex/Rust runtime's own trust. -::: +**Fastest fix:** install your corporate CA in the OS trust store and retry. APM picks it up automatically on its Python paths. APM verifies HTTPS against the **operating-system trust store** by default (via [`truststore`](https://pypi.org/project/truststore/)), the same source `git` and `curl` use. This covers in-process commands such as `apm install` and the standalone frozen binary, with bundled `certifi` as a fallback. -For the Python-based `llm` child runtime, `apm runtime setup llm` installs `truststore` in its virtual environment and adds a self-contained bootstrap. Corporate CAs installed in Keychain on macOS, through `update-ca-certificates`/`update-ca-trust` on Linux, or in the Windows Trusted Root store then work without APM-specific configuration. +**Scope caveat:** `APM_EXTRA_CA_BUNDLE` applies to APM's own package-management HTTPS, including installation, registry access, and downloads. It does not derive trust settings for experimental `apm run` children. Node/Copilot and Rust/Codex retain their runtime-owned trust configuration; configure Node's native `NODE_EXTRA_CA_CERTS` separately when needed. + +For the Python-based `llm` child runtime, `apm runtime setup llm` installs `truststore` in its virtual environment and adds a self-contained OS-trust bootstrap. Corporate CAs installed in Keychain on macOS, through `update-ca-certificates`/`update-ca-trust` on Linux, or in the Windows Trusted Root store then work without APM-specific configuration. + +You only need the settings below when the CA is *not* in the OS store, or you intentionally want to pin a replacement bundle: -You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: +- `APM_EXTRA_CA_BUNDLE` adds a readable PEM bundle to APM's active defaults: OS roots while `truststore` is active, or bundled `certifi` for the Requests fallback. This is the recommended per-shell corporate CA setting. +- `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` makes APM's Python HTTP layer verify against that bundle instead of the OS store. (`SSL_CERT_FILE` configures the stdlib `ssl` layer but is *not* read by `requests`, so on its own it does not override the HTTP path -- use `REQUESTS_CA_BUNDLE` for that.) +- `APM_DISABLE_TRUSTSTORE=1` disables APM's OS/additive trust. It does not unset `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE`; an explicit replacement still controls Requests. -- Setting `REQUESTS_CA_BUNDLE` or `CURL_CA_BUNDLE` makes APM's HTTP layer verify against that bundle instead of the OS store. (`SSL_CERT_FILE` configures the stdlib `ssl` layer but is *not* read by `requests`, so on its own it does not override the HTTP path -- use `REQUESTS_CA_BUNDLE` for that.) -- `APM_DISABLE_TRUSTSTORE=1` restores the legacy behaviour (verify against APM's bundled `certifi` set only). +The exact order is `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `APM_DISABLE_TRUSTSTORE`, `APM_EXTRA_CA_BUNDLE`, the OS trust store, then bundled `certifi` as the final Requests fallback. Higher-precedence controls suppress additive-bundle validation. Leaving the extra bundle unset or blank preserves existing behavior. + +### Runtime coverage + +| Path | Behaviour when `APM_EXTRA_CA_BUNDLE` is selected | +|---|---| +| APM package-management Requests HTTPS | Retains native OS roots and adds the selected PEM certificates; falls back to `certifi` plus the extra CA if OS injection is unavailable. | +| In-process stdlib metadata HTTPS | Receives OS-plus-extra trust while truststore is active; on injection failure its HTTPS context retains the stdlib defaults plus the extra certificates. | +| Python/Requests execution child | Unchanged; no additive settings are derived. | +| Managed Python `llm` child | Unchanged; the existing setup-time bootstrap still provides OS trust when available. | +| Node/Copilot child | Unchanged; set `NODE_EXTRA_CA_CERTS` using the runtime's own trust settings. | +| Git | Unchanged; configure `GIT_SSL_CAINFO` or Git's native trust settings separately. | +| Rust/Codex | Unchanged; configure the runtime's own trust settings. | ### Known limitations -- Node (Copilot) and Rust (Codex) coverage -- see the scope caveat above. -- The `llm` child runtime's OS-trust bootstrap needs the runtime venv's interpreter to be **Python 3.10+** (the `truststore` library requires 3.10). On systems where `apm runtime setup llm` builds the venv from a stock **Python 3.9** (for example Apple's `/usr/bin/python3`), `truststore` cannot install and the `llm` child silently falls back to its bundled `certifi` set behind a proxy. Use a Python 3.10+ `python3` on your `PATH` before running setup. +- Git uses its own trust configuration; `APM_EXTRA_CA_BUNDLE` does not change `git clone`, `git fetch`, or `git ls-remote`. Configure `GIT_SSL_CAINFO` separately when Git needs the same CA. +- Rust-based Codex uses its own runtime trust configuration. APM does not translate `APM_EXTRA_CA_BUNDLE` into a Rust/OpenSSL setting. +- If truststore injection is unavailable, Requests-based HTTPS retains `certifi` roots plus the extra certificates. The stdlib HTTPS context used by metadata requests retains its own default roots plus the extra certificates. Raw stdlib SSL contexts keep their existing settings. +- The `llm` child runtime's OS-trust bootstrap needs the runtime venv's interpreter to be **Python 3.10+** (the `truststore` library requires 3.10). On systems where `apm runtime setup llm` builds the venv from a stock **Python 3.9** (for example Apple's `/usr/bin/python3`), `truststore` cannot install, so CAs present only in the OS store are unavailable to that child. That child keeps its existing certificate defaults; the additive APM setting does not apply to it. Use a Python 3.10+ `python3` on your `PATH` before running setup when the child must use native OS trust. - The initial `pip install` run *during* `apm runtime setup llm` uses pip's **own** certificate resolution, not APM's OS-trust path. Behind a MITM proxy, `pip` may fail to fetch `llm`/`truststore` before the bootstrap is even in place. Export `PIP_CERT=/path/to/org-ca-bundle.pem` (or run `pip config set global.cert /path/to/org-ca-bundle.pem`) before running setup so pip trusts your proxy CA. -- APM cannot currently combine the OS store with an additional PEM bundle. Use `REQUESTS_CA_BUNDLE` to pin a single bundle instead. ## 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's primary Python HTTP paths use `requests`, a small number of metadata paths use the stdlib `urllib` stack, and repository operations shell out to `git`. Their fallback trust settings differ as described above. Set them at the shell or in your profile (`~/.zshrc`, `~/.bashrc`, or the Windows user environment). ### Python HTTP layer +```bash +export APM_EXTRA_CA_BUNDLE=/path/to/corporate-ca.pem +``` + +This retains native OS roots and adds the selected PEM certificates. If APM cannot inject OS trust, Requests-based HTTPS retains the additive certificates over its `certifi` fallback. APM validates one in-memory copy of the bundle before using it: the file must be regular, readable, non-empty, no larger than 8 MiB, certificate-only ASCII PEM, and contain at least one certificate. Private-key blocks are rejected. An invalid selected bundle fails closed before the command runs, rather than silently ignoring the requested trust or disabling verification. Set trust controls in the environment that launches APM. + +Use a replacement bundle only when you intend to pin the entire Requests trust set: + ```bash export REQUESTS_CA_BUNDLE=/path/to/ca-bundle.pem ``` `REQUESTS_CA_BUNDLE` wins for `requests`. `SSL_CERT_FILE` / `SSL_CERT_DIR` cover parts of the stdlib TLS stack, but on their own they are not reliable overrides for the `requests` HTTP path APM uses. +### Node children + +Configure Node independently; APM does not translate `APM_EXTRA_CA_BUNDLE` into a Node setting: + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/node-ca-bundle.pem +``` + ### Git operations ```bash @@ -107,11 +137,11 @@ The trailing slash matters - it scopes the setting to that origin. ### Windows (PowerShell) ```powershell -$env:REQUESTS_CA_BUNDLE = "C:\certs\corporate-ca.pem" +$env:APM_EXTRA_CA_BUNDLE = "C:\certs\corporate-ca.pem" $env:GIT_SSL_CAINFO = "C:\certs\corporate-ca.pem" # Persist for the current user: -[Environment]::SetEnvironmentVariable("REQUESTS_CA_BUNDLE", "C:\certs\corporate-ca.pem", "User") +[Environment]::SetEnvironmentVariable("APM_EXTRA_CA_BUNDLE", "C:\certs\corporate-ca.pem", "User") ``` ### Where do I get the CA file? @@ -163,7 +193,7 @@ APM_LOG_LEVEL=DEBUG apm install GIT_CURL_VERBOSE=1 git ls-remote https://github.example.com/org/repo.git 2>&1 | grep -i 'ssl\|cert' ``` -Look for `TLS: verifying against OS trust store (truststore)` in the debug output. That line plus a clean install confirms APM's in-process Python path; a successful `ls-remote` confirms Git trust separately. Verify the managed `llm` child with its normal HTTPS-backed command after `apm runtime setup llm`. +The debug output identifies whether APM selected the OS trust store, additive bundle, replacement bundle, or `certifi` fallback. That line plus a clean install confirms APM's in-process Python path; a successful `ls-remote` confirms Git trust separately. ## Development-only escape hatches @@ -190,7 +220,8 @@ unset GIT_SSL_NO_VERIFY PYTHONHTTPSVERIFY [>] Re-run with `--verbose` and capture the full exception chain. [>] Check `curl -v https://` from the same shell - if it fails, the problem is the system trust store, not APM. +[>] Confirm `APM_EXTRA_CA_BUNDLE` points at a readable, non-empty regular file no larger than 8 MiB and contains certificate-only ASCII PEM. APM rejects malformed bundles and private-key blocks rather than silently ignoring them. Unset it to return to normal OS trust, or replace it with the correct CA file. [>] 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). Note `REQUESTS_CA_BUNDLE` *replaces* the OS store rather than augmenting it (like `git`'s `http.sslCAInfo` and `curl --cacert`), so a bundle missing your proxy root will still fail even though the OS store has it. -[>] If `git`/`curl` succeed but `apm` does not, suspect a **stale `REQUESTS_CA_BUNDLE`** (or `CURL_CA_BUNDLE`) pinning APM to an old bundle that predates the OS store. `unset REQUESTS_CA_BUNDLE CURL_CA_BUNDLE` and retry to let APM fall back to the OS trust store. +[>] If `git`/`curl` succeed but `apm` does not, check the precedence settings. `APM_DISABLE_TRUSTSTORE` bypasses OS and additive trust; a stale `REQUESTS_CA_BUNDLE` (or `CURL_CA_BUNDLE`) pins APM to a replacement bundle. Unset those variables and retry to let `APM_EXTRA_CA_BUNDLE`, or the OS store when it is unset, take effect. [>] 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/). diff --git a/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md b/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md index a91581f668..2e00207657 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md +++ b/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md @@ -9,7 +9,7 @@ | File collision on install | A local file conflicts with a dependency file. Use `--force` to overwrite, or rename the local file. | | Stale dependencies | Run `apm install --update` to refresh to latest refs. | | MCP path contains `.apm-resolution-staging` | Upgrade APM and retry the same install once. If it repeats, stop and report the redacted error and named MCP entry. Do not edit package files, delete the lockfile, or use `--refresh`/`--force` solely for this repair. | -| TLS verification failed | Install your corporate CA into the OS trust store. For a per-shell override, set `REQUESTS_CA_BUNDLE=/path/to/ca-bundle.pem`; `SSL_CERT_FILE` alone is not a reliable requests override. | +| TLS verification failed | Install your corporate CA into the OS trust store. For additive per-shell trust, set `APM_EXTRA_CA_BUNDLE=/path/to/ca-bundle.pem`; configure `GIT_SSL_CAINFO` separately for Git. Use `REQUESTS_CA_BUNDLE` only when you intend to replace Requests' normal trust set. | | Orphaned packages in lockfile | Run `apm prune` to remove packages no longer in apm.yml. | | Security findings block install | Run `apm audit` to review findings, then `apm install --force` if acceptable. | | Compilation not picking up changes | Run `apm compile --clean` to remove orphaned output, or `apm compile --watch` for auto-regeneration. | diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 16f1e394ac..ddd3603857 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -14,9 +14,20 @@ import click -from apm_cli.core.tls_trust import configure_process_tls_trust, log_tls_trust_status +from apm_cli.core.tls_trust import ( + TLSConfigurationError, + configure_process_tls_trust, + log_tls_trust_status, +) -configure_process_tls_trust() +_TLS_BOOTSTRAP_ERROR: TLSConfigurationError | None = None +try: + configure_process_tls_trust() +except TLSConfigurationError as exc: + # Command modules may create Sessions at import time but do not perform + # network I/O. Preserve early TLS ordering, then surface a concise Click + # error before any command callback can make a request. + _TLS_BOOTSTRAP_ERROR = exc from apm_cli.commands._helpers import ( ERROR, @@ -261,6 +272,12 @@ def _configure_logging(verbose: bool = False) -> None: @click.pass_context def cli(ctx, verbose: bool) -> None: """Main entry point for the APM CLI.""" + if _TLS_BOOTSTRAP_ERROR is not None: + raise click.ClickException( + f"{_TLS_BOOTSTRAP_ERROR}. " + "Set it to a readable certificate-only PEM, or unset it. " + "See https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + ) ctx.ensure_object(dict) ctx.obj["verbose"] = verbose from apm_cli.core.output_mode import configure_output_mode, detect_output_mode @@ -476,7 +493,6 @@ def main(): """Main entry point for the CLI.""" _configure_logging() # honours APM_LOG_LEVEL env var; --verbose upgrades in cli() _configure_encoding() - configure_process_tls_trust() try: cli(obj={}) except Exception as e: diff --git a/src/apm_cli/core/script_executors.py b/src/apm_cli/core/script_executors.py index 80eeedcba4..596984e739 100644 --- a/src/apm_cli/core/script_executors.py +++ b/src/apm_cli/core/script_executors.py @@ -961,6 +961,11 @@ def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): # _dispatch_http_request (which honors the operator's env proxy explicitly); # this session is used only when no env proxy applies to the destination. session.trust_env = False + from .tls_trust import explicit_ca_bundle_path + + explicit_ca = explicit_ca_bundle_path() + if explicit_ca: + session.verify = explicit_ca session.mount("https://", _SSRFGuardAdapter()) return session @@ -1057,6 +1062,11 @@ def proxy_manager_for(self, proxy, **proxy_kwargs): # Never auto-honor env proxies: the proxy is passed EXPLICITLY per-dispatch # in _run, so a stray env var cannot silently re-route a dispatch here. session.trust_env = False + from .tls_trust import explicit_ca_bundle_path + + explicit_ca = explicit_ca_bundle_path() + if explicit_ca: + session.verify = explicit_ca adapter = _CapturingAdapter() session.mount("http://", adapter) session.mount("https://", adapter) diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py index ac77ef0ca6..0f68b02d62 100644 --- a/src/apm_cli/core/tls_trust.py +++ b/src/apm_cli/core/tls_trust.py @@ -7,20 +7,18 @@ ``requests`` through the OS store via ``truststore`` so the two agree, with no per-shell config. -Best-effort -- ``configure_tls_trust`` never raises: +Best-effort unless the operator selects an invalid additive bundle: * An explicit ``REQUESTS_CA_BUNDLE`` / ``CURL_CA_BUNDLE`` wins (no injection). +* ``APM_EXTRA_CA_BUNDLE`` adds a validated PEM bundle to the selected defaults. * Missing ``truststore`` or a failed injection falls back to ``certifi``. -* ``APM_DISABLE_TRUSTSTORE`` forces the legacy ``certifi``-only behaviour. - -Child runtimes are Python/CLI subprocesses spawned after ``exec``; the parent -cannot monkeypatch their ``ssl`` module. Trust is delivered to the Python -``llm`` runtime at venv-setup time: :func:`ensure_child_tls_bootstrap` drops a -self-contained ``.pth`` bootstrap into the runtime venv's site-packages so its -interpreter injects ``truststore`` at startup with no ``apm_cli`` dependency and -no ``PYTHONPATH`` mutation (which would shadow a user ``sitecustomize.py``). -:func:`build_child_tls_env` is now an env-hygiene pass only. Node (Copilot) and -Rust (Codex) runtimes verify against their own default trust for now (#2034). +* ``APM_DISABLE_TRUSTSTORE`` disables OS/additive trust without unsetting + an independently configured Requests/curl replacement bundle. + +The additive bundle applies to APM package-management HTTPS only. +Execution runtimes retain their existing trust configuration: the Python +``llm`` bootstrap still injects OS trust at venv setup, and Node/Rust use +their native settings. No additive settings are derived for children. """ from __future__ import annotations @@ -29,10 +27,13 @@ import functools import logging import os +import ssl +import stat import sys import tempfile from collections.abc import Mapping, MutableMapping from pathlib import Path +from typing import Any, cast from ..utils.path_security import PathTraversalError, ensure_path_within @@ -46,9 +47,18 @@ # shipped artifact. _EXPLICIT_CA_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE") -# Escape hatch: set truthy to force the legacy certifi-only behaviour. +# Escape hatch: set truthy to disable APM's OS/additive trust. +# Explicit Requests/curl replacement variables remain authoritative. _DISABLE_ENV_VAR = "APM_DISABLE_TRUSTSTORE" +# Additive CA bundle layered on top of the selected OS/certifi defaults. +_EXTRA_CA_ENV_VAR = "APM_EXTRA_CA_BUNDLE" + +# Bound operator-provided CA input before reading it into memory. Enterprise +# bundles are normally well below 1 MiB; 8 MiB leaves ample room without +# allowing a device or unexpectedly huge file to consume unbounded memory. +_MAX_EXTRA_CA_BUNDLE_BYTES = 8 * 1024 * 1024 + # stdlib ``ssl`` CA-file variable. truststore's Linux backend calls # ``ctx.set_default_verify_paths()`` which honours SSL_CERT_FILE, so a bundled # certifi value would shadow the OS store -- we pop it before injecting. @@ -79,6 +89,15 @@ _TRUTHY = {"1", "true", "yes", "on"} _LAST_TLS_STATUS: tuple[str, tuple[object, ...]] | None = None _KNOWN_BUNDLED_CERT_FILE: str | None = None +_MISSING_TLS_REFERENCE = object() + +# Retain the unpatched stdlib class for parse validation and certifi fallback. +# ``tls_trust`` is imported before Requests/urllib3 at CLI startup. +_STDLIB_SSL_CONTEXT = ssl.SSLContext + + +class TLSConfigurationError(RuntimeError): + """Raised when an explicitly selected TLS trust input is unusable.""" def _record_tls_trust_status(message: str, *args: object) -> None: @@ -106,8 +125,13 @@ def has_explicit_ca_override(env: Mapping[str, str] | None = None) -> bool: return any((environ.get(var) or "").strip() for var in _EXPLICIT_CA_ENV_VARS) -def _explicit_ca_path(env: Mapping[str, str] | None = None) -> str: - """Return the first explicit CA-bundle path set, or an empty string.""" +def explicit_ca_bundle_path(env: Mapping[str, str] | None = None) -> str: + """Return the Requests-compatible replacement bundle, or an empty string. + + Requests gives ``REQUESTS_CA_BUNDLE`` precedence over + ``CURL_CA_BUNDLE``. Callers whose hardened Sessions set ``trust_env=False`` + use this helper to retain those replacement semantics explicitly. + """ environ = os.environ if env is None else env for var in _EXPLICIT_CA_ENV_VARS: value = (environ.get(var) or "").strip() @@ -116,6 +140,218 @@ def _explicit_ca_path(env: Mapping[str, str] | None = None) -> str: return "" +def _safe_path_display(path: Path) -> str: + """Return an ASCII-safe path for CLI diagnostics.""" + return ascii(str(path))[1:-1] + + +def _read_extra_ca_bundle(env: Mapping[str, str] | None = None) -> tuple[str, str] | None: + """Read and validate ``APM_EXTRA_CA_BUNDLE`` from one stable file snapshot. + + Returns ``(absolute_path, pem_text)`` when configured and ``None`` for an + unset/blank value. The opened descriptor must identify a non-empty regular + file no larger than :data:`_MAX_EXTRA_CA_BUNDLE_BYTES`. The snapshot must + be printable-ASCII PEM containing at least one certificate. Validation + uses an unpatched stdlib context, so a prior process-wide TLS injection + cannot change the parser or recursively add another bundle. + + Raises: + TLSConfigurationError: If the selected path or PEM is unusable. + """ + environ = os.environ if env is None else env + raw_value = (environ.get(_EXTRA_CA_ENV_VAR) or "").strip() + if not raw_value: + return None + + try: + requested = Path(raw_value).expanduser() + resolved = requested.resolve(strict=True) + except (OSError, RuntimeError) as exc: + display = _safe_path_display(Path(raw_value)) + raise TLSConfigurationError(f"{_EXTRA_CA_ENV_VAR} path does not exist: {display}") from exc + + display = _safe_path_display(resolved) + try: + with resolved.open("rb") as handle: + metadata = os.fstat(handle.fileno()) + if not stat.S_ISREG(metadata.st_mode): + raise TLSConfigurationError( + f"{_EXTRA_CA_ENV_VAR} must reference a regular file: {display}" + ) + if metadata.st_size <= 0: + raise TLSConfigurationError(f"{_EXTRA_CA_ENV_VAR} file is empty: {display}") + if metadata.st_size > _MAX_EXTRA_CA_BUNDLE_BYTES: + raise TLSConfigurationError( + f"{_EXTRA_CA_ENV_VAR} exceeds the 8 MiB limit: {display}" + ) + bundle_bytes = handle.read(_MAX_EXTRA_CA_BUNDLE_BYTES + 1) + except TLSConfigurationError: + raise + except OSError as exc: + raise TLSConfigurationError(f"{_EXTRA_CA_ENV_VAR} file is not readable: {display}") from exc + + if len(bundle_bytes) > _MAX_EXTRA_CA_BUNDLE_BYTES: + raise TLSConfigurationError(f"{_EXTRA_CA_ENV_VAR} exceeds the 8 MiB limit: {display}") + try: + bundle_pem = bundle_bytes.decode("ascii") + except UnicodeDecodeError as exc: + raise TLSConfigurationError( + f"{_EXTRA_CA_ENV_VAR} must contain ASCII PEM certificates: {display}" + ) from exc + if any( + line.startswith("-----BEGIN ") and line.endswith("PRIVATE KEY-----") + for line in bundle_pem.splitlines() + ): + raise TLSConfigurationError( + f"{_EXTRA_CA_ENV_VAR} must contain certificates only; private keys are not allowed: " + f"{display}" + ) + + validation_context = _STDLIB_SSL_CONTEXT(ssl.PROTOCOL_TLS_CLIENT) + try: + validation_context.load_verify_locations(cadata=bundle_pem) + except (OSError, ValueError, ssl.SSLError) as exc: + raise TLSConfigurationError( + f"{_EXTRA_CA_ENV_VAR} is not a valid PEM certificate bundle: {display}" + ) from exc + if validation_context.cert_store_stats().get("x509", 0) < 1: + raise TLSConfigurationError(f"{_EXTRA_CA_ENV_VAR} contains no certificates: {display}") + return str(resolved), bundle_pem + + +def _capture_tls_publication_state() -> tuple[type[ssl.SSLContext], Any, object, Any, object, Any]: + """Capture every loaded global reference changed by TLS injection. + + This deliberately does not import urllib3 or Requests. The CLI must inject + truststore before its HTTP stack is first imported; modules loaded during a + failed injection are normalized by :func:`_restore_tls_publication_state`. + """ + urllib3_ssl = sys.modules.get("urllib3.util.ssl_") + requests_adapters = sys.modules.get("requests.adapters") + urllib3_context = ( + getattr(urllib3_ssl, "SSLContext", _MISSING_TLS_REFERENCE) + if urllib3_ssl is not None + else _MISSING_TLS_REFERENCE + ) + preloaded = ( + getattr(requests_adapters, "_preloaded_ssl_context", _MISSING_TLS_REFERENCE) + if requests_adapters is not None + else _MISSING_TLS_REFERENCE + ) + return ( + ssl.SSLContext, + urllib3_ssl, + urllib3_context, + requests_adapters, + preloaded, + ssl._create_default_https_context, + ) + + +def _restore_tls_publication_state( + state: tuple[type[ssl.SSLContext], Any, object, Any, object, Any], +) -> None: + """Restore a state captured before a process-wide TLS publication.""" + ( + original_ssl_context, + original_urllib3_module, + original_urllib3_context, + original_requests_module, + original_preloaded, + original_https_factory, + ) = state + ssl.SSLContext = original_ssl_context # type: ignore[misc] + ssl._create_default_https_context = original_https_factory + urllib3_ssl = cast(Any, original_urllib3_module or sys.modules.get("urllib3.util.ssl_")) + if urllib3_ssl is not None: + urllib3_ssl.SSLContext = ( + original_ssl_context + if original_urllib3_context is _MISSING_TLS_REFERENCE + else original_urllib3_context + ) + requests_adapters = cast(Any, original_requests_module or sys.modules.get("requests.adapters")) + if requests_adapters is not None: + if original_requests_module is None: + if hasattr(requests_adapters, "_preloaded_ssl_context"): + # Requests 2.32 preloads certifi into a urllib3-created + # context. Rebuild that exact default after a failed injection; + # a bare stdlib context would contain no CA roots here. + try: + restored_preloaded = requests_adapters.create_urllib3_context() + restored_preloaded.load_verify_locations( + requests_adapters.extract_zipped_paths( + requests_adapters.DEFAULT_CA_BUNDLE_PATH + ) + ) + except Exception: + restored_preloaded = None + requests_adapters._preloaded_ssl_context = restored_preloaded + elif original_preloaded is _MISSING_TLS_REFERENCE: + with contextlib.suppress(AttributeError): + del requests_adapters._preloaded_ssl_context + else: + requests_adapters._preloaded_ssl_context = original_preloaded + + +def _install_additive_ca_context(base_context: type[ssl.SSLContext], bundle_pem: str) -> None: + """Publish an SSLContext type that loads *bundle_pem* on construction. + + Validate the candidate before publishing it. ``configure_tls_trust`` owns + rollback for the entire injection, including any failure in this helper. + Replace Requests 2.32's optional preloaded context without mutating it. + """ + + # On fallback, specialize urllib3's context and the stdlib HTTPS factory. + # Replacing ssl.SSLContext with a stdlib subclass makes its setters recurse. + # Keeping the + # setting in-process also avoids exporting new trust to execution children. + certifi_path = None + urllib3_ssl = sys.modules.get("urllib3.util.ssl_") + if base_context is _STDLIB_SSL_CONTEXT: + import certifi + import urllib3.util.ssl_ as urllib3_ssl + + certifi_path = certifi.where() + + class _APMExtraCAContext(base_context): # type: ignore[valid-type,misc] + def __init__(self, protocol: int | None = None) -> None: + if certifi_path is None: + super().__init__(protocol) + else: + # stdlib SSLContext initializes in __new__, not __init__. + self.load_verify_locations(cafile=certifi_path) + self.load_verify_locations(cadata=bundle_pem) + + # Keep existing trust-source diagnostics meaningful. + _APMExtraCAContext.__module__ = base_context.__module__ + + candidate = _APMExtraCAContext(ssl.PROTOCOL_TLS_CLIENT) + if not candidate.check_hostname or candidate.verify_mode != ssl.CERT_REQUIRED: + raise TLSConfigurationError("Additive TLS context did not preserve peer verification") + + if certifi_path is None: + ssl.SSLContext = _APMExtraCAContext # type: ignore[misc] + else: + # urllib.request/http.client use this factory for metadata HTTPS. + # Preserve its existing defaults and settings, then add the same PEM. + original_https_factory = ssl._create_default_https_context + + def _extra_https_context(*args: Any, **kwargs: Any) -> ssl.SSLContext: + context = original_https_factory(*args, **kwargs) + context.load_verify_locations(cadata=bundle_pem) + return context + + https_candidate = _extra_https_context() + if not https_candidate.check_hostname or https_candidate.verify_mode != ssl.CERT_REQUIRED: + raise TLSConfigurationError("Additive HTTPS context did not preserve peer verification") + ssl._create_default_https_context = _extra_https_context + if urllib3_ssl is not None: + urllib3_ssl.SSLContext = _APMExtraCAContext + requests_adapters = sys.modules.get("requests.adapters") + if requests_adapters is not None and hasattr(requests_adapters, "_preloaded_ssl_context"): + requests_adapters._preloaded_ssl_context = candidate + + def _mutable_environ(env: Mapping[str, str] | None) -> MutableMapping[str, str]: """Return the environment truststore/OpenSSL will actually read. @@ -136,8 +372,15 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: Call once at process startup, before the first HTTPS request. Returns ``True`` when ``truststore`` was injected, ``False`` when the default - ``certifi`` behaviour was left in place (explicit override, opt-out, - ``truststore`` missing, or injection failure). Never raises. + ``certifi`` behaviour was retained (explicit override, opt-out, + ``truststore`` missing, or injection failure). When + ``APM_EXTRA_CA_BUNDLE`` is selected, its validated PEM is added to the OS + context or to the certifi fallback without changing replacement-variable + semantics. + + Raises: + TLSConfigurationError: If an explicitly selected additive bundle is + missing, unreadable, non-regular, oversized, empty, or malformed. """ global _KNOWN_BUNDLED_CERT_FILE environ = _mutable_environ(env) @@ -147,46 +390,71 @@ def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: # override, truststore-import failure, inject success, or inject failure). # Capture its truthiness first -- the pop-before-inject logic below needs # to know whether the current SSL_CERT_FILE was OUR bundled default. - had_bundled_marker = _env_flag(_BUNDLED_CERT_MARKER, env) + had_bundled_marker = _env_flag(_BUNDLED_CERT_MARKER, environ) environ.pop(_BUNDLED_CERT_MARKER, None) if had_bundled_marker and environ.get(_SSL_CERT_FILE_VAR): _KNOWN_BUNDLED_CERT_FILE = os.path.abspath(environ[_SSL_CERT_FILE_VAR]) - if _env_flag(_DISABLE_ENV_VAR, env): - _record_tls_trust_status("TLS: OS trust-store injection disabled (%s)", _DISABLE_ENV_VAR) + if has_explicit_ca_override(environ): + explicit_path = explicit_ca_bundle_path(environ) + _record_tls_trust_status( + "TLS: explicit CA bundle in use: %s", _safe_path_display(Path(explicit_path)) + ) return False - if has_explicit_ca_override(env): - _record_tls_trust_status("TLS: explicit CA bundle in use: %s", _explicit_ca_path(env)) + if _env_flag(_DISABLE_ENV_VAR, environ): + _record_tls_trust_status("TLS: OS trust-store injection disabled (%s)", _DISABLE_ENV_VAR) return False + extra_ca = _read_extra_ca_bundle(environ) + extra_ca_path = extra_ca[0] if extra_ca is not None else "" + extra_ca_pem = extra_ca[1] if extra_ca is not None else "" + extra_ca_display = _safe_path_display(Path(extra_ca_path)) if extra_ca_path else "" + + bundled_cert: str | None = None + publication_state = _capture_tls_publication_state() try: - # Broad except: a broken/incompatible install can fail at import, not - # only with ImportError -- degrade instead of crashing startup. + # Import and injection failures share the same verified fallback. import truststore - except Exception as exc: - _record_tls_trust_status("TLS: verifying against bundled CA (certifi fallback) [%s]", exc) - return False - # If the frozen hook pinned SSL_CERT_FILE to bundled certifi, pop it so - # truststore's set_default_verify_paths() reads the genuine system default. - # A user-set SSL_CERT_FILE (no marker) is left untouched. - bundled_cert: str | None = None - if environ.get(_SSL_CERT_FILE_VAR) and had_bundled_marker: - bundled_cert = environ.get(_SSL_CERT_FILE_VAR) - environ.pop(_SSL_CERT_FILE_VAR, None) + # Remove only the frozen hook's certifi default so truststore can read + # the OS roots. Preserve a user-selected SSL_CERT_FILE. + if environ.get(_SSL_CERT_FILE_VAR) and had_bundled_marker: + bundled_cert = environ.pop(_SSL_CERT_FILE_VAR) - try: truststore.inject_into_ssl() + if extra_ca is not None: + _install_additive_ca_context(truststore.SSLContext, extra_ca_pem) except Exception as exc: - # Never end with zero trust: restore the bundled certifi path so - # musl/minimal-container hosts still verify against certifi. + # Injection changes process-wide globals and can fail after publishing + # only some of them. Restore the exact pre-injection state before + # selecting a fallback so no caller observes a mixed trust mode. + _restore_tls_publication_state(publication_state) if bundled_cert is not None: environ[_SSL_CERT_FILE_VAR] = bundled_cert + if extra_ca is not None: + try: + _install_additive_ca_context(_STDLIB_SSL_CONTEXT, extra_ca_pem) + except Exception as fallback_exc: + _restore_tls_publication_state(publication_state) + raise TLSConfigurationError( + "Could not apply APM_EXTRA_CA_BUNDLE to the HTTPS client" + ) from fallback_exc + _record_tls_trust_status( + "TLS: verifying against bundled CA plus additive CA: %s (certifi fallback) [%s]", + extra_ca_display, + exc, + ) + return False _record_tls_trust_status("TLS: verifying against bundled CA (certifi fallback) [%s]", exc) return False - _record_tls_trust_status("TLS: verifying against OS trust store (truststore)") + if extra_ca is not None: + _record_tls_trust_status( + "TLS: verifying against OS trust store plus additive CA: %s", extra_ca_display + ) + else: + _record_tls_trust_status("TLS: verifying against OS trust store (truststore)") return True diff --git a/src/apm_cli/install/validation.py b/src/apm_cli/install/validation.py index 63ec9de1bf..f758230909 100644 --- a/src/apm_cli/install/validation.py +++ b/src/apm_cli/install/validation.py @@ -140,7 +140,9 @@ def _log_tls_failure(host_display: str, exc: BaseException, verbose_log, logger) "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. " + "APM_EXTRA_CA_BUNDLE to a readable PEM bundle to add it while retaining " + "public trust. Use REQUESTS_CA_BUNDLE only to replace the complete " + "Requests trust set. " "See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" ) if verbose_log: diff --git a/tests/integration/_tls_ca_server.py b/tests/integration/_tls_ca_server.py index b7a2877dff..3021e090d2 100644 --- a/tests/integration/_tls_ca_server.py +++ b/tests/integration/_tls_ca_server.py @@ -1,66 +1,3 @@ -"""Shared private-CA HTTPS server harness for the child-runtime/frozen TLS tests. +"""Shared private-CA server for the existing child-runtime and frozen tests.""" -Reuses the certificate factory (``_mint_ca_and_leaf``) and request handler -(``_OkHandler``) already proven in ``test_tls_custom_ca`` -- single source of -truth for minting a private CA that is present in neither ``certifi`` nor the OS -trust store. Exposes a context manager that boots a loopback HTTPS server whose -leaf is signed by that private CA, so the child-runtime (B1) and frozen-binary -(B2) verifiers can drive real ``requests`` traffic across a genuine trust -boundary. -""" - -from __future__ import annotations - -import contextlib -import http.server -import ssl -import threading -from pathlib import Path -from types import SimpleNamespace - -from .test_tls_custom_ca import _mint_ca_and_leaf, _OkHandler - - -@contextlib.contextmanager -def private_ca_https_server(dirpath: Path): - """Yield a running loopback HTTPS server backed by a fresh private CA. - - Yields a namespace with ``url`` (https://localhost:/), ``ca_path`` - (PEM of the private CA), and ``ca_pem``/``srv_pem``/``srv_key`` paths. - """ - ca_pem, srv_pem, srv_key = _mint_ca_and_leaf(dirpath) - - # A prior test may have left truststore globally injected; a truststore-backed - # server-side SSLContext raises on wrap_socket. Extract first (best-effort) so - # the server always presents its chain via the stdlib ssl backend. - try: - import truststore - - truststore.extract_from_ssl() - except Exception: - pass - - context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - # Pin a modern floor -- the default context still permits TLSv1/TLSv1.1 - # (CodeQL py/insecure-protocol). This is a loopback test server, but keep - # it correct so the scanner stays clean. - context.minimum_version = ssl.TLSVersion.TLSv1_2 - context.load_cert_chain(certfile=str(srv_pem), keyfile=str(srv_key)) - - httpd = http.server.HTTPServer(("127.0.0.1", 0), _OkHandler) - httpd.socket = context.wrap_socket(httpd.socket, server_side=True) - port = httpd.server_address[1] - thread = threading.Thread(target=httpd.serve_forever, daemon=True) - thread.start() - try: - yield SimpleNamespace( - url=f"https://localhost:{port}/", - ca_path=str(ca_pem), - ca_pem=ca_pem, - srv_pem=srv_pem, - srv_key=srv_key, - ) - finally: - httpd.shutdown() - httpd.server_close() - thread.join(timeout=5) +from .test_tls_custom_ca import private_ca_https_server as private_ca_https_server diff --git a/tests/integration/test_tls_custom_ca.py b/tests/integration/test_tls_custom_ca.py index c49ca6fc55..51205979b7 100644 --- a/tests/integration/test_tls_custom_ca.py +++ b/tests/integration/test_tls_custom_ca.py @@ -4,42 +4,89 @@ freshly generated private CA (never present in any trust store), then exercises the real ``requests`` -> ``urllib3`` -> ``ssl`` stack that APM uses for the Contents API. This is the end-to-end counterpart to the unit tests in -``tests/unit/core/test_tls_trust.py`` and covers the behaviour the triage -panel asked for on #2004: +``tests/unit/core/test_tls_trust.py`` and covers the additive behaviour from +#2034 as well as the original #2004 trust-store contract: - an untrusted custom CA is genuinely rejected (verification is on), - an explicit ``REQUESTS_CA_BUNDLE`` is honoured and makes the request pass (and ``configure_tls_trust`` correctly declines to override it), - injecting the OS trust store via truststore does NOT weaken verification -- - a CA that is not in the OS store is still rejected. + a CA that is not in the OS store is still rejected, +- ``APM_EXTRA_CA_BUNDLE`` trusts a private CA without replacing an independent + pre-existing root. Requires the ``openssl`` CLI to mint the certificates; skipped where absent. """ from __future__ import annotations +import contextlib import http.server +import json +import os import shutil +import socket import ssl import subprocess +import sys import threading +from collections.abc import Iterator +from pathlib import Path from types import SimpleNamespace import pytest import requests -from apm_cli.core.tls_trust import configure_tls_trust +from apm_cli.core.tls_trust import _install_additive_ca_context, configure_tls_trust + + +def _resolve_openssl() -> str | None: + """Resolve OpenSSL, including Git for Windows when PATH is sanitized.""" + executable = shutil.which("openssl") + if executable: + return executable + if os.name != "nt": + return None + + candidates: list[Path] = [] + git_executable = shutil.which("git") + if git_executable: + git_root = Path(git_executable).resolve().parent.parent + candidates.append(git_root / "usr" / "bin" / "openssl.exe") + for variable, suffix in ( + ("ProgramFiles", Path("Git/usr/bin/openssl.exe")), + ("ProgramFiles(x86)", Path("Git/usr/bin/openssl.exe")), + ("LOCALAPPDATA", Path("Programs/Git/usr/bin/openssl.exe")), + ): + root = os.environ.get(variable) + if root: + candidates.append(Path(root) / suffix) + # Managed Windows test processes may omit ProgramFiles from their + # environment even though the standard Git for Windows install exists. + candidates.append(Path("C:/Program Files/Git/usr/bin/openssl.exe")) + + for candidate in candidates: + if candidate.is_file(): + return str(candidate.resolve()) + return None + + +_OPENSSL_EXECUTABLE = _resolve_openssl() pytestmark = [ pytest.mark.integration, - pytest.mark.skipif(shutil.which("openssl") is None, reason="openssl CLI not available"), + pytest.mark.skipif(_OPENSSL_EXECUTABLE is None, reason="openssl CLI not available"), ] _TRUST_ENV_VARS = ( "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "SSL_CERT_FILE", + "SSL_CERT_DIR", "APM_DISABLE_TRUSTSTORE", + "APM_EXTRA_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT", ) _CA_CNF = """\ @@ -70,10 +117,11 @@ def _openssl(*args) -> None: - subprocess.run(["openssl", *[str(a) for a in args]], check=True, capture_output=True) + assert _OPENSSL_EXECUTABLE is not None + subprocess.run([_OPENSSL_EXECUTABLE, *[str(a) for a in args]], check=True, capture_output=True) -def _mint_ca_and_leaf(dirpath): +def _mint_ca_and_leaf(dirpath, ca_common_name: str = "APM Test Root CA"): """Generate a private CA and a localhost leaf cert signed by it.""" ca_key, ca_pem = dirpath / "ca.key", dirpath / "ca.pem" srv_key, srv_csr, srv_pem = ( @@ -82,7 +130,7 @@ def _mint_ca_and_leaf(dirpath): dirpath / "server.pem", ) ca_cnf, srv_cnf = dirpath / "ca.cnf", dirpath / "server.cnf" - ca_cnf.write_text(_CA_CNF) + ca_cnf.write_text(_CA_CNF.replace("APM Test Root CA", ca_common_name)) srv_cnf.write_text(_SERVER_CNF) _openssl( @@ -145,43 +193,65 @@ def log_message(self, *_args): # silence per-request stderr logging pass -@pytest.fixture(scope="module") -def custom_ca_server(tmp_path_factory): - """A loopback HTTPS server presenting a leaf signed by a private CA.""" +@contextlib.contextmanager +def private_ca_https_server( + dirpath: Path, + ca_common_name: str = "APM Test Root CA", + *, + handler: type[http.server.BaseHTTPRequestHandler] = _OkHandler, +) -> Iterator[SimpleNamespace]: + """Run one private-CA loopback server and yield its trust material.""" + dirpath.mkdir(parents=True, exist_ok=True) try: import truststore truststore.extract_from_ssl() except Exception: pass - dirpath = tmp_path_factory.mktemp("tls_custom_ca") - ca_pem, srv_pem, srv_key = _mint_ca_and_leaf(dirpath) + ca_pem, srv_pem, srv_key = _mint_ca_and_leaf(dirpath, ca_common_name) context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - # Pin a modern floor -- the default context still permits TLSv1/TLSv1.1 - # (CodeQL py/insecure-protocol). Loopback test server, but keep it clean. context.minimum_version = ssl.TLSVersion.TLSv1_2 context.load_cert_chain(certfile=str(srv_pem), keyfile=str(srv_key)) - httpd = http.server.HTTPServer(("127.0.0.1", 0), _OkHandler) + httpd = http.server.HTTPServer(("127.0.0.1", 0), handler) httpd.socket = context.wrap_socket(httpd.socket, server_side=True) port = httpd.server_address[1] thread = threading.Thread(target=httpd.serve_forever, daemon=True) thread.start() try: - yield SimpleNamespace(url=f"https://localhost:{port}/", ca_path=str(ca_pem)) + yield SimpleNamespace( + url=f"https://localhost:{port}/", + ca_path=str(ca_pem), + ca_pem=ca_pem, + srv_pem=srv_pem, + srv_key=srv_key, + port=port, + ) finally: httpd.shutdown() httpd.server_close() thread.join(timeout=5) +@pytest.fixture(scope="module") +def custom_ca_server(tmp_path_factory): + """A loopback HTTPS server presenting a leaf signed by a private CA.""" + dirpath = tmp_path_factory.mktemp("tls_custom_ca") + with private_ca_https_server(dirpath) as server: + yield server + + @pytest.fixture(autouse=True) def _isolate_trust(monkeypatch): """Pristine trust env per test, and undo any global ssl/truststore mutation.""" + import urllib3.util.ssl_ as urllib3_ssl + for var in _TRUST_ENV_VARS: monkeypatch.delenv(var, raising=False) original_ssl_context = ssl.SSLContext + original_urllib3_context = urllib3_ssl.SSLContext + original_preloaded_context = getattr(requests.adapters, "_preloaded_ssl_context", None) try: yield finally: @@ -192,6 +262,9 @@ def _isolate_trust(monkeypatch): except Exception: pass ssl.SSLContext = original_ssl_context + urllib3_ssl.SSLContext = original_urllib3_context + if hasattr(requests.adapters, "_preloaded_ssl_context"): + requests.adapters._preloaded_ssl_context = original_preloaded_context def test_untrusted_custom_ca_is_rejected(custom_ca_server): @@ -226,3 +299,281 @@ def test_truststore_injection_keeps_verification_on(custom_ca_server): # injection routes trust to the OS store, it does not disable it. with pytest.raises(requests.exceptions.SSLError): requests.get(custom_ca_server.url, timeout=5) + + +def test_apm_extra_ca_bundle_trusts_private_ca( + custom_ca_server: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """The real parent Requests stack accepts a selected private root.""" + monkeypatch.setenv("APM_EXTRA_CA_BUNDLE", custom_ca_server.ca_path) + # Avoid permanently extending Requests' module-level preloaded context; + # this test exercises the published ssl/urllib3 context class instead. + monkeypatch.setattr(requests.adapters, "_preloaded_ssl_context", None, raising=False) + + assert configure_tls_trust() is True + response = requests.get(custom_ca_server.url, timeout=5) + + assert response.status_code == 200 + assert response.text == "ok" + + +def test_apm_extra_ca_bundle_updates_requests_preloaded_context( + custom_ca_server: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """Requests 2.32's successful preloaded-context path receives the extra CA.""" + original = ssl.create_default_context() + monkeypatch.setattr(requests.adapters, "_preloaded_ssl_context", original, raising=False) + monkeypatch.setenv("APM_EXTRA_CA_BUNDLE", custom_ca_server.ca_path) + + assert configure_tls_trust() is True + published = requests.adapters._preloaded_ssl_context + + assert published is not original + assert published.check_hostname is True + assert published.verify_mode == ssl.CERT_REQUIRED + _assert_tls_handshake(custom_ca_server.port, published) + + +@pytest.mark.windows_compat +def test_invalid_extra_ca_fails_before_real_cli_command( + tmp_path: Path, apm_engine_command: tuple[str, ...] +) -> None: + """A fresh CLI reports one ASCII-safe error before executing the script.""" + project = tmp_path / "invalid-ca-project" + project.mkdir() + sentinel = project / "command-ran.txt" + (project / "should_not_run.py").write_text( + "from pathlib import Path\nPath('command-ran.txt').write_text('ran')\n", + encoding="ascii", + ) + (project / "apm.yml").write_text( + 'name: invalid-ca-probe\nversion: "0.1.0"\nscripts:\n blocked: python should_not_run.py\n', + encoding="ascii", + ) + invalid = project / f"missing-{chr(9731)}.pem" + env = {key: value for key, value in os.environ.items() if key not in _TRUST_ENV_VARS} + env.update( + { + "APM_E2E_TESTS": "1", + "APM_EXTRA_CA_BUNDLE": str(invalid), + "PYTHONPATH": str(Path(__file__).resolve().parents[2] / "src"), + } + ) + + result = subprocess.run( + [*apm_engine_command, "run", "blocked"], + cwd=project, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert result.stderr.count("APM_EXTRA_CA_BUNDLE") == 1 + assert "path does not exist" in result.stderr + assert "readable certificate-only PEM, or unset it" in result.stderr + result.stderr.encode("ascii") + assert not sentinel.exists() + + +@pytest.mark.parametrize("fallback", [False, True], ids=["os-trust", "certifi-fallback"]) +def test_apm_run_does_not_export_additive_trust( + custom_ca_server: SimpleNamespace, + tmp_path: Path, + apm_engine_command: tuple[str, ...], + fallback: bool, +) -> None: + """The real shell child keeps its own trust, including on parent fallback.""" + (tmp_path / "probe.py").write_text( + "import os, requests, sys\n" + "assert not any(os.environ.get(key) for key in " + "('REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'NODE_EXTRA_CA_CERTS'))\n" + "try:\n" + " requests.get(sys.argv[1], timeout=5)\n" + "except requests.exceptions.SSLError:\n" + " print('child rejected private CA')\n" + "else:\n" + " raise AssertionError('APM exported additive trust to its child')\n", + encoding="ascii", + ) + interpreter = Path(sys.executable).as_posix() + (tmp_path / "apm.yml").write_text( + 'name: tls-scope\nversion: "0.1.0"\nscripts:\n probe: >-\n' + f' "{interpreter}" probe.py "{custom_ca_server.url}"\n', + encoding="ascii", + ) + env = {key: value for key, value in os.environ.items() if key not in _TRUST_ENV_VARS} + env.update( + APM_EXTRA_CA_BUNDLE=custom_ca_server.ca_path, + APM_E2E_TESTS="1", + NO_PROXY="localhost,127.0.0.1", + PYTHONPATH=str(Path(__file__).resolve().parents[2] / "src"), + ) + if fallback: + (tmp_path / "sitecustomize.py").write_text( + "import sys\nsys.modules['truststore'] = None\n", encoding="ascii" + ) + env["PYTHONPATH"] = str(tmp_path) + os.pathsep + env["PYTHONPATH"] + result = subprocess.run( + [*apm_engine_command, "run", "probe"], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "child rejected private CA" in result.stdout + + +def test_additive_ca_still_rejects_wrong_server_identity( + custom_ca_server: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + """Trusting the issuer never weakens hostname verification.""" + monkeypatch.setenv("APM_EXTRA_CA_BUNDLE", custom_ca_server.ca_path) + monkeypatch.setattr(requests.adapters, "_preloaded_ssl_context", None, raising=False) + + assert configure_tls_trust() is True + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + with socket.create_connection(("127.0.0.1", custom_ca_server.port), timeout=5) as raw_socket: + with pytest.raises(ssl.SSLCertVerificationError): + context.wrap_socket(raw_socket, server_hostname="wrong.example") + + +@pytest.mark.parametrize("failure", ["import", "publication"]) +def test_additive_ca_survives_real_requests_fallback(tmp_path: Path, failure: str) -> None: + """Both HTTP stacks retain default roots and verification on fallback.""" + with ( + private_ca_https_server(tmp_path / "fallback", "APM Fallback Extra Root") as server, + private_ca_https_server(tmp_path / "baseline", "APM Baseline Root") as baseline, + private_ca_https_server(tmp_path / "untrusted", "APM Untrusted Root") as untrusted, + ): + probe = """ +import json +import os +import socket +import ssl +import sys +import urllib.error +import urllib.request +from urllib.parse import urlparse + +import apm_cli.core.tls_trust as tls + +if sys.argv[2] == "import": + sys.modules["truststore"] = None +else: + original_install = tls._install_additive_ca_context + def fail_after_publication(*args): + original_install(*args) + if args[0] is not tls._STDLIB_SSL_CONTEXT: + raise RuntimeError("forced failure after additive TLS publication") + tls._install_additive_ca_context = fail_after_publication + +configured = tls.configure_tls_trust() +context = ssl.create_default_context() +import requests + +response = requests.get(sys.argv[1], timeout=5) +derived = os.environ.get("REQUESTS_CA_BUNDLE") +with urllib.request.urlopen(sys.argv[1], timeout=5) as extra_response: + extra_status = extra_response.status +with urllib.request.urlopen(sys.argv[3], timeout=5) as baseline_response: + baseline_status = baseline_response.status +try: + urllib.request.urlopen(sys.argv[4], timeout=5) +except urllib.error.URLError as exc: + assert isinstance(exc.reason, ssl.SSLCertVerificationError), exc +else: + raise AssertionError("Untrusted root accepted") +https_context = ssl._create_default_https_context() +port = urlparse(sys.argv[1]).port +with socket.create_connection(("127.0.0.1", port), timeout=5) as connection: + try: + https_context.wrap_socket(connection, server_hostname="wrong.example") + except ssl.SSLCertVerificationError: + pass + else: + raise AssertionError("Wrong hostname accepted") +print(json.dumps({ + "configured": configured, + "ssl_module": type(context).__module__, + "check_hostname": context.check_hostname, + "verify_mode": int(context.verify_mode), + "response_status": response.status_code, + "response_text": response.text, + "derived_bundle": derived, + "stdlib_extra_status": extra_status, + "stdlib_baseline_status": baseline_status, +})) +""" + env = {key: value for key, value in os.environ.items() if key not in _TRUST_ENV_VARS} + env["APM_EXTRA_CA_BUNDLE"] = server.ca_path + env["SSL_CERT_FILE"] = baseline.ca_path + env["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src") + result = subprocess.run( + [sys.executable, "-c", probe, server.url, failure, baseline.url, untrusted.url], + cwd=Path(__file__).resolve().parents[2], + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + evidence = json.loads(result.stdout.splitlines()[-1]) + assert evidence == { + "configured": False, + "ssl_module": "ssl", + "check_hostname": True, + "verify_mode": int(ssl.CERT_REQUIRED), + "response_status": 200, + "response_text": "ok", + "derived_bundle": None, + "stdlib_extra_status": 200, + "stdlib_baseline_status": 200, + } + + +def _assert_tls_handshake(port: int, context: ssl.SSLContext) -> None: + with socket.create_connection(("127.0.0.1", port), timeout=5) as raw_socket: + with context.wrap_socket(raw_socket, server_hostname="localhost"): + pass + + +def test_additive_context_retains_independent_existing_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Two independent synthetic roots prove the extra CA does not replace trust.""" + import urllib3.util.ssl_ as urllib3_ssl + + with ( + private_ca_https_server(tmp_path / "baseline", "APM Synthetic Baseline Root") as baseline, + private_ca_https_server(tmp_path / "extra", "APM Synthetic Extra Root") as extra, + ): + stdlib_context = ssl.SSLContext + + class SyntheticDefaultContext(stdlib_context): + def __init__(self, protocol: int | None = None) -> None: + # SSLContext configures PROTOCOL_TLS_CLIENT in __new__; loading + # this root here models the trust that existed before #2034. + self.load_verify_locations(cafile=baseline.ca_path) + + # Register restoration before the helper publishes its context class. + monkeypatch.setattr(ssl, "SSLContext", stdlib_context) + monkeypatch.setattr(urllib3_ssl, "SSLContext", urllib3_ssl.SSLContext) + monkeypatch.setattr(requests.adapters, "_preloaded_ssl_context", None, raising=False) + + _install_additive_ca_context( + SyntheticDefaultContext, Path(extra.ca_path).read_text(encoding="ascii") + ) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # Stdlib property setters resolve SSLContext through the module global. + # Restore that name after constructing the additive synthetic context. + monkeypatch.setattr(ssl, "SSLContext", stdlib_context) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + + _assert_tls_handshake(baseline.port, context) + _assert_tls_handshake(extra.port, context) diff --git a/tests/integration/test_tls_frozen_hook.py b/tests/integration/test_tls_frozen_hook.py index 3b12d05858..3867656391 100644 --- a/tests/integration/test_tls_frozen_hook.py +++ b/tests/integration/test_tls_frozen_hook.py @@ -241,13 +241,14 @@ def test_bundled_default_neutralized_system_store_wins(tmp_path): @_requires_truststore @_requires_openssl +@pytest.mark.windows_compat def test_genuine_user_override_still_honored(tmp_path): """B2 Test B: a genuine user CA override (no marker) is honored -> request succeeds. Same private-CA bundle, delivered as a real user value WITHOUT the bundled-default marker. Only OUR bundled default is ever neutralized; user intent must survive. Delivered via the channel the platform's truststore - backend honors (REQUESTS_CA_BUNDLE on macOS, SSL_CERT_FILE on Linux). Run in + backend honors (SSL_CERT_FILE on Linux, REQUESTS_CA_BUNDLE elsewhere). Run in a child process to avoid global-state bleed. """ from ._tls_ca_server import private_ca_https_server @@ -255,10 +256,10 @@ def test_genuine_user_override_still_honored(tmp_path): with private_ca_https_server(tmp_path) as server: bundle = _bundle_with_private_ca(tmp_path, server.ca_pem) env = _clean_child_env() - if sys.platform == "darwin": - env["REQUESTS_CA_BUNDLE"] = str(bundle) - else: + if sys.platform == "linux": env["SSL_CERT_FILE"] = str(bundle) + else: + env["REQUESTS_CA_BUNDLE"] = str(bundle) result = subprocess.run( [sys.executable, "-c", _B2_CHILD, server.url], diff --git a/tests/integration/test_tls_install_custom_ca.py b/tests/integration/test_tls_install_custom_ca.py new file mode 100644 index 0000000000..148c6f3d49 --- /dev/null +++ b/tests/integration/test_tls_install_custom_ca.py @@ -0,0 +1,164 @@ +"""Real CLI installs over private-CA HTTPS with independent default-root proof. + +Only the test process's certifi default is seeded with a synthetic root. The +CLI, truststore, Requests, registry downloader, extraction, and integration are +real. The isolated environment permits only the two loopback servers. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import zipfile +from http.server import BaseHTTPRequestHandler +from pathlib import Path +from urllib.parse import urlparse + +import pytest + +from apm_cli.utils.yaml_io import dump_yaml +from tests.utils.apm_lifecycle_runner import ApmLifecycleRunner +from tests.utils.isolated_apm_environment import IsolatedApmEnvironment + +from .test_tls_custom_ca import _OPENSSL_EXECUTABLE, _TRUST_ENV_VARS, private_ca_https_server + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.integration, + pytest.mark.lifecycle_smoke, + pytest.mark.skipif(_OPENSSL_EXECUTABLE is None, reason="openssl CLI not available"), +] + +_GUIDE = "---\napplyTo: '**'\ndescription: TLS install fixture\n---\n# Corporate package\n" + + +@pytest.mark.parametrize("fallback", [False, True], ids=["os-trust", "certifi-fallback"]) +def test_apm_install_trusts_private_ca_and_retains_default_root( + tmp_path: Path, apm_engine_command: tuple[str, ...], fallback: bool +) -> None: + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as package: + package.writestr( + "apm.yml", + "name: testrepo\nversion: 1.0.0\ndescription: TLS install fixture\n", + ) + package.writestr(".apm/instructions/guide.instructions.md", _GUIDE) + archive_bytes = archive.getvalue() + versions = json.dumps( + { + "versions": [ + { + "version": "1.0.0", + "digest": hashlib.sha256(archive_bytes).hexdigest(), + "published_at": "2026-01-01T00:00:00Z", + } + ] + } + ).encode() + requests_seen = [] + + class ArchiveHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + path = urlparse(self.path).path + if path == "/v1/packages/fixture/testrepo/versions": + body, content_type = versions, "application/json" + elif path == "/v1/packages/fixture/testrepo/versions/1.0.0/download": + body, content_type = archive_bytes, "application/zip" + else: + self.send_error(404) + return + requests_seen.append(self.server.server_port) + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args: object) -> None: + pass + + with ( + private_ca_https_server( + tmp_path / "baseline", "Existing Root", handler=ArchiveHandler + ) as baseline, + private_ca_https_server( + tmp_path / "extra", "Corporate Root", handler=ArchiveHandler + ) as extra, + ): + base_env = {key: value for key, value in os.environ.items() if key not in _TRUST_ENV_VARS} + isolated = IsolatedApmEnvironment.create(tmp_path / "isolated", base_env=base_env) + env = isolated.subprocess_env( + overrides={ + "APM_E2E_TESTS": "1", + "APM_TEST_DEFAULT_CA": baseline.ca_path, + } + ) + env["APM_TEST_LOOPBACK_PORTS"] = f"{baseline.port},{extra.port}" + guard = Path(env["PYTHONPATH"]) / "sitecustomize.py" + # Model an existing default root without changing the machine trust + # store or supplying a Requests replacement override to APM. + with guard.open("a", encoding="utf-8") as stream: + stream.write( + "\nimport certifi\ncertifi.where = lambda: os.environ['APM_TEST_DEFAULT_CA']\n" + ) + if fallback: + stream.write("\nimport sys\nsys.modules['truststore'] = None\n") + runner = ApmLifecycleRunner(apm_engine_command, timeout_seconds=45) + enabled = runner.run( + ("experimental", "enable", "registries"), cwd=isolated.work_root, env=env + ) + assert enabled.returncode == 0, enabled.stdout + enabled.stderr + cases = ( + ("baseline-control", baseline, {}, True), + ("private-untrusted", extra, {}, False), + ("private-additive", extra, {"APM_EXTRA_CA_BUNDLE": extra.ca_path}, True), + ("baseline-retained", baseline, {"APM_EXTRA_CA_BUNDLE": extra.ca_path}, True), + ("replacement-control", baseline, {"REQUESTS_CA_BUNDLE": extra.ca_path}, False), + ) + for name, server, trust_env, succeeds in cases: + project = isolated.work_root / name + project.mkdir() + dump_yaml( + { + "name": "tls-consumer", + "version": "1.0.0", + "description": "TLS acceptance fixture", + "registries": { + "fixture": {"url": f"https://127.0.0.1:{server.port}"}, + "default": "fixture", + }, + "dependencies": {"apm": ["fixture/testrepo#1.0.0"]}, + }, + project / "apm.yml", + ) + child_env = { + **env, + **trust_env, + } + count_before = len(requests_seen) + result = runner.run( + ("install", "--target", "copilot"), + scenario_id=name, + cwd=project, + env=child_env, + ) + diagnostics = result.stdout + result.stderr + installed = ( + project / "apm_modules/fixture/testrepo/.apm/instructions/guide.instructions.md" + ) + if succeeds: + assert result.returncode == 0, diagnostics + assert installed.read_text(encoding="utf-8") == _GUIDE + assert (project / "apm.lock.yaml").is_file() + assert requests_seen[count_before:] == [server.port, server.port] + assert any( + path.read_text(encoding="utf-8").endswith("# Corporate package\n") + for path in (project / ".github/instructions").glob("*.md") + ) + else: + assert result.returncode != 0, diagnostics + assert "SSLCertVerificationError" in diagnostics, diagnostics + assert not installed.exists() + assert len(requests_seen) == count_before diff --git a/tests/integration/test_tls_r3_verify.py b/tests/integration/test_tls_r3_verify.py index b0372e4d0e..57eed8e7d9 100644 --- a/tests/integration/test_tls_r3_verify.py +++ b/tests/integration/test_tls_r3_verify.py @@ -370,19 +370,20 @@ def test_child_bootstrap_leaves_pip_vendored_truststore_in_control( assert injections == [] -def test_v4_best_effort_control_flow_does_not_abort(tmp_path): +@pytest.mark.windows_compat +def test_v4_best_effort_control_flow_does_not_abort() -> None: # Dynamically prove the bash guard: a failing `pip` under `set -euo # pipefail` guarded by `|| log_warning` still exits 0 and runs later steps. - script = tmp_path / "probe.sh" - script.write_text( + script = ( "set -euo pipefail\n" 'log_warning() { echo "[!] $*"; }\n' "pip() { echo 'simulated failure' >&2; return 1; }\n" "pip install 'truststore>=0.10.0' || log_warning 'truststore install failed'\n" - "echo CONTINUED\n", - encoding="ascii", + "echo CONTINUED\n" + ) + result = subprocess.run( + [shutil.which("bash") or "bash"], input=script, capture_output=True, text=True, check=False ) - result = subprocess.run(["bash", str(script)], capture_output=True, text=True, check=False) assert result.returncode == 0, result.stderr assert "CONTINUED" in result.stdout diff --git a/tests/integration/test_wave6_validation_uninstall_coverage.py b/tests/integration/test_wave6_validation_uninstall_coverage.py index 5e35bac617..0455528656 100644 --- a/tests/integration/test_wave6_validation_uninstall_coverage.py +++ b/tests/integration/test_wave6_validation_uninstall_coverage.py @@ -75,7 +75,11 @@ def test_log_tls_failure_default_verbosity(self) -> None: logger = MagicMock() _log_tls_failure("github.com", RuntimeError("ssl err"), None, logger) logger.warning.assert_called_once() - assert "TLS" in logger.warning.call_args[0][0] + guidance = logger.warning.call_args[0][0] + assert "TLS" in guidance + assert "APM_EXTRA_CA_BUNDLE" in guidance + assert "retaining public trust" in guidance + assert "REQUESTS_CA_BUNDLE only to replace" in guidance def test_log_tls_failure_verbose(self) -> None: from apm_cli.install.validation import _log_tls_failure diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py index 9a2d32f677..59ef6a989b 100644 --- a/tests/unit/core/test_tls_trust.py +++ b/tests/unit/core/test_tls_trust.py @@ -14,6 +14,7 @@ import ast import logging import os +import ssl import subprocess import sys import types @@ -25,6 +26,9 @@ _BUNDLED_CERT_MARKER, _DISABLE_ENV_VAR, _EXPLICIT_CA_ENV_VARS, + _EXTRA_CA_ENV_VAR, + _MAX_EXTRA_CA_BUNDLE_BYTES, + TLSConfigurationError, build_child_tls_env, configure_tls_trust, ensure_child_tls_bootstrap, @@ -33,7 +37,12 @@ ) _NON_REQUESTS_CA_ENV_VARS = ("SSL_CERT_FILE", "SSL_CERT_DIR") -_ALL_TRUST_ENV = (_DISABLE_ENV_VAR, *_NON_REQUESTS_CA_ENV_VARS, *_EXPLICIT_CA_ENV_VARS) +_ALL_TRUST_ENV = ( + _DISABLE_ENV_VAR, + *_NON_REQUESTS_CA_ENV_VARS, + *_EXPLICIT_CA_ENV_VARS, + _EXTRA_CA_ENV_VAR, +) @pytest.fixture(autouse=True) @@ -43,7 +52,7 @@ def _clean_env(monkeypatch): monkeypatch.delenv(var, raising=False) -def _install_fake_truststore(monkeypatch, inject=None): +def _install_fake_truststore(monkeypatch, inject=None, ssl_context=None): """Put a fake ``truststore`` module in sys.modules and return its inject mock.""" calls = {"n": 0} @@ -52,6 +61,7 @@ def _default_inject(): module = types.ModuleType("truststore") module.inject_into_ssl = inject or _default_inject # type: ignore[attr-defined] + module.SSLContext = ssl_context or object # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "truststore", module) return calls @@ -101,6 +111,89 @@ def _boom(): assert configure_tls_trust() is False +def test_post_injection_additive_failure_rolls_back_all_globals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A late additive failure cannot leave a partially injected process.""" + import certifi + import requests.adapters + import urllib3.util.ssl_ as urllib3_ssl + + import apm_cli.core.tls_trust as tls + + original_ssl = ssl.SSLContext + original_https_factory = ssl._create_default_https_context + original_urllib3 = urllib3_ssl.SSLContext + preloaded_was_present = hasattr(requests.adapters, "_preloaded_ssl_context") + original_preloaded = getattr(requests.adapters, "_preloaded_ssl_context", None) + + class PartiallyPublishedContext: + pass + + module = types.ModuleType("truststore") + module.SSLContext = PartiallyPublishedContext # type: ignore[attr-defined] + + def _partial_inject() -> None: + ssl.SSLContext = PartiallyPublishedContext # type: ignore[misc] + ssl._create_default_https_context = lambda: object() + urllib3_ssl.SSLContext = PartiallyPublishedContext # type: ignore[assignment] + requests.adapters._preloaded_ssl_context = object() + + module.inject_into_ssl = _partial_inject # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "truststore", module) + + def _late_failure(_base_context: type, _bundle_pem: str) -> None: + raise TLSConfigurationError("forced post-injection failure") + + monkeypatch.setattr(tls, "_install_additive_ca_context", _late_failure) + env = {_EXTRA_CA_ENV_VAR: certifi.where()} + + with pytest.raises(TLSConfigurationError, match="Could not apply"): + configure_tls_trust(env=env) + assert ssl.SSLContext is original_ssl + assert ssl._create_default_https_context is original_https_factory + assert urllib3_ssl.SSLContext is original_urllib3 + assert hasattr(requests.adapters, "_preloaded_ssl_context") is preloaded_was_present + assert getattr(requests.adapters, "_preloaded_ssl_context", None) is original_preloaded + assert env == {_EXTRA_CA_ENV_VAR: certifi.where()} + + +def test_failed_injection_rebuilds_new_requests_232_preloaded_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A Requests module first imported mid-injection retains its certifi roots.""" + fake_adapters = types.ModuleType("requests.adapters") + + class RestoredContext: + def __init__(self) -> None: + self.loaded_paths: list[str] = [] + + def load_verify_locations(self, path: str) -> None: + self.loaded_paths.append(path) + + fake_adapters._preloaded_ssl_context = object() # type: ignore[attr-defined] + fake_adapters.DEFAULT_CA_BUNDLE_PATH = "/bundled/certifi.pem" # type: ignore[attr-defined] + fake_adapters.extract_zipped_paths = lambda path: path # type: ignore[attr-defined] + fake_adapters.create_urllib3_context = RestoredContext # type: ignore[attr-defined] + + monkeypatch.delitem(sys.modules, "requests.adapters", raising=False) + + module = types.ModuleType("truststore") + module.SSLContext = object # type: ignore[attr-defined] + + def _partial_inject_then_fail() -> None: + sys.modules["requests.adapters"] = fake_adapters + raise RuntimeError("forced partial injection") + + module.inject_into_ssl = _partial_inject_then_fail # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "truststore", module) + + assert configure_tls_trust() is False + restored = fake_adapters._preloaded_ssl_context # type: ignore[attr-defined] + assert isinstance(restored, RestoredContext) + assert restored.loaded_paths == ["/bundled/certifi.pem"] + + def test_happy_path_injects_once(monkeypatch): calls = _install_fake_truststore(monkeypatch) @@ -108,6 +201,117 @@ def test_happy_path_injects_once(monkeypatch): assert calls["n"] == 1 +def test_valid_additive_bundle_extends_injected_parent_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A valid extra bundle is applied on top of the injected OS context.""" + import certifi + + import apm_cli.core.tls_trust as tls + + class FakeOSContext: + pass + + calls = _install_fake_truststore(monkeypatch, ssl_context=FakeOSContext) + installed: dict[str, object] = {} + + def _capture_install(base_context: type, bundle_pem: str) -> None: + installed["base_context"] = base_context + installed["bundle_pem"] = bundle_pem + + monkeypatch.setattr(tls, "_install_additive_ca_context", _capture_install) + + assert configure_tls_trust(env={_EXTRA_CA_ENV_VAR: certifi.where()}) is True + assert calls["n"] == 1 + assert installed["base_context"] is FakeOSContext + assert "-----BEGIN CERTIFICATE-----" in str(installed["bundle_pem"]) + + +@pytest.mark.parametrize( + "precedence", + [ + {_DISABLE_ENV_VAR: "1"}, + {"REQUESTS_CA_BUNDLE": "/replacement/requests.pem"}, + {"CURL_CA_BUNDLE": "/replacement/curl.pem"}, + ], + ids=["disabled", "requests", "curl"], +) +def test_higher_precedence_controls_skip_invalid_additive_bundle( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, precedence: dict[str, str] +) -> None: + """Disable/replacement settings win before extra-path validation or mapping.""" + missing = tmp_path / "must-not-be-read.pem" + env = {_EXTRA_CA_ENV_VAR: str(missing), **precedence} + calls = _install_fake_truststore(monkeypatch) + + assert configure_tls_trust(env=env) is False + assert calls["n"] == 0 + + +def test_explicit_requests_bundle_remains_authoritative_with_disable( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """The opt-out suppresses injection; it never unsets a replacement bundle.""" + replacement = str(tmp_path / "replacement.pem") + env = { + _DISABLE_ENV_VAR: "1", + "REQUESTS_CA_BUNDLE": replacement, + _EXTRA_CA_ENV_VAR: str(tmp_path / "must-not-be-read.pem"), + } + + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + assert configure_tls_trust(env=env) is False + + assert any("explicit CA bundle in use" in message for message in _trust_source_messages(caplog)) + + +def _invalid_extra_ca_path(tmp_path: Path, case: str) -> Path: + candidate = tmp_path / f"{case}.pem" + if case == "missing": + return candidate + if case == "empty": + candidate.touch() + elif case == "directory": + candidate.mkdir() + elif case == "malformed": + candidate.write_text("this is not a PEM certificate\n", encoding="ascii") + elif case == "non-ascii": + candidate.write_bytes(b"\xff\xfe\xfd") + elif case == "oversized": + # Seek makes this sparse where supported; only the bounded-size check + # matters, so the test need not allocate an 8 MiB in-memory payload. + with candidate.open("wb") as handle: + handle.seek(_MAX_EXTRA_CA_BUNDLE_BYTES) + handle.write(b"x") + elif case == "private-key": + import certifi + + private_key_label = b"PRIVATE " + b"KEY" + candidate.write_bytes( + Path(certifi.where()).read_bytes() + + b"\n-----BEGIN " + + private_key_label + + b"-----\nAA==\n-----END " + + private_key_label + + b"-----\n" + ) + else: # pragma: no cover - parametrization is the closed set + raise AssertionError(case) + return candidate + + +@pytest.mark.parametrize( + "case", + ["missing", "empty", "directory", "malformed", "non-ascii", "oversized", "private-key"], +) +def test_invalid_additive_bundle_fails_before_injection(tmp_path: Path, case: str) -> None: + selected = _invalid_extra_ca_path(tmp_path, case) + env = {_EXTRA_CA_ENV_VAR: str(selected)} + + with pytest.raises(TLSConfigurationError): + configure_tls_trust(env=env) + + def _repo_root() -> Path: current = Path(__file__).resolve().parent for parent in (current, *current.parents): @@ -137,13 +341,7 @@ def test_cli_bootstrap_injects_before_requests_import(tmp_path): ) env = os.environ.copy() - for name in ( - _DISABLE_ENV_VAR, - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - ): + for name in _ALL_TRUST_ENV: env.pop(name, None) env["PYTHONPATH"] = f"{tmp_path}{os.pathsep}{_repo_root() / 'src'}" env["TRUSTSTORE_SENTINEL"] = str(sentinel) @@ -180,13 +378,7 @@ def test_cli_bootstrap_is_idempotent_across_import_and_main(tmp_path): ) env = os.environ.copy() - for name in ( - _DISABLE_ENV_VAR, - "REQUESTS_CA_BUNDLE", - "CURL_CA_BUNDLE", - "SSL_CERT_FILE", - "SSL_CERT_DIR", - ): + for name in _ALL_TRUST_ENV: env.pop(name, None) env["PYTHONPATH"] = f"{tmp_path}{os.pathsep}{_repo_root() / 'src'}" env["TRUSTSTORE_SENTINEL"] = str(sentinel) @@ -250,13 +442,14 @@ def test_diag_disabled_names_opt_out(caplog): message.encode("ascii") -def test_diag_explicit_bundle_names_the_path(caplog): - ca_path = "/etc/ssl/certs/corp-root.pem" +def test_diag_explicit_bundle_names_the_path(tmp_path, caplog): + ca_path = str(tmp_path / "corp-root.pem") with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): assert configure_tls_trust(env={"REQUESTS_CA_BUNDLE": ca_path}) is False messages = _trust_source_messages(caplog) - assert f"TLS: explicit CA bundle in use: {ca_path}" in messages + display = ascii(str(Path(ca_path)))[1:-1] + assert f"TLS: explicit CA bundle in use: {display}" in messages for message in messages: message.encode("ascii") @@ -344,8 +537,7 @@ def test_marker_cleared_on_inject_success(monkeypatch): # --------------------------------------------------------------------------- -# build_child_tls_env is now an env-hygiene pass: it strips the bundled-default -# marker and does NOT mutate PYTHONPATH (no more sitecustomize shim hijack). +# build_child_tls_env retains its existing marker and PYTHONPATH hygiene. # --------------------------------------------------------------------------- diff --git a/tests/unit/test_lifecycle_executor_paths.py b/tests/unit/test_lifecycle_executor_paths.py index 6312d66412..cfecbee1db 100644 --- a/tests/unit/test_lifecycle_executor_paths.py +++ b/tests/unit/test_lifecycle_executor_paths.py @@ -251,6 +251,44 @@ def test_get_guarded_session_none_on_build_failure(self, reset_guarded_session) assert se._get_guarded_session() is None +@pytest.mark.parametrize("builder_name", ["_build_guarded_session", "_build_capturing_session"]) +@pytest.mark.parametrize("env_var", ["REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]) +def test_hardened_sessions_honor_explicit_ca_bundle_with_trust_env_disabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + builder_name: str, + env_var: str, +) -> None: + """Lifecycle direct/proxy sessions retain explicit replacement trust.""" + selected = tmp_path / f"{env_var.lower()}.pem" + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.delenv("CURL_CA_BUNDLE", raising=False) + monkeypatch.setenv(env_var, str(selected)) + + session = getattr(se, builder_name)() + settings = session.merge_environment_settings( + "https://example.com/hook", proxies={}, stream=None, verify=None, cert=None + ) + + assert session.trust_env is False + assert settings["verify"] == str(selected) + + +@pytest.mark.parametrize("builder_name", ["_build_guarded_session", "_build_capturing_session"]) +def test_hardened_sessions_prefer_requests_ca_bundle( + monkeypatch: pytest.MonkeyPatch, builder_name: str +) -> None: + monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/trust/requests.pem") + monkeypatch.setenv("CURL_CA_BUNDLE", "/trust/curl.pem") + + session = getattr(se, builder_name)() + settings = session.merge_environment_settings( + "https://example.com/hook", proxies={}, stream=None, verify=None, cert=None + ) + + assert settings["verify"] == "/trust/requests.pem" + + class TestConnectLayerHost: def test_plain_url_host(self) -> None: assert se._connect_layer_host("https://example.com/path") == "example.com" diff --git a/tests/unit/test_tls_docs_scope.py b/tests/unit/test_tls_docs_scope.py index 5b74509106..49df466237 100644 --- a/tests/unit/test_tls_docs_scope.py +++ b/tests/unit/test_tls_docs_scope.py @@ -1,10 +1,7 @@ -"""T3: the #2005 docs/CHANGELOG must scope OS-trust honestly. +"""Documentation drift guards for the #2005 and #2034 TLS trust contracts. -Round-1 shipped copy claiming ``apm run`` child runtimes (incl. ``codex``) -re-run the OS-trust bootstrap. That was a field no-op for the ``llm`` venv and -never true for the Node/Rust runtimes. These tests are the silent-drift guard -that keeps the prose scoped to what actually ships: ``apm install`` plus the -Python ``llm`` runtime, with Node (Copilot) / Rust (Codex) tracked in #2034. +The additive bundle applies to package-management HTTPS. Execution children +retain their existing trust configuration. """ from __future__ import annotations @@ -44,11 +41,10 @@ def test_ssl_docs_scope_and_known_limitations(): ).read_text(encoding="utf-8") assert "### Known limitations" in docs, "ssl-issues.md must have a Known limitations section" - assert "#2034" in docs, "ssl-issues.md must reference the Node/Rust follow-up (#2034)" - # Node (Copilot) / Rust (Codex) must be described as NOT covered. - assert "not yet covered" in docs - # The stale round-1 claim that codex re-runs the bootstrap must be gone. - assert "the `llm` and `codex` CLIs) re-run the same OS-trust bootstrap" not in docs + assert "APM_EXTRA_CA_BUNDLE" in docs + assert "NODE_EXTRA_CA_CERTS" in docs + assert "Rust-based Codex" in docs + assert "runtime-owned trust configuration" in docs def test_changelog_names_tls_precedence_controls(): @@ -60,21 +56,23 @@ def test_changelog_names_tls_precedence_controls(): assert "`CURL_CA_BUNDLE`" in entry -def test_ssl_docs_node_caveat_appears_early(): +def test_ssl_docs_runtime_scope_appears_early() -> None: docs = ( _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" ).read_text(encoding="utf-8") heading = "## Default behaviour: the OS trust store" start = docs.index(heading) - known_limits = docs.index("### Known limitations") - # The Node/Codex caveat must surface EARLY -- inside the Default behaviour - # section, well before the Known limitations block far below. - caveat = docs.index("Scope caveat", start) - assert caveat < known_limits, "Node/Codex caveat must appear before Known limitations" - # And it must offer the workaround users can apply today. - caveat_region = docs[start:known_limits] - assert "NODE_EXTRA_CA_CERTS" in caveat_region + configure = docs.index("## Configure trust") + # Runtime coverage must be visible in the default-behaviour explanation, + # before users reach configuration recipes. + scope = docs.index("### Runtime coverage", start) + assert scope < configure + scope_region = docs[scope:configure] + assert "Node/Copilot child" in scope_region + assert "NODE_EXTRA_CA_CERTS" in scope_region + assert "Rust/Codex" in scope_region + assert "runtime's own trust settings" in scope_region def test_ssl_docs_pip_cert_and_replaces_notes(): @@ -90,13 +88,16 @@ def test_ssl_docs_pip_cert_and_replaces_notes(): assert "stale `REQUESTS_CA_BUNDLE`" in docs -def test_ssl_docs_keep_planned_configuration_generic(): +def test_ssl_docs_describe_additive_validation_and_precedence() -> None: docs = ( _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" ).read_text(encoding="utf-8") - assert "APM_EXTRA_CA_BUNDLE" not in docs - assert docs.count("#2034") == 1 + assert "APM_EXTRA_CA_BUNDLE" in docs + assert "retains native OS roots" in docs + assert "no larger than 8 MiB" in docs + assert "invalid selected bundle fails closed" in docs + assert "`REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `APM_DISABLE_TRUSTSTORE`, " in docs def test_enterprise_security_docs_transport_trust_model(): @@ -108,6 +109,8 @@ def test_enterprise_security_docs_transport_trust_model(): assert "APM_DISABLE_TRUSTSTORE" in security assert "REQUESTS_CA_BUNDLE" in security assert "CURL_CA_BUNDLE" in security + assert "APM_EXTRA_CA_BUNDLE" in security + assert "does not extend that bootstrap" in security assert ".pth" in security assert "Node" in security assert "Rust" in security @@ -130,12 +133,22 @@ def test_enterprise_security_docs_do_not_claim_transport_aware_policy(): assert "Forbid `allow_insecure: true` via the policy allow list" not in normalized -def test_ssl_docs_verify_apm_path_and_mark_planned_scope(): +def test_ssl_docs_verify_apm_path_and_shipped_scope() -> None: docs = ( _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" ).read_text(encoding="utf-8") - assert ":::note[Planned]" in docs + assert "export APM_EXTRA_CA_BUNDLE=/path/to/corporate-ca.pem" in docs + assert "export NODE_EXTRA_CA_CERTS=/path/to/node-ca-bundle.pem" in docs assert 'python -c "import requests' not in docs assert "APM_LOG_LEVEL=DEBUG apm install" in docs assert "schannel" not in docs.lower() + + +def test_changelog_scopes_additive_bundle_to_package_management() -> None: + changelog = (_repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + entry = _changelog_entry(changelog, "#2741") + assert "`APM_EXTRA_CA_BUNDLE`" in entry + assert "package-management HTTPS" in entry + assert "retaining default trust roots" in entry + assert "Node" not in entry