Skip to content

Commit 007adfd

Browse files
vdavezclaude
andcommitted
Redact sensitive values (API keys, tokens) in sanitized error details
The initial sanitization only truncated long messages. This adds regex-based redaction of credential patterns (api_key=, token=, secret=, password=, authorization:, bearer, credential) so they never appear in exception messages or logs. Also sanitizes httpx.HTTPError messages which can contain URLs with key query params. Raw response_data is preserved for programmatic access. Adds 4 tests covering redaction behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 98b8a56 commit 007adfd

File tree

2 files changed

+79
-2
lines changed

2 files changed

+79
-2
lines changed

tango/client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tango API Client"""
22

33
import os
4+
import re
45
from datetime import date, datetime
56
from decimal import Decimal
67
from typing import Any
@@ -143,14 +144,24 @@ def _int_or_none(val: str | None) -> int | None:
143144
burst_reset=_int_or_none(headers.get("X-RateLimit-Burst-Reset")),
144145
)
145146

147+
_SENSITIVE_PATTERNS = re.compile(
148+
r"""
149+
(?:api[_-]?key|token|secret|password|authorization|bearer|credential)
150+
\s*[=:]\s*\S+
151+
""",
152+
re.IGNORECASE | re.VERBOSE,
153+
)
154+
146155
@staticmethod
147156
def _sanitize_error_detail(detail: Any, max_length: int = 200) -> str:
148157
"""Sanitize an error detail from an API response for safe inclusion in exception messages.
149158
150-
Truncates long messages to prevent information disclosure in logs
159+
Redacts values that look like API keys, tokens, or credentials, and
160+
truncates long messages to prevent information disclosure in logs
151161
and error tracking systems.
152162
"""
153163
text = str(detail)
164+
text = TangoClient._SENSITIVE_PATTERNS.sub("[REDACTED]", text)
154165
if len(text) > max_length:
155166
text = text[:max_length] + "..."
156167
return text
@@ -211,7 +222,9 @@ def _request(
211222
return response.json() if response.content else {}
212223

213224
except httpx.HTTPError as e:
214-
raise TangoAPIError(f"Request failed: {str(e)}") from e
225+
raise TangoAPIError(
226+
f"Request failed: {self._sanitize_error_detail(e)}"
227+
) from e
215228

216229
def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
217230
"""Make a GET request"""

tests/test_client.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,6 +1336,70 @@ def test_network_error(self, mock_request):
13361336

13371337
assert "Connection failed" in str(exc_info.value)
13381338

1339+
@patch("tango.client.httpx.Client.request")
1340+
def test_400_error_redacts_api_key_in_detail(self, mock_request):
1341+
"""Test that API keys echoed in error details are redacted"""
1342+
mock_response = Mock()
1343+
mock_response.is_success = False
1344+
mock_response.status_code = 400
1345+
mock_response.content = b'{"detail": "Invalid api_key=saacp7h_WW__nZb3zaXJCPV4zRcbvhmBhHFrJxQLxdc"}'
1346+
mock_response.json.return_value = {
1347+
"detail": "Invalid api_key=saacp7h_WW__nZb3zaXJCPV4zRcbvhmBhHFrJxQLxdc"
1348+
}
1349+
mock_response.headers = {}
1350+
mock_request.return_value = mock_response
1351+
1352+
client = TangoClient(api_key="test-key")
1353+
1354+
with pytest.raises(TangoValidationError) as exc_info:
1355+
client.list_agencies()
1356+
1357+
assert "saacp7h" not in str(exc_info.value)
1358+
assert "[REDACTED]" in str(exc_info.value)
1359+
# Raw response_data still has the original for programmatic access
1360+
assert "saacp7h" in exc_info.value.response_data["detail"]
1361+
1362+
@patch("tango.client.httpx.Client.request")
1363+
def test_429_error_redacts_sensitive_values(self, mock_request):
1364+
"""Test that sensitive values in rate limit error details are redacted"""
1365+
mock_response = Mock()
1366+
mock_response.is_success = False
1367+
mock_response.status_code = 429
1368+
mock_response.content = b'{"detail": "Rate limited. token=sk-abc123secret"}'
1369+
mock_response.json.return_value = {
1370+
"detail": "Rate limited. token=sk-abc123secret"
1371+
}
1372+
mock_response.headers = {}
1373+
mock_request.return_value = mock_response
1374+
1375+
client = TangoClient(api_key="test-key")
1376+
1377+
with pytest.raises(TangoRateLimitError) as exc_info:
1378+
client.list_agencies()
1379+
1380+
assert "sk-abc123secret" not in str(exc_info.value)
1381+
assert "[REDACTED]" in str(exc_info.value)
1382+
1383+
def test_sanitize_error_detail_truncates_long_messages(self):
1384+
"""Test that very long error details are truncated"""
1385+
long_detail = "A" * 300
1386+
result = TangoClient._sanitize_error_detail(long_detail)
1387+
assert len(result) == 203 # 200 + "..."
1388+
assert result.endswith("...")
1389+
1390+
def test_sanitize_error_detail_redacts_credentials(self):
1391+
"""Test that various credential patterns are redacted"""
1392+
cases = [
1393+
("Invalid api_key=abc123", "[REDACTED]"),
1394+
("Bad token: secretvalue", "[REDACTED]"),
1395+
("Error: password=hunter2", "[REDACTED]"),
1396+
("Failed authorization: Bearer xyz", "[REDACTED]"),
1397+
("secret=mysecretvalue in request", "[REDACTED]"),
1398+
]
1399+
for input_text, expected_replacement in cases:
1400+
result = TangoClient._sanitize_error_detail(input_text)
1401+
assert expected_replacement in result, f"Failed to redact: {input_text}"
1402+
13391403
@patch("tango.client.httpx.Client.request")
13401404
def test_empty_response_content(self, mock_request):
13411405
"""Test handling of empty response content"""

0 commit comments

Comments
 (0)