mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F022][F023][F024][F025][F026] api: scrub secrets from 422 log, gate a2a/dashboard/orchestrator routes, SSE session-per-query
- middleware: redact known credential fields (git_token/api_key/token/...) from the 422 request-validation log line; response body unchanged - a2a: require_any_authenticated_agent on /message/send + /message/stream; subscribe_to_task opens a short-lived session per poll instead of holding one asyncpg connection for the full SSE lifetime (pool exhaustion) + auth - dashboard: gate auditor flag/report mutating routes to Auditor or CEO - orchestrator: router-level CEO gate on all control routes (spawn/stop/...) TDD; ruff/mypy clean; 449 unit/api tests green; no type:ignore/noqa.
This commit is contained in:
@@ -38,6 +38,7 @@ from roboco.services.base import (
|
||||
from roboco.services.base import (
|
||||
ValidationError as ServiceValidationError,
|
||||
)
|
||||
from structlog.testing import capture_logs
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_status_code
|
||||
@@ -275,3 +276,107 @@ def test_request_validation_handler_returns_422_with_details() -> None:
|
||||
body = response.json()
|
||||
assert "detail" in body
|
||||
assert "body" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F022: secret scrubbing in the 422 log line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SecretBody(BaseModel):
|
||||
"""Module-level model so FastAPI can resolve the annotation under
|
||||
`from __future__ import annotations` (function-local classes with complex
|
||||
field types aren't resolvable from the function's module globals)."""
|
||||
|
||||
name: str
|
||||
git_token: str | None = None
|
||||
api_key: str | None = None
|
||||
auth_token: str | None = None
|
||||
nested: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def test_request_validation_handler_scrubs_secrets_from_log() -> None:
|
||||
"""F022: a 422 on a secret-bearing request must not dump the plaintext
|
||||
secret into the log line — only the redacted placeholder. The 422
|
||||
response body is unchanged (the client sent those values; the server
|
||||
only redacts its own log)."""
|
||||
|
||||
app = FastAPI()
|
||||
setup_middleware(app)
|
||||
|
||||
@app.post("/project")
|
||||
async def _create(_data: _SecretBody) -> Any:
|
||||
return {"ok": True}
|
||||
|
||||
secret_pat = "ghp_livesecret_123456"
|
||||
secret_key = "ollama-key-do-not-log"
|
||||
secret_token = "bearer-should-not-leak"
|
||||
payload = {
|
||||
# Missing required `name` -> 422, but the secret fields are still
|
||||
# parsed into rve.body and would be logged verbatim without the scrub.
|
||||
"git_token": secret_pat,
|
||||
"api_key": secret_key,
|
||||
"auth_token": secret_token,
|
||||
"nested": {"git_token": "nested-secret-abc", "safe": "keep"},
|
||||
}
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
with capture_logs() as logs:
|
||||
response = client.post("/project", json=payload)
|
||||
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
|
||||
# The response body is NOT scrubbed (the client sent these values).
|
||||
resp_body = response.json()
|
||||
assert resp_body["body"]["git_token"] == secret_pat
|
||||
assert resp_body["body"]["api_key"] == secret_key
|
||||
|
||||
# Exactly one "Request validation failed" warning was emitted.
|
||||
fails = [e for e in logs if e["event"] == "Request validation failed"]
|
||||
assert len(fails) == 1
|
||||
logged_body = fails[0]["body"]
|
||||
|
||||
# The log line must not contain any of the plaintext secrets.
|
||||
assert secret_pat not in str(logged_body)
|
||||
assert secret_key not in str(logged_body)
|
||||
assert secret_token not in str(logged_body)
|
||||
assert "nested-secret-abc" not in str(logged_body)
|
||||
|
||||
# The redaction placeholder appears for each secret field (so ops can see
|
||||
# WHICH secret field was present), and the per-field errors are still
|
||||
# logged (they don't carry secrets).
|
||||
assert logged_body["git_token"] == "***REDACTED***"
|
||||
assert logged_body["api_key"] == "***REDACTED***"
|
||||
assert logged_body["auth_token"] == "***REDACTED***"
|
||||
assert logged_body["nested"]["git_token"] == "***REDACTED***"
|
||||
assert logged_body["nested"]["safe"] == "keep" # non-secret preserved
|
||||
assert "errors" in fails[0]
|
||||
|
||||
|
||||
def test_request_validation_handler_log_preserves_non_secret_fields() -> None:
|
||||
"""F022: non-secret fields in the body are still logged in full — only
|
||||
the known credential-looking field names are redacted."""
|
||||
|
||||
app = FastAPI()
|
||||
setup_middleware(app)
|
||||
|
||||
@app.post("/project")
|
||||
async def _create(_data: _SecretBody) -> Any:
|
||||
return {"ok": True}
|
||||
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
# `title` is not a field on _SecretBody -> 422, and `title` is non-secret
|
||||
# so it should still appear in the log; `git_token` is secret and must be
|
||||
# redacted.
|
||||
with capture_logs() as logs:
|
||||
response = client.post(
|
||||
"/project",
|
||||
json={"title": "visible-title", "git_token": "ghp_secret_xyz"},
|
||||
)
|
||||
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
fails = [e for e in logs if e["event"] == "Request validation failed"]
|
||||
assert len(fails) == 1
|
||||
logged_body = fails[0]["body"]
|
||||
assert logged_body["title"] == "visible-title" # non-secret preserved
|
||||
assert logged_body["git_token"] == "***REDACTED***" # secret redacted
|
||||
assert "ghp_secret_xyz" not in str(logged_body)
|
||||
|
||||
Reference in New Issue
Block a user