Skip to content

safe_json_serialize dumps AuthCredential secret fields into span attributes; MCP headers are redacted, these are notΒ #7311

Description

@IdoGol24

πŸ”΄ Required Information

Describe the Bug:

google.adk.telemetry._serialization.safe_json_serialize serializes pydantic
models with model_dump(mode="json"), which renders every field β€” including
the ones AuthCredential, OAuth2Auth, HttpCredentials and
ServiceAccountCredential declare as Field(repr=False).

adk_request_credential carries an AuthConfig as its arguments
(AuthToolArguments.auth_config) and receives one back as its function
response, and AuthConfig.exchanged_auth_credential is a full
AuthCredential. So the tool-call span for that flow exports the OAuth
access_token, refresh_token and client_secret in the clear, in
gcp.vertex.agent.tool_call_args and gcp.vertex.agent.tool_response.

This is the default configuration, not an opt-in:
ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS defaults on
(TelemetryContext.should_add_content_to_legacy_spans). The spans go wherever
the exporter points β€” Cloud Trace, or any third-party OTel backend.

ADK already decides in three places that credentials must not reach an
exported span attribute:

  1. telemetry/tracing.py, _build_llm_request_for_trace excludes
    http_options.headers, extra_body and *client_args from the dumped
    llm_request.config, with the comment "http_options carries
    caller-supplied credentials … None of it may reach an exported span
    attribute."
  2. The MCP HTTP exchange record takes headers that "arrive already redacted,
    so allowlisting a credential header yields the redaction marker rather
    than the secret."
  3. plugins/auto_tracing_helpers.py masks captured arguments through
    _CREDENTIAL_TYPE_NAMES β€” which already names AuthConfig,
    AuthCredential, AuthToolArguments and OAuth2Auth β€” plus a field-name
    list and a bounded structural walk.

The rule and the list both already exist. They are applied in
AutoTracingPlugin's argument capture and not on the default tracing.py
path.

To be explicit about what is not being claimed: Field(repr=False) is a
repr control, and model_dump() rendering those fields is documented
pydantic behaviour, not a pydantic bug. The point is that ADK marks these
fields as not-for-display and maintains a redaction list naming their types,
and the telemetry serializer consults neither.

Steps to Reproduce:

  1. pip install google-adk (or run from a checkout of main).
  2. Save the script under Minimal Reproduction Code below as repro.py.
  3. Run python repro.py with no ADK environment variables set.
  4. It exits 1 and prints one LEAK line per secret that reached a span
    attribute.

Expected Behavior:

No credential value reaches an exported span attribute. A trace may show
that a credential was present β€” the key, a redaction marker β€” but not its
value, consistent with what _build_llm_request_for_trace and the MCP header
record already do.

Observed Behavior:

LEAK  span='execute_tool' attribute='gcp.vertex.agent.tool_call_args' secret=access_token
LEAK  span='execute_tool' attribute='gcp.vertex.agent.tool_call_args' secret=refresh_token
LEAK  span='execute_tool' attribute='gcp.vertex.agent.tool_call_args' secret=client_secret
LEAK  span='execute_tool' attribute='gcp.vertex.agent.tool_response' secret=access_token
LEAK  span='execute_tool' attribute='gcp.vertex.agent.tool_response' secret=refresh_token
LEAK  span='execute_tool' attribute='gcp.vertex.agent.tool_response' secret=client_secret

repr() on the same object masks all three, which is the contrast worth
seeing:

access_token in repr(): False
refresh_token in repr(): False
client_secret in repr(): False
safe_json_serialize -> leaked: ['access_token', 'refresh_token', 'client_secret']

Environment Details:

  • ADK Library Version: 2.10.0 (reproduced from source at commit 044a1ec,
    2026-09-25)
  • Desktop OS: Windows 11
  • Python Version: 3.12.3

Model Information:

  • Are you using LiteLLM: No
  • Which model is being used: N/A β€” the repro calls trace_tool_call directly
    and needs no model.

🟑 Optional Information

Regression: Not a regression; safe_json_serialize has dumped models this
way for as long as it has existed.

Minimal Reproduction Code:

"""An OAuth access token lands in an exported ADK span attribute.

Default config: ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS defaults on.
Exits 1 if a secret reaches a span.
"""
import sys
from types import SimpleNamespace

from fastapi.openapi.models import OAuth2, OAuthFlows, OAuthFlowAuthorizationCode
from google.genai import types
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

from google.adk.auth.auth_credential import (
    AuthCredential, AuthCredentialTypes, OAuth2Auth)
from google.adk.auth.auth_tool import AuthConfig, AuthToolArguments
from google.adk.telemetry.tracing import trace_tool_call

TOKEN = "ya29.LEAKED-ACCESS-TOKEN"
REFRESH = "1//LEAKED-REFRESH-TOKEN"
SECRET = "LEAKED-CLIENT-SECRET"
SECRETS = {"access_token": TOKEN, "refresh_token": REFRESH,
           "client_secret": SECRET}

exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)

scheme = OAuth2(flows=OAuthFlows(authorizationCode=OAuthFlowAuthorizationCode(
    authorizationUrl="https://example.com/auth",
    tokenUrl="https://example.com/token", scopes={})))
cred = AuthCredential(
    auth_type=AuthCredentialTypes.OAUTH2,
    oauth2=OAuth2Auth(client_id="cid", client_secret=SECRET,
                      access_token=TOKEN, refresh_token=REFRESH))
auth_config = AuthConfig(auth_scheme=scheme, exchanged_auth_credential=cred)

# What ADK puts on the wire: adk_request_credential's args, and the client's
# auth response coming back as that call's function_response.
args = AuthToolArguments(function_call_id="fc-1",
                         auth_config=auth_config).model_dump(mode="json")
response_event = SimpleNamespace(
    id="ev-1",
    content=types.Content(role="user", parts=[types.Part(
        function_response=types.FunctionResponse(
            id="fc-1", name="adk_request_credential",
            response=auth_config.model_dump(mode="json")))]))
tool = SimpleNamespace(name="adk_request_credential",
                       description="request end user credentials",
                       custom_metadata=None)

tracer = provider.get_tracer("repro")
with tracer.start_as_current_span("execute_tool") as span:
    trace_tool_call(tool=tool, args=args,
                    function_response_event=response_event, span=span)

leaks = []
for s in exporter.get_finished_spans():
    for key, value in (s.attributes or {}).items():
        for name, secret in SECRETS.items():
            if secret in str(value):
                leaks.append((s.name, key, name))

for span_name, key, name in leaks:
    print(f"LEAK  span={span_name!r} attribute={key!r} secret={name}")
if not leaks:
    print("no secret reached any span attribute")
sys.exit(1 if leaks else 0)

How often has this issue occurred?: Always (100%)

Additional Context β€” suggested fix:

Apply the existing redaction rule at the serializer, reusing
auto_tracing_helpers' type and field-name lists rather than adding a second
set β€” which means lifting those lists into a module both sites can import.
Mask by declared credential type and by field name, keep the key so a trace
still shows a credential was present, and bound the walk so hitting a bound
elides rather than leaks.

I have this working with tests and no regressions against
tests/unittests/telemetry and tests/unittests/plugins, and can open a PR.

Disclosure: Reported to the Google VRP as issue 558689693; assessed as
below their security-tracking threshold, with public disclosure on GitHub
invited.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions