Files
beaver-gateway/tests/test_redact.py
T

121 lines
4.1 KiB
Python

from __future__ import annotations
import io
import logging
import pytest
from beaver_gateway.security import redact as redact_mod
from beaver_gateway.security.redact import (
RedactFilter,
RedactingFormatter,
env_secrets,
redact,
)
# The two lines the gateway actually printed (httpx at INFO), verbatim
# apart from the token bodies: the calendar MCP takes the private ical
# feed as a query parameter, so the feed's secret is in the URL.
GOOGLE = (
"HTTP Request: POST https://calendar-mcp.com/api/mcp"
"?email=someone%40gmail.com&icsUrl=https%3A%2F%2Fcalendar.google.com"
"%2Fcalendar%2Fical%2Fsomeone%2540gmail.com"
"%2Fprivate-1f4a9c0de1f4a9c0de1f4a9c0de%2Fbasic.ics"
' "HTTP/1.1 200 OK"'
)
USOS = (
"HTTP Request: POST https://calendar-mcp.com/api/mcp"
"?email=someone%40gmail.com&icsUrl=https%3A%2F%2Fusosapps.put.poznan.pl"
"%2Fservices%2Ftt%2Fupcoming_ical%3Flang%3Dpl%26user_id%3D145744"
"%26key%3DABCDEF0123456789abcdef"
' "HTTP/1.1 202 Accepted"'
)
@pytest.fixture(autouse=True)
def _no_env_secrets() -> None:
redact_mod._ENV_SECRETS[:] = [] # noqa: SLF001
def test_google_private_ical_token_is_masked() -> None:
out = redact(GOOGLE)
assert "1f4a9c0de1f4a9c0de1f4a9c0de" not in out
assert "private-<…>" in out
# The harmless part of the line survives, so the log stays useful.
assert out.startswith("HTTP Request: POST https://calendar-mcp.com/api/mcp")
assert out.endswith('"HTTP/1.1 200 OK"')
def test_usos_key_is_masked_through_percent_encoding() -> None:
out = redact(USOS)
assert "ABCDEF0123456789abcdef" not in out
assert "key%3D<…>" in out
assert out.endswith('"HTTP/1.1 202 Accepted"')
@pytest.mark.parametrize(
("line", "secret"),
[
("GET /hooks/komodo?token=s3cret-value&x=1", "s3cret-value"),
("GET /x?api_key=s3cret-value", "s3cret-value"),
("GET /x?session_key=s3cret-value", "s3cret-value"),
("headers={'Authorization': 'Bearer s3cret-value'}", "s3cret-value"),
("authorization: token s3cret-value", "s3cret-value"),
("retrying with Bearer s3cret-value", "s3cret-value"),
],
)
def test_credentials_in_urls_and_headers(line: str, secret: str) -> None:
assert secret not in redact(line)
def test_known_env_values_are_masked() -> None:
redact_mod._ENV_SECRETS[:] = env_secrets( # noqa: SLF001
{"FIREFLY_PAT": "pat-0123456789", "POSTGRES_USER": "beaver"}
)
out = redact("firefly said no to pat-0123456789 (user beaver)")
assert "pat-0123456789" not in out
# Only credential-shaped names contribute; ordinary config survives.
assert "beaver" in out
def test_env_values_are_masked_percent_encoded_too() -> None:
raw = "https://cal/private-xyz/basic.ics"
redact_mod._ENV_SECRETS[:] = env_secrets({"CALENDAR_MCPS": raw}) # noqa: SLF001
assert raw not in redact(f"fetching {raw}")
assert "%2Fprivate-xyz%2F" not in redact(
"fetching https%3A%2F%2Fcal%2Fprivate-xyz%2Fbasic.ics"
)
def test_formatter_redacts_the_traceback_too() -> None:
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(RedactingFormatter(logging.Formatter("%(message)s")))
log = logging.getLogger("test_redact.traceback")
log.propagate = False
log.handlers = [handler]
try:
msg = f"connect failed: {GOOGLE}"
raise ConnectionError(msg) # noqa: TRY301
except ConnectionError:
log.exception("failed to list tools for MCP %r", "calendar-personal")
out = stream.getvalue()
assert "calendar-personal" in out
assert "1f4a9c0de1f4a9c0de1f4a9c0de" not in out
def test_filter_masks_a_secret_split_across_template_and_args() -> None:
record = logging.LogRecord(
"uvicorn.access", logging.INFO, __file__, 1, "key=%s", ("s3cret-value",), None
)
assert RedactFilter().filter(record)
assert record.getMessage() == "key=<…>"
def test_filter_leaves_a_clean_record_structured() -> None:
record = logging.LogRecord(
"uvicorn.access", logging.INFO, __file__, 1, "closing %s", ("conv-1",), None
)
assert RedactFilter().filter(record)
assert record.args == ("conv-1",)