diff --git a/server/secops-soar/README.md b/server/secops-soar/README.md index 97e52fb5..96c2f7b6 100644 --- a/server/secops-soar/README.md +++ b/server/secops-soar/README.md @@ -119,6 +119,22 @@ $Env:SOAR_APP_KEY = "your-soar-app-key" $Env:SOAR_INTEGRATIONS = "ServiceNow,CSV,Siemplify" ``` +## Troubleshooting + +If the server shuts down at startup with an error like `Failed to fetch valid +scopes from SOAR due to an SSL certificate verification error`, this is a +local CA certificate problem, not incorrect `SOAR_URL`/`SOAR_APP_KEY` values. +Install the certifi CA bundle: + +- **macOS:** run `Install Certificates.command` from your Python install, + e.g. `/Applications/Python\ 3.12/Install\ Certificates.command` (match the + Python minor version you're running). +- **Linux/Windows:** point `SSL_CERT_FILE` at the certifi bundle, e.g. + `export SSL_CERT_FILE=$(python -m certifi)` (or the PowerShell equivalent + `$Env:SSL_CERT_FILE = (python -m certifi)`). + +See `docs/usage_guide.md` for further details. + ## Requirements - Python 3.11+ diff --git a/server/secops-soar/secops_soar_mcp/bindings.py b/server/secops-soar/secops_soar_mcp/bindings.py index 077aafda..2e99db81 100644 --- a/server/secops-soar/secops_soar_mcp/bindings.py +++ b/server/secops-soar/secops_soar_mcp/bindings.py @@ -14,7 +14,9 @@ """Bindings for the SOAR client.""" import os +import ssl +import aiohttp import dotenv from logger_utils import get_logger from secops_soar_mcp.http_client import HttpClient @@ -28,9 +30,23 @@ http_client: HttpClient = None valid_scopes = set() +_CERTIFICATE_ERROR_MESSAGE = ( + "Failed to fetch valid scopes from SOAR due to an SSL certificate " + "verification error. This is usually a local CA certificate " + "configuration issue, not incorrect SOAR credentials. Install the " + "certifi CA bundle, e.g. run Python's `Install Certificates.command` " + "(macOS) or point SSL_CERT_FILE at the output of `python -m certifi`. " + "See the 'Troubleshooting' section in README.md or setup notes in " + "docs/usage_guide.md for setup steps. " + "Shutting down..." +) + async def _get_valid_scopes(): - valid_scopes_list = await http_client.get(consts.Endpoints.GET_SCOPES) + try: + valid_scopes_list = await http_client.get(consts.Endpoints.GET_SCOPES) + except (ssl.SSLError, aiohttp.ClientSSLError) as e: + raise RuntimeError(_CERTIFICATE_ERROR_MESSAGE) from e 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..." diff --git a/server/secops-soar/secops_soar_mcp/http_client.py b/server/secops-soar/secops_soar_mcp/http_client.py index 04afc16d..13aa5ed1 100644 --- a/server/secops-soar/secops_soar_mcp/http_client.py +++ b/server/secops-soar/secops_soar_mcp/http_client.py @@ -14,6 +14,7 @@ """HTTP client for making requests to the SecOps SOAR API.""" import json +import ssl from typing import Any, Dict import aiohttp @@ -64,6 +65,10 @@ async def get( return await response.json() except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) + except (ssl.SSLError, aiohttp.ClientSSLError): + # Don't mask certificate configuration problems as a plain + # "no data" result; callers need to see and report on these. + raise except Exception as e: logger.debug("An error occurred: %s", e) return None @@ -95,6 +100,10 @@ async def post( return json.loads(decoded_data) except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) + except (ssl.SSLError, aiohttp.ClientSSLError): + # Don't mask certificate configuration problems as a plain + # "no data" result; callers need to see and report on these. + raise except Exception as e: logger.debug("An error occurred: %s", e) return None @@ -124,6 +133,10 @@ async def patch( return await response.json() except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) + except (ssl.SSLError, aiohttp.ClientSSLError): + # Don't mask certificate configuration problems as a plain + # "no data" result; callers need to see and report on these. + raise except Exception as e: logger.debug("An error occurred: %s", e) return None diff --git a/server/secops-soar/tests/unit/__init__.py b/server/secops-soar/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/secops-soar/tests/unit/conftest.py b/server/secops-soar/tests/unit/conftest.py new file mode 100644 index 00000000..efa02d43 --- /dev/null +++ b/server/secops-soar/tests/unit/conftest.py @@ -0,0 +1,23 @@ +# Copyright 2026 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. + +import pytest_asyncio + + +@pytest_asyncio.fixture(loop_scope="session", autouse=True) +async def setup_bindings(): + """Overrides tests/conftest.py's fixture: these unit tests mock + bindings/http_client directly and must not require real SOAR + credentials or network access.""" + yield diff --git a/server/secops-soar/tests/unit/test_certificate_error_handling.py b/server/secops-soar/tests/unit/test_certificate_error_handling.py new file mode 100644 index 00000000..98549f15 --- /dev/null +++ b/server/secops-soar/tests/unit/test_certificate_error_handling.py @@ -0,0 +1,105 @@ +# Copyright 2026 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 that certificate errors get a helpful message instead of the +generic "wrong SOAR credentials" one, and that HttpClient doesn't swallow +SSL errors as a plain None result (see issue #191).""" + +import ssl +from unittest import mock + +import aiohttp +import pytest + +from secops_soar_mcp import bindings +from secops_soar_mcp.http_client import HttpClient + + +class _RaisingSession: + """Minimal aiohttp session stand-in whose .get() raises.""" + + def __init__(self, exc: Exception): + self._exc = exc + + def get(self, *args, **kwargs): + raise self._exc + + async def close(self): + pass + + +@pytest.mark.asyncio +async def test_http_client_get_reraises_ssl_error(): + client = HttpClient("https://example.com", "app-key") + client._session = _RaisingSession(ssl.SSLCertVerificationError("bad cert")) + + with pytest.raises(ssl.SSLError): + await client.get("/some/endpoint") + + +@pytest.mark.asyncio +async def test_http_client_get_swallows_generic_connection_error(): + client = HttpClient("https://example.com", "app-key") + client._session = _RaisingSession(aiohttp.ClientConnectionError("refused")) + + assert await client.get("/some/endpoint") is None + + +@pytest.mark.asyncio +async def test_get_valid_scopes_reports_certificate_issue_not_credentials(): + with ( + mock.patch.object( + bindings, + "http_client", + new=mock.AsyncMock(get=mock.AsyncMock(side_effect=ssl.SSLCertVerificationError())), + ), + pytest.raises(RuntimeError) as exc_info, + ): + await bindings._get_valid_scopes() + + message = str(exc_info.value) + assert "certifi" in message.lower() + assert "certificate" in message.lower() + assert "not" in message.lower() and "credentials" in message.lower() + + +@pytest.mark.asyncio +async def test_get_valid_scopes_still_blames_credentials_when_no_data(): + with ( + mock.patch.object( + bindings, + "http_client", + new=mock.AsyncMock(get=mock.AsyncMock(return_value=None)), + ), + pytest.raises(RuntimeError) as exc_info, + ): + await bindings._get_valid_scopes() + + assert "credentials" in str(exc_info.value).lower() + + +@pytest.mark.asyncio +async def test_get_valid_scopes_reports_certificate_issue_on_client_ssl_error(): + conn_key = aiohttp.client_reqrep.ConnectionKey("example.com", 443, True, True, None, None, None) + client_ssl_err = aiohttp.ClientSSLError(conn_key, OSError("handshake failed")) + with ( + mock.patch.object( + bindings, + "http_client", + new=mock.AsyncMock(get=mock.AsyncMock(side_effect=client_ssl_err)), + ), + pytest.raises(RuntimeError) as exc_info, + ): + await bindings._get_valid_scopes() + assert "certificate" in str(exc_info.value).lower()