Skip to content

Commit a86bd25

Browse files
GWealecopybara-github
authored andcommitted
fix: redact credentials from DebugLoggingPlugin output file
Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 970851405
1 parent 99660d9 commit a86bd25

2 files changed

Lines changed: 504 additions & 8 deletions

File tree

src/google/adk/plugins/debug_logging_plugin.py

Lines changed: 182 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616

1717
from __future__ import annotations
1818

19+
from datetime import date
1920
from datetime import datetime
21+
from datetime import time
22+
from enum import Enum
2023
import logging
24+
import os
2125
from pathlib import Path
2226
from typing import Any
2327
from typing import TYPE_CHECKING
@@ -30,9 +34,16 @@
3034

3135
from ..agents.base_agent import BaseAgent
3236
from ..agents.callback_context import CallbackContext
37+
from ..auth.auth_credential import AuthCredential
38+
from ..auth.auth_credential import HttpAuth
39+
from ..auth.auth_credential import HttpCredentials
40+
from ..auth.auth_credential import OAuth2Auth
41+
from ..auth.auth_credential import ServiceAccount
42+
from ..auth.auth_credential import ServiceAccountCredential
3343
from ..events.event import Event
3444
from ..models.llm_request import LlmRequest
3545
from ..models.llm_response import LlmResponse
46+
from ..sessions.state import State
3647
from ..tools.base_tool import BaseTool
3748
from .base_plugin import BasePlugin
3849

@@ -42,6 +53,103 @@
4253

4354
logger = logging.getLogger("google_adk." + __name__)
4455

56+
_REDACTED = "[REDACTED]"
57+
58+
# Models that exist to carry a secret; an instance is replaced wholesale
59+
# rather than dumped field by field.
60+
_CREDENTIAL_MODELS = (
61+
AuthCredential,
62+
HttpAuth,
63+
HttpCredentials,
64+
OAuth2Auth,
65+
ServiceAccount,
66+
ServiceAccountCredential,
67+
)
68+
69+
# Mapping keys whose value is a secret, for credentials that reach the plugin
70+
# as plain dicts rather than as models: session state rehydrated from a session
71+
# service, or the already dumped credential the OpenAPI tool auth handler keeps
72+
# in state. Starts from the set bigquery_agent_analytics_plugin applies, plus
73+
# the OAuth2 authorization-code fields, which ADK itself populates and which
74+
# are enough on their own to complete a token exchange.
75+
_SENSITIVE_KEYS = frozenset({
76+
"access_token",
77+
"api_key",
78+
"auth_code",
79+
"auth_response_uri",
80+
"authorization",
81+
"client_secret",
82+
"code_verifier",
83+
"google_access_id",
84+
"id_token",
85+
"password",
86+
"private_key",
87+
"private_key_id",
88+
"proxy_authorization",
89+
"refresh_token",
90+
"secret",
91+
"sig",
92+
"signature",
93+
"token",
94+
"x_amz_credential",
95+
"x_amz_signature",
96+
"x_api_key",
97+
"x_goog_credential",
98+
"x_goog_security_token",
99+
"x_goog_signature",
100+
})
101+
102+
# The debug file is written with the process umask otherwise, which commonly
103+
# leaves it world-readable.
104+
_OUTPUT_FILE_MODE = 0o600
105+
106+
# Bounds both walks below, which are otherwise unterminated on a
107+
# self-referential object. Deeper than any credential model nests.
108+
_MAX_WALK_DEPTH = 20
109+
110+
111+
def _is_sensitive_key(key: Any) -> bool:
112+
"""Whether a mapping key names a credential-bearing value."""
113+
if not isinstance(key, str):
114+
return False
115+
# `str.__str__` drops a subclass override of `lower`; hyphens are folded so
116+
# that header spellings such as `X-Api-Key` match, as the analytics plugin
117+
# does.
118+
normalized = str.__str__(key).lower().replace("-", "_")
119+
# ADK stores exchanged auth credentials under a `temp:`-prefixed state key.
120+
return normalized in _SENSITIVE_KEYS or normalized.startswith(
121+
State.TEMP_PREFIX
122+
)
123+
124+
125+
def _model_items(model: BaseModel) -> list[tuple[str, Any]]:
126+
"""The (name, value) pairs held by a model, including any extra fields."""
127+
return [
128+
*model.__dict__.items(),
129+
*(model.__pydantic_extra__ or {}).items(),
130+
]
131+
132+
133+
def _holds_credential(obj: Any, depth: int = 0) -> bool:
134+
"""Whether a credential model instance is reachable from `obj`.
135+
136+
Dumping a model flattens a credential nested inside it into a plain dict, at
137+
which point only its key name could still identify it. A model that carries
138+
one anywhere below it therefore has to be walked field by field instead.
139+
"""
140+
if depth > _MAX_WALK_DEPTH:
141+
return False
142+
if isinstance(obj, _CREDENTIAL_MODELS):
143+
return True
144+
child_depth = depth + 1
145+
if isinstance(obj, BaseModel):
146+
return any(_holds_credential(v, child_depth) for _, v in _model_items(obj))
147+
if isinstance(obj, dict):
148+
return any(_holds_credential(v, child_depth) for v in obj.values())
149+
if isinstance(obj, (list, tuple, set, frozenset)):
150+
return any(_holds_credential(v, child_depth) for v in obj)
151+
return False
152+
45153

46154
class _DebugEntry(BaseModel):
47155
"""A single debug log entry."""
@@ -77,7 +185,14 @@ class DebugLoggingPlugin(BasePlugin):
77185
78186
The output is written as YAML format for human readability. Each invocation
79187
is appended to the file as a separate YAML document (separated by ---).
80-
This format is easy to read and can be shared for debugging purposes.
188+
This format is easy to read. Credentials are redacted, but the file still
189+
holds whole prompts and responses, so it is created readable only by its
190+
owner and is not safe to hand around.
191+
192+
Redaction covers credential models wherever they appear, mapping keys that
193+
name a secret, and every `temp:`-prefixed state key. That last rule blanks
194+
all temporary state, not only credentials, so an intermediate value passed
195+
between agents under a `temp:` key reads as `[REDACTED]` here.
81196
82197
Example:
83198
>>> debug_plugin = DebugLoggingPlugin(output_path="/tmp/adk_debug.yaml")
@@ -113,6 +228,7 @@ def __init__(
113228
self._include_session_state = include_session_state
114229
self._include_system_instruction = include_system_instruction
115230
self._invocation_states: dict[str, _InvocationDebugState] = {}
231+
self._warned_about_output_mode = False
116232

117233
def _get_timestamp(self) -> str:
118234
"""Get current timestamp in ISO format."""
@@ -135,7 +251,7 @@ def _serialize_content(
135251
part_data["function_call"] = {
136252
"id": part.function_call.id,
137253
"name": part.function_call.name,
138-
"args": part.function_call.args,
254+
"args": self._safe_serialize(part.function_call.args),
139255
}
140256
if part.function_response:
141257
part_data["function_response"] = {
@@ -170,21 +286,64 @@ def _serialize_content(
170286

171287
return {"role": content.role, "parts": parts}
172288

173-
def _safe_serialize(self, obj: Any) -> Any:
174-
"""Safely serialize an object to JSON-compatible format."""
289+
def _safe_serialize(self, obj: Any, depth: int = 0) -> Any:
290+
"""Safely serialize an object to JSON-compatible format.
291+
292+
A credential model is replaced with a redaction marker wherever it sits:
293+
at the top level, or nested inside a dict, list, tuple or another model,
294+
under any key name. Mapping keys that name a secret are redacted too, for
295+
credentials that arrive already dumped to a plain dict.
296+
"""
175297
if obj is None:
176298
return None
299+
if isinstance(obj, _CREDENTIAL_MODELS):
300+
return _REDACTED
301+
if depth > _MAX_WALK_DEPTH:
302+
# Terminates a self-referential object. Only the type name survives, so
303+
# reaching the bound cannot uncover a value.
304+
return f"<{type(obj).__name__} ...>"
305+
child_depth = depth + 1
306+
if isinstance(obj, Enum):
307+
# A member of a `str` or `int` subclass enum passes the scalar check
308+
# below unchanged, and then reaches `yaml.dump` as a Python object,
309+
# which writes a `!!python/object` tag that `yaml.safe_load` refuses.
310+
return self._safe_serialize(obj.value, child_depth)
177311
if isinstance(obj, (str, int, float, bool)):
178312
return obj
313+
if isinstance(obj, (date, time)):
314+
return obj.isoformat()
179315
if isinstance(obj, (list, tuple)):
180-
return [self._safe_serialize(item) for item in obj]
316+
return [self._safe_serialize(item, child_depth) for item in obj]
181317
if isinstance(obj, dict):
182-
return {k: self._safe_serialize(v) for k, v in obj.items()}
318+
return {
319+
k: (
320+
_REDACTED
321+
if _is_sensitive_key(k)
322+
else self._safe_serialize(v, child_depth)
323+
)
324+
for k, v in obj.items()
325+
}
183326
if isinstance(obj, BaseModel):
327+
if _holds_credential(obj):
328+
# Serialize the raw field values, so that the nested credential is
329+
# still a model instance when it is reached. Dumping first would leave
330+
# only its key name to go on, and that name is caller-chosen. The
331+
# values skip `model_dump`, so each one is normalized on the way
332+
# through the branches above rather than by pydantic.
333+
return self._safe_serialize(
334+
{
335+
name: value
336+
for name, value in _model_items(obj)
337+
if value is not None
338+
},
339+
depth,
340+
)
184341
try:
185-
return obj.model_dump(mode="json", exclude_none=True)
342+
dumped = obj.model_dump(mode="json", exclude_none=True)
186343
except Exception:
187344
return str(obj)
345+
# Recurse so that credential-named keys within the dump are redacted.
346+
return self._safe_serialize(dumped, depth)
188347
if isinstance(obj, bytes):
189348
return f"<bytes: {len(obj)} bytes>"
190349
try:
@@ -349,7 +508,22 @@ async def after_run_callback(
349508
# Write to file as YAML
350509
try:
351510
output_data = state.model_dump(mode="json", exclude_none=True)
352-
with self._output_path.open("a", encoding="utf-8") as f:
511+
fd = os.open(
512+
self._output_path,
513+
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
514+
_OUTPUT_FILE_MODE,
515+
)
516+
# The mode above only applies to a file this call creates. A file left
517+
# behind by an earlier run keeps whatever mode it had, so say so rather
518+
# than silently changing permissions the user may have chosen.
519+
if not self._warned_about_output_mode and os.fstat(fd).st_mode & 0o077:
520+
self._warned_about_output_mode = True
521+
logger.warning(
522+
"Debug output file %s is readable beyond its owner and holds"
523+
" whole prompts and responses; restrict it to mode 600.",
524+
self._output_path,
525+
)
526+
with os.fdopen(fd, "a", encoding="utf-8") as f:
353527
f.write("---\n")
354528
yaml.dump(
355529
output_data,

0 commit comments

Comments
 (0)