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
810import dataclasses
911import functools
1012import json
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
7277DEFAULT_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+
127189DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 10 * 1024
128190
129191
@@ -1270,16 +1332,14 @@ def _looks_like_secret(value):
12701332
12711333def _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
12851345def _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
13141372def _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.
0 commit comments