Skip to content

Commit e3cfe17

Browse files
fix: mask authorization credentials and signed urls in code variables (#1015)
1 parent caaa20f commit e3cfe17

4 files changed

Lines changed: 149 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: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
# 💖open source (under MIT License)
66
# We want to keep payloads as similar to Sentry as possible for easy interoperability
77

8+
import base64
9+
import binascii
810
import dataclasses
911
import functools
1012
import json
@@ -67,6 +69,9 @@
6769
r"(?i)conn_str",
6870
r"(?i)connstr",
6971
r"(?i)dsn",
72+
# The signature parameter of a signed URL, such as an Azure SAS URL. It is matched
73+
# as a query parameter, because the bare word `sig` occurs in common names.
74+
r"(?i)[?&]sig=",
7075
]
7176

7277
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
@@ -124,6 +129,63 @@ def _redact_url_credentials(value):
124129
)
125130

126131

132+
# Matches the credential of an HTTP `Authorization` value, e.g. `Bearer <token>` or
133+
# `Basic <base64 user:pass>`. A header pair list or an ASGI scope holds this value apart
134+
# from its header name, so the name patterns never see it. The separator also accepts a
135+
# colon or an opening quote, as in `Bearer: <token>`, but it must not be empty, so that
136+
# names such as `basicConfig` stay untouched.
137+
_AUTH_HEADER_CREDENTIALS_RE = re.compile(
138+
r"\b(bearer|basic)((?:\s*:\s*|\s+)['\"]?)([A-Za-z0-9._~+/-]+=*)", re.IGNORECASE
139+
)
140+
141+
# Shorter values are prose, such as "the bearer of", and so is a lowercase word of up to
142+
# 15 letters, such as "bearer transportation". A random lowercase token is longer than
143+
# that. A `Basic` credential of any length is still redacted when it decodes to
144+
# `user:password`, e.g. `YTpi` for `a:b`.
145+
_AUTH_HEADER_CREDENTIAL_MIN_LENGTH = 8
146+
_AUTH_HEADER_PROSE_WORD_MAX_LENGTH = 15
147+
148+
149+
def _is_basic_credential(credential):
150+
try:
151+
decoded = base64.b64decode(credential, validate=True).decode("utf-8")
152+
except (binascii.Error, ValueError):
153+
return False
154+
return ":" in decoded
155+
156+
157+
def _is_prose_word(credential):
158+
return (
159+
len(credential) <= _AUTH_HEADER_PROSE_WORD_MAX_LENGTH
160+
and credential.isalpha()
161+
and credential.islower()
162+
)
163+
164+
165+
def _redact_auth_header_match(match):
166+
scheme, credential = match.group(1), match.group(3)
167+
is_basic_pair = scheme.lower() == "basic" and _is_basic_credential(credential)
168+
if not is_basic_pair and (
169+
len(credential) < _AUTH_HEADER_CREDENTIAL_MIN_LENGTH
170+
or _is_prose_word(credential)
171+
):
172+
return match.group(0)
173+
return scheme + match.group(2) + CODE_VARIABLES_REDACTED_VALUE
174+
175+
176+
def _redact_auth_header_credentials(value):
177+
return _AUTH_HEADER_CREDENTIALS_RE.sub(_redact_auth_header_match, value)
178+
179+
180+
def _redact_embedded_credentials(value, config):
181+
"""Scrub credentials embedded in otherwise safe text: URL credentials when that toggle
182+
is on, then `Authorization` values always. URLs go first, because the `Authorization`
183+
pass can consume a URL scheme, as in `Bearer postgresql://user:pass@host`."""
184+
if config.mask_url_credentials:
185+
value = _redact_url_credentials(value)
186+
return _redact_auth_header_credentials(value)
187+
188+
127189
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 10 * 1024
128190

129191

@@ -1270,16 +1332,14 @@ def _looks_like_secret(value):
12701332

12711333
def _mask_string(value, config):
12721334
"""Apply the string masking policy: over-length cap, name/value patterns,
1273-
entropy-based secret detection, then embedded URL credentials."""
1335+
entropy-based secret detection, then embedded `Authorization` and URL credentials."""
12741336
if len(value) > _MAX_VALUE_LENGTH_FOR_PATTERN_MATCH:
12751337
return CODE_VARIABLES_TOO_LONG_VALUE
12761338
if _matcher_matches(value, config.mask):
12771339
return CODE_VARIABLES_REDACTED_VALUE
12781340
if config.detect_secrets and _looks_like_secret(value):
12791341
return CODE_VARIABLES_REDACTED_VALUE
1280-
if config.mask_url_credentials:
1281-
return _redact_url_credentials(value)
1282-
return value
1342+
return _redact_embedded_credentials(value, config)
12831343

12841344

12851345
def _safe_type_name(value):
@@ -1306,9 +1366,7 @@ def _safe_repr(value, config):
13061366
# A __repr__ that is itself a bare secret would otherwise bypass detection.
13071367
if config.detect_secrets and _looks_like_secret(rendered):
13081368
return CODE_VARIABLES_REDACTED_VALUE
1309-
if config.mask_url_credentials:
1310-
return _redact_url_credentials(rendered)
1311-
return rendered
1369+
return _redact_embedded_credentials(rendered, config)
13121370

13131371

13141372
def _extract_object_attrs(value):
@@ -1485,8 +1543,8 @@ def _mask_mapping(items, config, seen, depth):
14851543
out_key = _redacted_key(result)
14861544
elif not key_is_json_safe and _key_parts_fail_masking(key, config, seen, depth):
14871545
out_key = _redacted_key(result)
1488-
elif config.mask_url_credentials and isinstance(out_key, str):
1489-
out_key = _redact_url_credentials(out_key)
1546+
elif isinstance(out_key, str):
1547+
out_key = _redact_embedded_credentials(out_key, config)
14901548
if out_key in result:
14911549
# Two keys can end up with the same text, for example URLs that differ only in
14921550
# their credentials. A placeholder keeps the later entry from overwriting.

‎posthog/test/test_code_variables.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,70 @@ 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+
# a short Basic credential: `YTpi` is `a:b`
268+
("Basic YTpi", "YTpi"),
269+
# a credential of lowercase letters only
270+
("Bearer " + _key("zqwklmno", "pxyzrstu"), _key("zqwklmno", "pxyzrstu")),
271+
# a DSN after the scheme keeps its own credential redacted
272+
(
273+
"Bearer postgresql://alice:" + _key("sunfl", "ower99") + "@db/app",
274+
_key("sunfl", "ower99"),
275+
),
276+
# a colon or a quote between the scheme and the credential
277+
("Bearer: " + _BEARER_TOKEN, _BEARER_TOKEN),
278+
("Bearer '" + _BEARER_TOKEN + "'", _BEARER_TOKEN),
279+
],
280+
)
281+
def test_credential_is_removed(self, value, credential):
282+
result = json.dumps(mask(value))
283+
assert credential not in result
284+
assert REDACTED in result
285+
286+
def test_scheme_is_kept(self):
287+
assert mask("Bearer " + _BEARER_TOKEN) == "Bearer " + REDACTED
288+
289+
@pytest.mark.parametrize(
290+
"value",
291+
[
292+
"basicConfig(level=10)",
293+
"basic auth",
294+
"basic: configuration",
295+
"Bearer token",
296+
"bearer transportation",
297+
"the bearer of bad news",
298+
],
299+
)
300+
def test_prose_is_left_untouched(self, value):
301+
# name patterns off, so only the Authorization check can change the text
302+
assert mask(value, patterns=[]) == value
303+
304+
241305
# --- 3. scalar masking ---------------------------------------------------------------
242306

243307

@@ -288,6 +352,17 @@ def test_url_scrubbing_can_be_turned_off(self):
288352
result = mask("postgresql://user:p4ss@host/db", patterns=[], mask_urls=False)
289353
assert result == "postgresql://user:p4ss@host/db"
290354

355+
def test_signed_url_is_redacted(self):
356+
# an Azure SAS URL carries its signature in the `sig` query parameter
357+
url = "https://acct.blob.core.windows.net/c/f?sv=2022-11-02&sp=r&sig=" + _key(
358+
"q2VxT8", "fKz1aB3dE%3D"
359+
)
360+
assert mask(url) == REDACTED
361+
362+
@pytest.mark.parametrize("value", ["design", "signal_handler", "/signup?step=2"])
363+
def test_sig_inside_other_words_is_left_untouched(self, value):
364+
assert mask(value) == value
365+
291366

292367
# --- 5. collection masking -----------------------------------------------------------
293368

‎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)