fix(mcp): wrap upstream credential secrets in SecretStr (Greptile P1)

Credential fields were plain str, so they would render verbatim in repr(), model_dump(), and
any structured log that serialises the config. Wrap them in pydantic SecretStr so they show as
'**********' everywhere while the resolver unwraps with get_secret_value(): client_secret on
the authorization_code and client_credentials configs, secret_access_key and session_token on
aws_sigv4, the shared api_key value, and the inbound passthrough token on Subject. The api_key
and passthrough arms unwrap at the point of use; a regression test asserts the value never
appears in model_dump_json.
This commit is contained in:
Tin Chi Lo 2026-06-17 17:06:16 -07:00
parent 6bae09bdeb
commit f73e9d61bc
3 changed files with 22 additions and 9 deletions

View file

@ -80,7 +80,9 @@ class UpstreamCredentialProvider:
case SharedKey() as source:
# The shared key is read straight from ServerSpec.config, not the per-user
# store; it is the same credential for every caller.
return Ok(StaticHeaderAuth(config.header_for(source.value)))
return Ok(
StaticHeaderAuth(config.header_for(source.value.get_secret_value()))
)
case PerUserEnvVar():
value = self._per_user_value(subject, server)
if value is None:
@ -119,7 +121,9 @@ class UpstreamCredentialProvider:
return Error(
CredError.of_unauthorized("passthrough: no inbound token to forward")
)
return Ok(StaticHeaderAuth(f"Bearer {subject.inbound_token}"))
return Ok(
StaticHeaderAuth(f"Bearer {subject.inbound_token.get_secret_value()}")
)
# --- arms awaiting their collaborators (typed stubs, fail closed) ----------------------
def _authorization_code(

View file

@ -28,7 +28,7 @@ from enum import Enum
from typing import Annotated, Literal
from expression import case, tag, tagged_union
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from typing_extensions import assert_never
from ..result import Error, Ok, Result
@ -133,7 +133,7 @@ class AuthorizationCodeConfig(BaseModel):
model_config = ConfigDict(frozen=True)
kind: Literal[AuthSpecKind.authorization_code] = AuthSpecKind.authorization_code
client_id: str
client_secret: str
client_secret: SecretStr
authorization_url: str
token_url: str
scopes: tuple[str, ...] = ()
@ -145,7 +145,7 @@ class ClientCredentialsConfig(BaseModel):
model_config = ConfigDict(frozen=True)
kind: Literal[AuthSpecKind.client_credentials] = AuthSpecKind.client_credentials
client_id: str
client_secret: str
client_secret: SecretStr
token_url: str
scopes: tuple[str, ...] = ()
@ -165,7 +165,7 @@ class SharedKey(BaseModel):
model_config = ConfigDict(frozen=True)
source: Literal["shared"] = "shared"
value: str
value: SecretStr
class PerUserEnvVar(BaseModel):
@ -240,8 +240,8 @@ class AwsSigV4Config(BaseModel):
region: str
service: str = "bedrock-agentcore"
access_key_id: str | None = None
secret_access_key: str | None = None
session_token: str | None = None
secret_access_key: SecretStr | None = None
session_token: SecretStr | None = None
role_arn: str | None = None
session_name: str | None = None
@ -266,7 +266,7 @@ class Subject(BaseModel):
tenant_id: str
subject_id: str
# Opaque, already-validated inbound identity. Only `token_exchange` / `passthrough` read it.
inbound_token: str | None = None
inbound_token: SecretStr | None = None
class ServerSpec(BaseModel):

View file

@ -88,6 +88,15 @@ def test_api_key_emits_the_right_scheme(scheme: str, expected: str):
assert _applied_headers(result.ok)["Authorization"] == expected
def test_secret_fields_are_masked_in_serialization():
# SecretStr keeps the value out of model_dump / repr / logs but usable in the resolver.
config = ApiKeyConfig(key_source=SharedKey(value="SUPER-SECRET"))
dumped = config.model_dump_json()
assert "SUPER-SECRET" not in dumped
assert "**********" in dumped
assert config.key_source.value.get_secret_value() == "SUPER-SECRET"
@pytest.mark.parametrize("source", [Byok(), PerUserEnvVar()])
def test_api_key_per_user_pulls_the_subject_credential(source: object):
store = InMemoryCredentialStore(