From 37c1122ac708dbab9ee66233fd1021fe51186cb1 Mon Sep 17 00:00:00 2001 From: Petre Ghita Date: Wed, 22 Apr 2026 09:18:04 +0200 Subject: [PATCH] fix: trust corporate TLS for Anthropic (NODE_EXTRA_CA_CERTS) --- README.md | 8 ++ .../context/application_context.py | 5 +- libs/openant-core/core/analyzer.py | 3 +- libs/openant-core/core/scanner.py | 29 ++++++- libs/openant-core/generate_report.py | 5 +- libs/openant-core/openant/cli.py | 4 +- libs/openant-core/pyproject.toml | 1 + libs/openant-core/report/generator.py | 7 +- .../utilities/agentic_enhancer/agent.py | 3 +- libs/openant-core/utilities/anthropic_http.py | 84 +++++++++++++++++++ .../utilities/context_enhancer.py | 3 +- .../utilities/finding_verifier.py | 4 +- libs/openant-core/utilities/llm_client.py | 3 +- 13 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 libs/openant-core/utilities/anthropic_http.py diff --git a/README.md b/README.md index f3f1b4a..8d4afba 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,14 @@ openant set-api-key **The key must have access to the Claude Opus 4.6 model.** Get a key at [console.anthropic.com](https://console.anthropic.com/settings/keys). +If HTTPS traffic to Anthropic is intercepted (for example by Zscaler), export your corporate root CA in PEM form—the same variable many tools use with Node: + +```bash +export NODE_EXTRA_CA_CERTS=/path/to/your-corporate-root-ca.pem +``` + +OpenAnt’s Python runtime loads that bundle for Anthropic API calls so TLS verification succeeds behind the proxy. + ## Data directories OpenAnt creates two directories: diff --git a/libs/openant-core/context/application_context.py b/libs/openant-core/context/application_context.py index f7fa55d..06f31ab 100644 --- a/libs/openant-core/context/application_context.py +++ b/libs/openant-core/context/application_context.py @@ -29,9 +29,10 @@ from pathlib import Path from typing import Any -from anthropic import Anthropic from dotenv import load_dotenv +from utilities.anthropic_http import create_anthropic_client + # Load environment variables load_dotenv() @@ -503,7 +504,7 @@ def generate_application_context( # Call LLM print(f"Generating context with {model}...", file=sys.stderr) - client = Anthropic() + client = create_anthropic_client() response = client.messages.create( model=model, max_tokens=2000, diff --git a/libs/openant-core/core/analyzer.py b/libs/openant-core/core/analyzer.py index 2776237..646ac84 100644 --- a/libs/openant-core/core/analyzer.py +++ b/libs/openant-core/core/analyzer.py @@ -316,8 +316,9 @@ def run_analysis( model_id = "claude-opus-4-6" if model == "opus" else "claude-sonnet-4-20250514" print(f"[Analyze] Model: {model_id}", file=sys.stderr) - # Initialize client + # Initialize client (uses global token tracker by default) client = AnthropicClient(model=model_id) + tracker = get_global_tracker() # Initialize JSON corrector json_corrector = JSONCorrector(client) diff --git a/libs/openant-core/core/scanner.py b/libs/openant-core/core/scanner.py index 08e2dfe..b9d1163 100644 --- a/libs/openant-core/core/scanner.py +++ b/libs/openant-core/core/scanner.py @@ -39,6 +39,25 @@ HAS_APP_CONTEXT = False +def _format_exception_chain(exc: BaseException, limit: int = 500) -> str: + """Readable error text; Anthropic often raises APIConnectionError with a hidden __cause__.""" + parts: list[str] = [] + cur: BaseException | None = exc + seen: set[str] = set() + depth = 0 + while cur is not None and depth < 6: + msg = (str(cur) or type(cur).__name__).strip() + if msg and msg not in seen: + parts.append(msg) + seen.add(msg) + cur = cur.__cause__ or cur.__context__ + depth += 1 + text = " | ".join(parts) if parts else type(exc).__name__ + if len(text) > limit: + return text[: limit - 3] + "..." + return text + + def scan_repository( repo_path: str, output_dir: str, @@ -463,8 +482,9 @@ def _step_label(name: str) -> str: outputs["summary_path"] = summary_path print(f" Summary: {summary_path}", file=sys.stderr) except Exception as e: - print(f" WARNING: Summary report failed: {e}", file=sys.stderr) - ctx.errors.append(f"Summary report: {e}") + detail = _format_exception_chain(e) + print(f" WARNING: Summary report failed: {detail}", file=sys.stderr) + ctx.errors.append(f"Summary report: {detail}") # Only generate disclosures if there are findings if has_findings: @@ -473,8 +493,9 @@ def _step_label(name: str) -> str: outputs["disclosures_dir"] = disclosures_dir print(f" Disclosures: {disclosures_dir}", file=sys.stderr) except Exception as e: - print(f" WARNING: Disclosure docs failed: {e}", file=sys.stderr) - ctx.errors.append(f"Disclosure docs: {e}") + detail = _format_exception_chain(e) + print(f" WARNING: Disclosure docs failed: {detail}", file=sys.stderr) + ctx.errors.append(f"Disclosure docs: {detail}") ctx.summary = {"formats_generated": list(outputs.keys())} ctx.outputs = outputs diff --git a/libs/openant-core/generate_report.py b/libs/openant-core/generate_report.py index 7b1ecd8..9aaf20a 100644 --- a/libs/openant-core/generate_report.py +++ b/libs/openant-core/generate_report.py @@ -29,9 +29,10 @@ import os from datetime import datetime -import anthropic from dotenv import load_dotenv +from utilities.anthropic_http import create_anthropic_client + # Load environment variables from .env file load_dotenv() @@ -142,7 +143,7 @@ def generate_remediation_guidance(findings: list) -> str: if not api_key: raise ValueError("ANTHROPIC_API_KEY not found in environment") - client = anthropic.Anthropic(api_key=api_key) + client = create_anthropic_client(api_key=api_key) response = client.messages.create( model=REPORT_MODEL, max_tokens=MAX_TOKENS, diff --git a/libs/openant-core/openant/cli.py b/libs/openant-core/openant/cli.py index 4c7d3a7..1dfcc12 100644 --- a/libs/openant-core/openant/cli.py +++ b/libs/openant-core/openant/cli.py @@ -554,9 +554,9 @@ def cmd_report_data(args): and step reports — everything display-ready. """ import html as html_mod - import anthropic from core.schemas import success, error from core.step_report import step_context + from utilities.anthropic_http import create_anthropic_client from utilities.llm_client import get_global_tracker results_path = args.results @@ -777,7 +777,7 @@ def cmd_report_data(args): {findings_text} """ print("[Report] Generating remediation guidance (LLM)...", file=sys.stderr) - client = anthropic.Anthropic() + client = create_anthropic_client() response = client.messages.create( model="claude-sonnet-4-20250514", max_tokens=4096, diff --git a/libs/openant-core/pyproject.toml b/libs/openant-core/pyproject.toml index 266e7db..a3949ce 100644 --- a/libs/openant-core/pyproject.toml +++ b/libs/openant-core/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.11" dependencies = [ "anthropic>=0.40.0", + "certifi>=2024.0.0", "python-dotenv>=1.0.0", "pydantic>=2.0.0", "httpx>=0.24.0", diff --git a/libs/openant-core/report/generator.py b/libs/openant-core/report/generator.py index 25a55e8..9b7d11d 100644 --- a/libs/openant-core/report/generator.py +++ b/libs/openant-core/report/generator.py @@ -7,10 +7,11 @@ import json import os import sys -import anthropic from pathlib import Path from dotenv import load_dotenv +from utilities.anthropic_http import create_anthropic_client + from .schema import validate_pipeline_output, ValidationError load_dotenv() @@ -135,7 +136,7 @@ def generate_summary_report(pipeline_data: dict) -> tuple[str, dict]: output_tokens, total_tokens, cost_usd. """ _check_api_key() - client = anthropic.Anthropic() + client = create_anthropic_client() summary_data = _compact_for_summary(pipeline_data) system_prompt = load_prompt("system") @@ -158,7 +159,7 @@ def generate_disclosure(vulnerability_data: dict, product_name: str) -> tuple[st (disclosure_text, usage_dict) """ _check_api_key() - client = anthropic.Anthropic() + client = create_anthropic_client() system_prompt = load_prompt("system") diff --git a/libs/openant-core/utilities/agentic_enhancer/agent.py b/libs/openant-core/utilities/agentic_enhancer/agent.py index 62061b7..28e9bfc 100644 --- a/libs/openant-core/utilities/agentic_enhancer/agent.py +++ b/libs/openant-core/utilities/agentic_enhancer/agent.py @@ -16,6 +16,7 @@ import anthropic +from ..anthropic_http import create_anthropic_client from ..llm_client import TokenTracker, get_global_tracker from ..rate_limiter import get_rate_limiter from .repository_index import RepositoryIndex @@ -126,7 +127,7 @@ def __init__( self.tool_executor = ToolExecutor(index) self.entry_points = entry_points or set() self.reachability = reachability - self.client = client or anthropic.Anthropic(max_retries=5) + self.client = client or create_anthropic_client(max_retries=5) def analyze_unit( self, diff --git a/libs/openant-core/utilities/anthropic_http.py b/libs/openant-core/utilities/anthropic_http.py new file mode 100644 index 0000000..c48e9f1 --- /dev/null +++ b/libs/openant-core/utilities/anthropic_http.py @@ -0,0 +1,84 @@ +"""TLS configuration for Anthropic API calls. + +TLS inspection (for example Zscaler) terminates HTTPS with a corporate root +that is not in the default trust store. Node and Claude Code honor +``NODE_EXTRA_CA_CERTS`` (a PEM file of extra CA certificates). The Anthropic +Python SDK uses httpx; this module applies the same PEM when the variable is +set so verification succeeds behind those proxies. + +Python 3.13+ enables ``VERIFY_X509_STRICT`` on default contexts, which rejects +many corporate intercept CAs (e.g. Zscaler) whose Basic Constraints extension +is not marked critical. When using ``NODE_EXTRA_CA_CERTS``, that strict bit is +cleared so TLS still verifies the chain while matching typical Node behavior. +""" + +from __future__ import annotations + +import os +import ssl +from typing import Any + +import httpx + +try: + from anthropic import DefaultHttpxClient +except ImportError: # pragma: no cover - extremely old anthropic + DefaultHttpxClient = httpx.Client # type: ignore[misc, assignment] + +try: + import certifi +except ImportError: # pragma: no cover - pulled in by httpx + certifi = None # type: ignore[assignment] + + +def _relax_x509_strict_for_corporate_cas(ctx: ssl.SSLContext) -> None: + """Turn off VERIFY_X509_STRICT so Zscaler-like CAs validate (Python 3.13+).""" + strict = getattr(ssl, "VERIFY_X509_STRICT", 0) + if strict: + ctx.verify_flags &= ~strict + + +def _ssl_context_from_node_extra_ca_certs() -> ssl.SSLContext | None: + """Build TLS context: public CA bundle plus PEM from ``NODE_EXTRA_CA_CERTS``. + + Node appends ``NODE_EXTRA_CA_CERTS`` to its built-in store. On some platforms + ``ssl.create_default_context()`` then ``load_verify_locations(file)`` does not + stack the way we need; anchoring with certifi's bundle then loading the extra + file matches the Node / Claude Code behavior more reliably (e.g. macOS + + Zscaler). + """ + path = (os.environ.get("NODE_EXTRA_CA_CERTS") or "").strip() + if not path or not os.path.isfile(path): + return None + try: + if certifi is not None: + ctx = ssl.create_default_context(cafile=certifi.where()) + else: + ctx = ssl.create_default_context() + ctx.load_verify_locations(path) + _relax_x509_strict_for_corporate_cas(ctx) + except OSError: + return None + return ctx + + +def anthropic_http_client_from_env() -> httpx.Client | None: + """Return an httpx client with extra CAs, or None if not configured.""" + ctx = _ssl_context_from_node_extra_ca_certs() + if ctx is None: + return None + return DefaultHttpxClient(verify=ctx) + + +def create_anthropic_client(**kwargs: Any): + """Construct ``anthropic.Anthropic`` honoring ``NODE_EXTRA_CA_CERTS``. + + If the caller passes ``http_client``, it is left unchanged. + """ + import anthropic + + if kwargs.get("http_client") is None: + http_client = anthropic_http_client_from_env() + if http_client is not None: + kwargs["http_client"] = http_client + return anthropic.Anthropic(**kwargs) diff --git a/libs/openant-core/utilities/context_enhancer.py b/libs/openant-core/utilities/context_enhancer.py index df1a8ac..20a1396 100644 --- a/libs/openant-core/utilities/context_enhancer.py +++ b/libs/openant-core/utilities/context_enhancer.py @@ -25,6 +25,7 @@ import anthropic +from .anthropic_http import create_anthropic_client from .llm_client import AnthropicClient, TokenTracker, get_global_tracker, reset_global_tracker from .agentic_enhancer import RepositoryIndex, enhance_unit_with_agent, load_index_from_file from .rate_limiter import get_rate_limiter, is_rate_limit_error, is_retryable_error @@ -569,7 +570,7 @@ def enhance_dataset_agentic( # which spawns a new httpx connection pool. With 1000+ units and 8 workers, # this exhausted file descriptors (macOS limit ~256). The httpx.Client # underlying anthropic.Anthropic is thread-safe, so sharing is correct. - shared_client = anthropic.Anthropic(max_retries=5) + shared_client = create_anthropic_client(max_retries=5) # Filter to unprocessed units units_to_process = [(i, unit) for i, unit in enumerate(units) if unit.get("id") not in processed_ids] diff --git a/libs/openant-core/utilities/finding_verifier.py b/libs/openant-core/utilities/finding_verifier.py index 2e66b7c..f5c40ec 100644 --- a/libs/openant-core/utilities/finding_verifier.py +++ b/libs/openant-core/utilities/finding_verifier.py @@ -39,7 +39,7 @@ from typing import Callable, Optional import anthropic - +from .anthropic_http import create_anthropic_client from .llm_client import TokenTracker, get_global_tracker from .rate_limiter import get_rate_limiter @@ -271,7 +271,7 @@ def __init__( self.verbose = verbose self.app_context = app_context self.tool_executor = ToolExecutor(index) - self.client = client or anthropic.Anthropic(max_retries=5) + self.client = client or create_anthropic_client(max_retries=5) self.logger = logger or _null_logger self._use_logger = logger is not None diff --git a/libs/openant-core/utilities/llm_client.py b/libs/openant-core/utilities/llm_client.py index ea356bf..5c97387 100644 --- a/libs/openant-core/utilities/llm_client.py +++ b/libs/openant-core/utilities/llm_client.py @@ -23,6 +23,7 @@ import anthropic from dotenv import load_dotenv +from .anthropic_http import create_anthropic_client from .rate_limiter import get_rate_limiter @@ -204,7 +205,7 @@ def __init__(self, model: str = "claude-opus-4-20250514", tracker: TokenTracker if not api_key: raise ValueError("ANTHROPIC_API_KEY not found in environment") - self.client = anthropic.Anthropic(api_key=api_key, max_retries=5) + self.client = create_anthropic_client(api_key=api_key, max_retries=5) self.model = model self.tracker = tracker or _global_tracker self.last_call = None # Store last call details