mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-15 09:21:36 +00:00
fix(ssl): validate CA bundle paths before provider calls
This commit is contained in:
parent
b42c5bf652
commit
af5b526472
6 changed files with 96 additions and 124 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
8
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue