diff --git a/CHANGELOG.md b/CHANGELOG.md index a2530ba47..a0567905b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Corporate proxy and internal-CA users can now use Python-based APM HTTPS paths + without per-shell TLS setup. APM verifies against the OS trust store through + `truststore` for `apm install`, the Python `llm` child runtime, and the frozen + binary, with `certifi` as fallback. `REQUESTS_CA_BUNDLE` / + `CURL_CA_BUNDLE` still wins, and `APM_DISABLE_TRUSTSTORE=1` restores + certifi-only behavior. Node (Copilot) and Rust (Codex) children are not yet covered + and retain their own trust configuration; tracked in #2034. + (closes #2004) (#2005) + ## [0.25.0] - 2026-07-12 ### Added diff --git a/NOTICE b/NOTICE index c3e856c38..17762ac1e 100644 --- a/NOTICE +++ b/NOTICE @@ -334,6 +334,43 @@ Copyright 2019 Kenneth Reitz --- +## Component. truststore + +- Version requirement: `>=0.10.0` +- Upstream: https://github.com/sethmlarson/truststore +- SPDX: `MIT` +- Notes: Verifies HTTPS against the operating-system trust store by default so `apm` works behind a corporate CA / TLS-inspecting proxy. + +### Open Source License/Copyright Notice. + +_Copyright (c) 2022 Seth Michael Larson_ + +``` +The MIT License (MIT) + +Copyright (c) 2022 Seth Michael Larson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +--- + ## Component. python-frontmatter - Version requirement: `>=1.0.0` diff --git a/build/apm.spec b/build/apm.spec index 10624330e..82c0e582e 100644 --- a/build/apm.spec +++ b/build/apm.spec @@ -88,6 +88,13 @@ entry_point = repo_root / 'src' / 'apm_cli' / 'cli.py' datas = [ (str(repo_root / 'scripts' / 'runtime'), 'scripts/runtime'), # Bundle runtime setup scripts (str(repo_root / 'pyproject.toml'), '.'), # Bundle pyproject.toml for version reading + # Child-runtime TLS trust bootstrap: ships at apm_cli/core/_child_tls/ so + # ensure_child_tls_bootstrap() can copy the self-contained .pth bootstrap + # into a child runtime venv's site-packages from the frozen binary. + (str(repo_root / 'src' / 'apm_cli' / 'core' / '_child_tls' / '_apm_tls_bootstrap.py'), + 'apm_cli/core/_child_tls'), + (str(repo_root / 'src' / 'apm_cli' / 'core' / '_child_tls' / '_apm_tls.pth'), + 'apm_cli/core/_child_tls'), ] # Bundle platform-appropriate token helper @@ -188,6 +195,7 @@ hiddenimports = [ 'frontmatter', 'requests', 'certifi', # CA certificate bundle for SSL verification in frozen binary + 'truststore', # OS trust-store verification (corporate CA / TLS proxy support) # Rich modules (lazily imported, must be explicitly included) 'rich', 'rich.console', diff --git a/build/hooks/runtime_hook_ssl_certs.py b/build/hooks/runtime_hook_ssl_certs.py index 4b6462d2f..afc505549 100644 --- a/build/hooks/runtime_hook_ssl_certs.py +++ b/build/hooks/runtime_hook_ssl_certs.py @@ -32,6 +32,11 @@ def _configure_ssl_certs() -> None: ca_bundle = certifi.where() if os.path.isfile(ca_bundle): os.environ["SSL_CERT_FILE"] = ca_bundle + # Marker: record that WE set this default (vs a genuine user + # SSL_CERT_FILE). apm_cli.core.tls_trust reads this to know it may + # pop SSL_CERT_FILE before truststore injection so the OS store is + # used, restoring it only if injection fails (certifi fallback). + os.environ["APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT"] = "1" except Exception: # certifi unavailable or broken -- fall through to system defaults. pass diff --git a/docs/src/content/docs/enterprise/registry-proxy.md b/docs/src/content/docs/enterprise/registry-proxy.md index 164ac178f..f495179f3 100644 --- a/docs/src/content/docs/enterprise/registry-proxy.md +++ b/docs/src/content/docs/enterprise/registry-proxy.md @@ -278,6 +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/) | | `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 | @@ -293,5 +294,6 @@ connected host with `apm pack` and restore offline. See - [Authentication](../consumer/authentication/) -- token resolution order - [Private and org packages](../consumer/private-and-org-packages/) -- per-host PAT scoping +- [SSL / TLS issues](../troubleshooting/ssl-issues/) -- corporate CA and TLS proxy trust - [Pack and distribute](../producer/pack-a-bundle/) -- air-gapped bundle delivery - [Governance deep-dive](./governance-guide/) -- policy-cache offline story diff --git a/docs/src/content/docs/enterprise/security.md b/docs/src/content/docs/enterprise/security.md index e513434ba..2dfb9495c 100644 --- a/docs/src/content/docs/enterprise/security.md +++ b/docs/src/content/docs/enterprise/security.md @@ -39,6 +39,17 @@ APM has no runtime footprint. Once `apm install` or `apm compile` completes, the - **No persistent background processes.** APM does not install daemons, services, or scheduled tasks. - **No telemetry or data collection.** APM collects no usage data, analytics, or diagnostics. Nothing is transmitted to Microsoft or any third party. +## HTTPS transport trust + +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. + +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. + ## Dependency provenance APM resolves dependencies directly from git repositories. There is no intermediary registry, proxy, or mirror. diff --git a/docs/src/content/docs/reference/environment-variables.md b/docs/src/content/docs/reference/environment-variables.md index 7820dd951..c4ad9444b 100644 --- a/docs/src/content/docs/reference/environment-variables.md +++ b/docs/src/content/docs/reference/environment-variables.md @@ -40,6 +40,16 @@ Controls how APM clones packages from Git hosts. These settings can also be pers | `APM_GIT_PROTOCOL` | Preferred clone protocol for shorthand (`owner/repo`) dependencies. Accepted values: `ssh`, `https`. | unset | Equivalent to `--ssh` / `--https` flag. Resolution: CLI flag → env var → `prefer-ssh` key in `~/.apm/config.json` → git `insteadOf` rules → HTTPS. | | `APM_ALLOW_PROTOCOL_FALLBACK` | Set to `1` (or `true`/`yes`/`on`) to enable the legacy cross-protocol fallback chain. When enabled, a failed clone is retried with the opposite protocol. | unset | Equivalent to `--allow-protocol-fallback`. Resolution: CLI flag → env var → `allow-protocol-fallback` key in `~/.apm/config.json` → `false`. | +## 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/). + +| 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. | + ## Registry (MCP and proxy) | Variable | Purpose | Default | Notes | diff --git a/docs/src/content/docs/troubleshooting/common-errors.md b/docs/src/content/docs/troubleshooting/common-errors.md index f43335b74..e2d3ecfcc 100644 --- a/docs/src/content/docs/troubleshooting/common-errors.md +++ b/docs/src/content/docs/troubleshooting/common-errors.md @@ -241,14 +241,15 @@ See also: [./install-failures/](./install-failures/) ### `TLS verification failed` ``` -TLS verification failed -- if you're behind a corporate proxy or -firewall, set the REQUESTS_CA_BUNDLE environment variable to the -path of your organisation's CA bundle (a PEM file) and retry. +TLS verification failed -- APM uses the system trust store by default. +If you're behind a corporate proxy or firewall, make sure your +organisation's CA is installed in the OS trust store, or set +REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. ``` Cause: Python's TLS stack rejected the server certificate. Almost always a corporate proxy doing TLS interception with a CA that is not in the system trust store. -Fix: export `REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem` and retry. Do not disable TLS verification. +Fix: install the corporate CA into the OS trust store and retry. For a per-shell override, export `REQUESTS_CA_BUNDLE=/path/to/corporate-ca.pem`; `SSL_CERT_FILE` alone is not a reliable requests override. Do not disable TLS verification. See also: [./ssl-issues/](./ssl-issues/) diff --git a/docs/src/content/docs/troubleshooting/install-failures.md b/docs/src/content/docs/troubleshooting/install-failures.md index 7ea9e2b2c..8103bb5d1 100644 --- a/docs/src/content/docs/troubleshooting/install-failures.md +++ b/docs/src/content/docs/troubleshooting/install-failures.md @@ -110,7 +110,7 @@ For end-to-end auth setup see [Authentication](../getting-started/authentication [!] TLS verification failed ``` -Behind a corporate proxy, set `REQUESTS_CA_BUNDLE` to your org's CA bundle (PEM file). 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 override, set `REQUESTS_CA_BUNDLE` to a readable PEM bundle. 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 706c80aaf..d9d74447c 100644 --- a/docs/src/content/docs/troubleshooting/ssl-issues.md +++ b/docs/src/content/docs/troubleshooting/ssl-issues.md @@ -14,8 +14,10 @@ Related: [environment variables](../reference/environment-variables/), [install Typical errors APM surfaces or passes through from the underlying HTTP/git stack: ```text -[!] TLS verification failed -- if you're behind a corporate proxy or - firewall, set REQUESTS_CA_BUNDLE to your organisation's CA bundle. +[!] TLS verification failed -- APM uses the system trust store by default. + If you're behind a corporate proxy or firewall, make sure your + organisation's CA is installed in the OS trust store, or set + REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. ``` ```text @@ -52,6 +54,30 @@ Re-run the failing command with `--verbose` to see the underlying exception and 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. +::: + +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. + +You only need the steps below when the CA is *not* in the OS store, or you want to pin a specific bundle: + +- 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). + +### 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. +- 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). @@ -60,12 +86,9 @@ APM uses `requests` for HTTP and shells out to `git` for repository operations. ```bash export REQUESTS_CA_BUNDLE=/path/to/ca-bundle.pem -# or, more general: -export SSL_CERT_FILE=/path/to/ca-bundle.pem -export SSL_CERT_DIR=/etc/ssl/certs ``` -`REQUESTS_CA_BUNDLE` wins for `requests`. `SSL_CERT_FILE` / `SSL_CERT_DIR` cover the rest of the Python TLS stack. +`REQUESTS_CA_BUNDLE` wins for `requests`. `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. ### Git operations @@ -133,17 +156,14 @@ If the proxy performs TLS interception, you also need the proxy's signing CA in ## Verify the fix ```bash -# Python side -python -c "import requests; print(requests.get('https://api.github.com').status_code)" +# APM Python HTTPS path +APM_LOG_LEVEL=DEBUG apm install # Git side GIT_CURL_VERBOSE=1 git ls-remote https://github.example.com/org/repo.git 2>&1 | grep -i 'ssl\|cert' - -# APM end-to-end -apm install --verbose ``` -A `200` from `requests`, a successful `ls-remote`, and a clean install confirm trust is wired through every layer APM uses. +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`. ## Development-only escape hatches @@ -170,6 +190,7 @@ 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 `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). +[>] 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 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 2411cfce6..2b6e0e802 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md +++ b/packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md @@ -6,6 +6,7 @@ | Authentication errors (401/403) | Set the correct token. Run `apm install --verbose` to see which token source is used. See [Authentication](./authentication.md). | | 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. | +| 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. | | 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/pyproject.toml b/pyproject.toml index 0d346b5a6..7a0804cce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "colorama>=0.4.6", "pyyaml>=6.0.0", "requests>=2.31.0", + "truststore>=0.10.0", "python-frontmatter>=1.0.0", "llm>=0.28", "llm-github-models>=0.18.0", @@ -63,6 +64,12 @@ apm = "apm_cli.cli:main" where = ["src"] include = ["apm_cli*"] +[tool.setuptools.package-data] +# Belt-and-suspenders: ship the child-TLS .pth alongside the bootstrap module. +# Delivery no longer depends on this (ensure_child_tls_bootstrap generates the +# .pth inline), but packaging it keeps the source tree and wheel in sync. +"apm_cli.core._child_tls" = ["*.pth"] + [tool.ruff] line-length = 100 target-version = "py310" diff --git a/scripts/lint-architecture-boundaries.sh b/scripts/lint-architecture-boundaries.sh index 5a0c9e99a..3df7eaf83 100755 --- a/scripts/lint-architecture-boundaries.sh +++ b/scripts/lint-architecture-boundaries.sh @@ -156,6 +156,12 @@ if ! grep -q '_clear_git_auth_env(env)' src/apm_cli/core/auth.py; then echo "[x] AuthResolver must scrub inherited Git authorization state" violations=$((violations + 1)) fi +check_pattern \ + "TLS trust injection belongs to canonical owners" \ + 'truststore\.inject_into_ssl\(' \ + $(find src/apm_cli -name '*.py' \ + ! -path 'src/apm_cli/core/tls_trust.py' \ + ! -path 'src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py') echo "[*] AC6: neutral IR and schema contracts" check_pattern \ diff --git a/scripts/notice-metadata.yaml b/scripts/notice-metadata.yaml index 161c4941c..64db4098a 100644 --- a/scripts/notice-metadata.yaml +++ b/scripts/notice-metadata.yaml @@ -288,3 +288,9 @@ components: spdx: BSD-3-Clause copyright_snippet: Copyright (c) Aymeric Augustin and contributors notes: Used by the copilot-app integrator to nudge the Copilot desktop App over its local WebSocket IPC channel after writing workflows to the App DB. + - name: truststore + pyproject_name: truststore + upstream: https://github.com/sethmlarson/truststore + spdx: MIT + copyright_snippet: Copyright (c) 2022 Seth Michael Larson + notes: Verifies HTTPS against the operating-system trust store by default so `apm` works behind a corporate CA / TLS-inspecting proxy. diff --git a/scripts/runtime/setup-llm.ps1 b/scripts/runtime/setup-llm.ps1 index 69af3f1f6..0cd2a78dd 100644 --- a/scripts/runtime/setup-llm.ps1 +++ b/scripts/runtime/setup-llm.ps1 @@ -32,12 +32,31 @@ function Install-Llm { $pipExe = Join-Path (Join-Path $llmVenv "Scripts") "pip.exe" $llmExe = Join-Path (Join-Path $llmVenv "Scripts") "llm.exe" + $venvPython = Join-Path (Join-Path $llmVenv "Scripts") "python.exe" # Install LLM Write-Info "Installing LLM library..." & $pipExe install --upgrade pip & $pipExe install llm + # Install truststore so the llm venv verifies HTTPS against the OS trust + # store (corporate CA / TLS-proxy support). APM drops a self-contained .pth + # bootstrap into this venv's site-packages after setup; that bootstrap + # depends only on truststore, which must be present here. + Write-Info "Installing truststore for OS-trust HTTPS verification..." + try { + & $pipExe install "truststore>=0.10.0" + if ($LASTEXITCODE -ne 0) { throw "pip exited $LASTEXITCODE" } + } catch { + & $venvPython -c "import sys; raise SystemExit(sys.version_info < (3, 10))" + if ($LASTEXITCODE -ne 0) { + $pythonVersion = & $venvPython -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" + Write-WarningText "truststore install failed: Python 3.10+ is required (found $pythonVersion); recreate the llm runtime with a supported Python. See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + } else { + Write-WarningText "truststore install failed: if pip is behind a TLS proxy, set PIP_CERT=C:\path\to\org-ca-bundle.pem and re-run setup; the llm runtime will otherwise use bundled CAs. See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + } + } + # Install GitHub Models plugin in non-vanilla mode if (-not $Vanilla) { Write-Info "Installing GitHub Models plugin for APM defaults..." diff --git a/scripts/runtime/setup-llm.sh b/scripts/runtime/setup-llm.sh index 24d0d43d3..a3fc08943 100755 --- a/scripts/runtime/setup-llm.sh +++ b/scripts/runtime/setup-llm.sh @@ -49,6 +49,20 @@ setup_llm() { log_info "Installing LLM library..." "$llm_venv/bin/pip" install --upgrade pip "$llm_venv/bin/pip" install llm + + # Install truststore so the llm venv verifies HTTPS against the OS trust + # store (corporate CA / TLS-proxy support). APM drops a self-contained .pth + # bootstrap into this venv's site-packages after setup; that bootstrap + # depends only on truststore, which must be present here. + log_info "Installing truststore for OS-trust HTTPS verification..." + if ! "$llm_venv/bin/pip" install "truststore>=0.10.0"; then + if ! "$llm_venv/bin/python" -c "import sys; raise SystemExit(sys.version_info < (3, 10))"; then + version=$("$llm_venv/bin/python" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") + log_warning "truststore install failed: Python 3.10+ is required (found $version); recreate the llm runtime with a supported Python. See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + else + log_warning "truststore install failed: if pip is behind a TLS proxy, set PIP_CERT=/path/to/org-ca-bundle.pem and re-run setup; the llm runtime will otherwise use bundled CAs. See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + fi + fi # Install GitHub Models plugin in non-vanilla mode if [[ "$VANILLA_MODE" == "false" ]]; then diff --git a/src/apm_cli/cli.py b/src/apm_cli/cli.py index 7152d4fad..0515655de 100644 --- a/src/apm_cli/cli.py +++ b/src/apm_cli/cli.py @@ -3,6 +3,8 @@ Thin wiring layer -- all command logic lives in ``apm_cli.commands.*`` modules. """ +# ruff: noqa: E402 + import ctypes import logging import os @@ -11,6 +13,10 @@ import click +from apm_cli.core.tls_trust import configure_process_tls_trust, log_tls_trust_status + +configure_process_tls_trust() + from apm_cli.commands._helpers import ( ERROR, RESET, @@ -153,6 +159,7 @@ def cli(ctx, verbose: bool) -> None: if verbose: # Upgrade to DEBUG when the flag is set; env-var path runs in main(). _configure_logging(verbose=True) + log_tls_trust_status() # Suppress only the agents-target deprecation warning so CLI users see # the formatted logger.warning() in the install phase, not a double print. @@ -352,6 +359,7 @@ 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/_child_tls/__init__.py b/src/apm_cli/core/_child_tls/__init__.py new file mode 100644 index 000000000..5e3c9dbeb --- /dev/null +++ b/src/apm_cli/core/_child_tls/__init__.py @@ -0,0 +1,15 @@ +"""Child-process TLS trust bootstrap package. + +Holds the two artifacts copied into a child runtime venv's site-packages by +``ensure_child_tls_bootstrap`` in ``apm_cli.core.tls_trust``: + +* ``_apm_tls_bootstrap.py`` -- a self-contained OS-trust bootstrap with NO + ``apm_cli`` dependency (it only needs ``truststore``). +* ``_apm_tls.pth`` -- a one-line path-config file (``import _apm_tls_bootstrap``) + that Python executes at interpreter startup, so trust is delivered at + venv-setup time instead of by mutating the child's ``PYTHONPATH`` at spawn + time (which would shadow a user/corporate ``sitecustomize.py``). + +The ``__init__`` exists only so the directory ships as package data; the +bootstrap files are copied by path, not imported as submodules of this package. +""" diff --git a/src/apm_cli/core/_child_tls/_apm_tls.pth b/src/apm_cli/core/_child_tls/_apm_tls.pth new file mode 100644 index 000000000..b24d5d8e7 --- /dev/null +++ b/src/apm_cli/core/_child_tls/_apm_tls.pth @@ -0,0 +1 @@ +import _apm_tls_bootstrap diff --git a/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py b/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py new file mode 100644 index 000000000..4d0ba3fbe --- /dev/null +++ b/src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py @@ -0,0 +1,48 @@ +"""Self-contained OS-trust bootstrap for child runtimes. NO apm_cli dependency. + +Depends only on ``truststore`` (installed into the child venv). Executed at +interpreter startup via a ``.pth`` file dropped into the child venv's +site-packages, so trust is delivered at venv-setup time rather than by +mutating the child's ``PYTHONPATH`` at spawn time (which would shadow a +user/corporate ``sitecustomize.py``). + +Must stay SILENT (write nothing to stdout/stderr) and never raise -- a broken +bootstrap must not disturb the child runtime's own output or startup. +""" + +import logging as _logging +import os as _os + +_logger = _logging.getLogger("apm.tls") + + +def _truthy(val): + return (val or "").strip().lower() in ("1", "true", "yes", "on") + + +def _bootstrap(): + if _truthy(_os.environ.get("APM_DISABLE_TRUSTSTORE")): + return + for var in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): + if (_os.environ.get(var) or "").strip(): + return + try: + import truststore + except Exception: + return + marker = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" + bundled = None + if _os.environ.get("SSL_CERT_FILE") and _truthy(_os.environ.get(marker)): + bundled = _os.environ.pop("SSL_CERT_FILE", None) + _os.environ.pop(marker, None) + try: + truststore.inject_into_ssl() + _logger.debug("TLS: child verifying against OS trust store") + except Exception: + if bundled is not None: + _os.environ["SSL_CERT_FILE"] = bundled + _logger.debug("TLS: child falling back to certifi") + + +_bootstrap() +del _bootstrap diff --git a/src/apm_cli/core/tls_trust.py b/src/apm_cli/core/tls_trust.py new file mode 100644 index 000000000..ac77ef0ca --- /dev/null +++ b/src/apm_cli/core/tls_trust.py @@ -0,0 +1,343 @@ +"""Verify HTTPS against the OS trust store by default. + +``requests`` verifies against the bundled ``certifi`` set, which lacks +internal/corporate root CAs and TLS-proxy certs. Because APM also shells out to +``git`` (which reads the OS trust store), ``git clone`` of an internal host +succeeds while APM's ``requests`` calls fail on the same chain. This routes +``requests`` through the OS store via ``truststore`` so the two agree, with no +per-shell config. + +Best-effort -- ``configure_tls_trust`` never raises: + +* An explicit ``REQUESTS_CA_BUNDLE`` / ``CURL_CA_BUNDLE`` wins (no injection). +* 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). +""" + +from __future__ import annotations + +import contextlib +import functools +import logging +import os +import sys +import tempfile +from collections.abc import Mapping, MutableMapping +from pathlib import Path + +from ..utils.path_security import PathTraversalError, ensure_path_within + +logger = logging.getLogger(__name__) + +# The CA-bundle env vars ``requests`` honours (via merge_environment_settings); +# when one is set, respect that pinned bundle and skip injection. SSL_CERT_FILE +# and SSL_CERT_DIR are excluded on purpose: requests ignores those standalone +# variables, and the frozen binary's runtime hook sets SSL_CERT_FILE to bundled +# certifi -- treating either as an override would make injection a no-op in the +# shipped artifact. +_EXPLICIT_CA_ENV_VARS = ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE") + +# Escape hatch: set truthy to force the legacy certifi-only behaviour. +_DISABLE_ENV_VAR = "APM_DISABLE_TRUSTSTORE" + +# 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. +_SSL_CERT_FILE_VAR = "SSL_CERT_FILE" + +# Marker set by build/hooks/runtime_hook_ssl_certs.py: records that the frozen +# binary set SSL_CERT_FILE to bundled certifi (vs a genuine user value). +_BUNDLED_CERT_MARKER = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" + +# Directory (relative to the installed package) that holds the child bootstrap. +_CHILD_SHIM_DIRNAME = "_child_tls" + +# The two artifacts delivered into a child venv's site-packages to deliver +# OS-trust at interpreter startup (see ensure_child_tls_bootstrap). The module +# ships as a data file; the .pth is generated inline (its content is trivial and +# setuptools' packages.find omits stray .pth files from the wheel). +_BOOTSTRAP_MODULE_FILE = "_apm_tls_bootstrap.py" +_BOOTSTRAP_PTH_FILE = "_apm_tls.pth" + +# Importable name of the bootstrap module (drives the generated .pth line). +_BOOTSTRAP_MODULE_NAME = _BOOTSTRAP_MODULE_FILE.removesuffix(".py") + +# Exact content of the generated .pth: a single import line the interpreter runs +# at startup. ASCII, trailing newline. Generated inline so child-trust delivery +# never depends on the .pth being packaged into the wheel. +_PTH_CONTENT = f"import {_BOOTSTRAP_MODULE_NAME}\n" + +_TRUTHY = {"1", "true", "yes", "on"} +_LAST_TLS_STATUS: tuple[str, tuple[object, ...]] | None = None +_KNOWN_BUNDLED_CERT_FILE: str | None = None + + +def _record_tls_trust_status(message: str, *args: object) -> None: + """Cache and emit the selected trust source at debug level.""" + global _LAST_TLS_STATUS + _LAST_TLS_STATUS = (message, args) + logger.debug(message, *args) + + +def log_tls_trust_status() -> None: + """Re-emit the cached trust source after CLI logging is configured.""" + if _LAST_TLS_STATUS is not None: + message, args = _LAST_TLS_STATUS + logger.debug(message, *args) + + +def _env_flag(name: str, env: Mapping[str, str] | None = None) -> bool: + environ = os.environ if env is None else env + return environ.get(name, "").strip().lower() in _TRUTHY + + +def has_explicit_ca_override(env: Mapping[str, str] | None = None) -> bool: + """Return True when requests has an explicit CA bundle override.""" + environ = os.environ if env is None else env + 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.""" + environ = os.environ if env is None else env + for var in _EXPLICIT_CA_ENV_VARS: + value = (environ.get(var) or "").strip() + if value: + return value + return "" + + +def _mutable_environ(env: Mapping[str, str] | None) -> MutableMapping[str, str]: + """Return the environment truststore/OpenSSL will actually read. + + ``env is None`` is the real runtime path -- operate on ``os.environ`` so the + pop/restore of SSL_CERT_FILE takes effect before OpenSSL reads it. When an + explicit mapping is passed (tests), operate on it if mutable, else fall back + to ``os.environ`` to match the read semantics of the other helpers. + """ + if env is None: + return os.environ + if isinstance(env, MutableMapping): + return env + return os.environ + + +def configure_tls_trust(env: Mapping[str, str] | None = None) -> bool: + """Route HTTPS verification through the OS trust store when possible. + + 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. + """ + global _KNOWN_BUNDLED_CERT_FILE + environ = _mutable_environ(env) + + # Clear the bundled-default marker unconditionally, up-front, so it can + # never leak into child processes on ANY return path (opt-out, explicit + # 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) + 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) + return False + + if has_explicit_ca_override(env): + _record_tls_trust_status("TLS: explicit CA bundle in use: %s", _explicit_ca_path(env)) + return False + + try: + # Broad except: a broken/incompatible install can fail at import, not + # only with ImportError -- degrade instead of crashing startup. + 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) + + try: + truststore.inject_into_ssl() + except Exception as exc: + # Never end with zero trust: restore the bundled certifi path so + # musl/minimal-container hosts still verify against certifi. + if bundled_cert is not None: + environ[_SSL_CERT_FILE_VAR] = bundled_cert + _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)") + return True + + +@functools.lru_cache(maxsize=1) +def configure_process_tls_trust() -> bool: + """Configure process TLS once while retaining the selected trust source.""" + return configure_tls_trust() + + +def _child_bootstrap_dir() -> str | None: + """Absolute path to the directory holding the child TLS bootstrap files. + + Resolves for both source-installed and frozen (PyInstaller) layouts. Returns + ``None`` if the path cannot be determined -- callers degrade gracefully. + """ + try: + if getattr(sys, "frozen", False): + base = getattr(sys, "_MEIPASS", None) + if not base: + return None + candidate = Path(base) / "apm_cli" / "core" / _CHILD_SHIM_DIRNAME + else: + candidate = Path(__file__).resolve().parent / _CHILD_SHIM_DIRNAME + return str(candidate) + except Exception: + return None + + +def _venv_site_packages(venv_path: Path) -> Path | None: + """Return the site-packages dir of *venv_path*, or ``None`` if not found. + + Handles the POSIX ``lib/pythonX.Y/site-packages`` and the Windows + ``Lib/site-packages`` layouts. Picks the first existing match. + """ + candidates = list(venv_path.glob("lib/python*/site-packages")) + candidates.extend(venv_path.glob("Lib/site-packages")) + for candidate in candidates: + if candidate.is_dir(): + try: + return ensure_path_within(candidate, venv_path) + except (OSError, PathTraversalError): + continue + return None + + +def _atomic_write(target: Path, data: bytes) -> None: + """Write *data* to *target* atomically via a same-dir temp file + os.replace. + + A same-directory temp file guarantees ``os.replace`` performs a rename + (atomic on POSIX and NTFS), so a reader never observes a truncated file + under a live ``.pth``. On any OSError the temp file is removed and the error + re-raised so the caller can report failure without leaving a partial file. + """ + fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=".apm_tls_", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + os.replace(tmp_name, target) + except OSError: + with contextlib.suppress(OSError): + os.unlink(tmp_name) + raise + + +def ensure_child_tls_bootstrap(venv_path: str | os.PathLike[str]) -> bool: + """Install the self-contained OS-trust bootstrap into a child venv. + + Writes ``_apm_tls_bootstrap.py`` (the shipped module) and a generated + ``_apm_tls.pth`` into the venv's site-packages so its interpreter injects + ``truststore`` at startup -- no ``apm_cli`` dependency, no ``PYTHONPATH`` + mutation. Python-driven (rather than shell-globbed) so it resolves the + shipped module identically for a source install (package dir) and a frozen + binary (``sys._MEIPASS``). + + The ``.pth`` content is GENERATED inline rather than copied: setuptools' + ``packages.find`` omits stray ``.pth`` data files from the wheel, so copying + it would silently no-op on the PyPI channel. Delivery therefore depends only + on the module data file, which is packaged. + + Both files are written atomically, module FIRST, so the ``.pth`` is never + present without a complete bootstrap module behind it (avoiding a truncated + module under a live ``.pth`` -> per-invocation stderr traceback storm). + + Idempotent and best-effort: returns ``True`` when both files are present + after the call, ``False`` on any failure (no partial file left). Never + raises. + """ + try: + site_packages = _venv_site_packages(Path(venv_path)) + if site_packages is None: + return False + source_dir = _child_bootstrap_dir() + if not source_dir: + return False + module_src = Path(source_dir) / _BOOTSTRAP_MODULE_FILE + if not module_src.is_file(): + return False + # Module first (atomic), then the generated .pth (atomic) -- the write + # order guarantees the .pth never activates an incomplete module. + _atomic_write(site_packages / _BOOTSTRAP_MODULE_FILE, module_src.read_bytes()) + _atomic_write(site_packages / _BOOTSTRAP_PTH_FILE, _PTH_CONTENT.encode("ascii")) + return True + except Exception: + return False + + +def _is_bundled_certifi(path: str) -> bool: + """Return True when *path* is APM's bundled certifi CA set (not a user value). + + The frozen runtime hook (build/hooks/runtime_hook_ssl_certs.py) sets + ``SSL_CERT_FILE`` to ``certifi.where()``. A genuine user-set ``SSL_CERT_FILE`` + must NEVER match. Compare exact normalized paths against the path captured + from the internal marker and against ``certifi.where()``. + """ + if not path: + return False + normalized = os.path.normcase(os.path.abspath(path)) + if _KNOWN_BUNDLED_CERT_FILE and normalized == os.path.normcase( + os.path.abspath(_KNOWN_BUNDLED_CERT_FILE) + ): + return True + try: + import certifi + + return normalized == os.path.normcase(os.path.abspath(certifi.where())) + except Exception: + return False + + +def build_child_tls_env(base_env: Mapping[str, str]) -> dict[str, str]: + """Return a copy of *base_env* scrubbed for a spawned child runtime. + + Trust is now delivered at venv-setup time via the ``.pth`` bootstrap + (:func:`ensure_child_tls_bootstrap`), NOT at spawn time via ``PYTHONPATH`` + -- prepending a shim dir would shadow a user/corporate ``sitecustomize.py`` + and only reached children that shared this process's ``sys.path``. + + This function is an env-hygiene pass: + + * strips the internal bundled-default marker so the frozen binary's + ``SSL_CERT_FILE`` marker never leaks into a child, and + * drops ``SSL_CERT_FILE`` WHEN it points at the bundled certifi set, so the + child's ``truststore`` reaches the OS store on Linux (where truststore + honours ``SSL_CERT_FILE``). A frozen parent whose injection failed + restores ``SSL_CERT_FILE=certifi`` and would otherwise leak it, pinning + the child to certifi instead of the OS store. A GENUINE user + ``SSL_CERT_FILE`` is preserved -- only the bundled default is dropped. + """ + child = dict(base_env) + child.pop(_BUNDLED_CERT_MARKER, None) + cert_file = child.get(_SSL_CERT_FILE_VAR) + if cert_file and _is_bundled_certifi(cert_file): + child.pop(_SSL_CERT_FILE_VAR, None) + return child diff --git a/src/apm_cli/install/validation.py b/src/apm_cli/install/validation.py index 45f49f26c..8a762f19a 100644 --- a/src/apm_cli/install/validation.py +++ b/src/apm_cli/install/validation.py @@ -122,9 +122,10 @@ def _log_tls_failure(host_display: str, exc: BaseException, verbose_log, logger) Verbose: also include the host name and the underlying exception text. """ logger.warning( - "TLS verification failed -- if you're behind a corporate proxy or " - "firewall, set the REQUESTS_CA_BUNDLE environment variable to the " - "path of your organisation's CA bundle (a PEM file) and retry. " + "TLS verification failed -- APM uses the system trust store by default. " + "If you're behind a corporate proxy or firewall, make sure your " + "organisation's CA is installed in the OS trust store, or set " + "REQUESTS_CA_BUNDLE to a readable PEM bundle and retry. " "See: https://microsoft.github.io/apm/troubleshooting/ssl-issues/" ) if verbose_log: diff --git a/src/apm_cli/runtime/base.py b/src/apm_cli/runtime/base.py index 3e1f1ca92..0e85f5182 100644 --- a/src/apm_cli/runtime/base.py +++ b/src/apm_cli/runtime/base.py @@ -10,6 +10,8 @@ from contextlib import suppress from typing import Any +from ..core.tls_trust import build_child_tls_env + def _terminate_and_reap(process: subprocess.Popen) -> None: """Terminate a runtime process group and always reap the parent.""" @@ -38,6 +40,7 @@ def _terminate_and_reap(process: subprocess.Popen) -> None: def _stream_subprocess_output( cmd: list, timeout: float | None = None, + env: dict | None = None, ) -> tuple[list, int]: """Run *cmd* as a subprocess, stream stdout in real-time, and return output. @@ -45,12 +48,18 @@ def _stream_subprocess_output( cmd: Command and arguments list passed to :class:`subprocess.Popen`. timeout: Optional wait timeout in seconds passed to :meth:`subprocess.Popen.wait`. ``None`` waits indefinitely. + env: Optional child environment. When ``None``, the current process + environment is used with the OS-trust child shim wired in so the + child runtime verifies HTTPS against the OS trust store too. Returns: ``(output_lines, return_code)`` where *output_lines* is the list of streamed stdout lines (including newlines) and *return_code* is the process exit code. """ + if env is None: + env = build_child_tls_env(os.environ) + process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -58,6 +67,7 @@ def _stream_subprocess_output( text=True, encoding="utf-8", bufsize=1, # Line buffered + env=env, start_new_session=os.name != "nt", ) diff --git a/src/apm_cli/runtime/llm_runtime.py b/src/apm_cli/runtime/llm_runtime.py index 69336e25f..eade877b0 100644 --- a/src/apm_cli/runtime/llm_runtime.py +++ b/src/apm_cli/runtime/llm_runtime.py @@ -1,8 +1,10 @@ """LLM runtime adapter for APM.""" +import os import subprocess from typing import Any +from ..core.tls_trust import build_child_tls_env from .base import RuntimeAdapter, _stream_subprocess_output @@ -20,7 +22,12 @@ def __init__(self, model_name: str | None = None): # Verify llm CLI is available try: result = subprocess.run( # noqa: F841 - ["llm", "--version"], capture_output=True, text=True, encoding="utf-8", check=True + ["llm", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + env=build_child_tls_env(os.environ), ) except (subprocess.CalledProcessError, FileNotFoundError): raise RuntimeError("llm CLI not found. Please install: pip install llm") # noqa: B904 @@ -74,6 +81,7 @@ def list_available_models(self) -> dict[str, Any]: text=True, encoding="utf-8", check=True, + env=build_child_tls_env(os.environ), ) models = {} for line in result.stdout.strip().split("\n"): @@ -121,7 +129,12 @@ def is_available() -> bool: """ try: subprocess.run( - ["llm", "--version"], capture_output=True, text=True, encoding="utf-8", check=True + ["llm", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + env=build_child_tls_env(os.environ), ) return True except (subprocess.CalledProcessError, FileNotFoundError): diff --git a/src/apm_cli/runtime/manager.py b/src/apm_cli/runtime/manager.py index df34c610e..3ae3052f2 100644 --- a/src/apm_cli/runtime/manager.py +++ b/src/apm_cli/runtime/manager.py @@ -3,6 +3,7 @@ Handles installation, configuration, and management of AI runtimes. """ +import logging import os import shutil import subprocess @@ -13,9 +14,12 @@ import click from colorama import Fore, Style +from ..core.tls_trust import build_child_tls_env, ensure_child_tls_bootstrap from ..core.token_manager import setup_runtime_environment from .registry import get_runtime_descriptor, runtime_descriptors +logger = logging.getLogger(__name__) + class RuntimeManager: """Manages AI runtime installation and configuration via embedded scripts.""" @@ -171,6 +175,10 @@ def run_embedded_script( # Setup GitHub tokens using centralized manager env = setup_runtime_environment(env) # Pass env to preserve CI tokens + # Wire OS-trust child shim so the setup subprocess (and any + # Python runtime it spawns) verifies HTTPS against the OS store. + env = build_child_tls_env(env) + result = subprocess.run( cmd, cwd=temp_dir, @@ -236,6 +244,14 @@ def setup_runtime( success = self.run_embedded_script(script_content, common_content, script_args) if success: + # Deliver the self-contained OS-trust bootstrap into the llm + # runtime venv so its interpreter (which has neither apm_cli nor + # truststore on PYTHONPATH) verifies HTTPS against the OS store. + # Done in Python -- not the shell script -- so the shipped + # bootstrap files resolve identically for source and frozen apm. + if runtime_name == "llm": + self._install_llm_tls_bootstrap() + click.echo( f"{Fore.GREEN}[+] Successfully set up {runtime_name} runtime{Style.RESET_ALL}" ) @@ -253,6 +269,28 @@ def setup_runtime( ) return False + def _install_llm_tls_bootstrap(self) -> None: + """Drop the OS-trust bootstrap into the llm runtime venv (best-effort). + + The llm venv is created by ``setup-llm.sh`` with ``truststore`` + installed; this generates the ``.pth`` and copies the bootstrap module + into its site-packages so the child interpreter injects the OS trust + store at startup. Silent and non-fatal -- a bootstrap failure must not + fail runtime setup. + """ + venv_path = self.runtime_dir / "llm-venv" + try: + if not ensure_child_tls_bootstrap(venv_path): + click.echo( + f"{Fore.YELLOW}[!] Could not install OS-trust bootstrap into the llm venv; " + "use Python 3.10+ or set PIP_CERT to your proxy CA bundle, then re-run " + "`apm runtime setup llm`. See: " + "https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + f"{Style.RESET_ALL}" + ) + except Exception as exc: + logger.debug("TLS bootstrap installation raised unexpectedly: %s", exc) + def list_runtimes(self) -> dict[str, dict[str, str]]: """List available and installed runtimes.""" runtimes = {} diff --git a/tests/integration/_tls_ca_server.py b/tests/integration/_tls_ca_server.py new file mode 100644 index 000000000..b7a2877df --- /dev/null +++ b/tests/integration/_tls_ca_server.py @@ -0,0 +1,66 @@ +"""Shared private-CA HTTPS server harness for the child-runtime/frozen TLS 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) diff --git a/tests/integration/test_architecture_authorities.py b/tests/integration/test_architecture_authorities.py index d84579796..853f4771c 100644 --- a/tests/integration/test_architecture_authorities.py +++ b/tests/integration/test_architecture_authorities.py @@ -199,3 +199,21 @@ def test_dependency_winner_selection_has_one_algorithm() -> None: "nodes_at_depth.sort", ): assert duplicate not in source + + +def test_tls_injection_has_one_canonical_authority() -> None: + """Only the parent TLS owner and standalone child bootstrap may inject.""" + root = Path(__file__).parents[2] + guard = (root / "scripts/lint-architecture-boundaries.sh").read_text() + allowed = { + root / "src/apm_cli/core/tls_trust.py", + root / "src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py", + } + duplicate_owners = [ + path.relative_to(root).as_posix() + for path in (root / "src/apm_cli").rglob("*.py") + if path not in allowed and "truststore.inject_into_ssl(" in path.read_text() + ] + + assert "TLS trust injection belongs to canonical owners" in guard + assert duplicate_owners == [] diff --git a/tests/integration/test_tls_child_runtime.py b/tests/integration/test_tls_child_runtime.py new file mode 100644 index 000000000..20c92263e --- /dev/null +++ b/tests/integration/test_tls_child_runtime.py @@ -0,0 +1,230 @@ +"""C1 verifier: OS-trust must reach a FOREIGN child venv via the .pth bootstrap. + +The flagship ``llm`` runtime runs in its own venv (``~/.apm/runtimes/llm-venv``) +that has NEITHER ``apm_cli`` NOR (historically) ``truststore``. The round-1 +design re-ran ``configure_tls_trust`` in the child by prepending a +``sitecustomize`` shim dir to the child ``PYTHONPATH``; in the real ``llm`` venv +that import failed silently, so the child fell back to ``certifi`` and ``apm +run`` still failed behind a proxy. It also shadowed any user ``sitecustomize``. + +The round-2 mechanism delivers trust at venv-setup time instead: APM installs +``truststore`` into the runtime venv and copies a self-contained ``.pth`` +bootstrap into its site-packages, so the child interpreter injects the OS trust +store at startup with no ``apm_cli`` dependency and no ``PYTHONPATH`` mutation. + +These tests spawn a genuine FOREIGN venv (created with ``python -m venv``, WITH +NO ``apm_cli`` installed) and prove: + +* C1 -- with the shipped ``_apm_tls_bootstrap.py`` + ``_apm_tls.pth`` dropped in, + the child's ``ssl.SSLContext`` becomes truststore-backed; remove the ``.pth`` + and it reverts to stdlib ``ssl`` (the asymmetry is the proof). +* T2 -- the ``.pth`` is additive: a pre-existing user ``sitecustomize.py`` in the + same venv still runs AND truststore still injects. + +Offline-by-design: ``truststore`` is copied from the running dev environment +into the foreign venv rather than ``pip install``-ed, so the tests need no +network. The interpreter under test is still a foreign venv without ``apm_cli``. +""" + +from __future__ import annotations + +import importlib.util +import logging +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from apm_cli.core.tls_trust import _child_bootstrap_dir, _venv_site_packages +from apm_cli.runtime.manager import RuntimeManager + +pytestmark = pytest.mark.integration + +_truststore_missing = importlib.util.find_spec("truststore") is None +_requires_truststore = pytest.mark.skipif( + _truststore_missing, reason="truststore not importable in this environment" +) + +# Child that reports which module owns ssl.SSLContext -- truststore-backed after +# the bootstrap runs, plain "ssl" otherwise. +_SSL_MODULE_PROBE = "import ssl; print(ssl.SSLContext.__module__)" + +_TRUST_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "APM_DISABLE_TRUSTSTORE", + "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT", + "PYTHONPATH", +) + + +def _clean_env() -> dict[str, str]: + """os.environ copy with every trust-related var stripped (pristine start).""" + return {k: v for k, v in os.environ.items() if k not in _TRUST_ENV_VARS} + + +def _venv_python(venv: Path) -> Path: + """Return the interpreter path inside *venv* for the current platform.""" + if sys.platform == "win32": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" + + +def _make_foreign_venv(root: Path) -> tuple[Path, Path]: + """Create a foreign venv (no apm_cli) and return (venv_python, site_packages). + + ``truststore`` is copied in from the running dev environment so the test is + fully offline; ``apm_cli`` is deliberately NOT installed so the interpreter + matches the real ``llm`` runtime venv. + """ + venv = root / "foreign-venv" + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(venv)], + check=True, + capture_output=True, + ) + site_packages = _venv_site_packages(venv) + assert site_packages is not None, "could not locate foreign venv site-packages" + + import truststore + + ts_src = Path(truststore.__file__).resolve().parent + shutil.copytree(ts_src, site_packages / "truststore") + + return _venv_python(venv), site_packages + + +def _drop_bootstrap(site_packages: Path) -> None: + """Copy the shipped bootstrap module + .pth into *site_packages*.""" + source = Path(_child_bootstrap_dir()) + shutil.copyfile(source / "_apm_tls_bootstrap.py", site_packages / "_apm_tls_bootstrap.py") + shutil.copyfile(source / "_apm_tls.pth", site_packages / "_apm_tls.pth") + + +def _probe_ssl_module(venv_python: Path) -> str: + result = subprocess.run( + [str(venv_python), "-c", _SSL_MODULE_PROBE], + env=_clean_env(), + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + +@_requires_truststore +def test_foreign_venv_bootstrap_injects_truststore(tmp_path): + """C1: the shipped .pth bootstrap makes a foreign venv verify via the OS store.""" + venv_python, site_packages = _make_foreign_venv(tmp_path) + + # Control first: no bootstrap -> stdlib ssl. Proves the venv is foreign and + # would otherwise verify against certifi (the field failure mode). + assert _probe_ssl_module(venv_python) == "ssl", "foreign venv should start on stdlib ssl" + + # Drop the bootstrap -> the child's ssl becomes truststore-backed. + _drop_bootstrap(site_packages) + module = _probe_ssl_module(venv_python) + assert module.startswith("truststore"), ( + f"child ssl module should be truststore-backed after bootstrap, got {module!r}" + ) + + +@_requires_truststore +def test_bootstrap_is_additive_to_user_sitecustomize(tmp_path): + """T2: the .pth bootstrap does not shadow a user sitecustomize -- both run.""" + venv_python, site_packages = _make_foreign_venv(tmp_path) + _drop_bootstrap(site_packages) + + sentinel = tmp_path / "sitecustomize-ran.txt" + (site_packages / "sitecustomize.py").write_text( + "\n".join( + [ + "import os", + "import pathlib", + "pathlib.Path(os.environ['APM_TEST_SENTINEL']).write_text('ran', encoding='utf-8')", + ] + ), + encoding="utf-8", + ) + + env = _clean_env() + env["APM_TEST_SENTINEL"] = str(sentinel) + result = subprocess.run( + [str(venv_python), "-c", _SSL_MODULE_PROBE], + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + # The user sitecustomize ran (bootstrap did not shadow it)... + assert sentinel.exists(), "user sitecustomize.py must still run alongside the .pth bootstrap" + assert sentinel.read_text(encoding="utf-8") == "ran" + # ...AND truststore still injected (the .pth is additive, not exclusive). + assert result.stdout.strip().startswith("truststore"), ( + f"truststore must still inject with a user sitecustomize present, got {result.stdout!r}" + ) + + +def test_runtime_manager_setup_llm_installs_tls_bootstrap(tmp_path, monkeypatch): + """Runtime setup must deliver both bootstrap files into the created venv.""" + manager = RuntimeManager() + manager.runtime_dir = tmp_path / "runtimes" + monkeypatch.setattr(manager, "get_embedded_script", lambda _name: "") + monkeypatch.setattr(manager, "get_common_script", lambda: "") + + def _create_llm_venv(_script, _common, _args): + site_packages = ( + manager.runtime_dir + / "llm-venv" + / "lib" + / f"python{sys.version_info.major}.{sys.version_info.minor}" + / "site-packages" + ) + site_packages.mkdir(parents=True) + return True + + monkeypatch.setattr(manager, "run_embedded_script", _create_llm_venv) + + assert manager.setup_runtime("llm") is True + site_packages = _venv_site_packages(manager.runtime_dir / "llm-venv") + assert site_packages is not None + assert (site_packages / "_apm_tls_bootstrap.py").is_file() + assert (site_packages / "_apm_tls.pth").read_text(encoding="ascii") == ( + "import _apm_tls_bootstrap\n" + ) + + +def test_runtime_manager_bootstrap_warning_is_actionable(tmp_path, capsys): + """A best-effort delivery failure must tell proxy users how to recover.""" + manager = RuntimeManager() + manager.runtime_dir = tmp_path / "runtimes" + + manager._install_llm_tls_bootstrap() + + output = capsys.readouterr().out + assert "PIP_CERT" in output + assert "Python 3.10+" in output + assert "https://microsoft.github.io/apm/troubleshooting/ssl-issues/" in output + + +def test_runtime_manager_bootstrap_exception_is_visible_in_debug_log(tmp_path, monkeypatch, caplog): + """Unexpected helper failures must remain visible under verbose logging.""" + import apm_cli.runtime.manager as manager_module + + manager = RuntimeManager() + manager.runtime_dir = tmp_path / "runtimes" + + def _raise(_venv_path): + raise RuntimeError("unexpected bootstrap failure") + + monkeypatch.setattr(manager_module, "ensure_child_tls_bootstrap", _raise) + with caplog.at_level(logging.DEBUG, logger="apm_cli.runtime.manager"): + manager._install_llm_tls_bootstrap() + + assert "unexpected bootstrap failure" in caplog.text diff --git a/tests/integration/test_tls_custom_ca.py b/tests/integration/test_tls_custom_ca.py new file mode 100644 index 000000000..c49ca6fc5 --- /dev/null +++ b/tests/integration/test_tls_custom_ca.py @@ -0,0 +1,228 @@ +"""Integration tests for TLS verification against a custom CA. + +Spins up a loopback HTTPS server whose leaf certificate is signed by a +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: + +- 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. + +Requires the ``openssl`` CLI to mint the certificates; skipped where absent. +""" + +from __future__ import annotations + +import http.server +import shutil +import ssl +import subprocess +import threading +from types import SimpleNamespace + +import pytest +import requests + +from apm_cli.core.tls_trust import configure_tls_trust + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif(shutil.which("openssl") is None, reason="openssl CLI not available"), +] + +_TRUST_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "APM_DISABLE_TRUSTSTORE", +) + +_CA_CNF = """\ +[req] +distinguished_name = dn +x509_extensions = v3_ca +prompt = no +[dn] +CN = APM Test Root CA +[v3_ca] +basicConstraints = critical, CA:TRUE +keyUsage = critical, keyCertSign, cRLSign +""" + +_SERVER_CNF = """\ +[req] +distinguished_name = dn +req_extensions = v3_req +prompt = no +[dn] +CN = localhost +[v3_req] +basicConstraints = CA:FALSE +keyUsage = digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = DNS:localhost, IP:127.0.0.1 +""" + + +def _openssl(*args) -> None: + subprocess.run(["openssl", *[str(a) for a in args]], check=True, capture_output=True) + + +def _mint_ca_and_leaf(dirpath): + """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 = ( + dirpath / "server.key", + dirpath / "server.csr", + dirpath / "server.pem", + ) + ca_cnf, srv_cnf = dirpath / "ca.cnf", dirpath / "server.cnf" + ca_cnf.write_text(_CA_CNF) + srv_cnf.write_text(_SERVER_CNF) + + _openssl( + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + ca_key, + "-out", + ca_pem, + "-days", + "2", + "-config", + ca_cnf, + ) + _openssl( + "req", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + srv_key, + "-out", + srv_csr, + "-config", + srv_cnf, + ) + _openssl( + "x509", + "-req", + "-in", + srv_csr, + "-CA", + ca_pem, + "-CAkey", + ca_key, + "-CAcreateserial", + "-out", + srv_pem, + "-days", + "2", + "-extfile", + srv_cnf, + "-extensions", + "v3_req", + ) + return ca_pem, srv_pem, srv_key + + +class _OkHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): # stdlib handler contract + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(b"ok") + + 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.""" + 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) + + 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.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)) + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + +@pytest.fixture(autouse=True) +def _isolate_trust(monkeypatch): + """Pristine trust env per test, and undo any global ssl/truststore mutation.""" + for var in _TRUST_ENV_VARS: + monkeypatch.delenv(var, raising=False) + original_ssl_context = ssl.SSLContext + try: + yield + finally: + try: + import truststore + + truststore.extract_from_ssl() + except Exception: + pass + ssl.SSLContext = original_ssl_context + + +def test_untrusted_custom_ca_is_rejected(custom_ca_server): + # Default trust (certifi) must reject a cert signed by an unknown CA. + with pytest.raises(requests.exceptions.SSLError): + requests.get(custom_ca_server.url, timeout=5) + + +def test_verify_with_ca_path_succeeds(custom_ca_server): + # Sanity: the minted chain is valid when the CA is explicitly trusted. + resp = requests.get(custom_ca_server.url, verify=custom_ca_server.ca_path, timeout=5) + assert resp.status_code == 200 + assert resp.text == "ok" + + +@pytest.mark.parametrize("env_var", ["REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]) +def test_explicit_ca_bundle_env_is_honored(custom_ca_server, monkeypatch, env_var): + # Both env vars requests consults must win end-to-end. + monkeypatch.setenv(env_var, custom_ca_server.ca_path) + + # An explicit bundle must win: we skip truststore injection... + assert configure_tls_trust() is False + # ...and requests verifies against it, so the request succeeds. + resp = requests.get(custom_ca_server.url, timeout=5) + assert resp.status_code == 200 + + +def test_truststore_injection_keeps_verification_on(custom_ca_server): + # With no explicit bundle, we inject the OS trust store. + assert configure_tls_trust() is True + # The private CA is in no OS store, so verification must still fail -- + # 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) diff --git a/tests/integration/test_tls_frozen_hook.py b/tests/integration/test_tls_frozen_hook.py new file mode 100644 index 000000000..317a51ac0 --- /dev/null +++ b/tests/integration/test_tls_frozen_hook.py @@ -0,0 +1,285 @@ +"""Regression test: the frozen-binary SSL runtime hook must not disable OS-trust injection. + +The PyInstaller build wires ``build/hooks/runtime_hook_ssl_certs.py``, which runs +before application code in the frozen binary and sets ``SSL_CERT_FILE`` to the +bundled certifi bundle (to fix the compiled-in OpenSSL cert path, see #428). + +``configure_tls_trust()`` must still inject ``truststore`` in that state. +Otherwise, because the hook sets ``SSL_CERT_FILE``, treating that var as an +explicit override would make OS-trust verification a silent no-op in exactly the +shipped artifact this feature exists for. This test drives the *real* hook under +a simulated frozen process so the two cannot drift apart. +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import ssl +import subprocess +import sys +import types +from pathlib import Path + +import certifi +import pytest + +from apm_cli.core.tls_trust import configure_tls_trust + +pytestmark = pytest.mark.integration + +_TRUST_ENV_VARS = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "APM_DISABLE_TRUSTSTORE", + "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT", +) +_HOOK_PATH = Path(__file__).resolve().parents[2] / "build" / "hooks" / "runtime_hook_ssl_certs.py" + +_BUNDLED_CERT_MARKER = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" + +_truststore_missing = importlib.util.find_spec("truststore") is None +_requires_truststore = pytest.mark.skipif( + _truststore_missing, reason="truststore not importable in this environment" +) +_requires_openssl = pytest.mark.skipif( + shutil.which("openssl") is None, reason="openssl CLI not available" +) + +# Child that bootstraps trust in its own process then does one real HTTPS GET. +# Reports the return value, whether SSL_CERT_FILE survived the bootstrap, and a +# single RESULT token -- so the parent can assert both mechanism and outcome +# without any global ssl mutation bleeding across tests. +_B2_CHILD = ( + "import os\n" + "import sys\n" + "import ssl\n" + "import requests\n" + "from apm_cli.core.tls_trust import configure_tls_trust\n" + "ret = configure_tls_trust()\n" + "print('RET:' + str(ret))\n" + "print('SSL_CERT_FILE_PRESENT:' + str('SSL_CERT_FILE' in os.environ))\n" + "try:\n" + " r = requests.get(sys.argv[1], timeout=5)\n" + " print('RESULT:OK' if r.status_code == 200 else 'RESULT:BAD')\n" + "except (ssl.SSLError, requests.exceptions.SSLError):\n" + " print('RESULT:SSLERROR')\n" +) + + +def _install_fake_truststore(monkeypatch, inject=None): + """Put a no-op (or raising) fake ``truststore`` in sys.modules for mechanism tests.""" + module = types.ModuleType("truststore") + module.inject_into_ssl = inject or (lambda: None) # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "truststore", module) + + +def _clean_child_env() -> dict[str, str]: + """os.environ copy with every trust-related var stripped.""" + return {k: v for k, v in os.environ.items() if k not in _TRUST_ENV_VARS} + + +def _parse_tokens(stdout: str) -> dict[str, str]: + """Split the B2 child's ``KEY:VALUE`` lines into a dict.""" + tokens: dict[str, str] = {} + for line in stdout.splitlines(): + if ":" in line: + key, _, value = line.partition(":") + tokens[key.strip()] = value.strip() + return tokens + + +def _bundle_with_private_ca(tmp_path, ca_pem: Path) -> Path: + """A CA bundle that is certifi + the private CA (a superset 'bundled default').""" + bundle = tmp_path / "bundle_with_private_ca.pem" + bundle.write_bytes(Path(certifi.where()).read_bytes() + b"\n" + ca_pem.read_bytes()) + return bundle + + +@pytest.fixture(autouse=True) +def _isolate_trust(): + """Snapshot/restore trust env, sys.frozen, and the global ssl.SSLContext. + + Manual snapshot (not monkeypatch) because the runtime hook mutates + ``os.environ`` directly, which monkeypatch would not track or undo. + """ + saved_env = {var: os.environ.get(var) for var in _TRUST_ENV_VARS} + for var in _TRUST_ENV_VARS: + os.environ.pop(var, None) + saved_ctx = ssl.SSLContext + had_frozen = hasattr(sys, "frozen") + saved_frozen = getattr(sys, "frozen", None) + try: + yield + finally: + try: + import truststore + + truststore.extract_from_ssl() + except Exception: + pass + ssl.SSLContext = saved_ctx + if had_frozen: + sys.frozen = saved_frozen + elif hasattr(sys, "frozen"): + del sys.frozen + for var, value in saved_env.items(): + if value is None: + os.environ.pop(var, None) + else: + os.environ[var] = value + + +def _run_runtime_hook(): + """Execute the real PyInstaller SSL runtime hook (runs _configure_ssl_certs).""" + spec = importlib.util.spec_from_file_location("runtime_hook_ssl_certs", _HOOK_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_frozen_hook_ssl_cert_file_does_not_disable_injection(): + sys.frozen = True + _run_runtime_hook() + + # The hook pinned SSL_CERT_FILE to the bundled certifi... + assert os.environ.get("SSL_CERT_FILE") == certifi.where() + # ...and that must NOT suppress OS-trust injection in the frozen binary. + assert configure_tls_trust() is True + assert "truststore" in ssl.SSLContext.__module__ + + +def test_user_override_still_wins_under_frozen_hook(): + # A user-pinned REQUESTS_CA_BUNDLE makes the hook leave SSL_CERT_FILE unset, + # and we must honour that bundle rather than inject the OS store. + os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() + sys.frozen = True + _run_runtime_hook() + + assert os.environ.get("SSL_CERT_FILE") is None + assert configure_tls_trust() is False + assert "truststore" not in ssl.SSLContext.__module__ + + +# --------------------------------------------------------------------------- +# B2 -- frozen binary uses the OS store; certifi only as a genuine fallback. +# These SIMULATE the frozen state (no binary is built). The bundled-default +# SSL_CERT_FILE (marked by the runtime hook) must be neutralized before +# injection so the true system store is consulted; a genuine user override +# must be left intact; and an injection failure must restore certifi so a +# minimal container never ends up with zero trust. +# --------------------------------------------------------------------------- + + +def test_bundled_default_marker_gates_ssl_cert_file_pop(monkeypatch): + """B2 mechanism: the bundled-default SSL_CERT_FILE is popped, a user value is kept. + + Platform-independent proof (fake truststore -> no global ssl mutation) that + the pop is gated strictly on the runtime-hook marker: OUR bundled default is + removed before injection, a real user SSL_CERT_FILE is preserved. + """ + _install_fake_truststore(monkeypatch) + + marked = {"SSL_CERT_FILE": "/bundled/certifi.pem", _BUNDLED_CERT_MARKER: "1"} + assert configure_tls_trust(env=marked) is True + assert "SSL_CERT_FILE" not in marked, "bundled default must be popped before injection" + assert _BUNDLED_CERT_MARKER not in marked, "marker must not leak to children" + + user = {"SSL_CERT_FILE": "/etc/ssl/user-ca.pem"} + assert configure_tls_trust(env=user) is True + assert user["SSL_CERT_FILE"] == "/etc/ssl/user-ca.pem", "user value must be preserved" + + +@_requires_truststore +@_requires_openssl +def test_bundled_default_neutralized_system_store_wins(tmp_path): + """B2 Test A: with the bundled-default marker, the private CA is NOT trusted. + + SSL_CERT_FILE points at a bundle that CONTAINS the private CA and the marker + is set. Because configure_tls_trust must pop that bundled default before + injecting, verification falls to the real system trust (which lacks the + private CA), so the request fails. If the B2 bug persisted and the bundled + SSL_CERT_FILE were honored, the request would SUCCEED -- so SSLERROR proves + the bundled certifi is no longer the trust source. Run in a child process to + avoid global-state bleed. + """ + from ._tls_ca_server import private_ca_https_server + + with private_ca_https_server(tmp_path) as server: + bundle = _bundle_with_private_ca(tmp_path, server.ca_pem) + env = _clean_child_env() + env["SSL_CERT_FILE"] = str(bundle) + env[_BUNDLED_CERT_MARKER] = "1" + + result = subprocess.run( + [sys.executable, "-c", _B2_CHILD, server.url], + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + tokens = _parse_tokens(result.stdout) + assert tokens.get("RET") == "True", result.stdout + assert tokens.get("SSL_CERT_FILE_PRESENT") == "False", ( + f"bundled default must be popped, got {result.stdout!r}" + ) + assert tokens.get("RESULT") == "SSLERROR", ( + f"private CA must not be trusted after neutralization, got {result.stdout!r}" + ) + + +@_requires_truststore +@_requires_openssl +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 + a child process to avoid global-state bleed. + """ + from ._tls_ca_server import private_ca_https_server + + 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: + env["SSL_CERT_FILE"] = str(bundle) + + result = subprocess.run( + [sys.executable, "-c", _B2_CHILD, server.url], + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + tokens = _parse_tokens(result.stdout) + assert tokens.get("RESULT") == "OK", ( + f"genuine user override must be honored, got {result.stdout!r}" + ) + + +def test_injection_failure_restores_bundled_certifi(monkeypatch): + """B2 Test C: an inject failure returns False and restores the bundled certifi path. + + Minimal-container safety: never end with zero trust. With the bundled-default + marker set and inject_into_ssl raising, configure_tls_trust must restore + SSL_CERT_FILE to the captured bundled value and clear the marker. + """ + + def _boom(): + raise RuntimeError("platform trust API unavailable") + + _install_fake_truststore(monkeypatch, inject=_boom) + bundled = certifi.where() + os.environ["SSL_CERT_FILE"] = bundled + os.environ[_BUNDLED_CERT_MARKER] = "1" + + assert configure_tls_trust() is False + assert os.environ.get("SSL_CERT_FILE") == bundled, "certifi fallback must be restored" + assert _BUNDLED_CERT_MARKER not in os.environ, "marker must be cleared" diff --git a/tests/integration/test_tls_r2_verify.py b/tests/integration/test_tls_r2_verify.py new file mode 100644 index 000000000..3e219217d --- /dev/null +++ b/tests/integration/test_tls_r2_verify.py @@ -0,0 +1,335 @@ +"""Independent round-2 verification of PR #2005 OS-trust child-runtime delivery. + +Written FROM SCRATCH by the verifier (not the implementer) to prove -- on a +surface the implementer could not have gamed -- that the shipped ``.pth`` +bootstrap delivers OS-trust into a FOREIGN venv that cannot import ``apm_cli``. +That foreign venv is the exact field scenario the round-1 ``PYTHONPATH``/ +``sitecustomize`` shim failed on: the real ``llm`` runtime lives in +``~/.apm/runtimes/llm-venv`` which has neither ``apm_cli`` nor (historically) +``truststore``, so the round-1 ``from apm_cli...`` import silently no-op'd and +the child fell back to ``certifi``. + +Assertions here are intentionally independent of the implementer's tests: + +* V1 -- a genuine foreign venv (no ``apm_cli``) with the shipped bootstrap + dropped in verifies HTTPS via ``truststore``; removing the ``.pth`` reverts to + stdlib ``ssl`` (the asymmetry is the proof, not the presence). +* V2 -- the ``.pth`` is additive: a pre-existing user ``sitecustomize.py`` still + runs (a distinct ``_VERIF_*`` sentinel) AND ``truststore`` still injects. +* V3 -- the real delivery helper ``ensure_child_tls_bootstrap`` lands both files + in a real venv's site-packages and that interpreter then injects, with + ``apm_cli`` still not importable in it. +* V4 -- ``configure_tls_trust`` clears the bundled-default marker on all five + return paths, pops the bundled ``SSL_CERT_FILE`` before a successful inject, + and restores it when inject raises. +* V6 -- ``build_child_tls_env`` performs no ``PYTHONPATH`` mutation and strips + the internal marker. + +Offline-by-design: ``truststore`` is copied from the running dev environment +into the foreign venv rather than ``pip install``-ed. The interpreter under test +is still a foreign venv with NO ``apm_cli``. +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import subprocess +import sys +import types +from pathlib import Path + +import pytest + +from apm_cli.core.tls_trust import ( + _BUNDLED_CERT_MARKER, + _DISABLE_ENV_VAR, + _child_bootstrap_dir, + _venv_site_packages, + build_child_tls_env, + configure_tls_trust, + ensure_child_tls_bootstrap, +) + +pytestmark = pytest.mark.integration + +_SSL_CERT_FILE = "SSL_CERT_FILE" + +# Independent probe: report the owning module of ssl.SSLContext. truststore +# rewires this to "truststore._api"; a stock interpreter reports plain "ssl". +_SSL_OWNER_PROBE = "import ssl; print(ssl.SSLContext.__module__)" + +# Sentinel used ONLY by this verifier's non-shadowing check (distinct name so it +# cannot collide with the implementer's APM_TEST_SENTINEL). +_SITECUSTOMIZE_SENTINEL = "_VERIF_SITECUSTOMIZE_RAN" + +_truststore_unavailable = importlib.util.find_spec("truststore") is None +_needs_truststore = pytest.mark.skipif( + _truststore_unavailable, + reason="truststore not importable in the dev environment (needed to seed the foreign venv)", +) + +# Env vars that must be cleared so the foreign-venv probe starts pristine. +_STRIP_ENV = ( + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + _SSL_CERT_FILE, + "SSL_CERT_DIR", + _DISABLE_ENV_VAR, + _BUNDLED_CERT_MARKER, + "PYTHONPATH", + _SITECUSTOMIZE_SENTINEL, +) + + +def _pristine_env() -> dict[str, str]: + """A copy of os.environ with every trust-related variable removed.""" + return {k: v for k, v in os.environ.items() if k not in _STRIP_ENV} + + +def _venv_interpreter(venv: Path) -> Path: + if sys.platform == "win32": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" + + +def _seed_foreign_venv(root: Path) -> tuple[Path, Path]: + """Create a real venv WITHOUT apm_cli and seed truststore into it. + + Returns ``(interpreter, site_packages)``. Uses ``sys.executable`` (the dev + interpreter, >=3.10) so the seeded truststore actually imports; the point is + that ``apm_cli`` is absent, matching the shipped ``llm`` runtime venv. + """ + venv = root / "foreign" + subprocess.run( + [sys.executable, "-m", "venv", "--without-pip", str(venv)], + check=True, + capture_output=True, + ) + site_packages = _venv_site_packages(venv) + assert site_packages is not None, "could not locate foreign venv site-packages" + + import truststore + + truststore_pkg = Path(truststore.__file__).resolve().parent + shutil.copytree(truststore_pkg, site_packages / "truststore") + return _venv_interpreter(venv), site_packages + + +def _copy_shipped_bootstrap(site_packages: Path) -> Path: + """Copy the SHIPPED bootstrap module + .pth into *site_packages*. + + Returns the path of the copied ``.pth`` so a test can delete it for the + control probe. + """ + source = Path(_child_bootstrap_dir()) + shutil.copyfile(source / "_apm_tls_bootstrap.py", site_packages / "_apm_tls_bootstrap.py") + pth = site_packages / "_apm_tls.pth" + shutil.copyfile(source / "_apm_tls.pth", pth) + return pth + + +def _run_probe(interpreter: Path, probe: str, env: dict[str, str] | None = None) -> str: + result = subprocess.run( + [str(interpreter), "-c", probe], + env=_pristine_env() if env is None else env, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"probe failed: {result.stderr}" + return result.stdout.strip() + + +def _assert_no_apm_cli(interpreter: Path) -> None: + """Fail unless ``import apm_cli`` genuinely fails in *interpreter*.""" + result = subprocess.run( + [str(interpreter), "-c", "import apm_cli"], + env=_pristine_env(), + capture_output=True, + text=True, + ) + assert result.returncode != 0, "foreign venv must NOT be able to import apm_cli" + assert "ModuleNotFoundError" in result.stderr or "No module named" in result.stderr + + +# --------------------------------------------------------------------------- V1 + + +@_needs_truststore +def test_v1_foreign_venv_without_apm_cli_gets_os_trust(tmp_path): + """V1: the .pth bootstrap injects truststore in a venv that cannot import apm_cli.""" + interpreter, site_packages = _seed_foreign_venv(tmp_path) + + # The venv is genuinely foreign: apm_cli is unreachable (round-1 field case). + _assert_no_apm_cli(interpreter) + + # Control BEFORE the bootstrap: stock stdlib ssl (would verify via certifi). + assert _run_probe(interpreter, _SSL_OWNER_PROBE) == "ssl" + + # Drop the SHIPPED bootstrap -> the child's ssl becomes truststore-backed. + pth = _copy_shipped_bootstrap(site_packages) + owner = _run_probe(interpreter, _SSL_OWNER_PROBE) + assert owner.startswith("truststore"), ( + f"expected truststore-backed ssl after bootstrap, got {owner!r}" + ) + + # Control AFTER: remove ONLY the .pth (leave the module) -> revert to stdlib + # ssl. Proves the .pth is the driver, not incidental import side effects. + pth.unlink() + assert _run_probe(interpreter, _SSL_OWNER_PROBE) == "ssl" + + +def test_v1_bootstrap_has_zero_apm_cli_imports(): + """V1: the shipped bootstrap must carry no apm_cli import dependency.""" + source = Path(_child_bootstrap_dir()) / "_apm_tls_bootstrap.py" + body = source.read_text(encoding="utf-8") + for line in body.splitlines(): + stripped = line.strip() + assert not stripped.startswith("import apm_cli"), "bootstrap must not import apm_cli" + assert not stripped.startswith("from apm_cli"), "bootstrap must not import from apm_cli" + + +# --------------------------------------------------------------------------- V2 + + +@_needs_truststore +def test_v2_pth_does_not_shadow_user_sitecustomize(tmp_path): + """V2: a pre-existing user sitecustomize.py still runs AND truststore injects.""" + interpreter, site_packages = _seed_foreign_venv(tmp_path) + _copy_shipped_bootstrap(site_packages) + + # A user/corporate sitecustomize that records it ran via a distinct sentinel. + (site_packages / "sitecustomize.py").write_text( + f'import os\nos.environ["{_SITECUSTOMIZE_SENTINEL}"] = "1"\n', + encoding="utf-8", + ) + + probe = ( + "import os, ssl;" + f' print(os.environ.get("{_SITECUSTOMIZE_SENTINEL}"));' + " print(ssl.SSLContext.__module__)" + ) + out = _run_probe(interpreter, probe).splitlines() + sentinel_value, ssl_owner = out[0].strip(), out[1].strip() + + assert sentinel_value == "1", "user sitecustomize.py must still run (no hijack)" + assert ssl_owner.startswith("truststore"), "bootstrap must ALSO run alongside sitecustomize" + + +# --------------------------------------------------------------------------- V3 + + +@_needs_truststore +def test_v3_delivery_helper_installs_and_injects(tmp_path): + """V3: ensure_child_tls_bootstrap lands both files and the venv then injects.""" + interpreter, site_packages = _seed_foreign_venv(tmp_path) + venv_root = tmp_path / "foreign" + + installed = ensure_child_tls_bootstrap(venv_root) + assert installed is True, "ensure_child_tls_bootstrap should report success" + assert (site_packages / "_apm_tls_bootstrap.py").is_file() + assert (site_packages / "_apm_tls.pth").is_file() + + # The venv still cannot import apm_cli, yet its interpreter now injects. + _assert_no_apm_cli(interpreter) + owner = _run_probe(interpreter, _SSL_OWNER_PROBE) + assert owner.startswith("truststore"), f"helper-installed bootstrap must inject, got {owner!r}" + + # Idempotent re-run and graceful failure on a non-venv path. + assert ensure_child_tls_bootstrap(venv_root) is True + assert ensure_child_tls_bootstrap(tmp_path / "does-not-exist") is False + + +# --------------------------------------------------------------------------- V4 + + +def _fake_truststore(monkeypatch, inject): + module = types.ModuleType("truststore") + module.inject_into_ssl = inject + monkeypatch.setitem(sys.modules, "truststore", module) + + +def _bundled_env(extra: dict[str, str] | None = None) -> dict[str, str]: + env = {_BUNDLED_CERT_MARKER: "1", _SSL_CERT_FILE: "/bundled/certifi.pem"} + if extra: + env.update(extra) + return env + + +def test_v4_marker_cleared_on_opt_out_branch(): + env = _bundled_env({_DISABLE_ENV_VAR: "1"}) + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_v4_marker_cleared_on_explicit_override_branch(): + env = _bundled_env({"REQUESTS_CA_BUNDLE": "/corp/ca.pem"}) + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_v4_marker_cleared_when_truststore_import_fails(monkeypatch): + # Force `import truststore` to raise ImportError inside configure_tls_trust. + monkeypatch.setitem(sys.modules, "truststore", None) + env = _bundled_env() + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + # Import fails before the pop-before-inject step, so SSL_CERT_FILE is kept. + assert env.get(_SSL_CERT_FILE) == "/bundled/certifi.pem" + + +def test_v4_marker_cleared_and_bundled_popped_on_successful_inject(monkeypatch): + calls = {"n": 0} + + def _inject(): + calls["n"] += 1 + + _fake_truststore(monkeypatch, _inject) + env = _bundled_env() + assert configure_tls_trust(env=env) is True + assert calls["n"] == 1 + assert _BUNDLED_CERT_MARKER not in env + # B2 crypto: the bundled certifi path is popped before injection so the OS + # store is consulted (a stale SSL_CERT_FILE would shadow it). + assert _SSL_CERT_FILE not in env + + +def test_v4_marker_cleared_and_bundled_restored_when_inject_raises(monkeypatch): + def _boom(): + raise RuntimeError("inject failure") + + _fake_truststore(monkeypatch, _boom) + env = _bundled_env() + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + # B2 crypto: on failure the bundled path is RESTORED so hosts without an OS + # store still verify against certifi (never end with zero trust). + assert env.get(_SSL_CERT_FILE) == "/bundled/certifi.pem" + + +# --------------------------------------------------------------------------- V6 + + +def test_v6_build_child_tls_env_strips_marker_without_pythonpath_mutation(): + base = { + _BUNDLED_CERT_MARKER: "1", + "PATH": "/usr/bin", + "HOME": "/home/verif", + } + child = build_child_tls_env(base) + assert _BUNDLED_CERT_MARKER not in child, "internal marker must be stripped from child env" + # No PYTHONPATH injection: the round-1 shim mechanism is gone. + assert "PYTHONPATH" not in child + # Non-marker vars pass through untouched. + assert child["PATH"] == "/usr/bin" + assert child["HOME"] == "/home/verif" + # The input mapping is not mutated in place. + assert _BUNDLED_CERT_MARKER in base + + +def test_v6_build_child_tls_env_preserves_existing_pythonpath(): + """A caller-set PYTHONPATH must pass through unchanged (no TLS shim prepend).""" + base = {"PYTHONPATH": "/caller/libs", _BUNDLED_CERT_MARKER: "1"} + child = build_child_tls_env(base) + assert child.get("PYTHONPATH") == "/caller/libs" diff --git a/tests/integration/test_tls_r3_verify.py b/tests/integration/test_tls_r3_verify.py new file mode 100644 index 000000000..37b234635 --- /dev/null +++ b/tests/integration/test_tls_r3_verify.py @@ -0,0 +1,397 @@ +"""Independent round-3 verification of PR #2005 OS-trust delivery (issue #2004). + +Written FROM SCRATCH by the verifier (not the implementer) to prove -- on the +one surface the HIGH bug lived on -- that the child-trust bootstrap survives a +real setuptools wheel build/install and reaches a foreign interpreter. + +Round-2 shipped a ``.pth`` + bootstrap module into a child venv's +site-packages, but a red team found the ``.pth`` was silently DROPPED from the +wheel (``packages.find`` ships only ``.py`` and there was no package-data), so +``ensure_child_tls_bootstrap`` had nothing to copy on the PyPI channel and child +trust was a no-op. Round-3 (a) generates the ``.pth`` inline so delivery no +longer depends on packaging, and (b) adds ``package-data`` so the wheel ships it +anyway. + +The gates here are deliberately independent of the implementer's tests: + +* V1 -- build the wheel, assert BOTH child-TLS files are in the archive, pip + install the wheel into a clean venv, then from THAT venv deliver the bootstrap + into a SECOND foreign venv (no ``apm_cli``) and prove its interpreter injects + ``truststore`` (with the ``.pth`` removed as the negative control). This is the + exact end-to-end chain the wheel bug broke. +* V2 -- ``build_child_tls_env`` drops a bundled-certifi ``SSL_CERT_FILE`` (so the + child reaches the OS store on Linux) but preserves a genuine user value. +* V3 -- ``ensure_child_tls_bootstrap`` writes atomically, leaves no partial file + when the atomic replace fails, and writes the module BEFORE the ``.pth``. +* V4 -- the runtime setup scripts install ``truststore`` best-effort (a failure + never aborts setup) and pin ``truststore>=0.10.0``. +* V5 -- the ssl-issues doc surfaces the Node/Codex scope caveat early with the + ``NODE_EXTRA_CA_CERTS`` workaround, keeps the ``PIP_CERT`` and stale-bundle + notes, and the CHANGELOG scopes coverage to the Python-based paths. + +Offline-by-design: ``truststore`` is copied from the running test environment +into the foreign venv rather than pip-installed. The interpreter under test is +still a foreign venv with NO ``apm_cli``. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.integration] + + +# --------------------------------------------------------------------------- # +# Shared helpers +# --------------------------------------------------------------------------- # +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def _uv() -> str | None: + return shutil.which("uv") + + +def _venv_python(venv: Path) -> Path: + posix = venv / "bin" / "python" + windows = venv / "Scripts" / "python.exe" + return posix if posix.exists() else windows + + +def _make_venv(venv: Path) -> None: + """Create a venv at *venv* using the same interpreter running the tests. + + Prefers ``uv venv`` (the repo toolchain) with the current interpreter + pinned, falling back to the stdlib ``venv`` module. + """ + uv = _uv() + if uv: + subprocess.run( + [uv, "venv", "--python", sys.executable, str(venv)], + check=True, + capture_output=True, + text=True, + ) + else: + subprocess.run( + [sys.executable, "-m", "venv", str(venv)], + check=True, + capture_output=True, + text=True, + ) + + +def _pip_install(target_python: Path, spec: str) -> None: + uv = _uv() + if uv: + subprocess.run( + [uv, "pip", "install", "--python", str(target_python), spec], + check=True, + capture_output=True, + text=True, + ) + else: + subprocess.run( + [str(target_python), "-m", "pip", "install", spec], + check=True, + capture_output=True, + text=True, + ) + + +def _site_packages(venv: Path) -> Path: + candidates = list(venv.glob("lib/python*/site-packages")) + candidates.extend(venv.glob("Lib/site-packages")) + for candidate in candidates: + if candidate.is_dir(): + return candidate + raise AssertionError(f"no site-packages under {venv}") + + +def _run_python(python: Path, code: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(python), "-c", code], + capture_output=True, + text=True, + check=False, + ) + + +def _build_wheel(out_dir: Path) -> Path: + repo = _repo_root() + uv = _uv() + if uv: + cmd = [uv, "build", "--wheel", "--out-dir", str(out_dir)] + else: + cmd = [sys.executable, "-m", "build", "--wheel", "--outdir", str(out_dir)] + result = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, check=False) + if result.returncode != 0: + pytest.skip(f"wheel build unavailable: {result.stdout}\n{result.stderr}") + wheels = list(out_dir.glob("*.whl")) + if not wheels: + pytest.skip("wheel build produced no .whl artifact") + return wheels[0] + + +def _copy_truststore_into(site_packages: Path) -> None: + """Copy the pure-Python ``truststore`` package offline into *site_packages*.""" + try: + import truststore + except Exception: # pragma: no cover - environment guard + pytest.skip("truststore not importable in the test environment to copy offline") + src = Path(truststore.__file__).resolve().parent + shutil.copytree(src, site_packages / "truststore", dirs_exist_ok=True) + + +# --------------------------------------------------------------------------- # +# V1 (H1): wheel build + install + foreign-venv delivery + injection +# --------------------------------------------------------------------------- # +@pytest.mark.slow +def test_v1_wheel_delivers_os_trust_end_to_end(tmp_path): + # 1. Build the wheel and 2. assert BOTH child-TLS files are archived. + wheel = _build_wheel(tmp_path / "wheel") + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + module_member = "apm_cli/core/_child_tls/_apm_tls_bootstrap.py" + pth_member = "apm_cli/core/_child_tls/_apm_tls.pth" + assert module_member in names, f"wheel missing bootstrap module: {names}" + assert pth_member in names, f"H1 regression: wheel missing .pth: {names}" + + # 3. Install the BUILT WHEEL into a clean venv (not editable, not source). + apm_venv = tmp_path / "apm" + _make_venv(apm_venv) + apm_python = _venv_python(apm_venv) + _pip_install(apm_python, str(wheel)) + located = _run_python( + apm_python, + "import apm_cli.core.tls_trust as t; print(t.__file__)", + ) + assert located.returncode == 0, located.stderr + installed_file = Path(located.stdout.strip()) + assert apm_venv in installed_file.parents, ( + f"apm_cli must import from the wheel venv, got {installed_file}" + ) + + # 4. From the wheel venv, deliver the bootstrap into a SECOND foreign venv. + foreign_venv = tmp_path / "foreign" + _make_venv(foreign_venv) + foreign_python = _venv_python(foreign_venv) + no_apm = _run_python(foreign_python, "import apm_cli") + assert no_apm.returncode != 0, "foreign venv must NOT have apm_cli" + + delivered = _run_python( + apm_python, + ( + "from apm_cli.core.tls_trust import ensure_child_tls_bootstrap;" + f"print(ensure_child_tls_bootstrap({str(foreign_venv)!r}))" + ), + ) + assert delivered.returncode == 0, delivered.stderr + assert delivered.stdout.strip() == "True", delivered.stdout + foreign_sp = _site_packages(foreign_venv) + assert (foreign_sp / "_apm_tls_bootstrap.py").is_file() + assert (foreign_sp / "_apm_tls.pth").is_file() + + # 5. Make truststore importable in the foreign venv and prove injection. + _copy_truststore_into(foreign_sp) + with_pth = _run_python(foreign_python, "import ssl; print(ssl.SSLContext.__module__)") + assert with_pth.returncode == 0, with_pth.stderr + assert with_pth.stdout.strip().startswith("truststore"), with_pth.stdout + + # Negative control: remove the .pth -> stdlib ssl is back. + (foreign_sp / "_apm_tls.pth").unlink() + without_pth = _run_python(foreign_python, "import ssl; print(ssl.SSLContext.__module__)") + assert without_pth.returncode == 0, without_pth.stderr + assert without_pth.stdout.strip() == "ssl", without_pth.stdout + + # apm_cli is still absent from the foreign interpreter under test. + still_no_apm = _run_python(foreign_python, "import apm_cli") + assert still_no_apm.returncode != 0 + + +# --------------------------------------------------------------------------- # +# V2 (M2): child env drops bundled certifi, preserves a user value +# --------------------------------------------------------------------------- # +def test_v2_build_child_env_drops_bundled_certifi(): + import certifi + + from apm_cli.core.tls_trust import build_child_tls_env + + marker = "APM_SSL_CERT_FILE_IS_BUNDLED_DEFAULT" + + # Bundled certifi (== certifi.where()) is dropped; marker is popped. + bundled = build_child_tls_env({"SSL_CERT_FILE": certifi.where(), marker: "1", "PATH": "/keep"}) + assert "SSL_CERT_FILE" not in bundled + assert marker not in bundled + assert bundled.get("PATH") == "/keep" + + # A genuine user SSL_CERT_FILE is preserved; marker still popped. + user = build_child_tls_env({"SSL_CERT_FILE": "/etc/pki/corp-ca.pem", marker: "1"}) + assert user.get("SSL_CERT_FILE") == "/etc/pki/corp-ca.pem" + assert marker not in user + + +def test_v2_build_child_env_drops_recorded_frozen_certifi_path(monkeypatch): + from apm_cli.core import tls_trust + + # The frozen hook sets SSL_CERT_FILE to a certifi/cacert.pem path under + # _MEIPASS. The parent records that exact path before clearing its internal + # marker, so unrelated user paths with the same suffix remain untouched. + posix_path = "/var/_MEIabc/certifi/cacert.pem" + monkeypatch.setattr(tls_trust, "_KNOWN_BUNDLED_CERT_FILE", posix_path) + posix = tls_trust.build_child_tls_env({"SSL_CERT_FILE": posix_path}) + assert "SSL_CERT_FILE" not in posix + windows_path = "C:\\Temp\\_MEI9\\certifi\\cacert.pem" + monkeypatch.setattr(tls_trust, "_KNOWN_BUNDLED_CERT_FILE", windows_path) + windows = tls_trust.build_child_tls_env({"SSL_CERT_FILE": windows_path}) + assert "SSL_CERT_FILE" not in windows + + +# --------------------------------------------------------------------------- # +# V3 (M3): atomic write, no partial file on failure, module before .pth +# --------------------------------------------------------------------------- # +def test_v3_no_partial_file_when_replace_fails(tmp_path, monkeypatch): + from apm_cli.core import tls_trust + + site_packages = tmp_path / "venv" / "lib" / "python3.12" / "site-packages" + site_packages.mkdir(parents=True) + + def boom(src, dst, *args, **kwargs): + raise OSError("simulated atomic replace failure") + + monkeypatch.setattr(os, "replace", boom) + result = tls_trust.ensure_child_tls_bootstrap(tmp_path / "venv") + + assert result is False + leftovers = sorted(p.name for p in site_packages.iterdir()) + assert not (site_packages / "_apm_tls_bootstrap.py").exists(), leftovers + assert not (site_packages / "_apm_tls.pth").exists(), leftovers + assert not any(name.endswith(".tmp") or name.startswith(".apm_tls_") for name in leftovers), ( + f"stray temp file left behind: {leftovers}" + ) + + +def test_v3_writes_module_before_pth(tmp_path, monkeypatch): + from apm_cli.core import tls_trust + + site_packages = tmp_path / "venv" / "lib" / "python3.12" / "site-packages" + site_packages.mkdir(parents=True) + + order: list[str] = [] + original = tls_trust._atomic_write + + def spy(target, data): + order.append(Path(target).name) + return original(target, data) + + monkeypatch.setattr(tls_trust, "_atomic_write", spy) + result = tls_trust.ensure_child_tls_bootstrap(tmp_path / "venv") + + assert result is True + assert order == ["_apm_tls_bootstrap.py", "_apm_tls.pth"], order + assert (site_packages / "_apm_tls_bootstrap.py").is_file() + assert (site_packages / "_apm_tls.pth").is_file() + assert (site_packages / "_apm_tls.pth").read_text(encoding="ascii") == ( + "import _apm_tls_bootstrap\n" + ) + + +# --------------------------------------------------------------------------- # +# V4 (M1): runtime setup scripts install truststore best-effort + pinned +# --------------------------------------------------------------------------- # +def test_v4_setup_llm_truststore_is_best_effort_and_pinned(): + root = _repo_root() + sh = (root / "scripts" / "runtime" / "setup-llm.sh").read_text(encoding="utf-8") + ps1 = (root / "scripts" / "runtime" / "setup-llm.ps1").read_text(encoding="utf-8") + + # Both scripts pin the floor so the child gets an OS-trust-capable truststore. + assert "truststore>=0.10.0" in sh + assert "truststore>=0.10.0" in ps1 + assert "sys.version_info < (3, 10)" in sh + assert "sys.version_info < (3, 10)" in ps1 + assert "PIP_CERT" in sh + assert "PIP_CERT" in ps1 + docs_url = "https://microsoft.github.io/apm/troubleshooting/ssl-issues/" + assert docs_url in sh + assert docs_url in ps1 + + # Bash: the pip install is guarded by an if block, so a failure under + # `set -euo pipefail` does not abort setup. + assert 'if ! "$llm_venv/bin/pip" install "truststore>=0.10.0"; then' in sh + + # PowerShell: the install lives inside a try/catch that downgrades to a + # warning rather than surfacing under `$ErrorActionPreference = 'Stop'`. + tail = ps1[ps1.index("truststore>=0.10.0") :] + assert "} catch {" in tail + assert "Write-WarningText" in tail + + +def test_v4_best_effort_control_flow_does_not_abort(tmp_path): + # 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( + "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", + ) + result = subprocess.run(["bash", str(script)], capture_output=True, text=True, check=False) + assert result.returncode == 0, result.stderr + assert "CONTINUED" in result.stdout + + +# --------------------------------------------------------------------------- # +# V5 (M5/L1/M4-docs): docs + changelog scope honesty +# --------------------------------------------------------------------------- # +def test_v5_ssl_docs_early_caveat_and_notes(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + lines = docs.splitlines() + heading = "## Default behaviour: the OS trust store" + heading_idx = next(i for i, line in enumerate(lines) if line.strip() == heading) + caveat_idx = next(i for i, line in enumerate(lines) if "Scope caveat" in line) + assert caveat_idx > heading_idx + # The caveat must be near the top of the section, not buried far below. + gap = sum(1 for line in lines[heading_idx + 1 : caveat_idx] if line.strip()) + assert gap <= 2, f"Node/Codex caveat must sit within ~2 content lines, got {gap}" + + known_limits = docs.index("### Known limitations") + early_region = docs[docs.index(heading) : known_limits] + assert "NODE_EXTRA_CA_CERTS" in early_region + + # M4-docs: pip's own cert resolution caveat during setup. + assert "PIP_CERT" in docs + # L1: REQUESTS_CA_BUNDLE replaces (not augments) + the stale-bundle note. + assert "*replaces*" in docs + assert "stale `REQUESTS_CA_BUNDLE`" in docs + + +def test_v5_changelog_scopes_python_based(): + changelog = (_repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + start = changelog.index("## [Unreleased]") + rest = changelog[start + len("## [Unreleased]") :] + end = rest.find("\n## [") + block = rest if end == -1 else rest[:end] + + assert "#2005" in block + assert "Python-based" in block + assert "#2034" in block + assert "not yet covered" in block + # The stale round-1 joint claim (child runtimes covered) must be gone. + assert "and `apm run` (child runtimes)" not in block diff --git a/tests/integration/test_tls_wheel_content.py b/tests/integration/test_tls_wheel_content.py new file mode 100644 index 000000000..c79c72b89 --- /dev/null +++ b/tests/integration/test_tls_wheel_content.py @@ -0,0 +1,112 @@ +"""T1 (H1 regression): the built wheel must ship the child-TLS delivery files. + +The round-2 mechanism drops ``_apm_tls_bootstrap.py`` + ``_apm_tls.pth`` into a +child venv's site-packages. A red team found the ``.pth`` was silently DROPPED +from the wheel (setuptools' ``packages.find`` ships only ``.py`` and there was +no package-data), so ``ensure_child_tls_bootstrap`` could not copy it on the +PyPI channel -> child trust was a silent no-op. + +Round-3 fixes this two ways: the ``.pth`` is generated inline (so delivery no +longer depends on packaging) AND ``[tool.setuptools.package-data]`` now ships +the ``.pth`` too. This test is the belt-and-suspenders regression guard: build +the wheel hermetically and assert BOTH files are present in the archive. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.integration, pytest.mark.slow] + + +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def _build_wheel(out_dir: Path) -> Path: + """Build a wheel into *out_dir* and return its path. Prefer uv, fall back to build.""" + repo = _repo_root() + if shutil.which("uv"): + cmd = ["uv", "build", "--wheel", "--out-dir", str(out_dir)] + else: + cmd = [sys.executable, "-m", "build", "--wheel", "--outdir", str(out_dir)] + result = subprocess.run( + cmd, + cwd=str(repo), + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip( + f"wheel build unavailable in this environment: {result.stdout}\n{result.stderr}" + ) + wheels = list(out_dir.glob("*.whl")) + if not wheels: + pytest.skip("wheel build produced no .whl artifact") + return wheels[0] + + +def test_wheel_ships_child_tls_bootstrap_and_pth(tmp_path): + wheel = _build_wheel(tmp_path) + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + + module = "apm_cli/core/_child_tls/_apm_tls_bootstrap.py" + pth = "apm_cli/core/_child_tls/_apm_tls.pth" + assert module in names, f"{module} missing from wheel: {names}" + assert pth in names, f"{pth} missing from wheel: {names}" + + +def _build_sdist(out_dir: Path) -> Path: + """Build an sdist into *out_dir* and return its path. Prefer uv, fall back to build.""" + repo = _repo_root() + if shutil.which("uv"): + cmd = ["uv", "build", "--sdist", "--out-dir", str(out_dir)] + else: + cmd = [sys.executable, "-m", "build", "--sdist", "--outdir", str(out_dir)] + result = subprocess.run( + cmd, + cwd=str(repo), + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip( + f"sdist build unavailable in this environment: {result.stdout}\n{result.stderr}" + ) + sdists = list(out_dir.glob("*.tar.gz")) + if not sdists: + pytest.skip("sdist build produced no .tar.gz artifact") + return sdists[0] + + +def test_sdist_ships_child_tls_bootstrap_and_pth(tmp_path): + """LOW-1 (round-4): guard the sdist channel too. + + The bootstrap MODULE is a plain package ``.py`` (auto-shipped), but the + ``.pth`` rides on ``package-data`` and can silently diverge from the wheel + across setuptools versions. Assert BOTH members are in the tarball so a + future backend bump that drops the ``.pth`` from the sdist is caught. + """ + sdist = _build_sdist(tmp_path) + with tarfile.open(sdist, "r:gz") as archive: + names = archive.getnames() + + # sdist members are prefixed with the top-level "-/" dir. + module_tail = "src/apm_cli/core/_child_tls/_apm_tls_bootstrap.py" + pth_tail = "src/apm_cli/core/_child_tls/_apm_tls.pth" + assert any(n.endswith(module_tail) for n in names), f"{module_tail} missing from sdist: {names}" + assert any(n.endswith(pth_tail) for n in names), f"{pth_tail} missing from sdist: {names}" diff --git a/tests/unit/core/test_tls_trust.py b/tests/unit/core/test_tls_trust.py new file mode 100644 index 000000000..9a2d32f67 --- /dev/null +++ b/tests/unit/core/test_tls_trust.py @@ -0,0 +1,576 @@ +"""Unit tests for apm_cli.core.tls_trust.configure_tls_trust. + +Covers every branch: +- opt-out via APM_DISABLE_TRUSTSTORE +- explicit CA bundle env vars win (REQUESTS_CA_BUNDLE / CURL_CA_BUNDLE) +- SSL_CERT_FILE / SSL_CERT_DIR do NOT suppress injection +- truststore missing -> graceful certifi fallback +- injection failure -> graceful certifi fallback +- happy path -> inject_into_ssl called exactly once +""" + +from __future__ import annotations + +import ast +import logging +import os +import subprocess +import sys +import types +from pathlib import Path + +import pytest + +from apm_cli.core.tls_trust import ( + _BUNDLED_CERT_MARKER, + _DISABLE_ENV_VAR, + _EXPLICIT_CA_ENV_VARS, + build_child_tls_env, + configure_tls_trust, + ensure_child_tls_bootstrap, + has_explicit_ca_override, + log_tls_trust_status, +) + +_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) + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """Start each test from a pristine env (no override / opt-out set).""" + for var in _ALL_TRUST_ENV: + monkeypatch.delenv(var, raising=False) + + +def _install_fake_truststore(monkeypatch, inject=None): + """Put a fake ``truststore`` module in sys.modules and return its inject mock.""" + calls = {"n": 0} + + def _default_inject(): + calls["n"] += 1 + + module = types.ModuleType("truststore") + module.inject_into_ssl = inject or _default_inject # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "truststore", module) + return calls + + +def test_opt_out_disables_injection(monkeypatch): + calls = _install_fake_truststore(monkeypatch) + + assert configure_tls_trust(env={_DISABLE_ENV_VAR: "1"}) is False + assert calls["n"] == 0 + + +@pytest.mark.parametrize("var", _EXPLICIT_CA_ENV_VARS) +def test_explicit_ca_bundle_wins(monkeypatch, var): + calls = _install_fake_truststore(monkeypatch) + + assert has_explicit_ca_override(env={var: "/etc/ssl/certs/custom-ca.pem"}) is True + assert configure_tls_trust(env={var: "/etc/ssl/certs/custom-ca.pem"}) is False + assert calls["n"] == 0 + + +@pytest.mark.parametrize("var", _NON_REQUESTS_CA_ENV_VARS) +def test_non_requests_ca_env_does_not_suppress_injection(monkeypatch, var): + # SSL_CERT_FILE and SSL_CERT_DIR are not requests CA overrides. The frozen + # runtime hook sets SSL_CERT_FILE to bundled certifi, so these vars must not + # disable OS-trust injection in the shipped artifact. + calls = _install_fake_truststore(monkeypatch) + env = {var: "/etc/ssl/certs/ca-certificates.crt"} + + assert has_explicit_ca_override(env=env) is False + assert configure_tls_trust(env=env) is True + assert calls["n"] == 1 + + +def test_missing_truststore_falls_back(monkeypatch): + # A None entry in sys.modules makes ``import truststore`` raise ImportError. + monkeypatch.setitem(sys.modules, "truststore", None) + + assert configure_tls_trust() is False + + +def test_injection_failure_falls_back(monkeypatch): + def _boom(): + raise RuntimeError("platform trust API unavailable") + + _install_fake_truststore(monkeypatch, inject=_boom) + + assert configure_tls_trust() is False + + +def test_happy_path_injects_once(monkeypatch): + calls = _install_fake_truststore(monkeypatch) + + assert configure_tls_trust() is True + assert calls["n"] == 1 + + +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def test_cli_bootstrap_injects_before_requests_import(tmp_path): + sentinel = tmp_path / "sentinel.txt" + fake_truststore = tmp_path / "truststore.py" + fake_truststore.write_text( + "\n".join( + [ + "import os", + "import pathlib", + "import sys", + "", + "def inject_into_ssl():", + " pathlib.Path(os.environ['TRUSTSTORE_SENTINEL']).write_text(", + " 'requests_imported=' + str('requests' in sys.modules),", + " encoding='utf-8',", + " )", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + for name in ( + _DISABLE_ENV_VAR, + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + ): + env.pop(name, None) + env["PYTHONPATH"] = f"{tmp_path}{os.pathsep}{_repo_root() / 'src'}" + env["TRUSTSTORE_SENTINEL"] = str(sentinel) + + result = subprocess.run( + [sys.executable, "-c", "import apm_cli.cli"], + cwd=_repo_root(), + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert sentinel.read_text(encoding="utf-8") == "requests_imported=False" + + +def test_cli_bootstrap_is_idempotent_across_import_and_main(tmp_path): + sentinel = tmp_path / "sentinel.txt" + fake_truststore = tmp_path / "truststore.py" + fake_truststore.write_text( + "\n".join( + [ + "import os", + "import pathlib", + "", + "def inject_into_ssl():", + " path = pathlib.Path(os.environ['TRUSTSTORE_SENTINEL'])", + " count = int(path.read_text(encoding='utf-8') or '0') if path.exists() else 0", + " path.write_text(str(count + 1), encoding='utf-8')", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + for name in ( + _DISABLE_ENV_VAR, + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + ): + env.pop(name, None) + env["PYTHONPATH"] = f"{tmp_path}{os.pathsep}{_repo_root() / 'src'}" + env["TRUSTSTORE_SENTINEL"] = str(sentinel) + + result = subprocess.run( + [ + sys.executable, + "-c", + "import apm_cli.cli as c\n" + "def fake_cli(*, obj):\n" + " return None\n" + "c.cli = fake_cli\n" + "c.main()\n", + ], + cwd=_repo_root(), + env=env, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert sentinel.read_text(encoding="utf-8") == "1" + + +# --------------------------------------------------------------------------- +# H2 -- visible trust-source diagnostic. Each branch of configure_tls_trust must +# emit an ASCII "TLS: ..." line at DEBUG naming which trust source is in +# effect, so an operator can tell OS-store vs certifi-fallback vs explicit +# bundle vs opt-out from the logs alone. +# --------------------------------------------------------------------------- + + +def _trust_source_messages(caplog): + """Rendered log messages emitted by configure_tls_trust that name a trust source.""" + return [ + record.getMessage() + for record in caplog.records + if record.name == "apm_cli.core.tls_trust" and "TLS:" in record.getMessage() + ] + + +def test_diag_default_inject_names_os_trust_store(monkeypatch, caplog): + _install_fake_truststore(monkeypatch) + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + assert configure_tls_trust() is True + + messages = _trust_source_messages(caplog) + assert "TLS: verifying against OS trust store (truststore)" in messages + for message in messages: + message.encode("ascii") # must not raise + + +def test_diag_disabled_names_opt_out(caplog): + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + assert configure_tls_trust(env={_DISABLE_ENV_VAR: "1"}) is False + + messages = _trust_source_messages(caplog) + assert "TLS: OS trust-store injection disabled (APM_DISABLE_TRUSTSTORE)" in messages + for message in messages: + message.encode("ascii") + + +def test_diag_explicit_bundle_names_the_path(caplog): + ca_path = "/etc/ssl/certs/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 + for message in messages: + message.encode("ascii") + + +def test_diag_import_failure_names_certifi_fallback(monkeypatch, caplog): + # A None entry makes ``import truststore`` raise -> certifi-fallback branch. + monkeypatch.setitem(sys.modules, "truststore", None) + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + assert configure_tls_trust() is False + + messages = _trust_source_messages(caplog) + # The branch appends the captured exception in brackets; match the stable core. + assert any( + m.startswith("TLS: verifying against bundled CA (certifi fallback)") for m in messages + ), messages + for message in messages: + message.encode("ascii") + + +def test_cached_trust_source_can_be_replayed_after_logging_configuration(monkeypatch, caplog): + _install_fake_truststore(monkeypatch) + configure_tls_trust() + caplog.clear() + + with caplog.at_level(logging.DEBUG, logger="apm_cli.core.tls_trust"): + log_tls_trust_status() + + assert _trust_source_messages(caplog) == ["TLS: verifying against OS trust store (truststore)"] + + +# --------------------------------------------------------------------------- +# T4 -- the internal bundled-default marker must NEVER leak out of +# configure_tls_trust, on ANY of its return branches. A leaked marker would tell +# a child interpreter to pop a SSL_CERT_FILE that is not actually a bundled +# default, silently weakening trust. +# --------------------------------------------------------------------------- + + +def _marker_env(**extra): + env = {_BUNDLED_CERT_MARKER: "1"} + env.update(extra) + return env + + +def test_marker_cleared_on_opt_out_branch(monkeypatch): + _install_fake_truststore(monkeypatch) + env = _marker_env(**{_DISABLE_ENV_VAR: "1"}) + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_marker_cleared_on_explicit_override_branch(monkeypatch): + _install_fake_truststore(monkeypatch) + env = _marker_env(REQUESTS_CA_BUNDLE="/etc/ssl/corp.pem") + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_marker_cleared_on_truststore_import_failure(monkeypatch): + monkeypatch.setitem(sys.modules, "truststore", None) + env = _marker_env(SSL_CERT_FILE="/bundled/certifi.pem") + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + + +def test_marker_cleared_on_inject_failure(monkeypatch): + def _boom(): + raise RuntimeError("platform trust API unavailable") + + _install_fake_truststore(monkeypatch, inject=_boom) + env = _marker_env(SSL_CERT_FILE="/bundled/certifi.pem") + assert configure_tls_trust(env=env) is False + assert _BUNDLED_CERT_MARKER not in env + # certifi fallback restored (never zero trust). + assert env.get("SSL_CERT_FILE") == "/bundled/certifi.pem" + + +def test_marker_cleared_on_inject_success(monkeypatch): + _install_fake_truststore(monkeypatch) + env = _marker_env(SSL_CERT_FILE="/bundled/certifi.pem") + assert configure_tls_trust(env=env) is True + assert _BUNDLED_CERT_MARKER not in env + # bundled default popped so the OS store is consulted. + assert "SSL_CERT_FILE" not in env + + +# --------------------------------------------------------------------------- +# 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). +# --------------------------------------------------------------------------- + + +def test_build_child_tls_env_strips_marker(): + base = {_BUNDLED_CERT_MARKER: "1", "PATH": "/usr/bin", "FOO": "bar"} + child = build_child_tls_env(base) + assert _BUNDLED_CERT_MARKER not in child + assert child["PATH"] == "/usr/bin" + assert child["FOO"] == "bar" + + +def test_build_child_tls_env_does_not_touch_pythonpath(): + base = {"PYTHONPATH": "/user/site"} + child = build_child_tls_env(base) + # No shim dir prepended -- a user/corporate PYTHONPATH survives untouched. + assert child["PYTHONPATH"] == "/user/site" + + +def test_build_child_tls_env_returns_independent_copy(): + base = {"PATH": "/usr/bin"} + child = build_child_tls_env(base) + child["PATH"] = "/mutated" + assert base["PATH"] == "/usr/bin" + + +# --------------------------------------------------------------------------- +# T7 -- ensure_child_tls_bootstrap drops both delivery artifacts into a venv's +# site-packages so the child interpreter can import the bootstrap. +# --------------------------------------------------------------------------- + + +def _fake_venv(tmp_path: Path) -> Path: + """Create a POSIX-style venv skeleton with an empty site-packages dir.""" + site = tmp_path / "venv" / "lib" / "python3.12" / "site-packages" + site.mkdir(parents=True) + return tmp_path / "venv" + + +def test_ensure_child_tls_bootstrap_installs_both_files(tmp_path): + venv = _fake_venv(tmp_path) + assert ensure_child_tls_bootstrap(venv) is True + + site = venv / "lib" / "python3.12" / "site-packages" + module = site / "_apm_tls_bootstrap.py" + pth = site / "_apm_tls.pth" + assert module.is_file() + assert pth.is_file() + # The .pth is exactly the one-line import that triggers the bootstrap. + assert pth.read_text(encoding="utf-8").strip() == "import _apm_tls_bootstrap" + # The bootstrap has no apm_cli dependency (self-contained). + assert "import apm_cli" not in module.read_text(encoding="utf-8") + + +def test_ensure_child_tls_bootstrap_is_idempotent(tmp_path): + venv = _fake_venv(tmp_path) + assert ensure_child_tls_bootstrap(venv) is True + assert ensure_child_tls_bootstrap(venv) is True + + +def test_ensure_child_tls_bootstrap_returns_false_for_missing_site_packages(tmp_path): + # A path with no venv site-packages layout -> best-effort False, no raise. + assert ensure_child_tls_bootstrap(tmp_path / "does-not-exist") is False + + +def test_ensure_child_tls_bootstrap_rejects_site_packages_symlink_escape(tmp_path): + venv = tmp_path / "venv" + python_dir = venv / "lib" / "python3.12" + python_dir.mkdir(parents=True) + outside = tmp_path / "shared-site-packages" + outside.mkdir() + try: + (python_dir / "site-packages").symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlinks unavailable: {exc}") + + assert ensure_child_tls_bootstrap(venv) is False + assert not (outside / "_apm_tls_bootstrap.py").exists() + assert not (outside / "_apm_tls.pth").exists() + + +# --------------------------------------------------------------------------- +# T2 (H1) -- the .pth is GENERATED inline, so delivery does not depend on the +# source .pth being packaged into the wheel (setuptools' packages.find drops +# stray .pth data files). Prove the content is produced, not copied. +# --------------------------------------------------------------------------- + + +def test_pth_is_generated_not_copied(tmp_path, monkeypatch): + import apm_cli.core.tls_trust as tls + + venv = _fake_venv(tmp_path) + + # Point _child_bootstrap_dir at a source dir that has the MODULE but NO + # .pth file -- if delivery copied the .pth it would fail; generation must + # still produce it. + fake_src = tmp_path / "shipped" + fake_src.mkdir() + (fake_src / "_apm_tls_bootstrap.py").write_text("# bootstrap\n", encoding="ascii") + assert not (fake_src / "_apm_tls.pth").exists() + monkeypatch.setattr(tls, "_child_bootstrap_dir", lambda: str(fake_src)) + + assert ensure_child_tls_bootstrap(venv) is True + + site = venv / "lib" / "python3.12" / "site-packages" + pth = site / "_apm_tls.pth" + assert pth.is_file() + # Exact generated content: a single import line the interpreter runs. + assert pth.read_text(encoding="ascii") == "import _apm_tls_bootstrap\n" + assert (site / "_apm_tls_bootstrap.py").read_text(encoding="ascii") == "# bootstrap\n" + + +def test_child_bootstrap_tls_policy_matches_parent_constants(): + import apm_cli.core.tls_trust as tls + + bootstrap = Path(tls._child_bootstrap_dir()) / "_apm_tls_bootstrap.py" + literals = { + node.value + for node in ast.walk(ast.parse(bootstrap.read_text(encoding="utf-8"))) + if isinstance(node, ast.Constant) and isinstance(node.value, str) + } + expected = { + tls._DISABLE_ENV_VAR, + *tls._EXPLICIT_CA_ENV_VARS, + tls._BUNDLED_CERT_MARKER, + tls._SSL_CERT_FILE_VAR, + } + + assert expected <= literals + + +def test_child_bootstrap_debug_messages_do_not_embed_console_symbols(): + import apm_cli.core.tls_trust as tls + + bootstrap = Path(tls._child_bootstrap_dir()) / "_apm_tls_bootstrap.py" + assert '"[i] TLS:' not in bootstrap.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# T4 (M2) -- build_child_tls_env drops a BUNDLED certifi SSL_CERT_FILE so the +# child truststore reaches the OS store on Linux, but PRESERVES a genuine user +# SSL_CERT_FILE. The marker is still stripped either way. +# --------------------------------------------------------------------------- + + +def test_build_child_tls_env_drops_bundled_certifi_ssl_cert_file(): + import certifi + + base = { + _BUNDLED_CERT_MARKER: "1", + "SSL_CERT_FILE": certifi.where(), + "PATH": "/usr/bin", + } + child = build_child_tls_env(base) + assert "SSL_CERT_FILE" not in child + assert _BUNDLED_CERT_MARKER not in child + assert child["PATH"] == "/usr/bin" + + +def test_build_child_tls_env_drops_recorded_frozen_certifi_path(monkeypatch): + import apm_cli.core.tls_trust as tls + + # The frozen hook path is recorded while its internal marker is present. + # Exact matching avoids classifying an unrelated user path by suffix. + frozen = "/tmp/_MEIabc123/certifi/cacert.pem" + monkeypatch.setattr(tls, "_KNOWN_BUNDLED_CERT_FILE", frozen) + child = build_child_tls_env({_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": frozen}) + assert "SSL_CERT_FILE" not in child + + +def test_build_child_tls_env_preserves_genuine_user_ssl_cert_file(tmp_path): + user_ca = tmp_path / "corp" / "custom-ca.pem" + user_ca.parent.mkdir(parents=True) + user_ca.write_text("-----BEGIN CERTIFICATE-----\n", encoding="ascii") + base = {"SSL_CERT_FILE": str(user_ca), "PATH": "/usr/bin"} + child = build_child_tls_env(base) + # A genuine user CA path must NEVER be dropped. + assert child["SSL_CERT_FILE"] == str(user_ca) + + +def test_build_child_tls_env_preserves_certifi_lookalike_dir(monkeypatch): + import apm_cli.core.tls_trust as tls + + # F1 (round-4): the match is on path COMPONENTS, not a raw suffix. A user + # bundle under a directory that merely ENDS in "certifi" (e.g. a corporate + # "mycertifi/") must be preserved, not mistaken for APM's bundled set. + for lookalike in ( + "/opt/mycertifi/cacert.pem", + "/opt/supercertifi/cacert.pem", + "/a/notcertifi/cacert.pem", + ): + child = build_child_tls_env({_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": lookalike}) + assert child.get("SSL_CERT_FILE") == lookalike, f"lookalike {lookalike} was wrongly dropped" + # Only the exact path recorded from the frozen hook matches. + frozen = "/x/certifi/cacert.pem" + monkeypatch.setattr(tls, "_KNOWN_BUNDLED_CERT_FILE", frozen) + child = build_child_tls_env({_BUNDLED_CERT_MARKER: "1", "SSL_CERT_FILE": frozen}) + assert "SSL_CERT_FILE" not in child + + +def test_build_child_tls_env_preserves_user_certifi_component_path(): + user_bundle = "/opt/certifi/cacert.pem" + child = build_child_tls_env({"SSL_CERT_FILE": user_bundle}) + + assert child["SSL_CERT_FILE"] == user_bundle + + +# --------------------------------------------------------------------------- +# T5 (M3) -- a write failure must leave NO partial _apm_tls_bootstrap.py under a +# live .pth and must return False (atomic-write contract). +# --------------------------------------------------------------------------- + + +def test_ensure_child_tls_bootstrap_write_failure_leaves_no_partial(tmp_path, monkeypatch): + import apm_cli.core.tls_trust as tls + + venv = _fake_venv(tmp_path) + site = venv / "lib" / "python3.12" / "site-packages" + + def _boom(src, dst): + raise OSError("simulated replace failure") + + monkeypatch.setattr(tls.os, "replace", _boom) + + assert ensure_child_tls_bootstrap(venv) is False + # No partial artifacts left behind, and no leftover temp files. + assert not (site / "_apm_tls_bootstrap.py").exists() + assert not (site / "_apm_tls.pth").exists() + assert list(site.glob(".apm_tls_*.tmp")) == [] diff --git a/tests/unit/install/test_validation_phase3.py b/tests/unit/install/test_validation_phase3.py index 0b32a1cb1..2bb48eb5b 100644 --- a/tests/unit/install/test_validation_phase3.py +++ b/tests/unit/install/test_validation_phase3.py @@ -94,7 +94,9 @@ def test_non_verbose_emits_single_warning(self) -> None: exc = RuntimeError("TLS verification failed") _log_tls_failure("example.com", exc, verbose_log=None, logger=logger) logger.warning.assert_called_once() - assert "REQUESTS_CA_BUNDLE" in logger.warning.call_args[0][0] + message = logger.warning.call_args[0][0] + assert "system trust store" in message + assert "REQUESTS_CA_BUNDLE" in message def test_verbose_log_called_with_host_and_exc(self) -> None: from apm_cli.install.validation import _log_tls_failure diff --git a/tests/unit/test_llm_runtime.py b/tests/unit/test_llm_runtime.py index 0190bb2b7..17bb5c5bc 100644 --- a/tests/unit/test_llm_runtime.py +++ b/tests/unit/test_llm_runtime.py @@ -1,5 +1,6 @@ """Test LLM runtime integration.""" +import os from unittest.mock import Mock, patch import pytest @@ -10,8 +11,9 @@ class TestLLMRuntime: """Test LLM runtime adapter.""" + @patch("apm_cli.runtime.llm_runtime.build_child_tls_env", return_value={"PATH": "clean"}) @patch("apm_cli.runtime.llm_runtime.subprocess.run") - def test_init_success(self, mock_run): + def test_init_success(self, mock_run, mock_child_env): """Test successful initialization.""" # Mock the --version check mock_run.return_value = Mock(returncode=0, stdout="llm 0.17.0") @@ -20,8 +22,14 @@ def test_init_success(self, mock_run): assert runtime.model_name == "gpt-4o-mini" mock_run.assert_called_once_with( - ["llm", "--version"], capture_output=True, text=True, encoding="utf-8", check=True + ["llm", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + env={"PATH": "clean"}, ) + mock_child_env.assert_called_once_with(os.environ) @patch("apm_cli.runtime.llm_runtime.subprocess.run") def test_init_fallback(self, mock_run): @@ -81,3 +89,19 @@ def test_str_representation(self, mock_run): runtime = LLMRuntime("claude-3-sonnet") assert str(runtime) == "LLMRuntime(model=claude-3-sonnet)" + + @patch("apm_cli.runtime.llm_runtime.build_child_tls_env", return_value={"PATH": "clean"}) + @patch("apm_cli.runtime.llm_runtime.subprocess.run") + def test_is_available_uses_child_tls_env(self, mock_run, mock_child_env): + mock_run.return_value = Mock(returncode=0) + + assert LLMRuntime.is_available() is True + mock_run.assert_called_once_with( + ["llm", "--version"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + env={"PATH": "clean"}, + ) + mock_child_env.assert_called_once_with(os.environ) diff --git a/tests/unit/test_tls_docs_scope.py b/tests/unit/test_tls_docs_scope.py new file mode 100644 index 000000000..cf5eeb779 --- /dev/null +++ b/tests/unit/test_tls_docs_scope.py @@ -0,0 +1,125 @@ +"""T3: the #2005 docs/CHANGELOG must scope OS-trust honestly. + +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. +""" + +from __future__ import annotations + +from pathlib import Path + + +def _repo_root() -> Path: + current = Path(__file__).resolve().parent + for parent in (current, *current.parents): + if (parent / "pyproject.toml").is_file(): + return parent + raise RuntimeError("Cannot locate repository root") + + +def _unreleased_block(changelog: str) -> str: + start = changelog.index("## [Unreleased]") + rest = changelog[start + len("## [Unreleased]") :] + end = rest.find("\n## [") + return rest if end == -1 else rest[:end] + + +def test_changelog_scopes_os_trust_and_references_followup(): + changelog = (_repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + block = _unreleased_block(changelog) + + # Follow-up issue for the uncovered runtimes must be cited. + assert "#2034" in block, "CHANGELOG must reference the Node/Rust follow-up (#2034)" + # The honest scope: llm runtime named, Node/Codex explicitly not-yet-covered. + assert "`llm`" in block + assert "not yet covered" in block + # The stale round-1 joint claim must be gone. + assert "and `apm run` (child runtimes)" not in block + + +def test_ssl_docs_scope_and_known_limitations(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).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 + + +def test_changelog_scopes_python_based(): + changelog = (_repo_root() / "CHANGELOG.md").read_text(encoding="utf-8") + block = _unreleased_block(changelog) + # The #2005 entry must scope coverage to the Python-based paths explicitly, + # so it never overclaims Node/Codex coverage. + assert "Python-based" in block + + +def test_ssl_docs_node_caveat_appears_early(): + 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 + + +def test_ssl_docs_pip_cert_and_replaces_notes(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + # M4-docs: the pip-own-cert caveat during runtime setup. + assert "PIP_CERT" in docs + # L1: REQUESTS_CA_BUNDLE replaces (not augments) the OS store, plus the + # stale-bundle "still failing?" note. + assert "*replaces*" in docs or "replaces" in docs + assert "stale `REQUESTS_CA_BUNDLE`" in docs + + +def test_ssl_docs_keep_planned_configuration_generic(): + 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 + + +def test_enterprise_security_docs_transport_trust_model(): + security = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "enterprise" / "security.md" + ).read_text(encoding="utf-8") + + assert "## HTTPS transport trust" in security + assert "APM_DISABLE_TRUSTSTORE" in security + assert "REQUESTS_CA_BUNDLE" in security + assert "CURL_CA_BUNDLE" in security + assert ".pth" in security + assert "Node" in security + assert "Rust" in security + + +def test_ssl_docs_verify_apm_path_and_mark_planned_scope(): + docs = ( + _repo_root() / "docs" / "src" / "content" / "docs" / "troubleshooting" / "ssl-issues.md" + ).read_text(encoding="utf-8") + + assert ":::note[Planned]" 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() diff --git a/uv.lock b/uv.lock index 2ef696cbd..491aa0158 100644 --- a/uv.lock +++ b/uv.lock @@ -218,6 +218,7 @@ dependencies = [ { name = "ruamel-yaml" }, { name = "toml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "truststore" }, { name = "tomlkit" }, { name = "watchdog" }, { name = "websockets" }, @@ -263,6 +264,7 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, { name = "toml", specifier = ">=0.10.2" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.2.0" }, + { name = "truststore", specifier = ">=0.10.0" }, { name = "tomlkit", specifier = ">=0.13" }, { name = "watchdog", specifier = ">=3.0.0" }, { name = "websockets", specifier = ">=12,<17" }, @@ -1965,6 +1967,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"