23036e20a6
Squashed integration of the user-facing message audit for this surface set. Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
477 lines
17 KiB
Python
477 lines
17 KiB
Python
"""MCP test/dashboard must not fingerprint Authorization credentials (#97460)."""
|
|
|
|
import argparse
|
|
import itertools
|
|
import json
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
|
|
SYNTHETIC = "SYNTHETIC_MCP_BEARER_NOT_A_SECRET_123456"
|
|
SYNTHETIC_PREFIX = "SYNTHETIC_MCP_BEARER"
|
|
SYNTHETIC_SUFFIX = "SECRET_123456"
|
|
HEADER = f"Authorization: Bearer {SYNTHETIC}"
|
|
OPAQUE_API_KEY = "opaquecredential1234567890ABCDEF"
|
|
OPAQUE_PREFIX = OPAQUE_API_KEY[:6]
|
|
OPAQUE_SUFFIX = OPAQUE_API_KEY[-4:]
|
|
|
|
|
|
def _assert_fully_redacted(text: str) -> None:
|
|
assert SYNTHETIC not in text
|
|
assert SYNTHETIC_PREFIX not in text
|
|
assert SYNTHETIC_SUFFIX not in text
|
|
assert OPAQUE_API_KEY not in text
|
|
assert OPAQUE_PREFIX not in text
|
|
assert OPAQUE_SUFFIX not in text
|
|
|
|
|
|
def _make_args(**kwargs):
|
|
defaults = {
|
|
"name": "test-server",
|
|
"url": None,
|
|
"mcp_command": None,
|
|
"args": None,
|
|
"auth": None,
|
|
"preset": None,
|
|
"env": None,
|
|
"mcp_action": None,
|
|
"connect_timeout": None,
|
|
}
|
|
defaults.update(kwargs)
|
|
return argparse.Namespace(**defaults)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_config(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: tmp_path)
|
|
config_path = tmp_path / "config.yaml"
|
|
env_path = tmp_path / ".env"
|
|
monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_path)
|
|
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
|
|
return tmp_path
|
|
|
|
|
|
def _seed_config(tmp_path, mcp_servers):
|
|
config_path = tmp_path / "config.yaml"
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
yaml.safe_dump({"mcp_servers": mcp_servers, "_config_version": 9}, f)
|
|
|
|
|
|
class TestRedactMcpProbeText:
|
|
def test_authorization_header_is_fully_replaced(self):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
out = redact_mcp_probe_text(f"401 {HEADER}")
|
|
_assert_fully_redacted(out)
|
|
assert "Authorization:" in out
|
|
assert "Bearer ***" in out
|
|
|
|
def test_bare_bearer_scheme_is_fully_replaced(self):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
out = redact_mcp_probe_text(f"probe Bearer {SYNTHETIC} failed")
|
|
_assert_fully_redacted(out)
|
|
assert "Bearer ***" in out
|
|
|
|
def test_opaque_api_key_header_is_fully_replaced(self):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
out = redact_mcp_probe_text(f"connect failed: X-Api-Key: {OPAQUE_API_KEY}")
|
|
_assert_fully_redacted(out)
|
|
assert "X-Api-Key: ***" in out
|
|
|
|
def test_digest_authorization_params_are_fully_replaced(self):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
out = redact_mcp_probe_text(
|
|
f'Authorization: Digest username="u", response="{OPAQUE_API_KEY}"'
|
|
)
|
|
_assert_fully_redacted(out)
|
|
assert "Digest ***" in out
|
|
|
|
def test_digest_response_first_does_not_orphan_quoted_value(self):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
out = redact_mcp_probe_text(
|
|
f'Authorization: Digest response="{OPAQUE_API_KEY}", username="u"'
|
|
)
|
|
_assert_fully_redacted(out)
|
|
assert "Digest ***" in out
|
|
assert "response=" not in out
|
|
|
|
def test_python_mapping_api_key_is_fully_replaced(self):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
payload = {"X-Api-Key": OPAQUE_API_KEY}
|
|
out = redact_mcp_probe_text(f"headers={payload!r}")
|
|
_assert_fully_redacted(out)
|
|
assert "***" in out
|
|
|
|
def test_json_mapping_api_key_is_fully_replaced(self):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
out = redact_mcp_probe_text(json.dumps({"X-Api-Key": OPAQUE_API_KEY}))
|
|
_assert_fully_redacted(out)
|
|
assert "***" in out
|
|
|
|
@pytest.mark.parametrize(
|
|
"params",
|
|
list(
|
|
itertools.permutations(
|
|
[
|
|
("username", "u"),
|
|
("response", OPAQUE_API_KEY),
|
|
("opaque", OPAQUE_API_KEY),
|
|
]
|
|
)
|
|
),
|
|
)
|
|
@pytest.mark.parametrize("quoted_values", [True, False])
|
|
def test_digest_parameter_order_and_quoting(self, params, quoted_values):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
parts = []
|
|
for key, value in params:
|
|
parts.append(f'{key}="{value}"' if quoted_values else f"{key}={value}")
|
|
wire = "Authorization: Digest " + ", ".join(parts)
|
|
out = redact_mcp_probe_text(wire)
|
|
_assert_fully_redacted(out)
|
|
assert "Digest ***" in out
|
|
|
|
@pytest.mark.parametrize("key_quote,value_quote", [
|
|
("'", "'"),
|
|
('"', '"'),
|
|
("'", '"'),
|
|
('"', "'"),
|
|
])
|
|
def test_mapping_quotes_on_api_key(self, key_quote, value_quote):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
text = (
|
|
"headers={"
|
|
f"{key_quote}X-Api-Key{key_quote}: "
|
|
f"{value_quote}{OPAQUE_API_KEY}{value_quote}"
|
|
"}"
|
|
)
|
|
out = redact_mcp_probe_text(text)
|
|
_assert_fully_redacted(out)
|
|
assert "***" in out
|
|
|
|
def test_mapping_digest_authorization_is_fully_replaced(self):
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
value = f'Digest response="{OPAQUE_API_KEY}", username="u"'
|
|
out = redact_mcp_probe_text(f"headers={{'Authorization': {value!r}}}")
|
|
_assert_fully_redacted(out)
|
|
assert "Digest ***" in out
|
|
|
|
def test_shared_secret_header_vocabulary_is_fully_replaced(self):
|
|
from agent.redact import _SECRET_HEADER_NAMES
|
|
from hermes_cli.mcp_config import redact_mcp_probe_text
|
|
|
|
inner = _SECRET_HEADER_NAMES
|
|
if inner.startswith("(?:") and inner.endswith(")"):
|
|
inner = inner[3:-1]
|
|
for name in inner.split("|"):
|
|
out = redact_mcp_probe_text({name: OPAQUE_API_KEY}.__repr__())
|
|
_assert_fully_redacted(out)
|
|
|
|
def test_header_display_masks_opaque_api_key(self):
|
|
from hermes_cli.mcp_config import redact_mcp_header_display
|
|
|
|
assert redact_mcp_header_display("X-Api-Key", OPAQUE_API_KEY) == "***"
|
|
assert redact_mcp_header_display(
|
|
"Authorization", f"Bearer ${{MCP_TEST_TOKEN}}; backup={SYNTHETIC}"
|
|
) == "***"
|
|
|
|
def test_header_display_keeps_pure_env_template(self):
|
|
from hermes_cli.mcp_config import redact_mcp_header_display
|
|
|
|
assert redact_mcp_header_display(
|
|
"Authorization", "Bearer ${MCP_TEST_TOKEN}"
|
|
) == "Bearer ${MCP_TEST_TOKEN}"
|
|
|
|
|
|
class TestProbeHelperRedactsBeforeRaise:
|
|
def test_probe_exception_leaving_helper_is_already_safe(self, monkeypatch):
|
|
import tools.mcp_tool_lifecycle as mcp_lifecycle
|
|
import tools.mcp_tool_loop as mcp_loop
|
|
from hermes_cli.mcp_config import _probe_single_server
|
|
|
|
monkeypatch.setattr(mcp_loop, "_ensure_mcp_loop", lambda: None)
|
|
monkeypatch.setattr(mcp_lifecycle, "_stop_mcp_loop_if_idle", lambda: None)
|
|
|
|
def boom(coro, timeout):
|
|
coro.close()
|
|
raise RuntimeError(f"connect failed: {HEADER}")
|
|
|
|
monkeypatch.setattr(mcp_loop, "_run_on_mcp_loop", boom)
|
|
|
|
with pytest.raises(Exception) as caught:
|
|
_probe_single_server("ink", {"url": "https://mcp.example/mcp"})
|
|
_assert_fully_redacted(str(caught.value))
|
|
assert "Bearer ***" in str(caught.value)
|
|
|
|
def test_probe_exception_redacts_opaque_api_key(self, monkeypatch):
|
|
import tools.mcp_tool_lifecycle as mcp_lifecycle
|
|
import tools.mcp_tool_loop as mcp_loop
|
|
from hermes_cli.mcp_config import _probe_single_server
|
|
|
|
monkeypatch.setattr(mcp_loop, "_ensure_mcp_loop", lambda: None)
|
|
monkeypatch.setattr(mcp_lifecycle, "_stop_mcp_loop_if_idle", lambda: None)
|
|
|
|
def boom(coro, timeout):
|
|
coro.close()
|
|
raise RuntimeError(f"connect failed: X-Api-Key: {OPAQUE_API_KEY}")
|
|
|
|
monkeypatch.setattr(mcp_loop, "_run_on_mcp_loop", boom)
|
|
|
|
with pytest.raises(Exception) as caught:
|
|
_probe_single_server("ink", {"url": "https://mcp.example/mcp"})
|
|
_assert_fully_redacted(str(caught.value))
|
|
assert "X-Api-Key: ***" in str(caught.value)
|
|
|
|
def test_probe_exception_redacts_digest_response_first(self, monkeypatch):
|
|
import tools.mcp_tool_lifecycle as mcp_lifecycle
|
|
import tools.mcp_tool_loop as mcp_loop
|
|
from hermes_cli.mcp_config import _probe_single_server
|
|
|
|
monkeypatch.setattr(mcp_loop, "_ensure_mcp_loop", lambda: None)
|
|
monkeypatch.setattr(mcp_lifecycle, "_stop_mcp_loop_if_idle", lambda: None)
|
|
|
|
def boom(coro, timeout):
|
|
coro.close()
|
|
raise RuntimeError(
|
|
f'connect failed: Authorization: Digest '
|
|
f'response="{OPAQUE_API_KEY}", username="u"'
|
|
)
|
|
|
|
monkeypatch.setattr(mcp_loop, "_run_on_mcp_loop", boom)
|
|
|
|
with pytest.raises(Exception) as caught:
|
|
_probe_single_server("ink", {"url": "https://mcp.example/mcp"})
|
|
_assert_fully_redacted(str(caught.value))
|
|
assert "Digest ***" in str(caught.value)
|
|
|
|
def test_probe_exception_redacts_python_mapping_api_key(self, monkeypatch):
|
|
import tools.mcp_tool_lifecycle as mcp_lifecycle
|
|
import tools.mcp_tool_loop as mcp_loop
|
|
from hermes_cli.mcp_config import _probe_single_server
|
|
|
|
monkeypatch.setattr(mcp_loop, "_ensure_mcp_loop", lambda: None)
|
|
monkeypatch.setattr(mcp_lifecycle, "_stop_mcp_loop_if_idle", lambda: None)
|
|
|
|
def boom(coro, timeout):
|
|
coro.close()
|
|
raise RuntimeError(f"headers={{'X-Api-Key': '{OPAQUE_API_KEY}'}}")
|
|
|
|
monkeypatch.setattr(mcp_loop, "_run_on_mcp_loop", boom)
|
|
|
|
with pytest.raises(Exception) as caught:
|
|
_probe_single_server("ink", {"url": "https://mcp.example/mcp"})
|
|
_assert_fully_redacted(str(caught.value))
|
|
assert "***" in str(caught.value)
|
|
|
|
|
|
class TestCmdMcpTestRedaction:
|
|
def test_success_display_keeps_env_template(self, tmp_path, capsys, monkeypatch):
|
|
monkeypatch.setenv("MCP_TEST_TOKEN", SYNTHETIC)
|
|
_seed_config(tmp_path, {
|
|
"ink": {
|
|
"url": "https://mcp.example/mcp",
|
|
"headers": {"Authorization": "Bearer ${MCP_TEST_TOKEN}"},
|
|
},
|
|
})
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server",
|
|
lambda *a, **k: [("ping", "Ping")],
|
|
)
|
|
from hermes_cli.mcp_config import cmd_mcp_test
|
|
|
|
cmd_mcp_test(argparse.Namespace(name="ink"))
|
|
out = capsys.readouterr().out
|
|
_assert_fully_redacted(out)
|
|
assert "Connected" in out
|
|
assert "${MCP_TEST_TOKEN}" in (tmp_path / "config.yaml").read_text(encoding="utf-8")
|
|
|
|
def test_success_display_masks_literal_header(self, tmp_path, capsys, monkeypatch):
|
|
_seed_config(tmp_path, {
|
|
"ink": {
|
|
"url": "https://mcp.example/mcp",
|
|
"headers": {"Authorization": f"Bearer {SYNTHETIC}"},
|
|
},
|
|
})
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server",
|
|
lambda *a, **k: [("ping", "Ping")],
|
|
)
|
|
from hermes_cli.mcp_config import cmd_mcp_test
|
|
|
|
cmd_mcp_test(argparse.Namespace(name="ink"))
|
|
out = capsys.readouterr().out
|
|
_assert_fully_redacted(out)
|
|
assert "Authorization:" in out
|
|
|
|
def test_success_display_masks_opaque_api_key_header(self, tmp_path, capsys, monkeypatch):
|
|
_seed_config(tmp_path, {
|
|
"ink": {
|
|
"url": "https://mcp.example/mcp",
|
|
"headers": {"X-Api-Key": OPAQUE_API_KEY},
|
|
},
|
|
})
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server",
|
|
lambda *a, **k: [("ping", "Ping")],
|
|
)
|
|
from hermes_cli.mcp_config import cmd_mcp_test
|
|
|
|
cmd_mcp_test(argparse.Namespace(name="ink"))
|
|
out = capsys.readouterr().out
|
|
_assert_fully_redacted(out)
|
|
assert "X-Api-Key:" in out
|
|
assert "***" in out
|
|
|
|
def test_success_display_masks_mixed_env_and_literal(self, tmp_path, capsys, monkeypatch):
|
|
_seed_config(tmp_path, {
|
|
"ink": {
|
|
"url": "https://mcp.example/mcp",
|
|
"headers": {
|
|
"Authorization": f"Bearer ${{MCP_TEST_TOKEN}}; backup={SYNTHETIC}",
|
|
},
|
|
},
|
|
})
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server",
|
|
lambda *a, **k: [("ping", "Ping")],
|
|
)
|
|
from hermes_cli.mcp_config import cmd_mcp_test
|
|
|
|
cmd_mcp_test(argparse.Namespace(name="ink"))
|
|
out = capsys.readouterr().out
|
|
_assert_fully_redacted(out)
|
|
assert "***" in out
|
|
|
|
def test_probe_exception_is_redacted(self, tmp_path, capsys, monkeypatch):
|
|
_seed_config(tmp_path, {
|
|
"ink": {"url": "https://mcp.example/mcp"},
|
|
})
|
|
|
|
def boom(*a, **k):
|
|
raise RuntimeError(f"connect failed: {HEADER}")
|
|
|
|
monkeypatch.setattr("hermes_cli.mcp_config._probe_single_server", boom)
|
|
from hermes_cli.mcp_config import cmd_mcp_test
|
|
|
|
cmd_mcp_test(argparse.Namespace(name="ink"))
|
|
out = capsys.readouterr().out
|
|
_assert_fully_redacted(out)
|
|
assert "Connection failed" in out
|
|
assert "***" in out # credential span masked, whichever redactor got it first
|
|
|
|
|
|
class TestDashboardMcpTestRedaction:
|
|
def test_probe_error_json_is_redacted(self, tmp_path, monkeypatch):
|
|
try:
|
|
from starlette.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("fastapi/starlette not installed")
|
|
|
|
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
|
import hermes_cli.mcp_config as mcp_config
|
|
|
|
_seed_config(tmp_path, {
|
|
"ink": {"url": "https://mcp.example/mcp"},
|
|
})
|
|
|
|
def boom(name, config, connect_timeout=30, details=None):
|
|
raise RuntimeError(f"connect failed: {HEADER}")
|
|
|
|
monkeypatch.setattr(mcp_config, "_probe_single_server", boom)
|
|
monkeypatch.setattr(mcp_config, "_get_mcp_servers", lambda: {
|
|
"ink": {"url": "https://mcp.example/mcp"},
|
|
})
|
|
|
|
client = TestClient(app)
|
|
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
|
resp = client.post("/api/mcp/servers/ink/test")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["ok"] is False
|
|
_assert_fully_redacted(body["error"])
|
|
assert "Bearer ***" in body["error"]
|
|
assert SYNTHETIC not in resp.text
|
|
|
|
def test_probe_error_json_redacts_digest_and_mapping(self, tmp_path, monkeypatch):
|
|
try:
|
|
from starlette.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("fastapi/starlette not installed")
|
|
|
|
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
|
import hermes_cli.mcp_config as mcp_config
|
|
|
|
_seed_config(tmp_path, {
|
|
"ink": {"url": "https://mcp.example/mcp"},
|
|
})
|
|
|
|
def boom(name, config, connect_timeout=30, details=None):
|
|
raise RuntimeError(
|
|
f"headers={{'X-Api-Key': '{OPAQUE_API_KEY}'}}; "
|
|
f'Authorization: Digest response="{OPAQUE_API_KEY}", username="u"'
|
|
)
|
|
|
|
monkeypatch.setattr(mcp_config, "_probe_single_server", boom)
|
|
monkeypatch.setattr(mcp_config, "_get_mcp_servers", lambda: {
|
|
"ink": {"url": "https://mcp.example/mcp"},
|
|
})
|
|
|
|
client = TestClient(app)
|
|
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
|
resp = client.post("/api/mcp/servers/ink/test")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["ok"] is False
|
|
_assert_fully_redacted(body["error"])
|
|
assert "***" in body["error"]
|
|
assert SYNTHETIC not in resp.text
|
|
assert OPAQUE_API_KEY not in resp.text
|
|
|
|
|
|
class TestSiblingProbeConsumersRedact:
|
|
def test_mcp_add_redacts_probe_exception(self, tmp_path, capsys, monkeypatch):
|
|
def boom(*a, **k):
|
|
raise RuntimeError(f"connect failed: {HEADER}")
|
|
|
|
monkeypatch.setattr("hermes_cli.mcp_config._probe_single_server", boom)
|
|
monkeypatch.setattr("hermes_cli.mcp_config._confirm", lambda *a, **k: False)
|
|
from hermes_cli.mcp_config import cmd_mcp_add
|
|
|
|
cmd_mcp_add(_make_args(name="ink", url="https://mcp.example/mcp"))
|
|
out = capsys.readouterr().out
|
|
_assert_fully_redacted(out)
|
|
assert "Failed to connect" in out
|
|
assert "***" in out # credential span masked, whichever redactor got it first
|
|
|
|
def test_mcp_login_redacts_probe_exception(self, tmp_path, capsys, monkeypatch):
|
|
_seed_config(tmp_path, {
|
|
"ink": {"url": "https://mcp.example/mcp", "auth": "oauth"},
|
|
})
|
|
|
|
def boom(*a, **k):
|
|
raise RuntimeError(f"connect failed: {HEADER}")
|
|
|
|
monkeypatch.setattr("hermes_cli.mcp_config._probe_single_server", boom)
|
|
monkeypatch.setattr(
|
|
"tools.mcp_oauth.humanize_oauth_registration_error",
|
|
lambda *a, **k: None,
|
|
)
|
|
from hermes_cli.mcp_config import cmd_mcp_login
|
|
|
|
cmd_mcp_login(_make_args(name="ink"))
|
|
out = capsys.readouterr().out
|
|
_assert_fully_redacted(out)
|
|
assert "Authentication failed" in out
|
|
assert "Bearer ***" in out
|