Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions src/google/adk/plugins/bigquery_agent_analytics_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,23 +842,31 @@ def _sanitize_free_text(
closed; a quoted fragment is walked through the full blob sanitizer
from the first quote. A stray
escape in the prose gap could shift a consumer's parse boundaries and
also fails closed. Quote-free, container-free prose passes through.
also fails closed. Quote-free, container-free prose still gets the
plain-text credential-pattern pass: this function is the fallback for
every non-container-shaped string reaching the recursive sanitizer
(tool args/results, state deltas, usage metadata, A2A payloads,
schemas), so an unkeyed "Authorization: Bearer ..."-shaped value must
not pass through unredacted just because it never picked up a JSON
container token or quote.
"""
if "{" in text or "[" in text:
return "[UNPARSEABLE_JSON_BLOB]", True, True
quote_idx = text.find('"')
if quote_idx == -1:
return text, False, False
redacted, changed = _redact_sensitive_patterns(text)
return redacted, changed, changed
gap = text[:quote_idx]
if "\\" in gap:
return "[UNPARSEABLE_JSON_BLOB]", True, True
tail = text[quote_idx:]
t_sanitized, changed, truncated = _sanitize_json_blob(
tail, seen, depth + 1, max_len, budget
)
if not changed:
gap_sanitized, gap_changed = _redact_sensitive_patterns(gap)
if not changed and not gap_changed:
return text, False, False
return gap + t_sanitized, True, truncated
return gap_sanitized + t_sanitized, True, truncated


def _sanitize_json_blob(
Expand Down Expand Up @@ -978,7 +986,16 @@ def _sanitize_json_blob(
suffix_text = suffix if not s_changed else s_sanitized
return prefix_text + suffix_text, True, p_truncated or s_truncated
if not stripped.startswith(("{", "[")):
return value, False, False
# Plain prose is the common case for tool args/results, state deltas,
# usage metadata, A2A payloads, and schemas — none of which are
# container-shaped, so without this pass a value like
# "Authorization: Bearer sk-..." embedded under an unrecognized key
# (e.g. a tool's own error/status message) would reach BigQuery
# completely unredacted. This mirrors the pattern-based pass already
# applied to URI segments, Part-content text, and error/traceback
# text elsewhere in this file.
redacted, changed = _redact_sensitive_patterns(value)
return redacted, changed, changed

# Enforce the inspection limit BEFORE materializing: json.loads runs
# synchronously on the callback path and can allocate far beyond the
Expand Down
47 changes: 47 additions & 0 deletions tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,53 @@ def test_recursive_smart_truncate_redaction():
assert truncated["nested"]["normal"] == "value"


def test_recursive_smart_truncate_redacts_credentials_in_free_text_values():
"""A credential embedded in prose under an unrecognized key must still be
redacted, not just an exact-matching dict key.

Regression test: tool args/results, state deltas, usage metadata, A2A
payloads, and schemas all reach BigQuery through this function, and none
of them are container-shaped JSON strings in the common case, so a value
like an "Authorization: Bearer ..." token embedded in an ordinary status
or error message previously passed through completely unredacted.
"""
obj = {
"status": "ok",
"message": "Authorization: Bearer sk-abcdef123456xyz",
}
truncated, is_truncated = (
bigquery_agent_analytics_plugin._recursive_smart_truncate(obj, -1)
)
assert is_truncated
assert "sk-abcdef123456xyz" not in truncated["message"]
assert truncated["message"] == "Authorization: [REDACTED]"

obj2 = {
"status": "ok",
"message": "here is the api_key: sk-abcdef123456xyz please use it",
}
truncated2, is_truncated2 = (
bigquery_agent_analytics_plugin._recursive_smart_truncate(obj2, -1)
)
assert is_truncated2
assert "sk-abcdef123456xyz" not in truncated2["message"]

# Benign prose must remain byte-identical.
obj3 = {"status": "ok", "message": "everything is fine, no secrets here"}
truncated3, is_truncated3 = (
bigquery_agent_analytics_plugin._recursive_smart_truncate(obj3, -1)
)
assert not is_truncated3
assert truncated3 == obj3

# A plain (non-dict-wrapped) string leaf must also be covered.
leaf, leaf_truncated = bigquery_agent_analytics_plugin._recursive_smart_truncate(
"Authorization: Bearer sk-abcdef123456xyz", -1
)
assert leaf_truncated
assert "sk-abcdef123456xyz" not in leaf


class TestBigQueryAgentAnalyticsPlugin:
"""Tests for the BigQueryAgentAnalyticsPlugin."""

Expand Down