diff --git a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py index 7c67e0fa13..f00b8cfbf3 100644 --- a/src/google/adk/plugins/bigquery_agent_analytics_plugin.py +++ b/src/google/adk/plugins/bigquery_agent_analytics_plugin.py @@ -842,13 +842,20 @@ 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 @@ -856,9 +863,10 @@ def _sanitize_free_text( 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( @@ -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 diff --git a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py index 9243d611d5..8067f183af 100644 --- a/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py +++ b/tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py @@ -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."""