diff --git a/README.md b/README.md index a14cd523..a8b39f8c 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,29 @@ Check your PATH(s): ```python --version || python3 --version``` +#### SecOps SOAR: SSL Certificate Verification Error + +If the SOAR MCP server fails to start with an error about SSL certificate verification +(or the generic "Failed to fetch valid scopes from SOAR"), this is typically caused by +Python not having access to the correct CA certificates. This is especially common on +**macOS**. + +**Fix for macOS** — run the `Install Certificates.command` script bundled with your +Python installation (replace `3.12` with your actual Python minor version): + +```bash +/Applications/Python\ 3.12/Install\ Certificates.command +``` + +**Fix for all platforms:** + +```bash +pip install --upgrade certifi +``` + +Then restart the MCP server. See the [SOAR server README](server/secops-soar/README.md#troubleshooting) +for more details. + ### Installing in Claude Desktop diff --git a/docs/usage_guide.md b/docs/usage_guide.md index a501b04c..e11882ec 100644 --- a/docs/usage_guide.md +++ b/docs/usage_guide.md @@ -244,3 +244,25 @@ If you encounter issues with the MCP servers: 3. **Check server logs**: Look for error messages in the server output 4. **Restart the client**: Sometimes restarting the LLM Desktop or VS Code can resolve connection issues 5. **Verify uv installation**: Ensure that `uv` is properly installed and accessible in your PATH + +### SecOps SOAR: SSL Certificate Verification Error + +If the SOAR MCP server fails to start with an error mentioning **SSL certificate verification failed**, this typically means Python cannot find the correct CA (Certificate Authority) certificates. This is a common issue on **macOS**. + +**Fix for macOS:** + +Run the `Install Certificates.command` script that ships with Python. Replace `3.12` with your installed Python minor version: + +```bash +/Applications/Python\ 3.12/Install\ Certificates.command +``` + +**Fix for all platforms:** + +```bash +pip install --upgrade certifi +``` + +Then restart the MCP server. + +> **Note:** The SOAR MCP server now detects this specific issue and prints an actionable error message with fix instructions. If you still see the generic "Failed to fetch valid scopes from SOAR" message, make sure you are running the latest version of the server. diff --git a/server/secops-soar/README.md b/server/secops-soar/README.md index 97e52fb5..b0635912 100644 --- a/server/secops-soar/README.md +++ b/server/secops-soar/README.md @@ -124,6 +124,69 @@ $Env:SOAR_INTEGRATIONS = "ServiceNow,CSV,Siemplify" - Python 3.11+ - SOAR URL and AppKey +## Troubleshooting + +### SSL Certificate Verification Error + +If you see an error like: + +``` +SSL certificate verification failed when connecting to SOAR. +``` + +or the older generic message: + +``` +Failed to fetch valid scopes from SOAR +``` + +This is typically caused by Python not having access to the correct CA (Certificate Authority) certificates. This is especially common on **macOS**. + +**Fix for macOS:** + +Run the `Install Certificates.command` script that ships with your Python installation. Replace `3.12` with your actual Python minor version: + +```bash +/Applications/Python\ 3.12/Install\ Certificates.command +``` + +This installs the [`certifi`](https://pypi.org/project/certifi/) CA bundle, which Python needs for SSL/TLS verification. + +**Fix for all platforms:** + +```bash +pip install --upgrade certifi +``` + +Then restart the MCP server. + +For further details, see the [usage guide](https://google.github.io/mcp-security/usage_guide.html#mcp-server-configuration-reference). + +### Connection Error + +If you see: + +``` +Failed to connect to SOAR at ''. +``` + +Verify that: +1. The `SOAR_URL` environment variable is set correctly (e.g., `https://yours-here.siemplify-soar.com:443`). +2. The SOAR server is reachable from your network. +3. Any required VPN or proxy is active. + +### Invalid Credentials + +If you see: + +``` +Failed to fetch valid scopes from SOAR. +``` + +Verify that: +1. `SOAR_URL` is set and correct. +2. `SOAR_APP_KEY` is set and valid. + ## License Apache 2.0 diff --git a/server/secops-soar/pyproject.toml b/server/secops-soar/pyproject.toml index 4a30bbf1..52f3aaf7 100644 --- a/server/secops-soar/pyproject.toml +++ b/server/secops-soar/pyproject.toml @@ -38,4 +38,4 @@ secops-soar = "secops_soar_mcp.server:run_main" [build-system] requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" \ No newline at end of file +build-backend = "setuptools.build_meta" diff --git a/server/secops-soar/secops_soar_mcp/bindings.py b/server/secops-soar/secops_soar_mcp/bindings.py index 094c1f1a..d5449069 100644 --- a/server/secops-soar/secops_soar_mcp/bindings.py +++ b/server/secops-soar/secops_soar_mcp/bindings.py @@ -17,7 +17,7 @@ import dotenv from logger_utils import get_logger -from secops_soar_mcp.http_client import HttpClient +from secops_soar_mcp.http_client import HttpClient, SoarSSLError, SoarConnectionError from secops_soar_mcp.utils import consts dotenv.load_dotenv() @@ -32,14 +32,20 @@ async def _get_valid_scopes(): valid_scopes_list = await http_client.get(consts.Endpoints.GET_SCOPES) if valid_scopes_list is None: - raise RuntimeError( - "Failed to fetch valid scopes from SOAR, please make sure you have configured the right SOAR credentials. Shutting down..." - ) + raise RuntimeError(consts.CREDENTIALS_ERROR_MESSAGE) return set(valid_scopes_list) async def bind(): - """Binds global variables.""" + """Binds global variables. + + Raises: + SoarSSLError: If an SSL certificate verification error occurs + when connecting to SOAR. + SoarConnectionError: If the SOAR server cannot be reached. + RuntimeError: If the SOAR credentials are invalid or scopes + cannot be fetched. + """ global http_client, valid_scopes http_client = HttpClient( os.getenv(consts.ENV_SOAR_URL), os.getenv(consts.ENV_SOAR_APP_KEY) diff --git a/server/secops-soar/secops_soar_mcp/http_client.py b/server/secops-soar/secops_soar_mcp/http_client.py index 1c03768b..6fdc7480 100644 --- a/server/secops-soar/secops_soar_mcp/http_client.py +++ b/server/secops-soar/secops_soar_mcp/http_client.py @@ -14,14 +14,46 @@ """HTTP client for making requests to the SecOps SOAR API.""" import json +import ssl from typing import Any, Dict import aiohttp from logger_utils import get_logger +from secops_soar_mcp.utils import consts logger = get_logger(__name__) +class SoarSSLError(Exception): + """Raised when an SSL certificate error occurs connecting to SOAR.""" + + +class SoarConnectionError(Exception): + """Raised when a connection to the SOAR server cannot be established.""" + + +def _is_cert_verify_error(error: BaseException) -> bool: + """Check if an error is caused by SSL certificate verification failure. + + Inspects the error message, its chain of causes (__cause__), and + type to determine if the root cause is a certificate verification + failure. + """ + # Walk the cause chain to find CERTIFICATE_VERIFY_FAILED anywhere + current = error + while current is not None: + error_str = str(current).lower() + if ( + "certificate_verify_failed" in error_str + or "certificate verify failed" in error_str + ): + return True + if isinstance(current, ssl.SSLCertVerificationError): + return True + current = getattr(current, "__cause__", None) + return False + + class HttpClient: """HTTP client for making requests to the SecOps SOAR API.""" @@ -41,6 +73,51 @@ async def _get_headers(self): headers["AppKey"] = self.app_key return headers + def _handle_ssl_error(self, error: Exception) -> None: + """Check for SSL errors and raise descriptive exceptions. + + Args: + error: The exception to inspect. + + Raises: + SoarSSLError: If the error is an SSL certificate verification issue. + SoarConnectionError: If the error is a non-SSL connection issue. + """ + if isinstance(error, aiohttp.ClientConnectorSSLError): + if _is_cert_verify_error(error): + logger.error( + "SSL certificate verification failed: %s", error + ) + raise SoarSSLError( + consts.SSL_CERTIFI_ERROR_MESSAGE + ) from error + else: + logger.error("SSL/TLS error: %s", error) + raise SoarSSLError( + consts.SSL_GENERIC_ERROR_MESSAGE.format(error=error) + ) from error + + if isinstance(error, aiohttp.ClientConnectorError): + logger.error("Connection error: %s", error) + raise SoarConnectionError( + consts.CONNECTION_ERROR_MESSAGE.format(url=self.base_url) + ) from error + + # Also catch raw ssl.SSLError that may not be wrapped by aiohttp + if isinstance(error, ssl.SSLError): + if _is_cert_verify_error(error): + logger.error( + "SSL certificate verification failed: %s", error + ) + raise SoarSSLError( + consts.SSL_CERTIFI_ERROR_MESSAGE + ) from error + else: + logger.error("SSL/TLS error: %s", error) + raise SoarSSLError( + consts.SSL_GENERIC_ERROR_MESSAGE.format(error=error) + ) from error + async def get( self, endpoint: str, @@ -54,6 +131,10 @@ async def get( Returns: The response as a JSON object, or None if an error occurred. + + Raises: + SoarSSLError: If an SSL certificate verification error occurs. + SoarConnectionError: If the SOAR server cannot be reached. """ headers = await self._get_headers() try: @@ -65,6 +146,7 @@ async def get( except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) except Exception as e: + self._handle_ssl_error(e) logger.debug("An error occurred: %s", e) return None @@ -83,6 +165,10 @@ async def post( Returns: The response as a JSON object, or None if an error occurred. + + Raises: + SoarSSLError: If an SSL certificate verification error occurs. + SoarConnectionError: If the SOAR server cannot be reached. """ headers = await self._get_headers() try: @@ -96,6 +182,7 @@ async def post( except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) except Exception as e: + self._handle_ssl_error(e) logger.debug("An error occurred: %s", e) return None @@ -114,6 +201,10 @@ async def patch( Returns: The response as a JSON object, or None if an error occurred. + + Raises: + SoarSSLError: If an SSL certificate verification error occurs. + SoarConnectionError: If the SOAR server cannot be reached. """ headers = await self._get_headers() try: @@ -125,6 +216,7 @@ async def patch( except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) except Exception as e: + self._handle_ssl_error(e) logger.debug("An error occurred: %s", e) return None diff --git a/server/secops-soar/secops_soar_mcp/server.py b/server/secops-soar/secops_soar_mcp/server.py index fdea7d19..49b3f15e 100755 --- a/server/secops-soar/secops_soar_mcp/server.py +++ b/server/secops-soar/secops_soar_mcp/server.py @@ -17,6 +17,7 @@ import importlib from pathlib import Path from secops_soar_mcp import bindings +from secops_soar_mcp.http_client import SoarSSLError, SoarConnectionError from mcp.server.fastmcp import FastMCP from logger_utils import get_logger, setup_logging from secops_soar_mcp.case_management import ( @@ -165,6 +166,10 @@ async def main(): await bindings.bind() register_tools(args.integrations) await mcp.run_stdio_async() + except SoarSSLError as e: + logger.error("\n%s", e) + except SoarConnectionError as e: + logger.error("\n%s", e) except Exception as e: logger.error("Error: %s", e) finally: diff --git a/server/secops-soar/secops_soar_mcp/utils/consts.py b/server/secops-soar/secops_soar_mcp/utils/consts.py index 3ff9448c..eb41ce09 100644 --- a/server/secops-soar/secops_soar_mcp/utils/consts.py +++ b/server/secops-soar/secops_soar_mcp/utils/consts.py @@ -13,9 +13,54 @@ # limitations under the License. """Constants used in the SOAR integration.""" +import platform +import sys + ENV_SOAR_URL = "SOAR_URL" ENV_SOAR_APP_KEY = "SOAR_APP_KEY" +# Python version info for certifi fix instructions +_PYTHON_MINOR = f"{sys.version_info.major}.{sys.version_info.minor}" + +SSL_CERTIFI_ERROR_MESSAGE = ( + "SSL certificate verification failed when connecting to SOAR.\n" + "This is commonly caused by missing or outdated CA certificates in your " + "Python installation.\n\n" + "To fix this issue:\n" + + ( + f" macOS: Run: /Applications/Python\\ {_PYTHON_MINOR}/Install\\ Certificates.command\n" + if platform.system() == "Darwin" + else "" + ) + + " All platforms: pip install --upgrade certifi\n" + " Then restart the MCP server.\n\n" + "For more details, see: " + "https://google.github.io/mcp-security/usage_guide.html" + "#mcp-server-configuration-reference" +) + +SSL_GENERIC_ERROR_MESSAGE = ( + "An SSL/TLS error occurred when connecting to SOAR: {error}\n" + "Please verify that your SOAR_URL is correct and the server's " + "SSL certificate is valid." +) + +CONNECTION_ERROR_MESSAGE = ( + "Failed to connect to SOAR at '{url}'.\n" + "Please verify that:\n" + " 1. The SOAR_URL environment variable is set correctly.\n" + " 2. The SOAR server is reachable from your network.\n" + " 3. Any required VPN or proxy is active." +) + +CREDENTIALS_ERROR_MESSAGE = ( + "Failed to fetch valid scopes from SOAR.\n" + "Please make sure you have configured the right SOAR credentials:\n" + " 1. SOAR_URL is set and correct.\n" + " 2. SOAR_APP_KEY is set and valid.\n" + "Shutting down..." +) + class Endpoints: """Endpoints for SOAR.""" diff --git a/server/secops-soar/tests/test_ssl_error_handling.py b/server/secops-soar/tests/test_ssl_error_handling.py new file mode 100644 index 00000000..f088b3d3 --- /dev/null +++ b/server/secops-soar/tests/test_ssl_error_handling.py @@ -0,0 +1,408 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for SSL/certifi error handling in the SOAR MCP server. + +These tests verify that SSL certificate errors are caught and reported with +actionable messages instead of the generic 'Failed to fetch valid scopes' +message. + +Issue: https://github.com/google/mcp-security/issues/191 + +To run these tests: + pytest -xvs server/secops-soar/tests/test_ssl_error_handling.py +""" + +import ssl +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest +import pytest_asyncio + +from secops_soar_mcp.http_client import ( + HttpClient, + SoarConnectionError, + SoarSSLError, + _is_cert_verify_error, +) +from secops_soar_mcp.utils import consts + + +# Override the autouse setup_bindings fixture from conftest.py so these +# unit tests can run without real SOAR credentials / config.json. +@pytest_asyncio.fixture(autouse=True) +async def setup_bindings(): + """No-op override — SSL error tests do not need a live SOAR connection.""" + yield + + +# --------------------------------------------------------------------------- +# Tests for _is_cert_verify_error helper +# --------------------------------------------------------------------------- + + +class TestIsCertVerifyError: + """Tests for the _is_cert_verify_error helper function.""" + + def test_detects_certificate_verify_failed_string(self): + error = Exception( + "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed" + ) + assert _is_cert_verify_error(error) is True + + def test_detects_lowercase_certificate_verify_failed(self): + error = Exception("certificate verify failed (_ssl.c:1007)") + assert _is_cert_verify_error(error) is True + + def test_detects_ssl_cert_verification_error_instance(self): + """ssl.SSLCertVerificationError is a subclass of ssl.SSLError.""" + try: + error = ssl.SSLCertVerificationError("test cert error") + except TypeError: + # SSLCertVerificationError may require more args on some platforms + error = ssl.SSLError(1, "CERTIFICATE_VERIFY_FAILED") + assert _is_cert_verify_error(error) is True + + def test_non_cert_error_returns_false(self): + error = Exception("Connection refused") + assert _is_cert_verify_error(error) is False + + def test_empty_error_returns_false(self): + error = Exception("") + assert _is_cert_verify_error(error) is False + + +# --------------------------------------------------------------------------- +# Tests for HttpClient SSL error handling +# --------------------------------------------------------------------------- + + +class TestHttpClientSSLErrorHandling: + """Tests for HttpClient._handle_ssl_error method.""" + + def setup_method(self): + self.client = HttpClient( + base_url="https://test-soar.example.com:443", + app_key="test-key", + ) + + def test_handle_ssl_error_raises_soar_ssl_error_for_cert_verify(self): + """ClientConnectorSSLError with CERTIFICATE_VERIFY_FAILED -> SoarSSLError.""" + # Create a realistic SSL cert verify error chain: + # ssl.SSLCertVerificationError -> OSError -> ClientConnectorSSLError + ssl_err = ssl.SSLError(1, "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed") + os_error = OSError("CERTIFICATE_VERIFY_FAILED") + os_error.__cause__ = ssl_err + connection_key = MagicMock() + error = aiohttp.ClientConnectorSSLError(connection_key, os_error) + # Attach the os_error as cause so the chain walker finds it + error.__cause__ = os_error + + with pytest.raises(SoarSSLError) as exc_info: + self.client._handle_ssl_error(error) + + # Verify the error message contains actionable fix instructions + error_msg = str(exc_info.value) + assert "SSL certificate verification failed" in error_msg + assert "certifi" in error_msg + assert "Install" in error_msg or "pip install" in error_msg + + def test_handle_ssl_error_raises_soar_ssl_error_for_generic_ssl(self): + """ClientConnectorSSLError without cert verify -> SoarSSLError with generic msg.""" + os_error = OSError("SSL handshake failed") + connection_key = MagicMock() + error = aiohttp.ClientConnectorSSLError(connection_key, os_error) + + with pytest.raises(SoarSSLError) as exc_info: + self.client._handle_ssl_error(error) + + error_msg = str(exc_info.value) + assert "SSL/TLS error" in error_msg + + def test_handle_ssl_error_raises_connection_error(self): + """ClientConnectorError -> SoarConnectionError.""" + os_error = OSError("Connection refused") + connection_key = MagicMock() + error = aiohttp.ClientConnectorError(connection_key, os_error) + + with pytest.raises(SoarConnectionError) as exc_info: + self.client._handle_ssl_error(error) + + error_msg = str(exc_info.value) + assert "Failed to connect to SOAR" in error_msg + assert "test-soar.example.com" in error_msg + + def test_handle_ssl_error_raises_for_raw_ssl_cert_error(self): + """Raw ssl.SSLError with CERTIFICATE_VERIFY_FAILED -> SoarSSLError.""" + error = ssl.SSLError(1, "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed") + + with pytest.raises(SoarSSLError) as exc_info: + self.client._handle_ssl_error(error) + + error_msg = str(exc_info.value) + assert "SSL certificate verification failed" in error_msg + assert "certifi" in error_msg + + def test_handle_ssl_error_does_nothing_for_non_ssl_errors(self): + """Non-SSL exceptions should pass through without raising.""" + error = ValueError("something else went wrong") + # Should not raise + self.client._handle_ssl_error(error) + + def test_handle_ssl_error_does_nothing_for_timeout(self): + """TimeoutError should pass through without raising.""" + import asyncio + + error = asyncio.TimeoutError() + # Should not raise + self.client._handle_ssl_error(error) + + +# --------------------------------------------------------------------------- +# Tests for HttpClient.get with mocked SSL errors +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestHttpClientGetSSLError: + """Tests that HttpClient.get properly propagates SSL errors.""" + + async def test_get_raises_soar_ssl_error_on_cert_verify_failure(self): + """GET request with SSL cert verify failure raises SoarSSLError.""" + client = HttpClient( + base_url="https://test-soar.example.com:443", + app_key="test-key", + ) + + ssl_err = ssl.SSLError(1, "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed") + os_error = OSError("CERTIFICATE_VERIFY_FAILED") + os_error.__cause__ = ssl_err + connection_key = MagicMock() + ssl_error = aiohttp.ClientConnectorSSLError(connection_key, os_error) + ssl_error.__cause__ = os_error + + mock_session = AsyncMock() + mock_session.get = MagicMock(side_effect=ssl_error) + client._session = mock_session + + with pytest.raises(SoarSSLError) as exc_info: + await client.get("/api/external/v1/settings/GetScopes") + + error_msg = str(exc_info.value) + assert "SSL certificate verification failed" in error_msg + assert "certifi" in error_msg + + async def test_get_raises_soar_connection_error_on_connection_failure(self): + """GET request with connection refused raises SoarConnectionError.""" + client = HttpClient( + base_url="https://test-soar.example.com:443", + app_key="test-key", + ) + + os_error = OSError("Connection refused") + connection_key = MagicMock() + conn_error = aiohttp.ClientConnectorError(connection_key, os_error) + + mock_session = AsyncMock() + mock_session.get = MagicMock(side_effect=conn_error) + client._session = mock_session + + with pytest.raises(SoarConnectionError) as exc_info: + await client.get("/api/external/v1/settings/GetScopes") + + error_msg = str(exc_info.value) + assert "Failed to connect to SOAR" in error_msg + + async def test_get_returns_none_for_http_error(self): + """GET request with HTTP 401 still returns None (existing behavior).""" + client = HttpClient( + base_url="https://test-soar.example.com:443", + app_key="test-key", + ) + + http_error = aiohttp.ClientResponseError( + request_info=MagicMock(), + history=(), + status=401, + message="Unauthorized", + ) + + # Mock the session.get() as an async context manager that raises + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(side_effect=http_error) + mock_cm.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_cm) + client._session = mock_session + + result = await client.get("/api/external/v1/settings/GetScopes") + assert result is None + + +# --------------------------------------------------------------------------- +# Tests for HttpClient.post with mocked SSL errors +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestHttpClientPostSSLError: + """Tests that HttpClient.post properly propagates SSL errors.""" + + async def test_post_raises_soar_ssl_error_on_cert_verify_failure(self): + """POST request with SSL cert verify failure raises SoarSSLError.""" + client = HttpClient( + base_url="https://test-soar.example.com:443", + app_key="test-key", + ) + + ssl_err = ssl.SSLError(1, "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed") + os_error = OSError("CERTIFICATE_VERIFY_FAILED") + os_error.__cause__ = ssl_err + connection_key = MagicMock() + ssl_error = aiohttp.ClientConnectorSSLError(connection_key, os_error) + ssl_error.__cause__ = os_error + + mock_session = AsyncMock() + mock_session.post = MagicMock(side_effect=ssl_error) + client._session = mock_session + + with pytest.raises(SoarSSLError): + await client.post("/api/external/v1/cases/ExecuteManualAction") + + +# --------------------------------------------------------------------------- +# Tests for bindings.bind() with SSL errors +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestBindingsSSLError: + """Tests that bindings.bind() properly propagates SSL errors.""" + + async def test_bind_raises_soar_ssl_error(self): + """bindings.bind() should propagate SoarSSLError from HttpClient. + + We patch the aiohttp session's get() so the SSL error goes through + HttpClient._handle_ssl_error and gets converted to SoarSSLError. + """ + from secops_soar_mcp import bindings + + with patch.dict( + "os.environ", + { + "SOAR_URL": "https://test-soar.example.com:443", + "SOAR_APP_KEY": "test-key", + }, + ): + ssl_err = ssl.SSLError( + 1, "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed" + ) + os_error = OSError("CERTIFICATE_VERIFY_FAILED") + os_error.__cause__ = ssl_err + connection_key = MagicMock() + ssl_error = aiohttp.ClientConnectorSSLError(connection_key, os_error) + ssl_error.__cause__ = os_error + + # Patch at the session level so HttpClient.get()'s exception + # handler (_handle_ssl_error) converts it to SoarSSLError. + mock_session = MagicMock() + mock_session.get = MagicMock(side_effect=ssl_error) + + with patch( + "secops_soar_mcp.http_client.aiohttp.ClientSession", + return_value=mock_session, + ): + with pytest.raises(SoarSSLError) as exc_info: + await bindings.bind() + + error_msg = str(exc_info.value) + assert "SSL certificate verification failed" in error_msg + assert "certifi" in error_msg + + # Cleanup + if bindings.http_client: + # Reset the session so cleanup doesn't fail + bindings.http_client._session = AsyncMock() + + async def test_bind_raises_runtime_error_for_invalid_credentials(self): + """bindings.bind() should raise RuntimeError for invalid credentials.""" + from secops_soar_mcp import bindings + + with patch.dict( + "os.environ", + { + "SOAR_URL": "https://test-soar.example.com:443", + "SOAR_APP_KEY": "bad-key", + }, + ): + # Mock the session to return an HTTP 401 (returns None from get()) + mock_response = AsyncMock() + mock_response.raise_for_status.side_effect = aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=401, message="Unauthorized" + ) + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=401, message="Unauthorized" + )) + mock_cm.__aexit__ = AsyncMock(return_value=False) + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=mock_cm) + + with patch( + "secops_soar_mcp.http_client.aiohttp.ClientSession", + return_value=mock_session, + ): + with pytest.raises(RuntimeError) as exc_info: + await bindings.bind() + + error_msg = str(exc_info.value) + assert "Failed to fetch valid scopes from SOAR" in error_msg + assert "SOAR_APP_KEY" in error_msg + + # Cleanup + if bindings.http_client: + bindings.http_client._session = AsyncMock() + + +# --------------------------------------------------------------------------- +# Tests for error message content quality +# --------------------------------------------------------------------------- + + +class TestErrorMessageContent: + """Verify that error messages are actionable and helpful.""" + + def test_ssl_certifi_message_contains_fix_instructions(self): + """The SSL certifi error message should contain actionable fix steps.""" + msg = consts.SSL_CERTIFI_ERROR_MESSAGE + assert "SSL certificate verification failed" in msg + assert "certifi" in msg + assert "pip install" in msg + assert "google.github.io/mcp-security" in msg + + def test_connection_error_message_contains_checklist(self): + """The connection error message should contain diagnostic steps.""" + msg = consts.CONNECTION_ERROR_MESSAGE.format(url="https://example.com") + assert "Failed to connect to SOAR" in msg + assert "SOAR_URL" in msg + assert "example.com" in msg + + def test_credentials_error_message_contains_env_vars(self): + """The credentials error message should mention required env vars.""" + msg = consts.CREDENTIALS_ERROR_MESSAGE + assert "SOAR_URL" in msg + assert "SOAR_APP_KEY" in msg