Skip to content

Commit 495caec

Browse files
ablaszkiewiczclaude
andcommitted
fix: mask authorization credentials and signed urls in code variables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MxaTpjkJ3QxjaCSrYxXFHW
1 parent 2273c7a commit 495caec

4 files changed

Lines changed: 103 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
Mask `Bearer` and `Basic` credentials in exception code variables, including values held apart from their header name, such as in header lists and ASGI scopes. Also mask signed URLs that carry a `sig` query parameter.

‎posthog/exception_utils.py‎

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@
6767
r"(?i)conn_str",
6868
r"(?i)connstr",
6969
r"(?i)dsn",
70+
# The signature parameter of a signed URL, such as an Azure SAS URL. It is matched
71+
# as a query parameter, because the bare word `sig` occurs in common names.
72+
r"(?i)[?&]sig=",
7073
]
7174

7275
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
@@ -124,6 +127,39 @@ def _redact_url_credentials(value):
124127
)
125128

126129

130+
# Matches the credential of an HTTP `Authorization` value, e.g. `Bearer <token>` or
131+
# `Basic <base64 user:pass>`. A header pair list or an ASGI scope holds this value apart
132+
# from its header name, so the name patterns never see it.
133+
_AUTH_HEADER_CREDENTIALS_RE = re.compile(
134+
r"\b(bearer|basic)(\s+)([A-Za-z0-9._~+/-]+=*)", re.IGNORECASE
135+
)
136+
137+
# Shorter values, and lowercase words, are prose such as "basic authentication".
138+
_AUTH_HEADER_CREDENTIAL_MIN_LENGTH = 8
139+
140+
141+
def _redact_auth_header_match(match):
142+
credential = match.group(3)
143+
if len(credential) < _AUTH_HEADER_CREDENTIAL_MIN_LENGTH or (
144+
credential.isalpha() and credential.islower()
145+
):
146+
return match.group(0)
147+
return match.group(1) + match.group(2) + CODE_VARIABLES_REDACTED_VALUE
148+
149+
150+
def _redact_auth_header_credentials(value):
151+
return _AUTH_HEADER_CREDENTIALS_RE.sub(_redact_auth_header_match, value)
152+
153+
154+
def _redact_embedded_credentials(value, config):
155+
"""Scrub credentials embedded in otherwise safe text: `Authorization` values always,
156+
and URL credentials when that toggle is on."""
157+
value = _redact_auth_header_credentials(value)
158+
if config.mask_url_credentials:
159+
value = _redact_url_credentials(value)
160+
return value
161+
162+
127163
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 10 * 1024
128164

129165

@@ -1270,16 +1306,14 @@ def _looks_like_secret(value):
12701306

12711307
def _mask_string(value, config):
12721308
"""Apply the string masking policy: over-length cap, name/value patterns,
1273-
entropy-based secret detection, then embedded URL credentials."""
1309+
entropy-based secret detection, then embedded `Authorization` and URL credentials."""
12741310
if len(value) > _MAX_VALUE_LENGTH_FOR_PATTERN_MATCH:
12751311
return CODE_VARIABLES_TOO_LONG_VALUE
12761312
if _matcher_matches(value, config.mask):
12771313
return CODE_VARIABLES_REDACTED_VALUE
12781314
if config.detect_secrets and _looks_like_secret(value):
12791315
return CODE_VARIABLES_REDACTED_VALUE
1280-
if config.mask_url_credentials:
1281-
return _redact_url_credentials(value)
1282-
return value
1316+
return _redact_embedded_credentials(value, config)
12831317

12841318

12851319
def _safe_type_name(value):
@@ -1306,9 +1340,7 @@ def _safe_repr(value, config):
13061340
# A __repr__ that is itself a bare secret would otherwise bypass detection.
13071341
if config.detect_secrets and _looks_like_secret(rendered):
13081342
return CODE_VARIABLES_REDACTED_VALUE
1309-
if config.mask_url_credentials:
1310-
return _redact_url_credentials(rendered)
1311-
return rendered
1343+
return _redact_embedded_credentials(rendered, config)
13121344

13131345

13141346
def _extract_object_attrs(value):
@@ -1485,8 +1517,8 @@ def _mask_mapping(items, config, seen, depth):
14851517
out_key = _redacted_key(result)
14861518
elif not key_is_json_safe and _key_parts_fail_masking(key, config, seen, depth):
14871519
out_key = _redacted_key(result)
1488-
elif config.mask_url_credentials and isinstance(out_key, str):
1489-
out_key = _redact_url_credentials(out_key)
1520+
elif isinstance(out_key, str):
1521+
out_key = _redact_embedded_credentials(out_key, config)
14901522
if out_key in result:
14911523
# Two keys can end up with the same text, for example URLs that differ only in
14921524
# their credentials. A placeholder keeps the later entry from overwriting.

‎posthog/test/test_code_variables.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,50 @@ def test_strings_without_credentials_are_left_untouched(self, value):
238238
assert _redact_url_credentials(value) == value
239239

240240

241+
# --- 2b. Authorization credential scrubbing ------------------------------------------
242+
243+
_BEARER_TOKEN = _key("tok_", "Zx81Qm7Lp2Vb9Nc4")
244+
_BASIC_CREDENTIAL = _key("c3ZjOmZha2Ut", "cGFzcy0xMjM=") # svc:fake-pass-123
245+
246+
247+
class TestAuthorizationCredentialScrubbing:
248+
"""A `Bearer` or `Basic` credential is removed even when no header name sits next
249+
to it, so name patterns can't catch it. The scheme stays for context."""
250+
251+
@pytest.mark.parametrize(
252+
"value, credential",
253+
[
254+
# header pair list: the name is redacted, the value used to survive
255+
([("authorization", "Bearer " + _BEARER_TOKEN)], _BEARER_TOKEN),
256+
# ASGI scope: raw byte headers
257+
(
258+
{
259+
"headers": [
260+
(b"authorization", b"Basic " + _BASIC_CREDENTIAL.encode())
261+
]
262+
},
263+
_BASIC_CREDENTIAL,
264+
),
265+
# a local with a neutral name
266+
("Basic " + _BASIC_CREDENTIAL, _BASIC_CREDENTIAL),
267+
],
268+
)
269+
def test_credential_is_removed(self, value, credential):
270+
result = json.dumps(mask(value))
271+
assert credential not in result
272+
assert REDACTED in result
273+
274+
def test_scheme_is_kept(self):
275+
assert mask("Bearer " + _BEARER_TOKEN) == "Bearer " + REDACTED
276+
277+
@pytest.mark.parametrize(
278+
"value", ["basic authentication", "Bearer token", "the bearer of bad news"]
279+
)
280+
def test_prose_is_left_untouched(self, value):
281+
# name patterns off, so only the Authorization check can change the text
282+
assert mask(value, patterns=[]) == value
283+
284+
241285
# --- 3. scalar masking ---------------------------------------------------------------
242286

243287

@@ -288,6 +332,17 @@ def test_url_scrubbing_can_be_turned_off(self):
288332
result = mask("postgresql://user:p4ss@host/db", patterns=[], mask_urls=False)
289333
assert result == "postgresql://user:p4ss@host/db"
290334

335+
def test_signed_url_is_redacted(self):
336+
# an Azure SAS URL carries its signature in the `sig` query parameter
337+
url = "https://acct.blob.core.windows.net/c/f?sv=2022-11-02&sp=r&sig=" + _key(
338+
"q2VxT8", "fKz1aB3dE%3D"
339+
)
340+
assert mask(url) == REDACTED
341+
342+
@pytest.mark.parametrize("value", ["design", "signal_handler", "/signup?step=2"])
343+
def test_sig_inside_other_words_is_left_untouched(self, value):
344+
assert mask(value) == value
345+
291346

292347
# --- 5. collection masking -----------------------------------------------------------
293348

‎references/public_api_snapshot.txt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,7 +825,7 @@ attribute posthog.exception_utils.CODE_VARIABLES_REDACTED_VALUE = '$$_posthog_re
825825
attribute posthog.exception_utils.CODE_VARIABLES_TOO_LONG_VALUE = '$$_posthog_value_too_long_$$'
826826
attribute posthog.exception_utils.DEFAULT_CODE_VARIABLES_DETECT_SECRETS = True
827827
attribute posthog.exception_utils.DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = ['^__.*']
828-
attribute posthog.exception_utils.DEFAULT_CODE_VARIABLES_MASK_PATTERNS = ['(?i)password', '(?i)secret', '(?i)passwd', '(?i)pwd', '(?i)api_key', '(?i)apikey', '(?i)auth', '(?i)credentials', '(?i)privatekey', '(?i)private_key', '(?i)token', '(?i)aws_access_key_id', '(?i)_pass', '(?i)sk_', '(?i)jwt', '(?i)connection_string', '(?i)connectionstring', '(?i)conn_str', '(?i)connstr', '(?i)dsn']
828+
attribute posthog.exception_utils.DEFAULT_CODE_VARIABLES_MASK_PATTERNS = ['(?i)password', '(?i)secret', '(?i)passwd', '(?i)pwd', '(?i)api_key', '(?i)apikey', '(?i)auth', '(?i)credentials', '(?i)privatekey', '(?i)private_key', '(?i)token', '(?i)aws_access_key_id', '(?i)_pass', '(?i)sk_', '(?i)jwt', '(?i)connection_string', '(?i)connectionstring', '(?i)conn_str', '(?i)connstr', '(?i)dsn', '(?i)[?&]sig=']
829829
attribute posthog.exception_utils.DEFAULT_CODE_VARIABLES_MASK_URL_CREDENTIALS = True
830830
attribute posthog.exception_utils.DEFAULT_MAX_VALUE_LENGTH = 1024
831831
attribute posthog.exception_utils.DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 10 * 1024
@@ -1408,6 +1408,7 @@ function posthog.load_feature_flags()
14081408
function posthog.mcp.asgi.autowire_stateless_mint(server: Any) -> None
14091409
function posthog.mcp.asgi.get_mcp_session(request_or_scope: Any) -> Optional[SessionTokenPayload]
14101410
function posthog.mcp.feedback.send_feedback_result() -> Dict[str, Any]
1411+
function posthog.mcp.get_tool_input_properties(input_value: Any, input_schema: Any = None, options: Optional[ToolInputOptions] = None)
14111412
function posthog.mcp.instrument(server: Any, posthog_client: Optional[Client] = None, options: Optional[MCPAnalyticsOptions] = None) -> McpAnalytics
14121413
function posthog.mcp.logger.set_logger(logger: Optional[LoggerFn]) -> None
14131414
function posthog.mcp.request_headers.get_request_headers(extra: Any) -> Optional[RequestHeaderBag]

0 commit comments

Comments
 (0)