fix: redact identity secrets through a public hook and mask issued agm_ tokens
ci / gitleaks (push) Successful in 8s
ci / docs-check (push) Failing after 30s
ci / lint (push) Successful in 42s
ci / test-integration (push) Successful in 46s
ci / security (push) Failing after 27s
ci / test-mcp (push) Successful in 1m16s
ci / test-unit (push) Successful in 2m17s
ci / build-and-push (push) Successful in 17s

Identity gains a redact field that the executor honours, so the OAuth
resolver no longer needs an executor subclass overriding private
internals. Opaque agm_ tokens issued by our authorization server are now
masked by the secret patterns wherever they appear in content.
This commit is contained in:
Latte committed 2026-10-14 16:45:36 +02:00
1 parent e02cede9f2
commit ce5ae6caa5
8 files changed
+37 -38

No files matched your search

+4 -2
View File
@@ -10,7 +10,7 @@ Security properties (all covered by tests):
* path values are validated and percent-encoded (see :mod:`.encoding`);
* redirects are never followed (a 3xx is an error of kind ``redirect``);
* cookies are never stored, so one identity can not leak into another call;
* the identity token, the configured Gitea token, the MCP bearer token, the
* the identity token and ``Identity.redact``, the configured Gitea token, the MCP bearer token, the
OAuth client secret, ``extra_secrets`` and secret values submitted in a
request body are replaced with ``[REDACTED]`` in every result, always;
* secret-pattern masking/blocking follows ``SECRET_DETECTION_MODE``, except for
@@ -412,7 +412,9 @@ class GiteaExecutor:
# Sending
def _secrets(self, identity: Identity, call: _Call) -> tuple[str, ...]:
return normalize_secrets([identity.token, *self._base_secrets, *call.call_secrets])
return normalize_secrets(
[identity.token, *identity.redact, *self._base_secrets, *call.call_secrets]
)
async def _send(self, call: _Call, identity: Identity) -> ToolOutcome:
headers = {
+4
View File
@@ -23,11 +23,15 @@ class Identity:
mode: ``admin-token`` or ``oauth``.
token: Gitea token for this identity. Excluded from ``repr`` and never
part of :meth:`metadata`.
redact: Further secrets of this identity (for example the OAuth user's
Gitea refresh token) that the executor replaces in every result.
Excluded from ``repr`` and comparisons.
"""
login: str | None
mode: IdentityMode
token: str = field(repr=False)
redact: tuple[str, ...] = field(default=(), repr=False, compare=False)
def metadata(self, sudo: str | None = None) -> dict[str, Any]:
"""Return result and audit metadata: ``{"login", "mode", "sudo"}``, never the token."""
+3 -2
View File
@@ -15,10 +15,11 @@ from starlette.routing import Route
from aegis_gitea_mcp.audit import AuditLogger
from aegis_gitea_mcp.auth import MCP_SCOPE, AdminIdentityResolver
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.gitea import GiteaExecutor
from aegis_gitea_mcp.oauth import OAuthSetupError
from aegis_gitea_mcp.oauth.callback import build_browser_routes
from aegis_gitea_mcp.oauth.provider import CONSENT_PATH, GiteaOAuthProvider, OAuthTokenVerifier
from aegis_gitea_mcp.oauth.resolver import OAuthGiteaExecutor, OAuthIdentityResolver
from aegis_gitea_mcp.oauth.resolver import OAuthIdentityResolver
from aegis_gitea_mcp.oauth.store import OAuthStore, OAuthStoreError
from aegis_gitea_mcp.oauth.upstream import CALLBACK_PATH, GiteaUpstream
from aegis_gitea_mcp.transports import HttpAuthExtras
@@ -88,7 +89,7 @@ def build_oauth_components(
upstream = GiteaUpstream(settings, client_factory=client_factory, clock=clock)
provider = GiteaOAuthProvider(settings, store, upstream, clock=clock, audit=audit)
executor = OAuthGiteaExecutor(
executor = GiteaExecutor(
settings, client=executor_client, extra_secrets=[key.get_secret_value()]
)
admin = AdminIdentityResolver(settings, executor) if settings.gitea_token is not None else None
+3 -25
View File
@@ -18,7 +18,6 @@ import dataclasses
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from mcp.server.auth.provider import AccessToken
@@ -26,14 +25,12 @@ from mcp.server.auth.provider import AccessToken
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger
from aegis_gitea_mcp.auth import STATIC_CLIENT_ID, IdentityResolver
from aegis_gitea_mcp.errors import ToolError
from aegis_gitea_mcp.gitea import GiteaExecutor
from aegis_gitea_mcp.gitea.redaction import normalize_secrets
from aegis_gitea_mcp.identity import Identity
from aegis_gitea_mcp.oauth.store import OAuthStore, OAuthStoreError, SessionRecord
from aegis_gitea_mcp.oauth.upstream import GiteaUpstream, UpstreamError, UpstreamInvalidGrant
if TYPE_CHECKING:
from aegis_gitea_mcp.gitea.executor import _Call
pass
logger = logging.getLogger(__name__)
@@ -47,26 +44,6 @@ OAUTH_ONLY = (
)
@dataclass(frozen=True)
class SessionIdentity(Identity):
"""An OAuth identity that also names session secrets to redact from results.
Attributes:
redact: Further secrets of the session (the Gitea refresh token) that must
never appear in a result. Excluded from ``repr`` and comparisons.
"""
redact: tuple[str, ...] = field(default=(), repr=False, compare=False)
class OAuthGiteaExecutor(GiteaExecutor):
"""Executor that also redacts the session secrets carried by a :class:`SessionIdentity`."""
def _secrets(self, identity: Identity, call: _Call) -> tuple[str, ...]:
extra = identity.redact if isinstance(identity, SessionIdentity) else ()
return normalize_secrets([*super()._secrets(identity, call), *extra])
class OAuthIdentityResolver:
"""Maps a verified access token to the identity a tool call runs as.
@@ -120,7 +97,8 @@ class OAuthIdentityResolver:
if self._needs_refresh(session):
session = await self._refresh(session_id)
redact = (session.gitea_refresh_token,) if session.gitea_refresh_token else ()
return SessionIdentity(
# The executor redacts ``Identity.redact`` (the Gitea refresh token) from results.
return Identity(
login=session.login, mode="oauth", token=session.gitea_access_token, redact=redact
)
+5
View File
@@ -28,6 +28,11 @@ _SECRET_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
"github_token",
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
),
(
# Opaque OAuth tokens this server issues (``agm_`` + token_urlsafe(32)).
"aegis_oauth_token",
re.compile(r"\bagm_[A-Za-z0-9_-]{32,}"),
),
(
"jwt",
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b"),