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
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:
1 parent
e02cede9f2
commit
ce5ae6caa5
8 files changed
+37
-38
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user