411 lines
14 KiB
Python
411 lines
14 KiB
Python
"""Secret values that pass through tool calls are never echoed back to the model.
|
|
|
|
Covers stored webhook authorization headers (SEC-02), Action secrets and
|
|
submitted credential fields. Credential-issuing operations are the documented
|
|
exception: they return the credential they just minted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from mcp import Client
|
|
from pytest_httpx import HTTPXMock
|
|
|
|
from aegis_gitea_mcp.gitea.executor import CREDENTIAL_ISSUING_OPERATIONS
|
|
from tests.test_server_helpers import API, audit_entries, connected, mock_user, server_settings
|
|
|
|
STORED_HEADER = "Bearer webhook-target-secret-0123456789"
|
|
BASIC_HEADER = "Basic dXNlcjpwYXNzd29yZC1vZi10aGUtd2ViaG9vaw=="
|
|
MODES = ["off", "mask", "block"]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _env(mock_env: None) -> None:
|
|
"""The audit logger reads the process-wide settings."""
|
|
|
|
|
|
def hook(header: str | None = STORED_HEADER) -> dict[str, Any]:
|
|
return {
|
|
"id": 3,
|
|
"type": "gitea",
|
|
"active": True,
|
|
"config": {"url": "https://ci.example.org/hook", "content_type": "json"},
|
|
"authorization_header": header,
|
|
}
|
|
|
|
|
|
async def call(client: Client, name: str, arguments: dict[str, Any]) -> tuple[str, Any]:
|
|
result = await client.call_tool(name, arguments)
|
|
return result.content[0].text, result.structured_content # type: ignore[union-attr]
|
|
|
|
|
|
HOOK_CASES = [
|
|
("gitea_hook_repo_list_hooks", {"owner": "o", "repo": "r"}, "GET", "/repos/o/r/hooks", True),
|
|
(
|
|
"gitea_hook_repo_get_hook",
|
|
{"owner": "o", "repo": "r", "id": 3},
|
|
"GET",
|
|
"/repos/o/r/hooks/3",
|
|
False,
|
|
),
|
|
(
|
|
"gitea_hook_repo_edit_hook",
|
|
{"owner": "o", "repo": "r", "id": 3, "body": {"active": True}},
|
|
"PATCH",
|
|
"/repos/o/r/hooks/3",
|
|
False,
|
|
),
|
|
("gitea_hook_org_list_hooks", {"org": "acme"}, "GET", "/orgs/acme/hooks", True),
|
|
("gitea_hook_org_get_hook", {"org": "acme", "id": 3}, "GET", "/orgs/acme/hooks/3", False),
|
|
("gitea_hook_user_list_hooks", {}, "GET", "/user/hooks", True),
|
|
("gitea_hook_user_get_hook", {"id": 3}, "GET", "/user/hooks/3", False),
|
|
("gitea_admin_list_hooks", {}, "GET", "/admin/hooks", True),
|
|
("gitea_admin_get_hook", {"id": 3}, "GET", "/admin/hooks/3", False),
|
|
(
|
|
"gitea_api_request",
|
|
{"method": "GET", "path": "/repos/o/r/hooks"},
|
|
"GET",
|
|
"/repos/o/r/hooks",
|
|
True,
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("mode", MODES)
|
|
@pytest.mark.parametrize(("tool", "arguments", "method", "path", "is_list"), HOOK_CASES)
|
|
@pytest.mark.parametrize("header", [STORED_HEADER, BASIC_HEADER])
|
|
async def test_stored_webhook_authorization_header_is_never_returned(
|
|
httpx_mock: HTTPXMock,
|
|
mode: str,
|
|
tool: str,
|
|
arguments: dict[str, Any],
|
|
method: str,
|
|
path: str,
|
|
is_list: bool,
|
|
header: str,
|
|
) -> None:
|
|
mock_user(httpx_mock)
|
|
stored = hook(header)
|
|
httpx_mock.add_response(
|
|
method=method, url=f"{API}{path}", json=[stored, hook(None)] if is_list else stored
|
|
)
|
|
async with connected(server_settings(secret_detection_mode=mode)) as client:
|
|
text, structured = await call(client, tool, arguments)
|
|
|
|
assert header not in text
|
|
assert header not in json.dumps(structured)
|
|
assert "[REDACTED]" in text
|
|
# The rest of the hook stays readable.
|
|
assert "ci.example.org" in text
|
|
assert header not in json.dumps(audit_entries())
|
|
|
|
|
|
async def test_empty_authorization_header_stays_visible_as_empty(httpx_mock: HTTPXMock) -> None:
|
|
mock_user(httpx_mock)
|
|
httpx_mock.add_response(url=f"{API}/repos/o/r/hooks/3", json=hook(""))
|
|
async with connected() as client:
|
|
_, structured = await call(
|
|
client, "gitea_hook_repo_get_hook", {"owner": "o", "repo": "r", "id": 3}
|
|
)
|
|
|
|
assert structured["data"]["authorization_header"] == ""
|
|
|
|
|
|
async def test_nested_secret_fields_are_redacted_by_key_name(httpx_mock: HTTPXMock) -> None:
|
|
mock_user(httpx_mock)
|
|
httpx_mock.add_response(
|
|
url=f"{API}/repos/o/r/hooks",
|
|
json=[{"config": {"url": "https://x.example", "secret": "plain-shared-secret"}}],
|
|
)
|
|
async with connected(server_settings(secret_detection_mode="off")) as client:
|
|
text, _ = await call(client, "gitea_hook_repo_list_hooks", {"owner": "o", "repo": "r"})
|
|
|
|
assert "plain-shared-secret" not in text
|
|
|
|
|
|
async def test_credential_issuing_operations_still_return_their_credential(
|
|
httpx_mock: HTTPXMock,
|
|
) -> None:
|
|
assert "userCreateToken" in CREDENTIAL_ISSUING_OPERATIONS
|
|
mock_user(httpx_mock)
|
|
minted = "a" * 20 + "0123456789abcdef"
|
|
httpx_mock.add_response(
|
|
method="POST", url=f"{API}/users/alice/tokens", json={"id": 1, "name": "ci", "sha1": minted}
|
|
)
|
|
async with connected() as client:
|
|
text, _ = await call(
|
|
client, "gitea_user_create_token", {"username": "alice", "body": {"name": "ci"}}
|
|
)
|
|
|
|
assert minted in text
|
|
# ... but the audit file never contains it.
|
|
assert minted not in json.dumps(audit_entries())
|
|
|
|
|
|
# Action secrets: names and metadata only, never values
|
|
|
|
VALUE = "Sup3r-Secret-Action-Value!"
|
|
SHORT_VALUE = "pw"
|
|
|
|
|
|
def listing_with_values() -> list[dict[str, Any]]:
|
|
"""What Gitea sends (names and creation times), plus a hostile ``data`` field."""
|
|
return [
|
|
{"name": "DEPLOY_KEY", "created": "2026-01-01T00:00:00Z", "data": VALUE},
|
|
{"name": "NPM_TOKEN", "created": "2026-01-02T00:00:00Z", "description": "ci"},
|
|
]
|
|
|
|
|
|
SECRET_LISTINGS = [
|
|
("gitea_actions_repo_list_secrets", {"owner": "o", "repo": "r"}, "/repos/o/r/actions/secrets"),
|
|
("gitea_actions_org_list_secrets", {"org": "acme"}, "/orgs/acme/actions/secrets"),
|
|
(
|
|
"gitea_api_request",
|
|
{"method": "GET", "path": "/repos/o/r/actions/secrets"},
|
|
"/repos/o/r/actions/secrets",
|
|
),
|
|
(
|
|
"gitea_api_request",
|
|
{"method": "GET", "path": "/user/actions/secrets"},
|
|
"/user/actions/secrets",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("mode", MODES)
|
|
@pytest.mark.parametrize(("tool", "arguments", "path"), SECRET_LISTINGS)
|
|
async def test_listing_secrets_returns_names_and_metadata_never_values(
|
|
httpx_mock: HTTPXMock, mode: str, tool: str, arguments: dict[str, Any], path: str
|
|
) -> None:
|
|
mock_user(httpx_mock)
|
|
httpx_mock.add_response(url=f"{API}{path}", json=listing_with_values())
|
|
async with connected(server_settings(secret_detection_mode=mode)) as client:
|
|
text, structured = await call(client, tool, arguments)
|
|
|
|
assert VALUE not in text and VALUE not in json.dumps(structured)
|
|
names = [item["name"] for item in structured["data"]]
|
|
assert names == ["DEPLOY_KEY", "NPM_TOKEN"]
|
|
assert structured["data"][0]["created"] == "2026-01-01T00:00:00Z"
|
|
assert structured["data"][1]["description"] == "ci"
|
|
|
|
|
|
async def test_a_secret_path_in_a_repository_is_not_mistaken_for_action_secrets(
|
|
httpx_mock: HTTPXMock,
|
|
) -> None:
|
|
mock_user(httpx_mock)
|
|
httpx_mock.add_response(
|
|
url=f"{API}/repos/o/r/contents/secrets", json=[{"name": "a", "data": "kept"}]
|
|
)
|
|
async with connected() as client:
|
|
_, structured = await call(
|
|
client, "gitea_api_request", {"method": "GET", "path": "/repos/o/r/contents/secrets"}
|
|
)
|
|
|
|
assert structured["data"][0]["data"] == "kept"
|
|
|
|
|
|
SECRET_WRITES = [
|
|
(
|
|
"gitea_actions_update_repo_secret",
|
|
{"owner": "o", "repo": "r", "secretname": "DEPLOY_KEY"},
|
|
"/repos/o/r/actions/secrets/DEPLOY_KEY",
|
|
),
|
|
(
|
|
"gitea_actions_update_org_secret",
|
|
{"org": "acme", "secretname": "DEPLOY_KEY"},
|
|
"/orgs/acme/actions/secrets/DEPLOY_KEY",
|
|
),
|
|
(
|
|
"gitea_actions_update_user_secret",
|
|
{"secretname": "DEPLOY_KEY"},
|
|
"/user/actions/secrets/DEPLOY_KEY",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("mode", MODES)
|
|
@pytest.mark.parametrize("value", [VALUE, SHORT_VALUE])
|
|
@pytest.mark.parametrize(("tool", "arguments", "path"), SECRET_WRITES)
|
|
async def test_updating_a_secret_never_echoes_the_submitted_value(
|
|
httpx_mock: HTTPXMock,
|
|
caplog: pytest.LogCaptureFixture,
|
|
mode: str,
|
|
value: str,
|
|
tool: str,
|
|
arguments: dict[str, Any],
|
|
path: str,
|
|
) -> None:
|
|
caplog.set_level("DEBUG")
|
|
mock_user(httpx_mock)
|
|
# Success echoing the value, then a validation error repeating it (as Gitea 422s do).
|
|
httpx_mock.add_response(
|
|
method="PUT",
|
|
url=f"{API}{path}",
|
|
status_code=201,
|
|
json={"echo": value, "name": "DEPLOY_KEY"},
|
|
)
|
|
httpx_mock.add_response(
|
|
method="PUT",
|
|
url=f"{API}{path}",
|
|
status_code=422,
|
|
json={"message": f"invalid secret data: {value}", "errors": [f"data {value} too long"]},
|
|
)
|
|
async with connected(server_settings(secret_detection_mode=mode)) as client:
|
|
ok = await call(client, tool, {**arguments, "body": {"data": value}})
|
|
failed = await call(client, tool, {**arguments, "body": {"data": value}})
|
|
|
|
for text, structured in (ok, failed):
|
|
assert value not in text
|
|
assert value not in json.dumps(structured)
|
|
assert "[REDACTED]" in ok[0] and "[REDACTED]" in failed[0]
|
|
assert value not in json.dumps(audit_entries()) and value not in caplog.text
|
|
|
|
|
|
async def test_the_raw_tool_also_hides_a_submitted_secret_value(httpx_mock: HTTPXMock) -> None:
|
|
mock_user(httpx_mock)
|
|
httpx_mock.add_response(
|
|
method="PUT",
|
|
url=f"{API}/repos/o/r/actions/secrets/K",
|
|
status_code=422,
|
|
json={"message": f"bad {VALUE}"},
|
|
)
|
|
async with connected() as client:
|
|
text, _ = await call(
|
|
client,
|
|
"gitea_api_request",
|
|
{
|
|
"method": "PUT",
|
|
"path": "/repos/o/r/actions/secrets/K",
|
|
"body": {"data": VALUE},
|
|
},
|
|
)
|
|
|
|
assert VALUE not in text and "[REDACTED]" in text
|
|
|
|
|
|
# Other submitted credential fields
|
|
|
|
CREDENTIAL_BODIES = [
|
|
(
|
|
"gitea_admin_create_user",
|
|
{},
|
|
"POST",
|
|
"/admin/users",
|
|
{"username": "bob", "email": "bob@example.com", "password": "Pa55word-Of-Bob"},
|
|
),
|
|
(
|
|
"gitea_hook_repo_create_hook",
|
|
{"owner": "o", "repo": "r"},
|
|
"POST",
|
|
"/repos/o/r/hooks",
|
|
{"type": "gitea", "authorization_header": "Bearer hook-target-token-123", "config": {}},
|
|
),
|
|
(
|
|
"gitea_repo_migrate",
|
|
{},
|
|
"POST",
|
|
"/repos/migrate",
|
|
{
|
|
"clone_addr": "https://example.org/x.git",
|
|
"repo_name": "x",
|
|
"auth_token": "migration-source-token-123",
|
|
"auth_password": "migration-source-pass-123",
|
|
"aws_secret_access_key": "aws-secret-access-key-value-123",
|
|
},
|
|
),
|
|
(
|
|
"gitea_api_request",
|
|
{},
|
|
"POST",
|
|
"/orgs/acme/hooks",
|
|
{"config": {"secret": "shared-hook-secret-123", "url": "https://x.example"}},
|
|
),
|
|
]
|
|
|
|
|
|
def secret_values(body: Any) -> list[str]:
|
|
found: list[str] = []
|
|
if isinstance(body, dict):
|
|
for key, item in body.items():
|
|
if isinstance(item, str) and key in {
|
|
"password",
|
|
"authorization_header",
|
|
"auth_token",
|
|
"auth_password",
|
|
"aws_secret_access_key",
|
|
"secret",
|
|
}:
|
|
found.append(item)
|
|
else:
|
|
found.extend(secret_values(item))
|
|
return found
|
|
|
|
|
|
@pytest.mark.parametrize("mode", MODES)
|
|
@pytest.mark.parametrize(("tool", "arguments", "method", "path", "body"), CREDENTIAL_BODIES)
|
|
async def test_submitted_credentials_are_redacted_from_results(
|
|
httpx_mock: HTTPXMock,
|
|
mode: str,
|
|
tool: str,
|
|
arguments: dict[str, Any],
|
|
method: str,
|
|
path: str,
|
|
body: dict[str, Any],
|
|
) -> None:
|
|
mock_user(httpx_mock)
|
|
secrets = secret_values(body)
|
|
assert secrets
|
|
httpx_mock.add_response(
|
|
method=method,
|
|
url=f"{API}{path}",
|
|
status_code=422,
|
|
json={"message": "rejected: " + " ".join(secrets), "errors": secrets},
|
|
)
|
|
request = (
|
|
{"method": method, "path": path, "body": body}
|
|
if tool == "gitea_api_request"
|
|
else {**arguments, "body": body}
|
|
)
|
|
async with connected(server_settings(secret_detection_mode=mode)) as client:
|
|
text, structured = await call(client, tool, request)
|
|
|
|
for secret in secrets:
|
|
assert secret not in text and secret not in json.dumps(structured)
|
|
assert secret not in json.dumps(audit_entries())
|
|
assert "[REDACTED]" in text
|
|
|
|
|
|
# Credential-issuing operations: the credential goes to the caller, nowhere else
|
|
|
|
|
|
async def test_issued_credentials_reach_the_caller_but_not_the_audit_file_or_the_logs(
|
|
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
caplog.set_level("DEBUG")
|
|
mock_user(httpx_mock)
|
|
token = "9f8e7d6c5b4a39281706f5e4d3c2b1a099887766"
|
|
registration = "runner-registration-token-0123456789ABCDEF"
|
|
httpx_mock.add_response(
|
|
method="POST", url=f"{API}/users/alice/tokens", json={"id": 1, "name": "ci", "sha1": token}
|
|
)
|
|
httpx_mock.add_response(
|
|
method="POST",
|
|
url=f"{API}/admin/actions/runners/registration-token",
|
|
headers={"token": registration},
|
|
status_code=200,
|
|
)
|
|
async with connected(server_settings(secret_detection_mode="block")) as client:
|
|
created, _ = await call(
|
|
client, "gitea_user_create_token", {"username": "alice", "body": {"name": "ci"}}
|
|
)
|
|
runner, _ = await call(client, "gitea_admin_create_runner_registration_token", {})
|
|
|
|
assert token in created and registration in runner # the documented exemption (W-07)
|
|
audit_text = json.dumps(audit_entries())
|
|
for secret in (token, registration):
|
|
assert secret not in audit_text
|
|
assert secret not in caplog.text
|