Coverage for dataexcept/redaction.py: 100%
73 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-03 20:46 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-03 20:46 +0000
1"""Redaction helpers for values that must not reach a log.
3Several exceptions here are raised with credentials in hand: an authentication
4token, a database URL carrying a password, a webhook URL whose *path* is the
5secret. Those values end up in the exception message, and
6:func:`dataexcept.logging_helpers.log_exception` logs ``str(exc)``, so without
7redaction a failed delivery writes the credential to the log.
9The aim is to keep an error debuggable while giving up the secret. A redacted
10value carries a short, non-reversible fingerprint, so repeated failures of the
11*same* credential stay recognisable in a log without the credential appearing
12in it.
14What this can and cannot do is stated in ``SECURITY.md``. In short: values the
15library is *given* as credentials are redacted, and URLs are redacted wherever
16they appear -- including inside a message you supplied and inside the text of a
17wrapped exception. A bare, non-URL secret pasted into free-form text cannot be
18recognised and is not redacted.
19"""
21from __future__ import annotations
23import hashlib
24import re
25from typing import Optional
26from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
28__all__ = [
29 "fingerprint",
30 "redact_if_url",
31 "redact_secret",
32 "redact_url",
33 "redact_urls_in_text",
34 "remove_secret",
35]
37PLACEHOLDER = "***"
39#: Below this length a "secret" is not removed from free text. Substring
40#: replacement of a short value corrupts ordinary words -- removing "tok" from
41#: "Invalid authentication token" mangles the message and tells a reader
42#: nothing. The structured field is redacted regardless of length.
43MIN_REMOVABLE_SECRET_LENGTH = 8
45#: Parameter-name tokens that mark a value as a secret. A name is split into
46#: tokens on separators and camelCase boundaries, and matched token by token.
47#:
48#: Substring matching was tried first and was wrong in both directions: it
49#: redacted "monkey", "design", "assign", "keyword" and "authors" -- mangling
50#: ordinary debugging information -- while still missing "passphrase". The
51#: point of keeping host, port and path is that the error stays actionable, and
52#: shredding a legitimate query parameter works against that.
53#:
54#: Deliberately absent: "code", "state", "nonce" and "client_id". An OAuth
55#: authorization code is a secret, but "code" is far more often a country
56#: code, an HTTP status or a discount code, and redacting those would destroy
57#: more debugging information than it protects. Checked against the parameter
58#: names used by AWS SigV4, Azure SAS, Google Cloud and OAuth 2.
59SENSITIVE_PARAM_TOKENS = frozenset(
60 {
61 "apikey",
62 "auth",
63 "authorization",
64 "bearer",
65 "credential",
66 "credentials",
67 "hmac",
68 "jwt",
69 "key",
70 "keys",
71 "passphrase",
72 "passwd",
73 "password",
74 "pwd",
75 "sas",
76 "secret",
77 "secrets",
78 "session",
79 "sig",
80 "signature",
81 "token",
82 "tokens",
83 }
84)
86#: Splits a parameter name into words: on separators, and between a lower-case
87#: or digit character and an upper-case one, so ``accessToken`` yields
88#: ``["access", "token"]``.
89_NAME_TOKENS = re.compile(r"[A-Za-z0-9]+")
90_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
93def _tokens(name: str) -> list[str]:
94 words: list[str] = []
95 for chunk in _NAME_TOKENS.findall(name):
96 words.extend(part.lower() for part in _CAMEL_BOUNDARY.split(chunk) if part)
97 return words
100def _is_sensitive(name: str) -> bool:
101 return any(token in SENSITIVE_PARAM_TOKENS for token in _tokens(name))
104#: Finds URLs inside free-form text, so a credential cannot slip through in a
105#: caller-supplied message or in the text of a wrapped exception.
106# No word-boundary anchor: a URL can directly follow a word character, as
107# in the step name "feature_https://..." that FeaturePreprocessingError
108# builds. The scheme character class excludes "_", so a match still starts
109# at the scheme rather than mid-word.
110_URL_IN_TEXT = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s'\"<>,;)\]}]+")
113def fingerprint(value: str) -> str:
114 """Return a short, one-way fingerprint of *value*.
116 Enough to tell "the same bad token again" from "a different bad token",
117 and not enough to recover the token.
118 """
119 digest = hashlib.sha256(value.encode("utf-8", "replace")).hexdigest()
120 return digest[:8]
123def redact_secret(value: Optional[str]) -> Optional[str]:
124 """Replace a secret with a placeholder and its fingerprint."""
125 if value is None:
126 return None
127 if not value:
128 return PLACEHOLDER
129 return f"{PLACEHOLDER}({fingerprint(value)})"
132def remove_secret(text: str, secret: Optional[str]) -> str:
133 """Replace every occurrence of a known *secret* in *text*.
135 Used where the library was handed the secret explicitly, so it can be
136 removed even from a message the caller wrote themselves.
137 """
138 if not secret or not text or len(secret) < MIN_REMOVABLE_SECRET_LENGTH:
139 return text
140 return text.replace(secret, f"{PLACEHOLDER}({fingerprint(secret)})")
143def _redact_params(query: str) -> tuple[str, bool]:
144 if not query:
145 return query, False
146 pairs = parse_qsl(query, keep_blank_values=True)
147 if not any(_is_sensitive(key) for key, _ in pairs):
148 return query, False
149 return (
150 urlencode(
151 [
152 (key, PLACEHOLDER if _is_sensitive(key) else value)
153 for key, value in pairs
154 ],
155 # Keep the placeholder legible rather than percent-encoded.
156 safe="*",
157 ),
158 True,
159 )
162def redact_url(url: Optional[str], *, keep_path: bool = True) -> Optional[str]:
163 """Strip credentials from *url*.
165 Scheme, host and port are always kept: those are what make an error
166 actionable. Userinfo, sensitive query parameters and sensitive fragment
167 parameters are always removed.
169 Pass ``keep_path=False`` where the path itself is the credential. An
170 incoming webhook URL is the common case -- Slack, Discord and others put
171 the secret in the path, so preserving it would defeat the point.
172 """
173 if not url or not isinstance(url, str):
174 # Anything that is not a string is handed back untouched. urlsplit
175 # would raise AttributeError from inside urllib, masking whatever the
176 # caller's real mistake was with a message about `.decode`.
177 return url
179 try:
180 parts = urlsplit(url)
181 except ValueError: # pragma: no cover - urlsplit is extremely permissive
182 return PLACEHOLDER
184 if not parts.scheme or not parts.netloc:
185 # Not a URL with a host; a bare path or plain string is returned
186 # untouched rather than mangled.
187 return url
189 redacted = False
191 netloc = parts.netloc
192 if "@" in netloc:
193 _, _, host = netloc.rpartition("@")
194 netloc = f"{PLACEHOLDER}:{PLACEHOLDER}@{host}"
195 redacted = True
197 query, query_redacted = _redact_params(parts.query)
198 redacted = redacted or query_redacted
200 fragment = parts.fragment
201 if "=" in fragment:
202 # OAuth implicit flow returns the token in the fragment.
203 fragment, fragment_redacted = _redact_params(fragment)
204 redacted = redacted or fragment_redacted
206 path = parts.path
207 if not keep_path and path.strip("/"):
208 path = f"/{PLACEHOLDER}"
209 redacted = True
211 if not redacted:
212 # Hand back exactly what was passed in. Rebuilding would normalise it,
213 # and "sqlite://" loses its slashes on the way through.
214 return url
216 return urlunsplit((parts.scheme, netloc, path, query, fragment))
219def redact_if_url(value: Optional[str], *, keep_path: bool = True) -> Optional[str]:
220 """Redact *value* only if it is a URL, leaving file paths untouched.
222 Fields such as ``DataLoadingError.source`` document themselves as "file
223 path or URL", so they cannot be redacted unconditionally without mangling
224 ordinary paths.
225 """
226 if not isinstance(value, str) or "://" not in value:
227 return value
228 return redact_url(value, keep_path=keep_path)
231def redact_urls_in_text(text: str, *, keep_path: bool = True) -> str:
232 """Redact every URL found in free-form *text*.
234 This is the boundary that stops a secret being reintroduced after the
235 structured argument was redacted -- through a caller-supplied ``message``,
236 or through the text of a wrapped exception that quotes the original URL.
237 """
238 if not text or "://" not in text:
239 return text
240 return _URL_IN_TEXT.sub(
241 lambda match: redact_url(match.group(0), keep_path=keep_path) or "",
242 text,
243 )