From 4312a27810ea92a5aa04dea9aee3a5d6c779b505 Mon Sep 17 00:00:00 2001 From: Som0111 Date: Sun, 6 Sep 2026 23:42:11 +0530 Subject: [PATCH 1/4] fix: improve certifi error message and docs for SOAR (#191) --- server/secops-soar/README.md | 16 ++++ .../secops-soar/secops_soar_mcp/bindings.py | 17 +++- .../secops_soar_mcp/http_client.py | 13 +++ server/secops-soar/tests/unit/__init__.py | 0 server/secops-soar/tests/unit/conftest.py | 23 +++++ .../unit/test_certificate_error_handling.py | 85 +++++++++++++++++++ 6 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 server/secops-soar/tests/unit/__init__.py create mode 100644 server/secops-soar/tests/unit/conftest.py create mode 100644 server/secops-soar/tests/unit/test_certificate_error_handling.py diff --git a/server/secops-soar/README.md b/server/secops-soar/README.md index 97e52fb5..999c8a58 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..b37ab9d2 100644 --- a/server/secops-soar/secops_soar_mcp/bindings.py +++ b/server/secops-soar/secops_soar_mcp/bindings.py @@ -14,6 +14,7 @@ """Bindings for the SOAR client.""" import os +import ssl import dotenv from logger_utils import get_logger @@ -28,9 +29,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 'Additionally, for the secops-soar MCP server...' note in " + "README.md / docs/usage_guide.md for the exact 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 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..762a0fa0 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: + # 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: + # 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: + # 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..01241bfb --- /dev/null +++ b/server/secops-soar/tests/unit/conftest.py @@ -0,0 +1,23 @@ +# 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. + +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..caebdb67 --- /dev/null +++ b/server/secops-soar/tests/unit/test_certificate_error_handling.py @@ -0,0 +1,85 @@ +# 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 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())), + ): + with 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)), + ): + with pytest.raises(RuntimeError) as exc_info: + await bindings._get_valid_scopes() + + assert "credentials" in str(exc_info.value).lower() From dde150588ee7761d72a5ccf18c311c84611f9401 Mon Sep 17 00:00:00 2001 From: Som0111 Date: Mon, 7 Sep 2026 09:59:55 +0530 Subject: [PATCH 2/4] fix: address review comments - PowerShell syntax, aiohttp SSL, ruff SIM117 --- server/secops-soar/README.md | 2 +- .../secops_soar_mcp/http_client.py | 6 ++-- .../unit/test_certificate_error_handling.py | 28 +++++++++++-------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/server/secops-soar/README.md b/server/secops-soar/README.md index 999c8a58..96c2f7b6 100644 --- a/server/secops-soar/README.md +++ b/server/secops-soar/README.md @@ -131,7 +131,7 @@ Install the certifi CA bundle: 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`). + `$Env:SSL_CERT_FILE = (python -m certifi)`). See `docs/usage_guide.md` for further details. diff --git a/server/secops-soar/secops_soar_mcp/http_client.py b/server/secops-soar/secops_soar_mcp/http_client.py index 762a0fa0..13aa5ed1 100644 --- a/server/secops-soar/secops_soar_mcp/http_client.py +++ b/server/secops-soar/secops_soar_mcp/http_client.py @@ -65,7 +65,7 @@ async def get( return await response.json() except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) - except ssl.SSLError: + 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 @@ -100,7 +100,7 @@ async def post( return json.loads(decoded_data) except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) - except ssl.SSLError: + 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 @@ -133,7 +133,7 @@ async def patch( return await response.json() except aiohttp.ClientResponseError as e: logger.debug("HTTP error occurred: %s", e) - except ssl.SSLError: + 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 diff --git a/server/secops-soar/tests/unit/test_certificate_error_handling.py b/server/secops-soar/tests/unit/test_certificate_error_handling.py index caebdb67..d7dd71ee 100644 --- a/server/secops-soar/tests/unit/test_certificate_error_handling.py +++ b/server/secops-soar/tests/unit/test_certificate_error_handling.py @@ -58,13 +58,15 @@ async def test_http_client_get_swallows_generic_connection_error(): @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())), + with ( + mock.patch.object( + bindings, + "http_client", + new=mock.AsyncMock(get=mock.AsyncMock(side_effect=ssl.SSLCertVerificationError())), + ), + pytest.raises(RuntimeError) as exc_info, ): - with pytest.raises(RuntimeError) as exc_info: - await bindings._get_valid_scopes() + await bindings._get_valid_scopes() message = str(exc_info.value) assert "certifi" in message.lower() @@ -74,12 +76,14 @@ async def test_get_valid_scopes_reports_certificate_issue_not_credentials(): @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)), + with ( + mock.patch.object( + bindings, + "http_client", + new=mock.AsyncMock(get=mock.AsyncMock(return_value=None)), + ), + pytest.raises(RuntimeError) as exc_info, ): - with pytest.raises(RuntimeError) as exc_info: - await bindings._get_valid_scopes() + await bindings._get_valid_scopes() assert "credentials" in str(exc_info.value).lower() From 52a2a2d6cab051dc7da6067840ab206f4700a703 Mon Sep 17 00:00:00 2001 From: Som0111 Date: Mon, 7 Sep 2026 10:39:50 +0530 Subject: [PATCH 3/4] fix: catch aiohttp.ClientSSLError in bindings.py and update error message --- server/secops-soar/secops_soar_mcp/bindings.py | 7 ++++--- .../unit/test_certificate_error_handling.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/server/secops-soar/secops_soar_mcp/bindings.py b/server/secops-soar/secops_soar_mcp/bindings.py index b37ab9d2..2e99db81 100644 --- a/server/secops-soar/secops_soar_mcp/bindings.py +++ b/server/secops-soar/secops_soar_mcp/bindings.py @@ -16,6 +16,7 @@ import os import ssl +import aiohttp import dotenv from logger_utils import get_logger from secops_soar_mcp.http_client import HttpClient @@ -35,8 +36,8 @@ "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 'Additionally, for the secops-soar MCP server...' note in " - "README.md / docs/usage_guide.md for the exact setup steps. " + "See the 'Troubleshooting' section in README.md or setup notes in " + "docs/usage_guide.md for setup steps. " "Shutting down..." ) @@ -44,7 +45,7 @@ async def _get_valid_scopes(): try: valid_scopes_list = await http_client.get(consts.Endpoints.GET_SCOPES) - except ssl.SSLError as e: + except (ssl.SSLError, aiohttp.ClientSSLError) as e: raise RuntimeError(_CERTIFICATE_ERROR_MESSAGE) from e if valid_scopes_list is None: raise RuntimeError( diff --git a/server/secops-soar/tests/unit/test_certificate_error_handling.py b/server/secops-soar/tests/unit/test_certificate_error_handling.py index d7dd71ee..08a7448a 100644 --- a/server/secops-soar/tests/unit/test_certificate_error_handling.py +++ b/server/secops-soar/tests/unit/test_certificate_error_handling.py @@ -87,3 +87,19 @@ async def test_get_valid_scopes_still_blames_credentials_when_no_data(): 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() From b4259e700725dd28743d84cae545eee860a8ec07 Mon Sep 17 00:00:00 2001 From: Som0111 Date: Mon, 7 Sep 2026 10:43:09 +0530 Subject: [PATCH 4/4] fix: update copyright year to 2026 in test files --- server/secops-soar/tests/unit/conftest.py | 2 +- .../secops-soar/tests/unit/test_certificate_error_handling.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/secops-soar/tests/unit/conftest.py b/server/secops-soar/tests/unit/conftest.py index 01241bfb..efa02d43 100644 --- a/server/secops-soar/tests/unit/conftest.py +++ b/server/secops-soar/tests/unit/conftest.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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. diff --git a/server/secops-soar/tests/unit/test_certificate_error_handling.py b/server/secops-soar/tests/unit/test_certificate_error_handling.py index 08a7448a..98549f15 100644 --- a/server/secops-soar/tests/unit/test_certificate_error_handling.py +++ b/server/secops-soar/tests/unit/test_certificate_error_handling.py @@ -1,4 +1,4 @@ -# Copyright 2025 Google LLC +# 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.