diff --git a/README.md b/README.md index a14cd523..ebe79837 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,13 @@ uvx scc_mcp uvx secops_soar_mcp --integrations CSV,OKTA ``` +If SecOps SOAR fails during startup with a TLS certificate verification error +on macOS, install the CA list bundled with `certifi` for your Python version: + +```bash +/Applications/Python\ 3.12/Install\ Certificates.command +``` + With environment variables: ```bash diff --git a/server/secops-soar/secops_soar_mcp/bindings.py b/server/secops-soar/secops_soar_mcp/bindings.py index 094c1f1a..865d23e9 100644 --- a/server/secops-soar/secops_soar_mcp/bindings.py +++ b/server/secops-soar/secops_soar_mcp/bindings.py @@ -13,7 +13,9 @@ # limitations under the License. """Bindings for the SOAR client.""" +from collections import deque import os +import ssl import dotenv from logger_utils import get_logger @@ -29,12 +31,56 @@ valid_scopes = set() +def _is_certificate_verification_error(error: BaseException | None) -> bool: + if error is None: + return False + pending = deque([error]) + seen: set[int] = set() + while pending: + current = pending.popleft() + if id(current) in seen: + continue + seen.add(id(current)) + message = str(current).lower() + if isinstance(current, ssl.SSLCertVerificationError): + return True + if ( + isinstance(current, ssl.SSLError) + and "certificate verify failed" in message + ): + return True + if "certificate verify failed" in message: + return True + for related in (current.__cause__, current.__context__): + if isinstance(related, BaseException): + pending.append(related) + for arg in getattr(current, "args", ()): + if isinstance(arg, BaseException): + pending.append(arg) + return False + + +def _valid_scopes_error_message(error: BaseException | None) -> str: + if _is_certificate_verification_error(error): + return ( + "Failed to fetch valid scopes from SOAR because TLS certificate " + "verification failed. If you are using the Python.org macOS " + "installer, run the Install Certificates.command for your Python " + "version, for example: " + "`/Applications/Python\\ 3.12/Install\\ Certificates.command`. " + "You can also point Python at certifi's CA bundle with " + "`SSL_CERT_FILE=$(python -m certifi)`." + ) + return ( + "Failed to fetch valid scopes from SOAR, please make sure you have " + "configured the right SOAR credentials. Shutting down..." + ) + + 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(_valid_scopes_error_message(http_client.last_error)) return set(valid_scopes_list) diff --git a/server/secops-soar/secops_soar_mcp/bindings_test.py b/server/secops-soar/secops_soar_mcp/bindings_test.py new file mode 100644 index 00000000..5bfe1c3b --- /dev/null +++ b/server/secops-soar/secops_soar_mcp/bindings_test.py @@ -0,0 +1,35 @@ +# 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. + +"""Tests for SecOps SOAR binding startup errors.""" + +import ssl + +from secops_soar_mcp import bindings + + +def test_valid_scopes_error_message_identifies_certifi_issue(): + error = ssl.SSLCertVerificationError("certificate verify failed") + + message = bindings._valid_scopes_error_message(error) + + assert "TLS certificate verification failed" in message + assert "Install Certificates.command" in message + assert "certifi" in message + + +def test_valid_scopes_error_message_preserves_credentials_hint(): + message = bindings._valid_scopes_error_message(RuntimeError("401 unauthorized")) + + assert "configured the right SOAR credentials" in message diff --git a/server/secops-soar/secops_soar_mcp/http_client.py b/server/secops-soar/secops_soar_mcp/http_client.py index 1c03768b..13636a3c 100644 --- a/server/secops-soar/secops_soar_mcp/http_client.py +++ b/server/secops-soar/secops_soar_mcp/http_client.py @@ -29,6 +29,7 @@ def __init__(self, base_url: str, app_key: str): self.base_url = base_url self.app_key = app_key self._session = None + self.last_error: Exception | None = None def _get_session(self) -> aiohttp.ClientSession: if self._session is None: @@ -56,6 +57,7 @@ async def get( The response as a JSON object, or None if an error occurred. """ headers = await self._get_headers() + self.last_error = None try: async with self._get_session().get( self.base_url + endpoint, params=params, headers=headers @@ -63,8 +65,10 @@ async def get( response.raise_for_status() # Raise an exception for 4xx/5xx responses return await response.json() except aiohttp.ClientResponseError as e: + self.last_error = e logger.debug("HTTP error occurred: %s", e) except Exception as e: + self.last_error = e logger.debug("An error occurred: %s", e) return None @@ -85,6 +89,7 @@ async def post( The response as a JSON object, or None if an error occurred. """ headers = await self._get_headers() + self.last_error = None try: async with self._get_session().post( self.base_url + endpoint, json=req, params=params, headers=headers @@ -94,8 +99,10 @@ async def post( decoded_data = data.decode("utf-8") return json.loads(decoded_data) except aiohttp.ClientResponseError as e: + self.last_error = e logger.debug("HTTP error occurred: %s", e) except Exception as e: + self.last_error = e logger.debug("An error occurred: %s", e) return None @@ -116,6 +123,7 @@ async def patch( The response as a JSON object, or None if an error occurred. """ headers = await self._get_headers() + self.last_error = None try: async with self._get_session().patch( self.base_url + endpoint, json=req, params=params, headers=headers @@ -123,8 +131,10 @@ async def patch( response.raise_for_status() return await response.json() except aiohttp.ClientResponseError as e: + self.last_error = e logger.debug("HTTP error occurred: %s", e) except Exception as e: + self.last_error = e logger.debug("An error occurred: %s", e) return None