fix: fail closed on an unwritable audit log, bound the rate limiter and harden startup
The audit logger is opened at startup (exit 2 on failure) and obtained before every call, which is refused with audit_unavailable otherwise. Audit records only take validated path and sudo values and use the request id as correlation id. The rate limiter evicts idle keys, caps tracked keys, audits the first excess per key and window and uses the settings passed to the app. FORWARDED_ALLOW_IPS is now a setting, and startup warns about a missing host allow-list and unavailable OAuth.
This commit is contained in:
1 parent
a3222d5ebe
commit
cf3d2e17d3
16 files changed
+532
-115
No files matched your search
+65
-1
@@ -469,7 +469,7 @@ async def test_audit_event_per_call_never_contains_argument_values(
|
||||
assert validate_audit_log_integrity(log_path) == (True, [])
|
||||
|
||||
|
||||
async def test_audit_failure_does_not_break_the_call(
|
||||
async def test_audit_write_failure_after_execution_does_not_break_the_call(
|
||||
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
mock_user(httpx_mock)
|
||||
@@ -482,11 +482,75 @@ async def test_audit_failure_does_not_break_the_call(
|
||||
async with connected(audit=BrokenAudit()) as client: # type: ignore[arg-type]
|
||||
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||
|
||||
# The call already ran (its side effect cannot be undone), so the result still goes out.
|
||||
assert not result.is_error
|
||||
assert "OSError" in caplog.text
|
||||
assert GITEA_TOKEN not in caplog.text
|
||||
|
||||
|
||||
async def test_unavailable_audit_log_refuses_the_call_before_gitea_is_contacted(
|
||||
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
def broken() -> None:
|
||||
raise OSError("permission denied: " + GITEA_TOKEN)
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server.get_audit_logger", broken)
|
||||
async with connected() as client:
|
||||
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||
written = await client.call_tool("gitea_issue_create_issue", {**REPO_ARGS, "body": {}})
|
||||
|
||||
assert result.is_error and written.is_error
|
||||
error = payload_of(result)["error"]
|
||||
assert error["kind"] == "audit_unavailable"
|
||||
assert GITEA_TOKEN not in json.dumps(payload_of(result)) + caplog.text
|
||||
assert httpx_mock.get_requests() == []
|
||||
|
||||
|
||||
async def test_audit_correlation_id_is_the_request_id(httpx_mock: HTTPXMock) -> None:
|
||||
mock_user(httpx_mock)
|
||||
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
|
||||
async with connected() as client:
|
||||
await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
|
||||
line = get_audit_logger().log_path.read_text(encoding="utf-8").splitlines()[-1]
|
||||
entry = json.loads(line)
|
||||
assert entry["payload"]["correlation_id"] == entry["request_id"] != "-"
|
||||
|
||||
|
||||
async def test_rejected_raw_call_audits_no_unvalidated_path_or_sudo() -> None:
|
||||
secret = "SECRET-IN-PATH-9f8e7d6c5b"
|
||||
async with connected() as client:
|
||||
bad_path = await client.call_tool(
|
||||
"gitea_api_request", {"method": "GET", "path": secret, "sudo": secret}
|
||||
)
|
||||
bad_sudo = await client.call_tool(
|
||||
"gitea_api_request", {"method": "GET", "path": "/user", "sudo": "bad name!"}
|
||||
)
|
||||
|
||||
assert bad_path.is_error and bad_sudo.is_error
|
||||
first, second = audit_entries()
|
||||
assert first["outcome"] == second["outcome"] == "invalid_argument"
|
||||
for entry in (first, second):
|
||||
assert "path" not in entry and "sudo" not in entry
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
|
||||
assert secret not in get_audit_logger().log_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_raw_call_audits_the_validated_method_and_path(httpx_mock: HTTPXMock) -> None:
|
||||
mock_user(httpx_mock)
|
||||
httpx_mock.add_response(url=f"{API}/version", json={})
|
||||
async with connected() as client:
|
||||
await client.call_tool(
|
||||
"gitea_api_request", {"method": "GET", "path": "/version", "sudo": "alice"}
|
||||
)
|
||||
|
||||
entry = audit_entries()[-1]
|
||||
assert (entry["method"], entry["path"], entry["sudo"]) == ("GET", "/version", "alice")
|
||||
|
||||
|
||||
# Internal errors
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user