test: security suite for auth, startup, redaction; fix: mask secrets in object keys

This commit is contained in:
Latte committed 2026-10-14 17:44:20 +02:00
1 parent 766968fa42
commit fef80a5b21
4 files changed
+836 -1

No files matched your search

+306
View File
@@ -0,0 +1,306 @@
"""Credentials never reach results, errors, logs, the audit file or ``repr`` output.
Every secret the server knows (the Gitea token, the MCP bearer, the OAuth client secret,
the store key, a user's Gitea access and refresh tokens and our own issued tokens) is
planted in Gitea's answers, successful and failed, and in the incoming requests.
"""
from __future__ import annotations
import json
import logging
from typing import Any
import pytest
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.oauth.provider import PendingAuthorization
from aegis_gitea_mcp.oauth.store import SessionRecord
from aegis_gitea_mcp.oauth.upstream import UpstreamTokens
from aegis_gitea_mcp.transports import AccessLogQueryFilter, run_http
from tests.test_oauth_helpers import (
API,
APP_CLIENT_SECRET,
BEARER_TOKEN,
ENCRYPTION_KEY,
GITEA_TOKEN,
USER_REFRESH,
USER_TOKEN,
browser,
build_oauth_app,
mcp_client,
oauth_settings,
sign_in,
)
@pytest.fixture(autouse=True)
def _env(mock_env: None) -> None:
"""The audit logger and the rate limiter read the process-wide settings."""
def static_secrets() -> list[str]:
return [GITEA_TOKEN, BEARER_TOKEN, APP_CLIENT_SECRET, ENCRYPTION_KEY, USER_TOKEN, USER_REFRESH]
def everything_secret(extra: list[str]) -> str:
return " ".join([*static_secrets(), *extra])
def assert_clean(text: str, secrets: list[str]) -> None:
for secret in secrets:
assert secret not in text, f"secret leaked: {secret[:6]}..."
@pytest.mark.parametrize("mode", ["off", "mask", "block"])
async def test_no_secret_reaches_a_result_the_audit_file_or_the_logs(
httpx_mock: HTTPXMock,
caplog: pytest.LogCaptureFixture,
capsys: pytest.CaptureFixture[str],
mode: str,
) -> None:
caplog.set_level(logging.DEBUG)
oauth = build_oauth_app(oauth_settings(secret_detection_mode=mode))
flow = sign_in(browser(oauth.app), httpx_mock)
# The client secret the SDK issues at registration is plain hex without a recognisable
# prefix: it is stored (encrypted) but cannot be pattern masked, and Gitea never sees it.
issued = [flow.tokens["access_token"], flow.tokens["refresh_token"]]
# SECRET_DETECTION_MODE=off disables pattern matching, which is what protects our own
# issued agm_ tokens (only hashes of them live on the server, so they cannot be redacted
# exactly); every other secret is redacted exactly in all modes.
secrets = static_secrets() if mode == "off" else [*static_secrets(), *issued]
# The admin identity is not the OAuth user: Gitea never shows it that user's tokens, so
# only the server's own secrets (and, with pattern masking, our issued tokens) apply.
admin_secrets = [GITEA_TOKEN, BEARER_TOKEN, APP_CLIENT_SECRET, ENCRYPTION_KEY]
blob = everything_secret(issued)
# The admin identity's lookup, then success and failure bodies that echo every secret.
httpx_mock.add_response(url=f"{API}/user", json={"login": "admin"}, is_reusable=True)
echo = {"is_reusable": True}
httpx_mock.add_response(
url=f"{API}/repos/octo/ok", json={"description": blob, blob: "key"}, **echo
)
httpx_mock.add_response(
url=f"{API}/repos/octo/bad",
status_code=422,
json={"message": blob, "errors": [blob]},
**echo,
)
httpx_mock.add_response(
url=f"{API}/repos/octo/text", text=blob, headers={"content-type": "text/plain"}, **echo
)
httpx_mock.add_response(url=f"{API}/repos/octo/boom", status_code=500, text=blob, **echo)
results: list[Any] = []
# One app per identity: the SDK's session manager can only run once per app.
apps = [
(oauth.app, flow.tokens["access_token"]),
(build_oauth_app(oauth.settings).app, BEARER_TOKEN),
]
for app, token in apps:
async with mcp_client(app, token) as mcp:
for repo in ("ok", "bad", "boom"):
results.append(
await mcp.call_tool("gitea_repo_get", {"owner": "octo", "repo": repo})
)
results.append(
await mcp.call_tool(
"gitea_api_request", {"method": "GET", "path": "/repos/octo/text"}
)
)
texts = [result.content[0].text for result in results]
per_identity = [
(results[:4], secrets),
(results[4:], [*admin_secrets, *(issued if mode != "off" else [])]),
]
for group, forbidden in per_identity:
for result in group:
assert_clean(result.content[0].text, forbidden)
assert_clean(json.dumps(result.structured_content), forbidden)
assert any("[REDACTED]" in text for text in texts) # the redaction happened, not an empty body
everything = [*secrets, *admin_secrets]
assert_clean(get_audit_logger().log_path.read_text(encoding="utf-8"), everything)
assert_clean(caplog.text, everything)
captured = capsys.readouterr()
assert_clean(captured.out + captured.err, everything)
async def test_exceptions_during_a_call_do_not_leak_secrets(
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
caplog.set_level(logging.DEBUG)
oauth = build_oauth_app()
secrets = static_secrets()
async def explode(*args: Any, **kwargs: Any) -> None:
raise RuntimeError("failure carrying " + " ".join(secrets))
monkeypatch.setattr(oauth.extras.executor, "execute", explode)
httpx_mock.add_response(url=f"{API}/user", json={"login": "admin"}, is_reusable=True)
async with mcp_client(oauth.app, BEARER_TOKEN) as mcp:
result = await mcp.call_tool("gitea_repo_get", {"owner": "o", "repo": "r"})
assert result.is_error
assert_clean(result.content[0].text, secrets)
assert_clean(caplog.text, secrets)
assert_clean(get_audit_logger().log_path.read_text(encoding="utf-8"), secrets)
assert "RuntimeError" not in result.content[0].text # no exception details by default
async def test_validation_errors_never_contain_argument_values(httpx_mock: HTTPXMock) -> None:
oauth = build_oauth_app()
planted = "sekret-value-" + GITEA_TOKEN
async with mcp_client(oauth.app, BEARER_TOKEN) as mcp:
results = [
await mcp.call_tool("gitea_repo_get", {"owner": planted + "?x#y", "repo": "r"}),
await mcp.call_tool("gitea_repo_get", {"owner": 12345, "repo": planted}),
await mcp.call_tool("gitea_repo_get", {"owner": "o", "repo": "r", "extra": planted}),
await mcp.call_tool("gitea_api_request", {"method": planted, "path": "/x"}),
await mcp.call_tool("gitea_api_request", {"method": "GET", "path": planted}),
await mcp.call_tool(
"gitea_api_request",
{"method": "GET", "path": "/user", "sudo": "not valid " + planted},
),
]
for result in results:
assert result.is_error
assert planted not in result.content[0].text
assert GITEA_TOKEN not in result.content[0].text
assert planted not in get_audit_logger().log_path.read_text(encoding="utf-8")
# Access logs
def uvicorn_access_record(path_with_query: str) -> logging.LogRecord:
return logging.LogRecord(
name="uvicorn.access",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg='%s - "%s %s HTTP/%s" %d',
args=("127.0.0.1:5000", "GET", path_with_query, "1.1", 302),
exc_info=None,
)
@pytest.mark.parametrize(
"path",
[
"/oauth/callback?code=one-time-gitea-code&state=state-secret-value",
"/oauth/consent?request=pending-request-secret",
"/authorize?client_id=c&code_challenge=abc&state=client-state-secret",
],
)
def test_access_log_lines_drop_the_query_string(path: str) -> None:
record = uvicorn_access_record(path)
assert AccessLogQueryFilter().filter(record)
line = record.getMessage()
assert "[redacted]" in line
assert path.split("?")[0] in line
for secret in (
"one-time-gitea-code",
"state-secret-value",
"pending-request-secret",
"client-state-secret",
"code_challenge",
):
assert secret not in line
def test_the_runner_installs_the_access_log_filter(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
from aegis_gitea_mcp import transports
access_logger = logging.getLogger("uvicorn.access")
monkeypatch.setattr(access_logger, "filters", [])
monkeypatch.setattr(transports.uvicorn, "run", lambda *a, **k: None)
caplog.set_level(logging.INFO, logger="uvicorn.access")
assert run_http(oauth_settings()) == 0
access_logger.info(
'%s - "%s %s HTTP/%s" %d',
"127.0.0.1:5000",
"GET",
"/oauth/callback?code=one-time-gitea-code&state=state-secret-value",
"1.1",
302,
)
assert "one-time-gitea-code" not in caplog.text
assert "[redacted]" in caplog.text
# repr
def test_repr_of_settings_and_secret_holding_objects_contains_no_secret() -> None:
settings = oauth_settings()
identity = Identity(login="alice", mode="oauth", token=USER_TOKEN, redact=(USER_REFRESH,))
session = SessionRecord(
session_id="session-id-value",
client_id="c1",
login="alice",
is_admin=False,
gitea_access_token=USER_TOKEN,
gitea_refresh_token=USER_REFRESH,
gitea_expires_at=None,
scopes=["gitea"],
resource="https://mcp.example.com/mcp",
created_at=0.0,
previous_refresh_hash="hash-of-previous-refresh",
)
upstream = UpstreamTokens(access_token=USER_TOKEN, refresh_token=USER_REFRESH, expires_at=None)
pending = PendingAuthorization(
client_id="c1",
client_name="Client",
redirect_uri="https://client.example/cb",
redirect_uri_provided_explicitly=True,
code_challenge="challenge",
client_state="client-state-secret",
scopes=["gitea"],
resource="https://mcp.example.com/mcp",
code_verifier="pkce-verifier-secret",
csrf_hash="csrf-hash-secret",
binding_hash="binding-hash-secret",
)
rendered = [
repr(settings),
str(settings),
repr(identity),
repr(session),
repr(upstream),
repr(pending),
repr(settings.gitea_token),
repr(settings.model_dump()),
]
for text in rendered:
assert_clean(text, [*static_secrets(), "client-state-secret", "pkce-verifier-secret"])
assert "csrf-hash-secret" not in text and "binding-hash-secret" not in text
assert "hash-of-previous-refresh" not in text
assert "**********" in repr(settings.gitea_token)
def test_secrets_in_object_keys_are_masked_like_values() -> None:
from aegis_gitea_mcp.security import sanitize_data
token = "agm_" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0U1v"
nested = {"outer": [{token: "value", "plain": token}]}
masked = sanitize_data(nested, mode="mask")
blocked = sanitize_data(nested, mode="block")
assert token not in json.dumps(masked) and token not in json.dumps(blocked)
assert masked["outer"][0]["plain"] != token
assert blocked != nested