fix(ssl): validate CA bundle paths before provider calls

This commit is contained in:
Teknium 2026-06-13 07:30:08 -07:00
parent b42c5bf652
commit af5b526472
6 changed files with 96 additions and 124 deletions

View file

@ -1,90 +1,84 @@
"""Preventive SSL CA certificate guard for Hermes Agent.
"""Preventive SSL CA certificate checks for Hermes Agent.
This module provides an early fail-fast check to detect corrupted or missing
certifi CA bundles before any network client is initialized.
This module catches broken CA bundle paths before OpenAI/httpx turns them into
opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures.
"""
from __future__ import annotations
import logging
import os
import platform
import ssl
from pathlib import Path
import certifi
from agent.errors import SSLConfigurationError
logger = logging.getLogger(__name__)
_CA_BUNDLE_ENV_VARS = (
"HERMES_CA_BUNDLE",
"SSL_CERT_FILE",
"REQUESTS_CA_BUNDLE",
"CURL_CA_BUNDLE",
)
def _repair_hint() -> str:
return (
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
"If you configured a custom corporate CA bundle, fix or unset the "
"broken CA bundle environment variable."
)
def _ssl_err(message: str) -> SSLConfigurationError:
"""Helper to create a consistent error with remediation hint."""
return SSLConfigurationError(message + "\nRun: pip install -e .")
"""Create a consistent, user-actionable SSL configuration error."""
return SSLConfigurationError(f"{message}\n{_repair_hint()}")
def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None:
path = Path(value).expanduser()
if not path.exists():
raise _ssl_err(f"{label} points to a missing CA bundle: {value}")
if not path.is_file():
raise _ssl_err(f"{label} does not point to a CA bundle file: {value}")
if require_substantial and path.stat().st_size < 1024:
raise _ssl_err(f"{label} at {value} appears corrupted (too small)")
try:
ctx = ssl.create_default_context(cafile=str(path))
except Exception as exc:
raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc
if not ctx.get_ca_certs():
raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates")
def verify_ca_bundle() -> None:
"""Verify that the certifi CA bundle is valid and loadable.
"""Verify configured and bundled CA certificates are present and loadable.
Raises:
SSLConfigurationError: If the bundle is missing, empty, or cannot be
used to create a working SSLContext.
SSLConfigurationError: If an explicit CA-bundle environment variable
points at a bad path, or if certifi's bundled ``cacert.pem`` is
missing/corrupt.
"""
if os.getenv("HERMES_SKIP_SSL_GUARD"):
logger.debug("SSL guard skipped via HERMES_SKIP_SSL_GUARD")
return
ca_bundle = str(certifi.where())
bundle_path = Path(ca_bundle)
if not bundle_path.exists():
raise _ssl_err(f"certifi CA bundle not found at {ca_bundle}")
if bundle_path.stat().st_size < 1024:
raise _ssl_err(f"certifi CA bundle at {ca_bundle} appears corrupted (too small)")
for env_var in _CA_BUNDLE_ENV_VARS:
value = os.getenv(env_var)
if value:
_validate_bundle_path(env_var, value)
try:
ctx = ssl.create_default_context(cafile=ca_bundle)
import certifi
except Exception as exc:
raise _ssl_err(
f"CA certificate bundle at {ca_bundle} cannot be loaded: {exc}"
) from exc
raise _ssl_err(f"certifi is not importable: {exc}") from exc
# Paranoid check + macOS fallback
if not ctx.get_ca_certs():
try:
fallback = ssl.create_default_context()
if not fallback.get_ca_certs():
raise _ssl_err(
f"CA certificate bundle at {ca_bundle} is empty and "
"no system CA certificates are available."
)
logger.debug(
"certifi bundle at %s is empty but system CA store is ok", ca_bundle
)
except Exception:
raise
ca_bundle = str(certifi.where())
_validate_bundle_path("certifi", ca_bundle, require_substantial=True)
def verify_ca_bundle_with_fallback() -> None:
"""Verify CA bundle with macOS paranoid fallback.
"""Backward-compatible wrapper for older call sites.
On macOS, if certifi fails but the system trust store works,
we allow startup (some corporate proxies / MDM setups break certifi).
The fallback only applies to "empty/unloadable" cases, not to
completely missing files.
The old PR name mentioned a platform fallback, but allowing startup with a
broken certifi bundle still leaves httpx/OpenAI and requests call sites
failing later. Keep the wrapper name but enforce the same check.
"""
try:
verify_ca_bundle()
except SSLConfigurationError as e:
if platform.system() == "Darwin" and "not found" not in str(e).lower():
try:
context = ssl.create_default_context()
if context.get_ca_certs():
logger.warning(
"certifi bundle invalid but macOS system trust store works. "
"Proceeding with reduced security."
)
return
except Exception:
pass
raise
verify_ca_bundle()

View file

@ -30,14 +30,6 @@ import inspect
import json
import logging
logger = logging.getLogger(__name__)
# Early SSL certificate guard (after hermes_bootstrap)
try:
from agent.ssl_guard import verify_ca_bundle_with_fallback
verify_ca_bundle_with_fallback()
except Exception as e:
logger.warning(f"SSL guard failed: {e}")
import os
import re
import shlex

View file

@ -43,6 +43,7 @@ dependencies = [
# user picks that backend. Smaller `dependencies` = smaller blast
# radius for the next supply-chain attack.
"openai==2.24.0",
"certifi==2026.5.20",
"python-dotenv==1.2.2",
"fire==0.7.1",
"httpx[socks]==0.28.1",

View file

@ -34,13 +34,6 @@ except ModuleNotFoundError:
import logging
logger = logging.getLogger(__name__)
# Early SSL certificate guard (after hermes_bootstrap)
try:
from agent.ssl_guard import verify_ca_bundle_with_fallback
verify_ca_bundle_with_fallback()
except Exception as e:
logger.warning(f"SSL guard failed: {e}")
import asyncio
import base64
import copy

View file

@ -1,82 +1,72 @@
"""Tests for the preventive SSL CA bundle guard."""
import os
import ssl
from pathlib import Path
from unittest.mock import patch
import certifi
import pytest
from agent.errors import SSLConfigurationError
from agent.ssl_guard import (
verify_ca_bundle,
verify_ca_bundle_with_fallback,
)
from agent.ssl_guard import verify_ca_bundle, verify_ca_bundle_with_fallback
def test_healthy_bundle_passes(tmp_path, monkeypatch):
def test_healthy_bundle_passes(monkeypatch):
"""A real, non-empty certifi bundle must verify without raising."""
# Sanity: certifi.where() must point to a real file in the test venv.
for key in ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"):
monkeypatch.delenv(key, raising=False)
bundle = Path(certifi.where())
assert bundle.exists()
assert bundle.stat().st_size > 1024
verify_ca_bundle() # should not raise
verify_ca_bundle()
def test_missing_bundle_raises_ssl_error(monkeypatch, tmp_path):
def test_missing_certifi_bundle_raises_ssl_error(monkeypatch, tmp_path):
"""Point certifi.where() at a non-existent path; expect a clear error."""
fake = tmp_path / "nope.pem"
monkeypatch.setattr(certifi, "where", lambda: str(fake))
with pytest.raises(SSLConfigurationError) as exc:
verify_ca_bundle()
assert "not found" in str(exc.value).lower()
message = str(exc.value).lower()
assert "certifi" in message
assert "missing" in message
assert "force-reinstall" in message
def test_empty_bundle_raises_ssl_error(monkeypatch, tmp_path):
def test_empty_certifi_bundle_raises_ssl_error(monkeypatch, tmp_path):
"""Empty file is treated as a corrupted bundle."""
fake = tmp_path / "empty.pem"
fake.write_bytes(b"")
monkeypatch.setattr(certifi, "where", lambda: str(fake))
with pytest.raises(SSLConfigurationError) as exc:
verify_ca_bundle()
assert "corrupted" in str(exc.value).lower() or "empty" in str(exc.value).lower()
assert "too small" in str(exc.value).lower()
def test_skip_env_var_disables_guard(monkeypatch, tmp_path):
"""HERMES_SKIP_SSL_GUARD=1 must make the guard a no-op."""
monkeypatch.setenv("HERMES_SKIP_SSL_GUARD", "1")
fake = tmp_path / "nope.pem" # would raise if guard ran
monkeypatch.setattr(certifi, "where", lambda: str(fake))
verify_ca_bundle() # should not raise
@pytest.mark.parametrize("env_var", ["HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"])
def test_missing_explicit_ca_bundle_env_raises_before_httpx(monkeypatch, tmp_path, env_var):
"""Bad CA-bundle env vars should be reported before OpenAI/httpx init."""
fake = tmp_path / "missing.pem"
monkeypatch.setenv(env_var, str(fake))
with pytest.raises(SSLConfigurationError) as exc:
verify_ca_bundle()
message = str(exc.value)
assert env_var in message
assert str(fake) in message
assert "force-reinstall" in message
def test_macos_fallback_allows_startup(monkeypatch, tmp_path):
"""On Darwin, an unloadable certifi bundle must fall back to system trust.
Only the fallback call (no cafile) is mocked — the certifi call must
fail naturally with SSLError from the broken PEM. The mock returns a
context with system CAs loaded, so the fallback succeeds.
"""
def test_invalid_explicit_ca_bundle_env_raises(monkeypatch, tmp_path):
"""An existing but invalid explicit bundle should get a user-facing error."""
fake = tmp_path / "broken.pem"
# > 1024 bytes so the size guard doesn't short-circuit before ssl runs.
fake.write_bytes(b"not a real bundle" + b" " * 2000)
monkeypatch.setattr(certifi, "where", lambda: str(fake))
monkeypatch.setattr("platform.system", lambda: "Darwin")
fake.write_text("not a cert bundle", encoding="utf-8")
monkeypatch.setenv("SSL_CERT_FILE", str(fake))
with pytest.raises(SSLConfigurationError) as exc:
verify_ca_bundle()
assert "cannot be loaded" in str(exc.value)
_real_create = ssl.create_default_context
def _mock_create(purpose=ssl.Purpose.SERVER_AUTH, **kwargs):
if kwargs.get("cafile"):
# Let the certifi call hit the real SSL stack → raises SSLError
# on the broken PEM, which verify_ca_bundle() wraps as
# SSLConfigurationError. This is the path the fallback rescues.
return _real_create(purpose, **kwargs)
# Fallback call: simulate a healthy system trust store.
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.load_default_certs()
return ctx
with patch("ssl.create_default_context", side_effect=_mock_create):
# Should NOT raise — macOS system trust store covers the broken bundle.
def test_verify_ca_bundle_with_fallback_keeps_same_contract(monkeypatch, tmp_path):
"""The compatibility wrapper still rejects broken explicit CA paths."""
fake = tmp_path / "missing.pem"
monkeypatch.setenv("SSL_CERT_FILE", str(fake))
with pytest.raises(SSLConfigurationError):
verify_ca_bundle_with_fallback()

8
uv.lock generated
View file

@ -542,11 +542,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2026.2.25"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
@ -1393,6 +1393,7 @@ name = "hermes-agent"
version = "0.16.0"
source = { editable = "." }
dependencies = [
{ name = "certifi" },
{ name = "croniter" },
{ name = "fastapi" },
{ name = "fire" },
@ -1597,6 +1598,7 @@ requires-dist = [
{ name = "azure-identity", marker = "extra == 'azure-identity'", specifier = "==1.25.3" },
{ name = "boto3", marker = "extra == 'bedrock'", specifier = "==1.42.89" },
{ name = "brotlicffi", marker = "extra == 'messaging'", specifier = "==1.2.0.1" },
{ name = "certifi", specifier = "==2026.5.20" },
{ name = "croniter", specifier = "==6.0.0" },
{ name = "daytona", marker = "extra == 'daytona'", specifier = "==0.155.0" },
{ name = "debugpy", marker = "extra == 'dev'", specifier = "==1.8.20" },