Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions server/secops-soar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,23 @@ 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"
```

**For Windows PowerShell:**
```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.
Expand Down
4 changes: 3 additions & 1 deletion server/secops-soar/secops_soar_mcp/bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
20 changes: 16 additions & 4 deletions server/secops-soar/secops_soar_mcp/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +29 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the SOAR_PROXY environment variable is set to an empty string or contains only whitespace, self.proxy will be an empty string. Passing an empty string as the proxy argument to aiohttp request methods will cause a ValueError (e.g., ValueError: Only http and https proxies are supported).

To prevent this, sanitize the proxy input in __init__ by converting empty or whitespace-only strings to None.

Suggested change
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
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.strip() if proxy and proxy.strip() else None

self._session = None

def _get_session(self) -> aiohttp.ClientSession:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions server/secops-soar/secops_soar_mcp/utils/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

ENV_SOAR_URL = "SOAR_URL"
ENV_SOAR_APP_KEY = "SOAR_APP_KEY"
ENV_SOAR_PROXY = "SOAR_PROXY"


class Endpoints:
Expand Down
89 changes: 89 additions & 0 deletions server/secops-soar/tests/unit/test_http_client_proxy.py
Original file line number Diff line number Diff line change
@@ -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,
}
Comment on lines +47 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure that empty or whitespace-only proxy strings are correctly sanitized and treated as None (preventing aiohttp from raising a ValueError), update the test to parameterize and assert this behavior.

@pytest.mark.parametrize(
    "proxy,expected_proxy",
    [
        (None, None),
        ("", None),
        ("   ", None),
        ("http://proxy.example.com:8080", "http://proxy.example.com:8080"),
    ],
)
async def test_http_client_routes_requests_with_optional_proxy(method, proxy, expected_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": expected_proxy}
    else:
        result = await getattr(client, method)("/cases", req=body, params=params)
        expected = {
            "json": body,
            "params": params,
            "headers": {"AppKey": "app-key"},
            "proxy": expected_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()
Comment on lines +74 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similarly, update the binding test to verify that empty or whitespace-only SOAR_PROXY environment variables are correctly sanitized to None when initializing the global HttpClient.

@pytest.mark.parametrize(
    "proxy,expected_proxy",
    [
        (None, None),
        ("", None),
        ("   ", None),
        ("http://proxy.example.com:8080", "http://proxy.example.com:8080"),
    ],
)
async def test_bind_passes_optional_soar_proxy_from_environment(monkeypatch, proxy, expected_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 == expected_proxy
    await bindings.cleanup()

Loading