diff --git a/agent/utils/auth.py b/agent/utils/auth.py index b279e730..4141b0c3 100644 --- a/agent/utils/auth.py +++ b/agent/utils/auth.py @@ -195,8 +195,10 @@ async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str return {"error": f"Unexpected auth result: {response_data}"} except httpx.HTTPStatusError as e: + # Log the full upstream body server-side only; the returned error becomes a + # user-facing Slack/Linear comment, so never echo the raw response text. logger.error("GitHub auth API HTTP error: %s - %s", e.response.status_code, e.response.text) - return {"error": f"HTTP error: {e.response.status_code} - {e.response.text}"} + return {"error": f"GitHub auth failed (status {e.response.status_code})"} except Exception as e: # noqa: BLE001 logger.error("GitHub auth API call failed: %s: %s", type(e).__name__, str(e)) return {"error": str(e)} @@ -383,7 +385,13 @@ async def _resolve_dashboard_user_token( async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str | None]: - """Get a GitHub App installation token and cache it for the thread.""" + """Get a GitHub App installation token and cache it for the thread. + + AUTHZ-003 (accepted): in bot-token-only mode every run shares one GitHub App + installation token, so its blast radius is the whole installation rather than + a single user. This is a documented, accepted prod posture for this + single-tenant deployment, not a defect. + """ bot_token, expires_at = await get_github_app_installation_token_with_expiry() if not bot_token: raise RuntimeError( diff --git a/tests/test_auth_error_leak.py b/tests/test_auth_error_leak.py new file mode 100644 index 00000000..02907887 --- /dev/null +++ b/tests/test_auth_error_leak.py @@ -0,0 +1,55 @@ +"""AUTH-RESP-LEAK-01: upstream auth-error bodies must not reach user-facing text.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import httpx +import pytest + +from agent.utils import auth + +_SECRET_BODY = "SENSITIVE-UPSTREAM-BODY-9999" + + +class _Resp: + def __init__(self, status_code: int, text: str) -> None: + self.status_code = status_code + self.text = text + + def raise_for_status(self) -> None: + raise httpx.HTTPStatusError( + "boom", + request=httpx.Request("POST", "http://example.test"), + response=self, # type: ignore[arg-type] + ) + + def json(self) -> Any: + return {} + + +class _Client: + def __init__(self, resp: _Resp) -> None: + self._resp = resp + + async def __aenter__(self) -> _Client: + return self + + async def __aexit__(self, *_a: Any) -> None: + return None + + async def post(self, *_a: Any, **_k: Any) -> _Resp: + return self._resp + + +def test_http_error_returns_generic_message(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(auth, "GITHUB_OAUTH_PROVIDER_ID", "provider-id") + monkeypatch.setattr(auth, "X_SERVICE_AUTH_JWT_SECRET", "jwt-secret") + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: _Client(_Resp(500, _SECRET_BODY))) + + result = asyncio.run(auth.get_github_token_for_user("ls-user", "tenant")) + + assert "error" in result + assert _SECRET_BODY not in result["error"] + assert "500" in result["error"]