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" },