From f0454a055133531bb2f973cc8e8197822bf1525c Mon Sep 17 00:00:00 2001 From: "yusheng.wang" Date: Fri, 16 Oct 2026 15:21:05 +0800 Subject: [PATCH] Support configurable SOAR proxy --- server/secops-soar/README.md | 5 ++ .../secops-soar/secops_soar_mcp/bindings.py | 4 +- .../secops_soar_mcp/http_client.py | 20 ++++- .../secops_soar_mcp/utils/consts.py | 1 + .../tests/unit/test_http_client_proxy.py | 89 +++++++++++++++++++ 5 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 server/secops-soar/tests/unit/test_http_client_proxy.py diff --git a/server/secops-soar/README.md b/server/secops-soar/README.md index 96c2f7b6..85cfd5b3 100644 --- a/server/secops-soar/README.md +++ b/server/secops-soar/README.md @@ -109,6 +109,7 @@ Set up these environment variables in your system: ```bash export SOAR_URL="your-soar-url" export SOAR_APP_KEY="your-soar-app-key" +export SOAR_PROXY="http://proxy.example.com:8080" # Optional export SOAR_INTEGRATIONS="ServiceNow,CSV,Siemplify" ``` @@ -116,11 +117,15 @@ export SOAR_INTEGRATIONS="ServiceNow,CSV,Siemplify" ```powershell $Env:SOAR_URL = "your-soar-url" $Env:SOAR_APP_KEY = "your-soar-app-key" +$Env:SOAR_PROXY = "http://proxy.example.com:8080" # Optional $Env:SOAR_INTEGRATIONS = "ServiceNow,CSV,Siemplify" ``` ## Troubleshooting +Set `SOAR_PROXY` to a proxy URL if requests to SOAR must pass through a proxy. +Leave it unset to connect directly. TLS certificate verification remains enabled. + 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. diff --git a/server/secops-soar/secops_soar_mcp/bindings.py b/server/secops-soar/secops_soar_mcp/bindings.py index 2e99db81..9110c1a9 100644 --- a/server/secops-soar/secops_soar_mcp/bindings.py +++ b/server/secops-soar/secops_soar_mcp/bindings.py @@ -58,7 +58,9 @@ async def bind(): """Binds global variables.""" global http_client, valid_scopes http_client = HttpClient( - os.getenv(consts.ENV_SOAR_URL), os.getenv(consts.ENV_SOAR_APP_KEY) + os.getenv(consts.ENV_SOAR_URL), + os.getenv(consts.ENV_SOAR_APP_KEY), + proxy=os.getenv(consts.ENV_SOAR_PROXY), ) valid_scopes = await _get_valid_scopes() diff --git a/server/secops-soar/secops_soar_mcp/http_client.py b/server/secops-soar/secops_soar_mcp/http_client.py index 13aa5ed1..c9181023 100644 --- a/server/secops-soar/secops_soar_mcp/http_client.py +++ b/server/secops-soar/secops_soar_mcp/http_client.py @@ -26,9 +26,10 @@ class HttpClient: """HTTP client for making requests to the SecOps SOAR API.""" - def __init__(self, base_url: str, app_key: str): + def __init__(self, base_url: str, app_key: str, proxy: str = None): self.base_url = base_url self.app_key = app_key + self.proxy = proxy self._session = None def _get_session(self) -> aiohttp.ClientSession: @@ -59,7 +60,10 @@ async def get( headers = await self._get_headers() try: async with self._get_session().get( - self.base_url + endpoint, params=params, headers=headers + self.base_url + endpoint, + params=params, + headers=headers, + proxy=self.proxy, ) as response: response.raise_for_status() # Raise an exception for 4xx/5xx responses return await response.json() @@ -92,7 +96,11 @@ async def post( headers = await self._get_headers() try: async with self._get_session().post( - self.base_url + endpoint, json=req, params=params, headers=headers + self.base_url + endpoint, + json=req, + params=params, + headers=headers, + proxy=self.proxy, ) as response: response.raise_for_status() data = await response.content.read() @@ -127,7 +135,11 @@ async def patch( headers = await self._get_headers() try: async with self._get_session().patch( - self.base_url + endpoint, json=req, params=params, headers=headers + self.base_url + endpoint, + json=req, + params=params, + headers=headers, + proxy=self.proxy, ) as response: response.raise_for_status() return await response.json() diff --git a/server/secops-soar/secops_soar_mcp/utils/consts.py b/server/secops-soar/secops_soar_mcp/utils/consts.py index ae0df312..77a668fd 100644 --- a/server/secops-soar/secops_soar_mcp/utils/consts.py +++ b/server/secops-soar/secops_soar_mcp/utils/consts.py @@ -15,6 +15,7 @@ ENV_SOAR_URL = "SOAR_URL" ENV_SOAR_APP_KEY = "SOAR_APP_KEY" +ENV_SOAR_PROXY = "SOAR_PROXY" class Endpoints: diff --git a/server/secops-soar/tests/unit/test_http_client_proxy.py b/server/secops-soar/tests/unit/test_http_client_proxy.py new file mode 100644 index 00000000..d16bb5f1 --- /dev/null +++ b/server/secops-soar/tests/unit/test_http_client_proxy.py @@ -0,0 +1,89 @@ +# 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. + +"""Proxy routing for the SOAR HTTP client.""" + +import json +from unittest import mock + +import pytest + +from secops_soar_mcp import bindings +from secops_soar_mcp.http_client import HttpClient + + +class _Response: + status = 200 + + def __init__(self): + self.content = mock.Mock(read=mock.AsyncMock(return_value=json.dumps({"ok": True}).encode())) + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + def raise_for_status(self): + pass + + async def json(self): + return {"ok": True} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["get", "post", "patch"]) +@pytest.mark.parametrize("proxy", [None, "http://proxy.example.com:8080"]) +async def test_http_client_routes_requests_with_optional_proxy(method, proxy): + client = HttpClient("https://soar.example.com", "app-key", proxy=proxy) + session = mock.Mock() + response = _Response() + getattr(session, method).return_value = response + client._session = session + + params = {"page": 1} + body = {"name": "case"} + if method == "get": + result = await client.get("/cases", params=params) + expected = {"params": params, "headers": {"AppKey": "app-key"}, "proxy": proxy} + else: + result = await getattr(client, method)("/cases", req=body, params=params) + expected = { + "json": body, + "params": params, + "headers": {"AppKey": "app-key"}, + "proxy": proxy, + } + + assert result == {"ok": True} + getattr(session, method).assert_called_once_with("https://soar.example.com/cases", **expected) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("proxy", [None, "http://proxy.example.com:8080"]) +async def test_bind_passes_optional_soar_proxy_from_environment(monkeypatch, proxy): + monkeypatch.setenv("SOAR_URL", "https://soar.example.com") + monkeypatch.setenv("SOAR_APP_KEY", "app-key") + if proxy is None: + monkeypatch.delenv("SOAR_PROXY", raising=False) + else: + monkeypatch.setenv("SOAR_PROXY", proxy) + + with mock.patch.object(bindings, "_get_valid_scopes", new=mock.AsyncMock(return_value=set())): + await bindings.bind() + + assert bindings.http_client.base_url == "https://soar.example.com" + assert bindings.http_client.app_key == "app-key" + assert bindings.http_client.proxy == proxy + await bindings.cleanup()