Skip to content
Closed
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
11 changes: 9 additions & 2 deletions agent/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,15 @@
# while the header name and scheme word are preserved for debuggability. The
# previous rule only matched ``Bearer``, so ``Basic <base64 user:pass>`` and
# ``token <pat>`` leaked verbatim into logs/transcripts.
# Characters valid in a credential token value. Matches everything
# ``\S+`` would except common code-syntax delimiters (quotes, braces,
# brackets, parens, angle brackets, backslash, comma, semicolon) so
# that redaction never consumes a trailing delimiter and corrupts
# surrounding code (issue #33801).
_TOKEN_CHARS = r"[^\s\"'<>{}\[\]()\\,;]+"

_AUTH_HEADER_RE = re.compile(
r"((?:Proxy-)?Authorization:\s*)([A-Za-z][\w.+-]*\s+)?(\S+)",
r"((?:Proxy-)?Authorization:\s*)([A-Za-z][\w.+-]*\s+)?(" + _TOKEN_CHARS + r")",
re.IGNORECASE,
)

Expand All @@ -138,7 +145,7 @@
r"(?:x-api-key|x-goog-api-key|api-key|apikey|x-api-token|x-auth-token|x-access-token)"
)
_SECRET_HEADER_RE = re.compile(
rf"({_SECRET_HEADER_NAMES}\s*:\s*)(\S+)",
rf"({_SECRET_HEADER_NAMES}\s*:\s*)(" + _TOKEN_CHARS + r")",
re.IGNORECASE,
)

Expand Down
114 changes: 114 additions & 0 deletions tests/agent/test_redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,120 @@ def test_authorization_prose_unchanged(self):
assert redact_sensitive_text(text) == text


class TestAuthHeaderSyntaxSafety:
"""Regression tests for #33801: \\S+ in _AUTH_HEADER_RE and
_SECRET_HEADER_RE greedily captures trailing quotes / braces / brackets,
corrupting code syntax in tool call arguments, execute_code, and heredocs.

Test strings are built dynamically from fragments so the redaction regex
doesn't fire on the test source itself.
"""

@staticmethod
def _auth_str(token, scheme="Bearer", header="Authorization"):
"""Build an 'Authorization: Bearer <token>' string without triggering
the redaction regex on the test source."""
dq = chr(34)
return f"{dq}{header}: {scheme} {token}{dq}"

@staticmethod
def _api_key_str(token, header="x-api-key"):
dq = chr(34)
return f"{dq}{header}: {token}{dq}"

# --- Layer 1-3: closing quote consumed by \S+ (short token) ---

def test_short_bearer_token_quote_preserved(self):
token = "abcdefghijkl" # 12 chars — token+quote = 13 < 18 floor
text = self._auth_str(token)
result = redact_sensitive_text(text)
assert token not in result, "token should be redacted"
assert result.count(chr(34)) == text.count(chr(34)), \
"closing quote must be preserved"
assert result.endswith(chr(34)), "closing quote must survive"

def test_short_basic_auth_quote_preserved(self):
token = "dXNlcjpsb25n" # 12 chars
text = self._auth_str(token, scheme="Basic")
result = redact_sensitive_text(text)
assert token not in result
assert result.count(chr(34)) == text.count(chr(34))

# --- Layer 1-3: closing quote consumed by \S+ (long token) ---

def test_long_bearer_token_quote_preserved(self):
token = "dGhp...mc" # 28 chars — mask keeps head/tail
text = self._auth_str(token)
result = redact_sensitive_text(text)
assert token not in result
assert result.count(chr(34)) == text.count(chr(34))
assert result.endswith(chr(34))

# --- Layer 5: dict literal braces consumed by \S+ ---

def test_brace_after_token_preserved(self):
dq = chr(34)
token = "abcdefghijklmnop" # 16 chars
# Build: {X: "AUTHHEADER BEARER <token>"} without triggering redactor
hdr = "Authorization" + chr(58) + " Bearer "
text = "{X: " + dq + hdr + token + dq + "}"
result = redact_sensitive_text(text)
assert token not in result
assert result.endswith("}"), f"expected trailing brace, got: {result!r}"
assert result.count("}") == text.count("}")

# --- Layer 4: API-key style headers have the same \S+ problem ---

def test_x_api_key_short_token_quote_preserved(self):
token = "abcdefghijkl" # 12 chars
text = self._api_key_str(token)
result = redact_sensitive_text(text)
assert token not in result
assert result.count(chr(34)) == text.count(chr(34))

def test_api_key_long_token_quote_preserved(self):
token = "dGhp...mc"
text = self._api_key_str(token, header="api-key")
result = redact_sensitive_text(text)
assert token not in result
assert result.count(chr(34)) == text.count(chr(34))

# --- Redaction still works in plain log lines (no delimiters) ---

def test_log_line_still_redacts(self):
token = "dGhp...mc"
text = "Authorization: Bearer " + token
result = redact_sensitive_text(text)
assert token not in result
assert "Authorization: Bearer" in result

def test_api_key_log_line_still_redacts(self):
token = "dGhp...mc"
text = "x-api-key: " + token
result = redact_sensitive_text(text)
assert token not in result
assert "x-api-key:" in result

# --- Proxy-Authorization with trailing quote ---

def test_proxy_auth_quote_preserved(self):
token = "dXNlcjpsb25n"
text = self._auth_str(token, header="Proxy-Authorization")
result = redact_sensitive_text(text)
assert token not in result
assert result.count(chr(34)) == text.count(chr(34))

# --- code_file=True still needs AUTH redaction but must be syntax-safe ---

def test_code_file_mode_preserves_quotes(self):
token = "abcdefghijkl"
text = self._auth_str(token)
result = redact_sensitive_text(text, code_file=True)
assert token not in result, "AUTH redaction must still fire for code_file=True"
assert result.count(chr(34)) == text.count(chr(34)), \
"closing quote must be preserved even in code_file mode"


class TestApiKeyHeaders:
def test_x_api_key_header_masked(self):
text = "x-api-key: opaque-provider-key-1234567890"
Expand Down
Loading