Coverage for src/local_deep_research/security/log_sanitizer.py: 93%

1592 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Sanitize raw strings before writing them to log output. 

2 

3``data_sanitizer.py`` handles dict-key redaction (e.g. stripping API keys 

4from structured data by key name). This module handles different 

5concerns: 

6 

7* :func:`strip_control_chars` / :func:`sanitize_for_log` \u2014 make a single 

8 string value safe to include in a log line by removing non-printable 

9 characters and truncating to a reasonable length. 

10 :func:`redact_and_bound_for_log` redacts credential shapes before it 

11 truncates, for a value whose cut could otherwise hide a credential from 

12 the log patcher's redaction. 

13* :func:`redact_secrets` \u2014 scrub known sensitive *values* (API keys, 

14 passwords, session tokens) from an arbitrary string before it is 

15 logged, returned in an error message, or persisted. 

16* :func:`sanitize_error_details` \u2014 recurse a structured ``details`` value 

17 and redact credential *shapes* from its string leaves before it is 

18 serialized to a client. (Value-shape based; contrast 

19 ``data_sanitizer.DataSanitizer``, which redacts by key *name*.) 

20 

21Threat model of the log-record redaction (:func:`redact_log_record`): 

22exception classes and the code that logs are application code, and are 

23trusted; the *data* inside exceptions -- messages, ``args``, fields and 

24notes built from upstream responses, URLs or user input -- and bound 

25``extra`` values are not. Credential-shaped data in exception text, 

26``args``, notes and ``extra`` (known key and token shapes, URL userinfo, 

27sensitive ``name=value`` pairs, and ``extra`` values under sensitive key 

28names) is redacted before any sink renders it, and every record is 

29picklable by an ``enqueue=True`` sink. Redaction matches shapes, not 

30meaning: a secret in another form passes through, such as an arbitrary 

31application-specific ``name: value`` pair with a colon, or a bare password 

32or token with no label and no known shape. Nothing is claimed about an 

33exception class or object deliberately written to defeat the redactor: 

34metaclass, descriptor, ``__class__`` or ``__dict__`` hooks, a ``__str__`` 

35with side effects on other exceptions, a class registered in 

36:mod:`builtins` under its own name, and the like. Such code can leak a 

37secret in many other ways anyway. The checks below that reject such 

38classes (rebuilding the exception from its checked text) are defence in 

39depth, not a complete barrier; where a docstring says a check or a step 

40"runs no code", that holds for exception classes and objects that do not 

41hook those mechanisms. 

42""" 

43 

44import builtins 

45import collections.abc 

46import contextlib 

47import contextvars 

48import copyreg 

49import bisect 

50import dataclasses 

51import functools 

52import itertools 

53import operator 

54import re 

55import sys 

56import types 

57from importlib.machinery import ModuleSpec 

58from multiprocessing.reduction import ForkingPickler 

59from typing import Any, Callable, Iterable, NamedTuple, Optional, Union 

60 

61from .data_sanitizer import ( 

62 DataSanitizer, 

63 REDACTION_TEXT, 

64 _EXEMPT_QUALIFIER_LEAF, 

65) 

66 

67 

68# Strip C0/C1 control characters and dangerous Unicode format characters, 

69# but preserve visible Unicode (accented, CJK, emoji, etc.) 

70_UNSAFE_CHAR_RE = re.compile( 

71 r"[\x00-\x1f\x7f-\x9f" # C0/C1 control chars 

72 r"\u061c" # Arabic letter mark 

73 r"\u200b-\u200f" # Zero-width chars + LTR/RTL marks 

74 r"\u202a-\u202e" # Embedding/override (incl. RLO) 

75 r"\u2028\u2029" # Line/paragraph separators — forced breaks in rendered 

76 # HTML per CSS Text, so a log line carrying one can forge what looks 

77 # like a separate entry even though re and str.splitlines() ignore them 

78 r"\u2060-\u2064" # Word joiner + math invisible operators 

79 r"\u2066-\u2069" # Isolate chars 

80 r"\u206a-\u206f" # Digit shape controls 

81 r"\ufeff" # BOM / zero-width no-break space 

82 r"]" 

83) 

84 

85# Default minimum length for a value to be considered a redactable secret. 

86# Values shorter than this are skipped because a literal ``str.replace`` on 

87# a short string would produce false positives in normal message content 

88# (e.g. redacting the 3-char string ``key`` would scrub the word "key" 

89# everywhere it appears). 

90_MIN_SECRET_LENGTH = 8 

91 

92# Replacement token written in place of any redacted secret. 

93_REDACTION_TOKEN = "***REDACTED***" # noqa: S105 # gitleaks:allow 

94 

95 

96def strip_control_chars(value: str) -> str: 

97 """Remove control and format characters from *value*, preserving visible Unicode.""" 

98 return _UNSAFE_CHAR_RE.sub("", value) 

99 

100 

101def sanitize_log_record(record) -> None: 

102 """loguru patcher stripping control characters from a record's message. 

103 

104 Control-character strip only. The web and MCP processes both install 

105 :func:`redact_log_record` instead, which does this strip as part of its 

106 message redaction and also redacts credentials. 

107 """ 

108 record["message"] = strip_control_chars(record["message"]) 

109 

110 

111def sanitize_for_log(value: str, max_length: int = 50) -> str: 

112 """Return a log-safe version of *value*. 

113 

114 * Control and format characters are stripped; valid Unicode is preserved. 

115 * The result is truncated to *max_length* characters. 

116 """ 

117 cleaned = strip_control_chars(value) 

118 if len(cleaned) > max_length: 

119 cleaned = ( 

120 cleaned[: max_length - 3] + "..." 

121 if max_length > 3 

122 else cleaned[:max_length] 

123 ) 

124 return cleaned 

125 

126 

127def redact_secrets( 

128 message: str, 

129 *secrets: Optional[str], 

130 min_length: int = _MIN_SECRET_LENGTH, 

131 replacement: str = _REDACTION_TOKEN, 

132) -> str: 

133 """Replace each occurrence of any *secret* in *message* with *replacement*. 

134 

135 Use this before writing a string to a log sink, returning it in an 

136 error response, or persisting it \u2014 when the string may have been 

137 constructed from upstream exception messages, URLs, or other 

138 sources that could contain a value the caller already knows is 

139 sensitive. 

140 

141 Each *secret* is matched as a literal substring (``str.replace``). 

142 The function does not normalize encodings: if a secret appears 

143 URL-encoded or otherwise transformed in *message*, the transformed 

144 form is NOT redacted unless the caller also passes that 

145 transformed form. 

146 

147 When multiple secrets are passed, they are applied in descending 

148 length order so a shorter secret that happens to be a substring of 

149 a longer one cannot consume part of the longer match. Example: 

150 given secrets ``"abc12345"`` and ``"sk-abc12345"``, the longer one 

151 is replaced first. 

152 

153 Args: 

154 message: The string to scrub (a ``str`` subclass is scrubbed, and 

155 returned, as a plain ``str``). Returned unchanged if it is 

156 empty or a falsy non-string. 

157 *secrets: Zero or more candidate secret values. ``None`` and 

158 values shorter than *min_length* are silently skipped \u2014 the 

159 caller is responsible for noticing missing config. 

160 min_length: Minimum secret length to redact. Values shorter than 

161 this are skipped to avoid corrupting normal message content 

162 (a 1- or 2-character secret would match too aggressively). 

163 Defaults to 8. Real API keys and session tokens are 

164 typically 16+ characters. 

165 replacement: String written in place of each redacted secret. 

166 Defaults to ``"***REDACTED***"``. 

167 

168 Returns: 

169 *message* with every occurrence of each qualifying secret 

170 replaced. 

171 

172 See ``tests/security/test_log_sanitizer.py::TestRedactSecrets`` for 

173 worked examples (doctest examples are constrained because the 

174 repository's gitleaks rule flags token-shaped literals in 

175 docstrings; ``.gitleaks.toml`` exempts only one audited historical 

176 example value in this file, while other credential literals and 

177 token-shaped values remain scanned). 

178 """ 

179 message = _plain_text(message) 

180 if not message: 

181 return message 

182 # Longest-first prevents a shorter overlapping secret from 

183 # truncating a longer one once the replacement token is in place. 

184 ordered = sorted( 

185 (s for s in secrets if s and len(s) >= min_length), 

186 key=len, 

187 reverse=True, 

188 ) 

189 for secret in ordered: 

190 message = message.replace(secret, replacement) 

191 return message 

192 

193 

194# Pre-compiled regex patterns for common credential formats found in HTTP 

195# library exception messages. Used by sanitize_error_message(). 

196# 

197# Order matters: the URL-credentials pattern must run BEFORE the URL-param 

198# pattern. Otherwise an input like ``?api-key=https://user:pass@host`` gets 

199# its ``https`` consumed by the param replacement, the credentials pattern 

200# no longer matches, and ``user:pass`` leaks. 

201_CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [ 

202 # Bearer tokens 

203 (re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*"), "Bearer [REDACTED]"), 

204 # Authorization header WITH an explicit scheme. The scheme word is a 

205 # strong anchor that rules out prose, so redact the credential on length 

206 # alone (>=8 chars) regardless of its shape — this catches even an 

207 # all-alphabetic Basic/Digest value. The scheme is preserved for 

208 # debuggability. (A short prose word after a scheme is rare and would 

209 # only be over-redacted, never leaked.) 

210 ( 

211 re.compile( 

212 r"(?i)(authorization\s*[:=]\s*)" 

213 r"(basic|bearer|digest|negotiate|apikey|token)\s+" 

214 r"[A-Za-z0-9\-._~+/]{8,}=*" 

215 ), 

216 r"\1\2 [REDACTED]", 

217 ), 

218 # Authorization header WITHOUT a scheme — here the value could be prose 

219 # ("Authorization: required"), so require a *token-shaped* value (>=8 

220 # chars containing a digit/+///=/_) to catch a raw token while leaving 

221 # all-alphabetic prose intact. 

222 ( 

223 re.compile( 

224 r"(?i)(authorization\s*[:=]\s*)" 

225 r"(?=[A-Za-z0-9\-._~+/]*[0-9+/=_])" 

226 r"[A-Za-z0-9\-._~+/]{8,}=*" 

227 ), 

228 r"\1[REDACTED]", 

229 ), 

230 # x-api-key header — the label is a strong anchor (it doesn't appear in 

231 # ordinary prose), so redact any sufficiently long value (>=16 chars) 

232 # regardless of shape. Short values like "invalid"/"missing" stay intact. 

233 ( 

234 re.compile(r"(?i)(x-api-key\s*[:=]\s*)[A-Za-z0-9\-._~+/]{16,}=*"), 

235 r"\1[REDACTED]", 

236 ), 

237 # URL credentials (user:pass@host) in ANY URL scheme. Userinfo in a URL is 

238 # always a credential, so this is not restricted to http(s): it also covers 

239 # URL-form database connection strings — including SQLAlchemy's 

240 # ``dialect+driver`` form (``postgresql+psycopg2://``, ``mysql+pymysql://``, 

241 # ``mongodb+srv://``) and password-only DSNs (``redis://:pass@host``) — 

242 # which are the most common credential-bearing strings in a raw DB/driver 

243 # exception message. (Key=value DSNs like pyodbc's ``Server=...;Pwd=...`` 

244 # have no ``://`` and are out of scope for a userinfo regex.) 

245 # 

246 # The scheme is matched case-insensitively (URL schemes are case-insensitive 

247 # per RFC 3986). NOTE: do NOT re-add a leading ``\b`` here — the scheme's 

248 # first char is a word char, so ``\b`` fails to anchor when the URL is glued 

249 # to a preceding word char (``Xhttps://user:pass@``) and the credential then 

250 # leaks. The ``://`` literal plus the leading-letter requirement are already 

251 # strong anchors against prose. The trailing ``@`` is required and ``/`` is 

252 # excluded from the userinfo, so a credential-less DSN with a port 

253 # (``postgresql://host:5432/db``) does not match. 

254 ( 

255 re.compile( 

256 r"([A-Za-z][A-Za-z0-9+.\-]{1,31}://)([^:\s/@]*):([^@\s/]+)@" 

257 ), 

258 r"\1[REDACTED]:[REDACTED]@", 

259 ), 

260 # Credential-bearing URL query parameters (?api_key=..., &access_token=...). 

261 # Specific multi-word names precede the short catch-alls so the full 

262 # parameter name is matched (e.g. ``secret_key`` not just ``secret``). 

263 ( 

264 re.compile( 

265 r"(?i)([?&])(" 

266 r"api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|" 

267 r"auth[_-]?token|session[_-]?token|secret[_-]?key|bearer[_-]?token|" 

268 r"subscription[_-]?key|client[_-]?secret|api[_-]?secret|" 

269 r"app[_-]?secret|private[_-]?key|" 

270 r"key|token|secret|password|passwd|pwd" 

271 r")=([^&\s#]+)" 

272 ), 

273 r"\1\2=[REDACTED]", 

274 ), 

275 # Common API key prefixes (sk-*, pk-*) — includes hyphens for modern 

276 # formats like sk-proj-... and sk-ant-api03-... 

277 (re.compile(r"\b(sk-[A-Za-z0-9\-]{20,})\b"), "[REDACTED_KEY]"), 

278 (re.compile(r"\b(pk-[A-Za-z0-9\-]{20,})\b"), "[REDACTED_KEY]"), 

279 # Google API keys (AIza...) — match generously to cover length variants 

280 # while the 20-char floor avoids short false positives. 

281 (re.compile(r"\bAIza[0-9A-Za-z\-_]{20,}\b"), "[REDACTED_KEY]"), 

282 # Distinctive provider token prefixes. These mirror the canonical, 

283 # actively-maintained gitleaks ruleset (https://github.com/gitleaks/ 

284 # gitleaks, config/gitleaks.toml) — refresh from there when new token 

285 # formats appear. They are prefix-anchored (very low false-positive risk 

286 # in prose); the dual-scrub redact_secrets(known_literal) path remains the 

287 # backstop for arbitrary/unknown secret shapes. See 

288 # docs/developing/credential-scrubbing.md for the maintenance process. 

289 # GitHub tokens: ghp_/gho_/ghu_/ghs_/ghr_ + fine-grained PATs. 

290 (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "[REDACTED_KEY]"), 

291 (re.compile(r"\bgithub_pat_[A-Za-z0-9_]{30,}\b"), "[REDACTED_KEY]"), 

292 # AWS access key IDs (AKIA/ASIA/ABIA/ACCA/A3T...). 

293 # Accepted false positive (over-redaction is the safe failure): a 

294 # contiguous 20-char all-caps word starting with one of these prefixes 

295 # is redacted. Mirrors gitleaks exactly. 

296 ( 

297 re.compile(r"\b(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b"), 

298 "[REDACTED_KEY]", 

299 ), 

300 # Slack tokens: xox[baeprs]-* (bot/user/app/refresh/config-refresh/...) 

301 # and app-level xapp-* tokens. The ``xapp``/``xox`` prefixes are generic 

302 # enough to appear in hyphenated identifiers, so (unlike the distinctive 

303 # GitHub/AWS prefixes) we additionally require the long numeric workspace 

304 # ID that every real Slack token carries (gitleaks expects ``[0-9]{10,13}`` 

305 # segments). This keeps real tokens redacted while leaving prose such as 

306 # ``xapp-release-notes-2026`` intact. 

307 # 

308 # Linear-time form (these patterns run on every log record through 

309 # redact_log_message): the naive ``\bxox[baeprs]-(?=[A-Za-z0-9-]*...)`` 

310 # lets EVERY ``xoxb-`` inside one ``[A-Za-z0-9-]`` run start an attempt 

311 # whose lookahead rescans the rest of the run, so ``"xoxb-" * n`` is 

312 # O(n^2) (measured: 10 KB 0.3 s, 20 KB 1.2 s). Every start inside the 

313 # same run sees a suffix of what the run's leftmost ``\bxox`` start sees 

314 # (same run end, same later digits, fewer characters), so if any start 

315 # in a run matches, the leftmost one does. Attempts therefore begin only 

316 # at a run start (the lookbehind) and commit, atomically, to the first 

317 # ``\bxox*-`` in the run; the run prefix before it is captured and kept. 

318 # Same matches as the naive form, one scan per run. 

319 ( 

320 re.compile( 

321 r"(?<![A-Za-z0-9-])(?>([A-Za-z0-9-]*?)\bxox[baeprs]-)" 

322 r"(?=[A-Za-z0-9-]*[0-9]{9,})[A-Za-z0-9-]{10,}\b" 

323 ), 

324 r"\1[REDACTED_KEY]", 

325 ), 

326 ( 

327 re.compile( 

328 r"(?<![A-Za-z0-9-])(?>([A-Za-z0-9-]*?)\bxapp-)" 

329 r"(?=[A-Za-z0-9-]*[0-9]{9,})[A-Za-z0-9-]{10,}\b" 

330 ), 

331 r"\1[REDACTED_KEY]", 

332 ), 

333 # Google OAuth access tokens (ya29...). 

334 (re.compile(r"\bya29\.[A-Za-z0-9_\-]{20,}"), "[REDACTED_KEY]"), 

335 # JSON Web Tokens (three base64url segments). The two literal dots make 

336 # this distinctive enough to avoid prose false positives. Accepted FP: 

337 # a 3-part dotted identifier whose segments start with "eyJ" and are 

338 # >=8 base64url chars (e.g. "eyJsonParser.eyJsonReader.eyJsonX") is 

339 # redacted — over-redaction, not a leak. ``/`` is intentionally omitted 

340 # (RFC 7515 JWTs are base64url); ``Bearer``/``Authorization`` paths 

341 # already catch standard-base64 JWTs. 

342 # 

343 # Linear-time form, for the reason given on the Slack patterns above: 

344 # ``.`` is outside the segment class, so a segment always runs to the end 

345 # of its ``[A-Za-z0-9_+-]`` run, and the naive ``\beyJ...`` retried (and 

346 # rescanned) at every ``eyJ`` inside one run — ``"eyJ-" * n`` was O(n^2) 

347 # (320 KB: ~60 s). Only the run's leftmost ``\beyJ`` can matter (it 

348 # has the longest first segment and the same run end), so attempts 

349 # start at run starts and commit to it; the segments are possessive 

350 # because backtracking inside a run can never reach a ``.``. 

351 ( 

352 re.compile( 

353 r"(?<![A-Za-z0-9_+\-])(?>([A-Za-z0-9_+\-]*?)\beyJ)" 

354 r"[A-Za-z0-9_+\-]{8,}+\.[A-Za-z0-9_+\-]{8,}+\.[A-Za-z0-9_+\-]+" 

355 ), 

356 r"\1[REDACTED_KEY]", 

357 ), 

358] 

359 

360# Query string of any URL appearing in a message bound for logs/DB, e.g. the 

361# "for url: https://host/path?..." suffix `requests.Response.raise_for_status` 

362# appends to HTTPError. Only credential-*shaped* query params are redacted by 

363# _CREDENTIAL_PATTERNS above; an arbitrary parameter (a search engine's `term=`, 

364# an `id=`, a filter) carries no credential shape yet may still be user-supplied 

365# text (e.g. the user's raw search query) that should not reach a log sink. 

366# Scoped to scrub_error() only (see below) — NOT part of sanitize_error_message, 

367# since that function also backs sanitize_error_for_client() — and, through it, 

368# sanitize_error_for_agent(), which is that helper at a 500-char cap — where 

369# preserving a failed request's own query string is useful for the caller 

370# debugging it. 

371# 

372# Scheme: RFC 3986 §3.1 is one ALPHA followed by zero or more 

373# ALPHA/DIGIT/+/-/. — the *lower* bound must stay 0 so a valid 

374# single-character scheme (``x://...``) still matches, but the 

375# *continuation* is capped at 31 chars (matches the sibling URL-credential 

376# pattern above). An unbounded ``*`` here is a measured quadratic ReDoS: 

377# on a message containing a long ALPHA/DIGIT/+/-/. run with no following 

378# "://" (e.g. a stringified response body reaching str(error)), the engine 

379# retries the "://" match at every run offset, backtracking the whole 

380# remaining run each time — O(n^2). Verified locally: unbounded, a 40k-char 

381# run takes ~1.5s (quadratic: ~4x per input doubling); bounded, the same 

382# input is back to microseconds. See test_scrub_error_is_linear_time_on_a_ 

383# long_scheme_like_run. 

384# 

385# URL body (authority + path, up to the "?"): excludes whitespace, "?" and 

386# "#" as before, plus the double quote and angle brackets, which are never 

387# valid unencoded inside a URL (an HTTP client percent-encodes them), and is 

388# capped at 4096 chars. Both are needed for the same reason the scheme 

389# continuation is bounded: without them, ``[^\s?#]+`` followed by ``\?`` is a 

390# second quadratic path — on a whitespace-free run dense in "://" (a 

391# minified JSON list of URLs, a stringified response body) the engine 

392# consumes to the end of the run at every "://" and backtracks the whole 

393# remainder looking for a "?" that never comes. Measured on the uncapped 

394# body: ~1s at 36k chars, ~4s at 72k, ~16s at 144k (4x per doubling), against 

395# milliseconds before the query pass existed. Excluding the quote makes the 

396# common JSON shape short per URL, and the cap bounds every other shape. A 

397# URL whose scheme+authority+path exceeds 4096 chars has its query left 

398# alone — no engine in this codebase builds one, and over-length is not a 

399# leak vector the way a redaction gap is. See 

400# test_scrub_error_is_linear_time_on_a_dense_url_run. 

401# 

402# Query stop-set: whitespace plus the wrappers a URL appears inside in 

403# prose/logs (a markdown link's ``)``, a quote) so the redaction doesn't 

404# consume past the URL's actual end and corrupt the surrounding message 

405# (e.g. eating a markdown link's closing paren, or swallowing a second, 

406# unspaced adjacent URL's own leading scheme once the first URL's wrapper is 

407# reached). ``]`` and ``}`` are deliberately NOT in the stop-set: this pass 

408# runs after sanitize_error_message, whose credential-shaped redaction emits 

409# ``[REDACTED]`` / ``[REDACTED_KEY]`` in place of a value, and a stop-set 

410# containing ``]`` halted the strip at that marker — so every parameter 

411# AFTER a credential-shaped one (Google PSE's ``key=...&cx=...&q=<query>``, 

412# ScaleSERP's ``api_key=...&q=<query>``) survived into the log while looking 

413# scrubbed. A URL inside square brackets is now over-redacted up to the 

414# closing bracket, which is the safe direction. Requires >=1 char (``+``, 

415# not ``*``): a bare trailing "?" with nothing after it is not a query at 

416# all, so a sentence-final "...page? yes" must not be rewritten into 

417# "...page?<redacted> yes", which would imply a query existed. 

418_URL_QUERY_STRING_RE = re.compile( 

419 r"([A-Za-z][A-Za-z0-9+.\-]{0,31}://[^\s?#\"<>]{1,4096})\?([^\s)\"']+)" 

420) 

421 

422 

423_URL_QUERY_MARKER = "<redacted>" 

424_URL_QUERY_WRAPPERS = {"<": ">", "`": "`"} 

425 

426 

427def _restore_query_wrapper(match: "re.Match[str]") -> str: 

428 """Keep only a final Markdown wrapper character, never query content.""" 

429 url, query = match.group(1), match.group(2) 

430 opener = match.string[match.start() - 1] if match.start() else "" 

431 closer = _URL_QUERY_WRAPPERS.get(opener) 

432 if ( 

433 closer is not None 

434 and len(query) > 1 

435 and query.endswith(closer) 

436 and query != _URL_QUERY_MARKER 

437 ): 

438 return f"{url}?{_URL_QUERY_MARKER}{closer}" 

439 return f"{url}?{_URL_QUERY_MARKER}" 

440 

441 

442def _query_needs_credential_guards(message: str, glued: str) -> bool: 

443 """Whether query removal must preserve later credential readings.""" 

444 return "=" in message and ( 

445 _has_name_assignment_hint(message) or _has_name_assignment_hint(glued) 

446 ) 

447 

448 

449def _redact_url_query_strings(message: str) -> str: 

450 """Replace the query component of any URL in *message* with a fixed 

451 marker, preserving the scheme/host/path so the endpoint stays legible. 

452 

453 A quoted value after a query assignment's ``=`` belongs to that query. 

454 The regex stops at its opening quote; consume the whole value before 

455 replacing the query, or a zero-width separator before the assignment 

456 could leave the quoted secret visible without its credential name. 

457 """ 

458 if "?" not in message or "://" not in message: 

459 return message 

460 queries = list(_URL_QUERY_STRING_RE.finditer(message)) 

461 if not queries: 

462 return message 

463 guards: list[list[tuple[int, int, int]]] = [] 

464 glued_query_ends: dict[int, int] = {} 

465 glued = strip_control_chars(message) 

466 split_offsets = _glued_offsets(message) if glued != message else None 

467 if _query_needs_credential_guards(message, glued): 

468 if glued == message: 

469 to_text: Callable[[int], int] = int 

470 else: 

471 glued_starts, text_starts = _glued_offsets(message) 

472 

473 def to_text(offset: int) -> int: 

474 run = bisect.bisect_right(glued_starts, offset) - 1 

475 return text_starts[run] + offset - glued_starts[run] 

476 

477 guards = _log_patcher_spans(message, glued, to_text) 

478 if glued != message: 

479 glued_query_ends = { 

480 to_text(query.start(2) - 1): to_text(query.end() - 1) + 1 

481 for query in _URL_QUERY_STRING_RE.finditer(glued) 

482 } 

483 

484 pieces: list[str] = [] 

485 done = 0 

486 for match in queries: 

487 if match.start() < done: 

488 continue 

489 query_end = match.end() 

490 end = _cover_query_cut_credentials( 

491 match.start(2) - 1, query_end, guards, query_end 

492 ) 

493 if end > query_end: 

494 # A name split by a control can sit inside a longer query in 

495 # the stripped view. Consume that whole query so the suffix 

496 # cannot be mistaken for URL userinfo on the next scrub round. 

497 end = _cover_query_cut_credentials( 

498 match.start(2) - 1, 

499 max(end, glued_query_ends.get(match.start(2) - 1, end)), 

500 guards, 

501 query_end, 

502 ) 

503 if ( 

504 end == match.end() 

505 and match.group(2).endswith("=") 

506 and end < len(message) 

507 ): 

508 quote = message[end] 

509 if quote in ('"', "'"): 509 ↛ 526line 509 didn't jump to line 526 because the condition on line 509 was always true

510 end += 1 

511 while end < len(message): 

512 if message[end] == "\\": 

513 end += 2 

514 elif message[end] == quote: 

515 end += 1 

516 break 

517 else: 

518 end += 1 

519 end = min(end, len(message)) 

520 # Extending through a quoted query value can consume the 

521 # name of a later credential. Include its complete value 

522 # so the log patcher is not left an unlabelled suffix. 

523 end = _cover_query_cut_credentials( 

524 match.start(2) - 1, end, guards, query_end 

525 ) 

526 if split_offsets is not None: 

527 while True: 

528 extended = _cover_query_cut_credentials( 

529 match.start(2) - 1, 

530 _extend_over_split_credential( 

531 glued, 

532 *split_offsets, 

533 match.start(2) - 1, 

534 end, 

535 ), 

536 guards, 

537 query_end, 

538 ) 

539 if extended == end: 

540 break 

541 end = extended 

542 pieces.append(message[done : match.start()]) 

543 pieces.append(_restore_query_wrapper(match)) 

544 if end > query_end: 

545 pieces.extend( 

546 char 

547 for char in message[query_end:end] 

548 if _UNSAFE_CHAR_RE.fullmatch(char) 

549 ) 

550 done = end 

551 if not pieces: 551 ↛ 552line 551 didn't jump to line 552 because the condition on line 551 was never true

552 return message 

553 pieces.append(message[done:]) 

554 return "".join(pieces) 

555 

556 

557_MAX_SCRUB_ROUNDS = 5 

558 

559 

560def _scrub_to_fixed_point(scrub_once: Callable[[str], str], text: str) -> str: 

561 """Repeat redaction until stable, with a bound on adversarial input.""" 

562 for _ in range(_MAX_SCRUB_ROUNDS): 562 ↛ 567line 562 didn't jump to line 567 because the loop on line 562 didn't complete

563 scrubbed = scrub_once(text) 

564 if scrubbed == text: 

565 break 

566 text = scrubbed 

567 return text 

568 

569 

570def sanitize_error_message(message: str) -> str: 

571 """Remove or mask API keys, tokens, and secrets from *message* using 

572 pattern matching for common credential formats. 

573 

574 Use this as a first scrub pass on exception messages before logging, 

575 followed by :func:`redact_secrets` with known literal values (the 

576 "dual-scrub" pattern). 

577 

578 Handles: 

579 * Bearer tokens (``Bearer sk-...``) 

580 * ``Authorization:`` (any scheme) and ``x-api-key:`` headers 

581 * URL query parameters (``?api_key=``, ``?access_token=``, 

582 ``?refresh_token=``, ``?subscription-key=``, ``?secret=``, ...) 

583 * URL-embedded credentials (``https://user:pass@host``) 

584 * Well-known token prefixes — ``sk-``/``pk-``, Google ``AIza``/``ya29.``, 

585 GitHub ``ghp_``/``github_pat_``, AWS ``AKIA``/``ASIA``, Slack ``xox*-``, 

586 and JWTs (``eyJ….….…``). See ``docs/developing/credential-scrubbing.md``. 

587 """ 

588 message = _plain_text(message) 

589 return _scrub_to_fixed_point(_sanitize_error_message_once, message) 

590 

591 

592def _sanitize_error_message_once(message: str) -> str: 

593 if not message: 

594 return message 

595 for pattern, replacement in _CREDENTIAL_PATTERNS: 

596 message = pattern.sub(replacement, message) 

597 return message 

598 

599 

600# A match found in the stripped view: start, end, replacement, and whether 

601# the replacement is a fixed string holding none of the matched text. 

602_GluedMatch = tuple[int, int, str, bool] 

603 

604 

605def _glued_offsets(text: str) -> tuple[list[int], list[int]]: 

606 """Map the text :func:`strip_control_chars` leaves back onto *text*. 

607 

608 Returns the start of each run of kept characters in the stripped text 

609 and in *text*, in step, so a stripped offset ``i`` in run ``k`` is 

610 ``text_starts[k] + i - glued_starts[k]`` in *text*. 

611 """ 

612 glued_starts: list[int] = [] 

613 text_starts: list[int] = [] 

614 glued = 0 

615 pos = 0 

616 for unsafe in _UNSAFE_CHAR_RE.finditer(text): 

617 if unsafe.start() > pos: 

618 glued_starts.append(glued) 

619 text_starts.append(pos) 

620 glued += unsafe.start() - pos 

621 pos = unsafe.end() 

622 if pos < len(text): 

623 glued_starts.append(glued) 

624 text_starts.append(pos) 

625 return glued_starts, text_starts 

626 

627 

628def _log_patcher_spans( 

629 text: str, glued: str, to_text: Callable[[int], int] 

630) -> list[list[tuple[int, int, int]]]: 

631 """Credentials in *text* that the log patcher's bare passes redact. 

632 

633 Every pass in :data:`_LOG_PATCHER_BARE_PASSES`, the list the patcher 

634 itself applies (``name=value`` assignments and ``?name=value`` 

635 parameters), found both with the original separators and in the 

636 stripped view (mapped back), as ``(start, value_start, end)``: one 

637 sorted, non-overlapping list per pass and view. 

638 """ 

639 lists = [] 

640 for pattern, _, name_group, value_after, _ in _LOG_PATCHER_BARE_PASSES: 

641 

642 def span( 

643 m: "re.Match[str]", 

644 name: int = name_group, 

645 value: int = value_after, 

646 ) -> tuple[int, int, int]: 

647 return m.start(name), m.end(value), m.end() 

648 

649 lists.append([span(m) for m in pattern.finditer(text)]) 

650 lists.append( 

651 [ 

652 (to_text(start), to_text(value), to_text(end - 1) + 1) 

653 for start, value, end in map(span, pattern.finditer(glued)) 

654 ] 

655 ) 

656 # Later passes can read a value differently after an inner name 

657 # has been redacted. Guard those actual readings as well as the 

658 # direct matches above; removing the outer name must also remove 

659 # every value that any of the log pipelines associates with it. 

660 for spans in _pipeline_credential_spans(text): 

661 lists.extend(([], spans)) 

662 return lists 

663 

664 

665def _fit_around_credentials( 

666 start: int, end: int, guards: list[list[tuple[int, int, int]]] 

667) -> Optional[tuple[int, int, bool]]: 

668 """Fit the span ``[start, end)`` so the log patcher can still redact 

669 every credential it touches. 

670 

671 A guard is a credential the patcher redacts (see 

672 :func:`_log_patcher_spans`); the patcher needs its name to find its 

673 value. A span that covers the whole value, or starts inside the value, 

674 leaves nothing of the value visible without its name and is kept. A 

675 span that starts before the guard and ends inside it is cut back to 

676 end where the guard starts. A span that starts in the name and ends 

677 before the value does is dropped (``None``). Returns the new span and 

678 whether it changed. 

679 """ 

680 lo, hi = start, end 

681 changed = True 

682 while changed: # cutting a span can uncover another partial overlap 

683 changed = False 

684 for spans in guards: 

685 # Non-overlapping and sorted, so the ends are sorted too. 

686 i = bisect.bisect_right(spans, lo, key=lambda span: span[2]) 

687 while i < len(spans) and spans[i][0] < hi: 

688 g_start, value_start, g_end = spans[i] 

689 i += 1 

690 if lo >= value_start or hi >= g_end: 

691 continue 

692 if lo >= g_start: 

693 return None 

694 hi = g_start 

695 changed = True 

696 return lo, hi, (lo, hi) != (start, end) 

697 

698 

699def _cover_cut_credentials( 

700 start: int, end: int, guards: list[list[tuple[int, int, int]]] 

701) -> int: 

702 """End of the span ``[start, end)``, extended to the end of every 

703 credential in *guards* (see :func:`_log_patcher_spans`) whose name it 

704 takes without the whole of its value, so the patcher is never left that 

705 value without its name.""" 

706 changed = True 

707 while changed: 

708 changed = False 

709 for spans in guards: 

710 i = bisect.bisect_right(spans, start, key=lambda span: span[2]) 

711 while i < len(spans) and spans[i][0] < end: 

712 g_start, value_start, g_end = spans[i] 

713 i += 1 

714 if start < value_start and end < g_end: 

715 end = g_end 

716 changed = True 

717 return end 

718 

719 

720def _cover_query_cut_credentials( 

721 start: int, 

722 end: int, 

723 guards: list[list[tuple[int, int, int]]], 

724 query_end: int, 

725) -> int: 

726 """Cover credentials cut by a query, retaining intact later assignments. 

727 

728 A glued-view match can begin in the final query character and extend 

729 across a line break into a complete assignment. If the original-view 

730 patcher already covers that whole assignment after *query_end*, leave 

731 the line and its credential untouched. Span lists alternate original 

732 and glued views, so only overlapping spans need to be checked. 

733 """ 

734 

735 def preserved_in_original(value_start: int, value_end: int) -> bool: 

736 for originals in guards[::2]: 

737 j = bisect.bisect_left( 

738 originals, value_start, key=lambda span: span[1] 

739 ) 

740 if ( 

741 j < len(originals) 

742 and originals[j][0] >= query_end 

743 and originals[j][1] == value_start 

744 and originals[j][2] >= value_end 

745 ): 

746 return True 

747 return False 

748 

749 changed = True 

750 while changed: 

751 changed = False 

752 for view, spans in enumerate(guards): 

753 i = bisect.bisect_right(spans, start, key=lambda span: span[2]) 

754 while i < len(spans) and spans[i][0] < end: 

755 g_start, value_start, g_end = spans[i] 

756 i += 1 

757 if start >= value_start or end >= g_end: 

758 continue 

759 if view % 2 and preserved_in_original(value_start, g_end): 

760 continue 

761 end = g_end 

762 changed = True 

763 return end 

764 

765 

766# What the log patcher's bare passes take as an unquoted value: the run of 

767# non-whitespace after the ``=`` (a stop inside it only makes it shorter). 

768_NON_SPACE_RUN_RE = re.compile(r"\S*") 

769 

770 

771def _reveals_log_patcher_name(covered: str, replacement: str) -> bool: 

772 """True when *replacement*, written in place of *covered*, shows the log 

773 patcher a ``name=`` or ``?name=`` it does not see in *covered*. 

774 

775 The stripped view can join a name split by an invisible character 

776 (``authorizatio<U+200B>n=...``), and a credential pattern's replacement 

777 keeps it (``authorization=[REDACTED]``). The patcher then takes the run 

778 of text after the replacement as that name's value, up to the next 

779 whitespace, and that run can be the first half of another name split by 

780 a line break (``'api_`` of ``'api_\\nkey=...``), which it would no 

781 longer recognise. :func:`_splice_across_controls` therefore takes that 

782 run into the redaction, and so the credential it starts. 

783 """ 

784 return any( 

785 bare.unanchored.search(replacement) 

786 and not bare.unanchored.search(covered) 

787 for bare in _LOG_PATCHER_BARE_PASSES 

788 ) 

789 

790 

791# How many line breaks (or other stripped characters) at the end of a span 

792# :func:`_extend_over_split_credential` looks at, and how far before the 

793# first of them and past the span it searches at a time. Bounds its cost 

794# on adversarial input. 

795_MAX_PROBED_CROSSINGS = 64 

796_PROBE_WINDOW = 128 

797 

798 

799def _extend_over_split_credential( 

800 glued: str, 

801 glued_starts: list[int], 

802 text_starts: list[int], 

803 start: int, 

804 end: int, 

805) -> int: 

806 """End of the span ``[start, end)`` of *text*, extended so it does not 

807 take only the start of a credential that runs on past it. 

808 

809 In the stripped view a span can run on through a line break into the 

810 next line and take the start of a credential there: a name, or a 

811 ``Bearer``/``Authorization:`` anchor split by a stripped character 

812 (``Beare\\rr``), whose value lies past the span. Without that start 

813 the log patcher no longer recognises the value. So when a credential 

814 pattern the patcher uses matches across the span's end (as a scan from 

815 before the span finds it, or starting right after a stripped character 

816 inside the span) and would still redact something, the span is extended 

817 to the end of that match, and the value is redacted with it. *glued_starts* and *text_starts* are from :func:`_glued_offsets`. 

818 """ 

819 probes = [ 

820 *_CREDENTIAL_PATTERNS, 

821 *( 

822 (bare.unanchored, bare.replacement) 

823 for bare in _LOG_PATCHER_BARE_PASSES 

824 ), 

825 ] 

826 anchored = { 

827 bare.unanchored: bare.pattern for bare in _LOG_PATCHER_BARE_PASSES 

828 } 

829 

830 def to_glued(offset: int) -> int: 

831 """Stripped-view offset of the first kept character at or after 

832 *offset* in *text*.""" 

833 run = bisect.bisect_right(text_starts, offset) - 1 

834 if run < 0: 834 ↛ 835line 834 didn't jump to line 835 because the condition on line 834 was never true

835 return 0 

836 run_len = ( 

837 glued_starts[run + 1] if run + 1 < len(glued_starts) else len(glued) 

838 ) - glued_starts[run] 

839 return glued_starts[run] + min(offset - text_starts[run], run_len) 

840 

841 def to_text_end(glued_end: int) -> int: 

842 """Offset in *text* just after the kept character before 

843 *glued_end*.""" 

844 run = bisect.bisect_right(glued_starts, glued_end - 1) - 1 

845 return text_starts[run] + glued_end - glued_starts[run] 

846 

847 # Bound chains that repeatedly expose another credential. If the work 

848 # limit is reached, redact the remaining tail instead of leaving a value 

849 # behind after consuming its label. 

850 extensions_left = 16 

851 while True: 

852 if extensions_left == 0: 

853 return to_text_end(len(glued)) 

854 extensions_left -= 1 

855 # Runs of kept text that begin inside the span follow a stripped 

856 # character there. 

857 first = bisect.bisect_right(text_starts, start) 

858 last = bisect.bisect_left(text_starts, end) 

859 runs = range(max(first, last - _MAX_PROBED_CROSSINGS), last) 

860 if not runs: 

861 return end 

862 crossings = [glued_starts[run] for run in runs] 

863 glued_end = to_glued(end) 

864 window_end = min(len(glued), glued_end + _PROBE_WINDOW) 

865 reach = glued_end 

866 

867 def runs_on(m: Optional["re.Match[str]"], template: str) -> int: 

868 """End of *m* if it runs past the span and would still redact 

869 something, else 0. A match cut off by the window is redone 

870 without it, so the whole value is covered.""" 

871 if ( 

872 m is None 

873 or m.end() <= glued_end 

874 or m.expand(template) == m.group() 

875 ): 

876 return 0 

877 if m.end() >= window_end: 

878 whole = m.re.match(glued, m.start()) 

879 if whole is not None: 879 ↛ 881line 879 didn't jump to line 881 because the condition on line 879 was always true

880 return max(m.end(), whole.end()) 

881 return m.end() 

882 

883 window_start = max(0, crossings[0] - _PROBE_WINDOW) 

884 for pattern, template in probes: 

885 # A match as the patcher finds it, scanning from before the 

886 # span, that straddles the span's end. 

887 natural = anchored.get(pattern, pattern) 

888 for m in natural.finditer(glued, window_start, window_end): 

889 if m.start() >= glued_end: 

890 break 

891 reach = max(reach, runs_on(m, template)) 

892 if "://" in pattern.pattern: 

893 continue # a URL scheme has no fixed start to anchor at 

894 # A match starting right after a stripped character: the 

895 # patcher may see it there once what precedes it is redacted. 

896 for crossing in crossings: 

897 m = pattern.match(glued, crossing, window_end) 

898 reach = max(reach, runs_on(m, template)) 

899 if reach == glued_end: 

900 return end 

901 end = to_text_end(reach) 

902 

903 

904def _splice_across_controls( 

905 text: str, glued: str, matches: Iterable[_GluedMatch] 

906) -> str: 

907 """Apply redactions found in *glued* (``strip_control_chars(text)``) 

908 to *text* itself. 

909 

910 Each match is mapped back to the span of *text* it covers, then fitted 

911 around any credential the log patcher would redact (see 

912 :func:`_fit_around_credentials`) and extended over any credential it 

913 would otherwise cut into (see :func:`_extend_over_split_credential`), 

914 and over the whole of any credential that extension takes the name of 

915 (see :func:`_cover_cut_credentials`). A replacement that would show the 

916 patcher a name it does not see in the span (a split name the stripped 

917 view joined, see :func:`_reveals_log_patcher_name`) is extended over the 

918 run of text the patcher would take as that name's value. These rules 

919 keep the patcher from being left a value without its name in the 

920 shapes they model; they are not a proof for every input. A span that 

921 had to change gets a plain 

922 ``[REDACTED]`` unless its replacement holds no matched text. The 

923 whitespace control characters inside a replaced span (line breaks, 

924 tabs, U+2028, ...) are written back after the replacement, so no two 

925 lines are joined. A span whose stripped text already equals its 

926 replacement is left alone. 

927 """ 

928 glued_starts, text_starts = _glued_offsets(text) 

929 

930 def to_text(offset: int) -> int: 

931 run = bisect.bisect_right(glued_starts, offset) - 1 

932 return text_starts[run] + offset - glued_starts[run] 

933 

934 guards = _log_patcher_spans(text, glued, to_text) 

935 pieces: list[str] = [] 

936 done = 0 

937 for g_start, g_end, replacement, literal in matches: 

938 if g_end <= g_start: 938 ↛ 939line 938 didn't jump to line 939 because the condition on line 938 was never true

939 continue 

940 fitted = _fit_around_credentials( 

941 to_text(g_start), to_text(g_end - 1) + 1, guards 

942 ) 

943 if fitted is None: 

944 continue 

945 start, end, shrunk = fitted 

946 if shrunk and not literal: 

947 # A shape replacement retains its anchor (header name, URL 

948 # scheme, ...). Cutting its match short and replacing that 

949 # anchor with a plain marker would expose the original value. 

950 # Keep the whole match; the extension below also conceals any 

951 # overlapping assignment whose name this span would take. 

952 end = to_text(g_end - 1) + 1 

953 if start < done: 

954 continue # inside a span an earlier match was extended over 

955 if not (shrunk and not literal) and _reveals_log_patcher_name( 

956 text[start:end], replacement 

957 ): 

958 run_end = _NON_SPACE_RUN_RE.match(text, end).end() 

959 if run_end != end: 

960 end, shrunk = run_end, True 

961 while True: 

962 # Extending can take the name of a credential the patcher sees 

963 # in *text*; the span then takes its whole value as well. 

964 extended = _cover_cut_credentials( 

965 start, 

966 _extend_over_split_credential( 

967 glued, glued_starts, text_starts, start, end 

968 ), 

969 guards, 

970 ) 

971 if extended == end: 

972 break 

973 end, shrunk = extended, True 

974 if shrunk and not literal: 

975 replacement = REDACTION_TEXT 

976 covered = text[start:end] 

977 if strip_control_chars(covered) == replacement: 

978 continue 

979 kept = "".join( 

980 c for c in covered if c.isspace() and _UNSAFE_CHAR_RE.match(c) 

981 ) 

982 pieces.append(text[done:start]) 

983 pieces.append(replacement + kept) 

984 done = end 

985 pieces.append(text[done:]) 

986 return "".join(pieces) 

987 

988 

989# Longest text :func:`_scrub_across_controls` scans. On adversarial input 

990# (a line break inside every ``Bearer``, ``Authorization:`` or URL) it costs 

991# up to about 55 microseconds per character, against about 0.4 for the 

992# plain passes, so at this bound about 0.2 s per round. Longer text gets the 

993# plain passes only, as before that view existed. 

994_GLUED_VIEW_MAX_CHARS = 4096 

995 

996 

997def _scrub_across_controls(text: str, secrets: list) -> str: 

998 """Re-run :func:`scrub_error`'s passes on *text* with control and 

999 invisible format characters removed, applying what they find to 

1000 *text* itself (see :func:`_splice_across_controls`). 

1001 

1002 Text without such characters is returned as it is: the plain passes 

1003 already saw it exactly as this view would. So is text longer than 

1004 ``_GLUED_VIEW_MAX_CHARS``, which bounds the cost of this view; such text 

1005 keeps only the plain passes, and the log patcher still strips control 

1006 characters and redacts again before it is logged. 

1007 """ 

1008 if len(text) > _GLUED_VIEW_MAX_CHARS or not _UNSAFE_CHAR_RE.search(text): 

1009 return text 

1010 glued = strip_control_chars(text) 

1011 

1012 def apply(find: Callable[[str], Iterable[_GluedMatch]]) -> None: 

1013 nonlocal text, glued 

1014 matches = list(find(glued)) 

1015 if matches: 

1016 spliced = _splice_across_controls(text, glued, matches) 

1017 if spliced != text: 

1018 text = spliced 

1019 glued = strip_control_chars(text) 

1020 

1021 for pattern, template in _CREDENTIAL_PATTERNS: 

1022 apply( 

1023 lambda glued, p=pattern, t=template: ( 

1024 (m.start(), m.end(), m.expand(t), "\\" not in t) 

1025 for m in p.finditer(glued) 

1026 ) 

1027 ) 

1028 for secret in sorted( 

1029 (s for s in secrets if s and len(s) >= _MIN_SECRET_LENGTH), 

1030 key=len, 

1031 reverse=True, 

1032 ): 

1033 apply(lambda glued, s=secret: _literal_matches(glued, s)) 

1034 

1035 def query_matches(glued: str) -> Iterable[_GluedMatch]: 

1036 for m in _URL_QUERY_STRING_RE.finditer(glued): 

1037 # Only the query is replaced; the URL before it keeps its text. 

1038 yield ( 

1039 m.start(2) - 1, 

1040 m.end(), 

1041 _restore_query_wrapper(m)[len(m.group(1)) :], 

1042 True, 

1043 ) 

1044 

1045 apply(query_matches) 

1046 return text 

1047 

1048 

1049def _literal_matches(glued: str, secret: str) -> Iterable[_GluedMatch]: 

1050 """Non-overlapping occurrences of *secret*, as ``str.replace`` finds them.""" 

1051 pos = glued.find(secret) 

1052 while pos != -1: 

1053 yield (pos, pos + len(secret), _REDACTION_TOKEN, True) 

1054 pos = glued.find(secret, pos + len(secret)) 

1055 

1056 

1057def scrub_error(error: Union[BaseException, str], *secrets: Any) -> str: 

1058 """Return a log/DB-safe rendering of *error* (the "triple-scrub"). 

1059 

1060 Composes the scrub passes every catch site needs: 

1061 :func:`sanitize_error_message` (catches credential *shapes* — Bearer 

1062 tokens, URL-embedded credentials, ``sk-``/``pk-`` keys), then 

1063 :func:`redact_secrets` with the caller's known literal secret values, 

1064 then a blanket strip of any URL's query string (``?...`` → 

1065 ``?<redacted>``) so a non-credential-shaped parameter — e.g. a search 

1066 engine's ``term=`` — cannot carry the caller's raw query text into 

1067 logs via a library-formatted message such as 

1068 ``requests``' ``"... for url: https://host/path?term=<query>"``. This 

1069 last pass is specific to ``scrub_error``, not :func:`sanitize_error_message` 

1070 (which also backs the client-facing :func:`sanitize_error_for_client` and, 

1071 through it, the agent-facing :func:`sanitize_error_for_agent` — where 

1072 preserving a failed request's own query string is useful for the caller 

1073 debugging it). 

1074 

1075 Use this at every catch site that logs or persists an exception so 

1076 the passes can never drift apart per-site. 

1077 ``BaseSearchEngine._scrub_error`` delegates here, resolving its 

1078 engine's ``_secret_attrs`` into the *secrets* arguments. 

1079 

1080 Query removal needs additional credential-boundary analysis when a 

1081 URL query and a sensitive assignment occur together. Such text is cut 

1082 at the log redactor's whitespace-safe 32 KiB bound after shape and 

1083 known-literal redaction, before that expensive analysis; the omitted 

1084 tail is replaced by a marker outside the fixed-point rounds. Other 

1085 long diagnostics retain their full-text behavior. 

1086 

1087 Defensive by design: this runs inside ``except`` blocks, so it must 

1088 never raise. ``str(error)`` is guarded (a custom exception whose 

1089 ``__str__`` raises won't crash the handler) and each secret is coerced 

1090 to ``str`` (a misconfigured non-string secret, e.g. an int from 

1091 settings, won't trip ``redact_secrets``' ``len()`` check). 

1092 

1093 Args: 

1094 error: An exception or a pre-built message string. 

1095 *secrets: Known literal secret values to redact. ``None`` and 

1096 falsy values are silently skipped. 

1097 

1098 Returns: 

1099 The scrubbed message, safe for production log sinks. 

1100 """ 

1101 try: 

1102 message = str(error) 

1103 except Exception: 

1104 message = "<unprintable {}>".format(type(error).__name__) 

1105 # Coerce truthy non-str secrets to str; keep None/falsy as-is 

1106 # (redact_secrets filters those out). Guarded per secret: a 

1107 # pathological value whose __bool__/__str__ raises cannot be 

1108 # literal-matched anyway, so it is skipped rather than allowed to 

1109 # crash the except handler this runs in. 

1110 safe_secrets = [] 

1111 for v in secrets: 

1112 try: 

1113 safe_secrets.append(v and str(v)) 

1114 except Exception: 

1115 continue 

1116 

1117 omitted = 0 

1118 

1119 def scrub_once(text: str) -> str: 

1120 nonlocal omitted 

1121 text = redact_secrets(sanitize_error_message(text), *safe_secrets) 

1122 if ( 

1123 len(text) > _LOG_REDACTION_MAX_CHARS 

1124 and "?" in text 

1125 and "://" in text 

1126 and _query_needs_credential_guards(text, strip_control_chars(text)) 

1127 and _URL_QUERY_STRING_RE.search(text) is not None 

1128 ): 

1129 # Shape and known-literal redaction must precede the cut: a 

1130 # known secret containing spaces could straddle its boundary. 

1131 # Guarded query removal tracks every character through several 

1132 # readings; limit that work before creating the origin arrays. 

1133 # Ordinary long diagnostics keep their existing full-text path. 

1134 head, _ = _truncate_for_log_redaction(text) 

1135 omitted += len(text) - len(head) 

1136 text = head 

1137 text = _redact_url_query_strings(text) 

1138 return _scrub_across_controls(text, safe_secrets) 

1139 

1140 result = _scrub_to_fixed_point(scrub_once, message) 

1141 if omitted: 

1142 # Keep generated metadata outside subsequent redaction rounds. 

1143 result += f" [... {omitted} characters omitted from log output]" 

1144 return result 

1145 

1146 

1147def sanitize_error_for_client(message: str, max_length: int = 200) -> str: 

1148 """Make an exception-derived string safe to return to an HTTP client. 

1149 

1150 Composes :func:`sanitize_error_message` (credential redaction) and 

1151 :func:`sanitize_for_log` (control-char strip + length cap). Credential 

1152 scrubbing runs FIRST, on the full untruncated string, so a secret near 

1153 the ``max_length`` boundary cannot be split by truncation and slip past 

1154 the regexes. 

1155 

1156 Scope, because it is easy to over-trust: this removes credential 

1157 *shapes*. It does NOT remove server filesystem paths, SQL text, provider 

1158 endpoints or dependency internals. Use it on a message an author already 

1159 knows to be safe apart from a possible embedded secret — not as a filter 

1160 that makes an arbitrary exception safe to show. 

1161 

1162 Where an exception can carry those other kinds of detail, do not pass its 

1163 text through here at all. Two worked examples, and they are NOT the same 

1164 technique: 

1165 

1166 * ``web/routers/rag.py``'s ``_format_test_embedding_error`` withholds the 

1167 text entirely — it uses the exception's module and class only to SELECT 

1168 one of a fixed set of messages, and interpolates neither. 

1169 * ``web/routers/zotero.py``'s ``_zotero_error_response`` forwards text, 

1170 but only text that is already one of the package's author-written 

1171 constants: ``client_safe_zotero_message`` looks ``str(exc)`` up in 

1172 ``CLIENT_SAFE_ZOTERO_MESSAGES`` and returns the module constant it 

1173 found (or the caller's own fallback literal), so the value in the 

1174 response never comes from the exception object. 

1175 

1176 Keep the detail server-side. ``logger.exception`` is the default, but it 

1177 is not unconditional: loguru's ``diagnose`` renders every frame-local in 

1178 an attached traceback, so in a handler whose frames hold credentials (an 

1179 API key, the SQLCipher password) log without the traceback instead — 

1180 ``logger.warning(scrub_error(exc, *known_secrets))``. See the Zotero sync 

1181 handler in ``research_library/zotero/sync_service.py`` and the 

1182 credential-frame handlers in ``web/queue/processor_v2.py``. Only the 

1183 stderr sink can have ``diagnose`` on, and only behind two explicit 

1184 opt-ins (``utilities/log_utils.py``), but that is exactly the 

1185 configuration an operator debugging such a failure runs. 

1186 """ 

1187 scrubbed = _scrub_to_fixed_point( 

1188 lambda text: strip_control_chars(_sanitize_error_message_once(text)), 

1189 message, 

1190 ) 

1191 return sanitize_for_log(scrubbed, max_length=max_length) 

1192 

1193 

1194# Larger cap than ``sanitize_error_for_client``'s HTTP-client default: these 

1195# strings feed the agent's reasoning AND the ErrorReporter pattern map, where 

1196# over-aggressive truncation drops the categorizable error signal. Credential 

1197# scrubbing still runs first on the full untruncated string (#4633). 

1198_AGENT_ERROR_MAX_LEN = 500 

1199 

1200 

1201def sanitize_error_for_agent(message: str) -> str: 

1202 """Scrub an exception-derived string that is bound for an LLM / agent. 

1203 

1204 A preset over :func:`sanitize_error_for_client`: it removes credential 

1205 *shapes* and control characters and caps the result at 500 characters. 

1206 It delegates rather than re-composing those passes, so the agent path 

1207 cannot drift away from the client path — the cap is the only 

1208 difference between the two. 

1209 

1210 Why 500 and not the client helper's 200-char default: a tool error 

1211 feeds the model's reasoning loop and the ``ErrorReporter`` pattern map, 

1212 and the classification signal often sits deep in a provider message (a 

1213 rate-limit phrase such as ``"429 Too Many Requests"`` behind a long 

1214 upstream prefix). A 200-char cap removes that signal before anything 

1215 downstream can act on it. 

1216 

1217 Scope, because it is easy to over-trust: this removes credential 

1218 *shapes*. It does NOT remove server filesystem paths, SQL text, 

1219 provider endpoints or dependency internals. Use it on a message the 

1220 caller already knows to be safe apart from a possible embedded secret 

1221 — not as a filter that makes an arbitrary exception safe to show. 

1222 Where an exception can carry those other kinds of detail, do not pass 

1223 its text through here at all: select one of a fixed set of messages 

1224 from the exception's type, or forward only author-written constants, 

1225 and keep the detail server-side in the logs. 

1226 

1227 "Agent" is not a routing boundary. Agent-facing text does reach HTTP 

1228 responses: a strategy's ``formatted_findings`` travels through 

1229 ``api/research_functions.py`` to ``web/routers/api_v1.py``, whose 

1230 boundary re-applies the same credential-shape pass. Choosing between 

1231 this helper and :func:`sanitize_error_for_client` picks a cap; it does 

1232 not decide where the string ends up. 

1233 """ 

1234 return sanitize_error_for_client(message, max_length=_AGENT_ERROR_MAX_LEN) 

1235 

1236 

1237#: Characters that delimit every secret-bearing part of a URL: path ``/`` 

1238#: (and ``\\``), query ``?``, fragment ``#``, userinfo ``@`` and path 

1239#: parameters ``;``. A string with none of them can hold at most a host. 

1240_URL_PART_DELIMITERS = frozenset("/\\?#@;") 

1241 

1242#: Placeholder logged instead of a failure reason that may embed a URL. 

1243FAILURE_REASON_WITHHELD = "<withheld: may embed a URL>" 

1244 

1245 

1246def failure_reason_for_log(reason: Any) -> str: 

1247 """Return a download failure reason in a form that is safe to log. 

1248 

1249 The reason strings that ``DownloadService.download_resource`` and 

1250 ``download_as_text`` return are usually fixed categories 

1251 (``"egress_policy_denied:..."``, ``"No compatible downloader 

1252 available"``), but their exception paths return 

1253 ``sanitize_error_for_client(str(e))``. That removes credential 

1254 *shapes* only: request and urllib3 exception texts keep the URL's path 

1255 and query (``with url: /p?X-Amz-Signature=...``), so presigned tokens 

1256 survive it. The exception path logs its own cause (exception type and 

1257 redacted URL) where it is caught, so a log line further up needs only 

1258 the category. 

1259 

1260 Fails closed on shape: a reason containing any URL-part delimiter is 

1261 replaced by :data:`FAILURE_REASON_WITHHELD`; anything else is returned 

1262 unchanged. The test is on shape, not origin, so a fixed reason that 

1263 happens to contain a delimiter (``"Unexpected content type: text/html 

1264 - expected PDF"``) is withheld too; that loss of detail is accepted 

1265 rather than trying to tell a fixed reason from exception text. The 

1266 result is for logs only: callers keep passing the original reason to 

1267 their response or database. 

1268 """ 

1269 if reason is None: 

1270 return "None" 

1271 if not isinstance(reason, str): 

1272 return f"<{type(reason).__name__}>" 

1273 if any(char in _URL_PART_DELIMITERS for char in reason): 

1274 return FAILURE_REASON_WITHHELD 

1275 return reason 

1276 

1277 

1278def sanitize_error_details(value: Any) -> Any: 

1279 """Recursively redact credential *shapes* from the string leaves of a 

1280 structured ``details`` value (``dict`` / ``list`` / ``tuple`` / dataclass). 

1281 

1282 Intended for the ``details`` payload of an exception ``to_dict()`` that is 

1283 serialized to a client (e.g. ``NewsAPIException`` / ``WebAPIException``). 

1284 This is the *value-shape* counterpart to :class:`data_sanitizer.DataSanitizer`, 

1285 which redacts by *key name*; use this one when the concern is a credential 

1286 embedded anywhere in the text, regardless of the key it sits under. 

1287 

1288 Behaviour by node type: 

1289 

1290 * ``dict`` — recurse into values; ``str`` keys are also run through 

1291 :func:`sanitize_error_message` (a credential used *as* a key would 

1292 otherwise ship verbatim as a JSON key). A key whose redacted form is 

1293 already taken (two URLs with different passwords, or a redacted key 

1294 equal to a later literal key) gets a ``#2``/``#3`` suffix instead of 

1295 overwriting that entry; the first key in iteration order keeps the 

1296 unsuffixed name. 

1297 * ``list`` / ``tuple`` — both rebuilt as a plain ``list``: the payload is 

1298 about to be JSON-serialized (a tuple already becomes a JSON array), and 

1299 rebuilding via ``type(value)(<generator>)`` would raise on a namedtuple / 

1300 tuple subclass whose constructor is not ``(iterable) -> instance`` — 

1301 inside a Flask error handler that degrades a structured error into a bare 

1302 500. 

1303 * **dataclass instance** — converted via ``dataclasses.asdict`` and recursed. 

1304 Flask's default JSON provider serializes a dataclass (via ``asdict``), so 

1305 without this a credential in a dataclass field would ship un-redacted. 

1306 * ``str`` leaf — redacted via :func:`sanitize_error_message` (credential-shape 

1307 redaction only — no length cap or control-char strip, so structured values 

1308 such as IDs survive). 

1309 * anything else (ints, bools, ``None``, and any other object) — passed 

1310 through untouched. Note some passthrough types (``set``/``frozenset``, 

1311 ``bytes``, a non-``str``-mixin ``Enum``) are not JSON-serializable, so a 

1312 credential inside them fails *closed* at ``jsonify`` (generic 500) rather 

1313 than leaking. 

1314 

1315 Redaction is shape-based, so it never removes a value by key name — a benign 

1316 ``{"query": "reset my password"}`` is unchanged. Containers are rebuilt fresh 

1317 (the input is not mutated); passthrough leaf objects are returned by 

1318 reference. 

1319 """ 

1320 if isinstance(value, dict): 

1321 out: dict = {} 

1322 for k, v in value.items(): 

1323 safe_key = sanitize_error_message(k) if isinstance(k, str) else k 

1324 out[_unique_log_key(out, safe_key)] = sanitize_error_details(v) 

1325 return out 

1326 if isinstance(value, (list, tuple)): 

1327 return [sanitize_error_details(v) for v in value] 

1328 # dataclass *instance* (not the class itself) — Flask serializes it via 

1329 # asdict(), so recurse into its fields to redact any credential-shaped one. 

1330 if dataclasses.is_dataclass(value) and not isinstance(value, type): 

1331 return sanitize_error_details(dataclasses.asdict(value)) 

1332 if isinstance(value, str): 

1333 return sanitize_error_message(value) 

1334 return value 

1335 

1336 

1337# --------------------------------------------------------------------------- 

1338# Log-sink credential redaction (the backstop applied by the loguru patcher) 

1339# --------------------------------------------------------------------------- 

1340# 

1341# ``utilities/log_utils.py`` installs :func:`redact_log_message` / 

1342# :func:`redact_log_extra` as a global loguru *patcher* so every record is 

1343# scrubbed once, before it reaches ANY sink — stderr, the encrypted per-user 

1344# DB, the browser progress stream, and (when enabled) the persistent, 

1345# unencrypted ``<LDR_DATA_DIR>/logs/*.log`` file. 

1346# 

1347# The sanctioned per-call-site pattern is ``logger.error(scrub_error(msg, 

1348# secret))`` (see :func:`scrub_error`). This sink-level pass is the BACKSTOP 

1349# for the recurring class of bug where a call site forgets it: a credential 

1350# that rides the message as a bare ``field=value`` pair, or that is bound as 

1351# a structured ``extra`` value under a key the name-based checks miss. Both 

1352# the message string and the structured extras are scrubbed here. 

1353 

1354# Settings redaction's default secret names, plus credentials that appear only 

1355# in logs. Routing fields remain explicitly exempt below. This is a set of 

1356# exact names. Message text also recognises environment-variable and dotted 

1357# setting prefixes for these names (``OPENAI_API_KEY``, ``db_password``, 

1358# ``llm.openai.api_key``); structured ``extra`` keys use the same suffix rule 

1359# (see ``_is_sensitive_log_key``). The later quoted-key pass also handles 

1360# JSON and Python-repr credential keys (see ``_QUOTED_KEY_PREFIX``). 

1361LOG_SENSITIVE_KEYS: frozenset = frozenset( 

1362 DataSanitizer.DEFAULT_SENSITIVE_KEYS 

1363) | frozenset( 

1364 { 

1365 "passwd", 

1366 "pwd", 

1367 "user_password", 

1368 "encryption_key", 

1369 "sqlcipher_key", 

1370 "derived_key", 

1371 "salt", 

1372 "authorization", 

1373 "id_token", 

1374 "subscription_key", 

1375 } 

1376) 

1377 

1378# ``extra`` keys the log sinks read for routing / behaviour. They are never 

1379# redacted, or the DB sink loses per-user attribution 

1380# (``database_sink``/``_get_research_id`` read ``username``/``research_id``) 

1381# and the frontend filter loses its ``policy_audit`` guard. None of these 

1382# hold a secret. 

1383_LOG_STRUCTURAL_EXTRA_KEYS: frozenset = frozenset( 

1384 { 

1385 "research_id", 

1386 "username", 

1387 "policy_audit", 

1388 } 

1389) 

1390 

1391# Keep query and bare-assignment credential names in agreement, but retain 

1392# their distinct value boundaries so harmless query parameters survive. 

1393_LOG_SENSITIVE_NAME_PATTERN = "|".join( 

1394 re.escape(name).replace("_", r"[_-]?") 

1395 for name in sorted(LOG_SENSITIVE_KEYS, key=lambda name: (-len(name), name)) 

1396) 

1397# A following assignment can use the same prefixed or dotted credential 

1398# names as the log patcher. The settings flag remains exempt: its value is 

1399# not a credential and must not be used to shorten a preceding redaction. 

1400_STOP_SENSITIVE_NAME_PATTERN = "".join( 

1401 ( 

1402 r"(?!(?:[a-z0-9]+[._])*requires[_-]api[_-]key\s*=)", 

1403 r"(?:[a-z0-9]+[._])*(?:", 

1404 _LOG_SENSITIVE_NAME_PATTERN, 

1405 r")", 

1406 ) 

1407) 

1408_SENSITIVE_QUERY_RE = re.compile( 

1409 r"(?i)([?&](?:" + _LOG_SENSITIVE_NAME_PATTERN + r")=)([^&\s#]+)" 

1410) 

1411 

1412# The name is a sensitive name, optionally preceded by any number of 

1413# ``<alnum>_`` / ``<alnum>.`` segments: the same suffix rule ``extra`` keys 

1414# get, applied to env-var (``OPENAI_API_KEY``), prefixed (``db_password``) 

1415# and dotted setting (``llm.openai.api_key``) spellings. The whole name must 

1416# start a word (not after ``\w``, ``.``, ``-``, or a query's ``?``/``&``), 

1417# so ``mytoken=`` and ``x-token=`` stay readable, and it must END at the 

1418# ``=``: ``max_tokens=`` is not ``_token``. After ``?``/``&``, 

1419# ``_SENSITIVE_QUERY_RE`` redacts the exact names only: a prefixed name in 

1420# a query string (``&google_api_key=x``) is not redacted, nor is any name 

1421# after ``-`` (``--db_password=x``, ``--password=x``). The prefix 

1422# segments cannot contain a separator, so each start position scans one 

1423# identifier once -- linear, like the rest of the pass. 

1424# 

1425# Quoted values may contain spaces and escaped quotes. An unfinished quote 

1426# consumes the remaining message. Unquoted values end at whitespace, not at 

1427# punctuation that may be part of a password -- except that ONE trailing 

1428# ``,`` or ``;`` directly before whitespace or the end of the text is left 

1429# as the list delimiter it almost always is (``max token = 4096, temp``). 

1430# An unquoted value also ends where another credential assignment starts, 

1431# but only where that assignment, redacted in its own right, covers 

1432# everything the longer value would have covered and leaves nothing after 

1433# it that a later pass would have redacted. Stripping control characters 

1434# glues such an assignment onto the value before it 

1435# (``token = [REDACTED]\nap<U+200B>i_key = x`` becomes 

1436# ``token = [REDACTED]api_key = x``); without the stop, the value of 

1437# ``token`` would run on and take the name ``api_key``, leaving ``x`` 

1438# visible. The next assignment qualifies in these shapes only: 

1439# 

1440# * ``name=`` with no whitespace in its match: an unquoted value, or a 

1441# quoted value without whitespace that closes or runs to the end of the 

1442# text. Its match ends at the same whitespace the longer value would 

1443# have, which must be one ``strip_control_chars`` keeps (or the end of 

1444# the text): at a stripped line break the patcher's second pass would 

1445# glue the next line onto the visible name. 

1446# * a quoted value that never closes: its match takes the rest of the text. 

1447# * whitespace around the ``=`` or inside a quoted value 

1448# (``token=x;password="p q" r``): its match then runs on past the 

1449# whitespace that ended the longer value, over text the scan after it 

1450# would otherwise read. So that text must hold nothing such a scan, or a 

1451# later pass, could redact differently: no character 

1452# ``strip_control_chars`` removes (a later pass would glue it to its 

1453# neighbours), no ``name=`` inside a quoted value (the value would close on 

1454# that name's opening quote, leaving the rest of its value visible), and 

1455# in an unquoted value, or in the text glued after a closing quote up to 

1456# the next whitespace, no ``name=`` and no ``Bearer``, ``Authorization:`` 

1457# or ``x-api-key:`` (anchors whose value lies past that whitespace), which 

1458# must again be kept whitespace or the end of the text. 

1459# 

1460# Anything else is not a stop, and the longer value runs on as before. 

1461_DOUBLE_QUOTED_VALUE = r"\"(?:\\.|[^\"\\])*" 

1462_SINGLE_QUOTED_VALUE = r"'(?:\\.|[^'\\])*" 

1463# A character ``strip_control_chars`` keeps, and kept whitespace. 

1464_KEPT = r"(?!{})".format(_UNSAFE_CHAR_RE.pattern) 

1465_KEPT_SPACE = _KEPT + r"\s" 

1466_ENDS_AT_KEPT_SPACE = r"(?={}|\Z)".format(_KEPT_SPACE) 

1467# How far past a candidate stop its lookahead reads to the end of a run of 

1468# non-whitespace. Each candidate in one run reads to the same end, so the 

1469# bound keeps a run dense with credential names linear; a value that runs 

1470# on longer is not a stop. 

1471_STOP_LOOKAHEAD_CHARS = 256 

1472_GAP = r"(?:\s|{})*".format(_UNSAFE_CHAR_RE.pattern) 

1473# An assignment as a pass reads it, before or after that strip: a credential 

1474# name where an assignment can start, then ``=``, with any whitespace or 

1475# character ``strip_control_chars`` removes between them. 

1476_NAME_THEN_EQUALS = ( 

1477 r"(?<![\w.?&-])(?:" + _STOP_SENSITIVE_NAME_PATTERN + r")" + _GAP + r"=" 

1478) 

1479# The same, or the anchor of a credential shape whose value may lie past 

1480# whitespace (``Authorization: Basic ...``, ``Bearer ...``, ``x-api-key: ...``). 

1481_NAME_OR_SHAPE_ANCHOR = ( 

1482 _NAME_THEN_EQUALS + r"|bearer|(?:authorization|x-api-key)" + _GAP + r"[:=]" 

1483) 

1484 

1485 

1486# A run of non-whitespace up to kept whitespace or the end of the text, 

1487# holding no assignment or credential-shape anchor. 

1488_PLAIN_RUN = ( 

1489 r"(?:(?!" 

1490 + _NAME_OR_SHAPE_ANCHOR 

1491 + r")" 

1492 + _KEPT 

1493 + r"\S){0," 

1494 + str(_STOP_LOOKAHEAD_CHARS) 

1495 + r"}" 

1496 + _ENDS_AT_KEPT_SPACE 

1497) 

1498 

1499 

1500def _spread_quoted_value(quote: str) -> str: 

1501 """A quoted value whose match may reach past whitespace (see above).""" 

1502 return ( 

1503 quote 

1504 + r"(?:(?!" 

1505 + _NAME_THEN_EQUALS 

1506 + r")" 

1507 + _KEPT 

1508 + r"(?:\\" 

1509 + _KEPT 

1510 + r"\S|[^" 

1511 + quote 

1512 + r"\\]))*(?:" 

1513 + quote 

1514 + r"(?=" 

1515 + _PLAIN_RUN 

1516 + r")|\\?\Z)" 

1517 ) 

1518 

1519 

1520def _space_before_closing(quote: str) -> str: 

1521 """Lookahead: whitespace inside the quoted value that starts here.""" 

1522 return r"(?=" + quote + r"(?:\\\S|[^" + quote + r"\\\s])*\s)" 

1523 

1524 

1525# An unquoted value that may reach past whitespace (see above). 

1526_SPREAD_UNQUOTED_VALUE = r"(?=[^\s\"'])" + _PLAIN_RUN 

1527_NEXT_ASSIGNMENT = ( 

1528 r"(?<![\w.?&-])(?:" + _STOP_SENSITIVE_NAME_PATTERN + r")" 

1529 # ``name=`` with no whitespace in its match. 

1530 r"(?:=(?:\"(?:\\[^\s]|[^\"\\\s])*(?:\"|\\?\Z)" 

1531 r"|'(?:\\[^\s]|[^'\\\s])*(?:'|\\?\Z)" 

1532 r"|[^\s\"']\S{0," 

1533 + str(_STOP_LOOKAHEAD_CHARS) 

1534 + r"})" 

1535 + _ENDS_AT_KEPT_SPACE 

1536 # A quoted value that never closes: its match takes the rest of the text. 

1537 + r"|\s*=\s*(?:" 

1538 + _DOUBLE_QUOTED_VALUE 

1539 + r"|" 

1540 + _SINGLE_QUOTED_VALUE 

1541 + r")\\?$" 

1542 # Whitespace around the ``=``. 

1543 + r"|(?:(?:" 

1544 + _KEPT_SPACE 

1545 + r")+=(?:" 

1546 + _KEPT_SPACE 

1547 + r")*|=(?:" 

1548 + _KEPT_SPACE 

1549 + r")+)(?:" 

1550 + _SPREAD_UNQUOTED_VALUE 

1551 + r"|" 

1552 + _spread_quoted_value('"') 

1553 + r"|" 

1554 + _spread_quoted_value("'") 

1555 + r")" 

1556 # Whitespace inside a quoted value. 

1557 + r"|=" 

1558 + _space_before_closing('"') 

1559 + _spread_quoted_value('"') 

1560 + r"|=" 

1561 + _space_before_closing("'") 

1562 + _spread_quoted_value("'") 

1563 + r")" 

1564) 

1565_ASSIGNMENT_SEPARATOR_PATTERN = r"(\s*=\s*)" 

1566_ASSIGNMENT_VALUE_PATTERN = ( 

1567 r"(?:\"(?:\\.|[^\"\\])*(?:\"|\\?$)|'(?:\\.|[^'\\])*(?:'|\\?$)" 

1568 r"|\S+?(?=[,;]?(?:\s|$)))" 

1569) 

1570_STOP_ASSIGNMENT_VALUE_PATTERN = ( 

1571 r"(?:\"(?:\\.|[^\"\\])*(?:\"|\\?$)|'(?:\\.|[^'\\])*(?:'|\\?$)" 

1572 r"|(?:(?!" 

1573 + _NEXT_ASSIGNMENT 

1574 + r")\S)+?(?=(?:" 

1575 + _NEXT_ASSIGNMENT 

1576 + r"|[,;]?(?:\s|$))))" 

1577) 

1578_ASSIGNMENT_REPLACEMENT = r"\1\2" + REDACTION_TEXT 

1579_ASSIGNMENT_REPLACEMENT_PARTS = (1, 2, REDACTION_TEXT) 

1580 

1581# The exact names (``password``, ``client_secret``, ``api-key``). Every 

1582# pass of the log redaction runs this before the prefixed-name pattern 

1583# below, which skips these names. Run after it, the prefixed pattern only 

1584# replaces more of the text: a prefixed value that swallows the start of an 

1585# exact-name assignment (``OPENAI_API_KEY=abc,password = x``) no longer 

1586# hides it. Across the two passes of :func:`_redact_log_text`'s 

1587# "restricted first pass" pipeline the same holds through 

1588# :data:`_FIRST_PASS_SENSITIVE_ASSIGNMENT_RE`. 

1589_BARE_SENSITIVE_ASSIGNMENT_RE = re.compile( 

1590 r"(?i)(?<![\w.?&-])(" 

1591 + _LOG_SENSITIVE_NAME_PATTERN 

1592 + r")" 

1593 + _ASSIGNMENT_SEPARATOR_PATTERN 

1594 + _ASSIGNMENT_VALUE_PATTERN 

1595) 

1596_STOP_BARE_SENSITIVE_ASSIGNMENT_RE = re.compile( 

1597 r"(?i)(?<![\w.?&-])(" 

1598 + _LOG_SENSITIVE_NAME_PATTERN 

1599 + r")" 

1600 + _ASSIGNMENT_SEPARATOR_PATTERN 

1601 + _STOP_ASSIGNMENT_VALUE_PATTERN 

1602) 

1603 

1604# Names the prefixed pattern must never match, checked by a lookahead 

1605# BEFORE anything is consumed, so the regex scans on inside their values: 

1606# - an exact name: the bare pass has already replaced its value, and 

1607# matching it again would let the unquoted rule run that value on into a 

1608# glued name (``client_secret='x'llm.api_key='y z'``) and hide it; 

1609# - the one leaf settings redaction exempts (``requires_api_key``, a 

1610# boolean flag; see ``data_sanitizer._has_non_secret_qualifier``), 

1611# following a ``.`` or starting the name, as that leaf is computed there. 

1612# A match that consumed the flag's value and handed it back unchanged 

1613# would make ``re.sub`` resume after that value, so an assignment inside 

1614# it (``requires_api_key=true,password=x``, or an unterminated quote 

1615# running to the end of the text) would never be scanned. 

1616_EXEMPT_ASSIGNMENT_NAME_PATTERN = "".join( 

1617 ( 

1618 r"(?:(?:(?:[a-z0-9]+[._])*[a-z0-9]+\.)?", 

1619 re.escape(_EXEMPT_QUALIFIER_LEAF).replace("_", "[_-]"), 

1620 r")", 

1621 ) 

1622) 

1623_NOT_PREFIXED_ASSIGNMENT_NAME_PATTERN = ( 

1624 r"(?!(?:" 

1625 + _EXEMPT_ASSIGNMENT_NAME_PATTERN 

1626 + r"|(?:" 

1627 + _LOG_SENSITIVE_NAME_PATTERN 

1628 + r"))\s*=)" 

1629) 

1630_PREFIXED_ASSIGNMENT_NAME_PATTERN = ( 

1631 # ``(?=[a-z0-9])`` first: rejects a non-name position before the 

1632 # lookahead's long alternation runs (a run of spaces stays cheap). 

1633 r"(?i)(?<![\w.?&-])(?=[a-z0-9])" 

1634 + _NOT_PREFIXED_ASSIGNMENT_NAME_PATTERN 

1635 + r"((?:[a-z0-9]+[._])*(?:" 

1636 + _LOG_SENSITIVE_NAME_PATTERN 

1637 + r"))" 

1638 + _ASSIGNMENT_SEPARATOR_PATTERN 

1639) 

1640_SENSITIVE_ASSIGNMENT_RE = re.compile( 

1641 _PREFIXED_ASSIGNMENT_NAME_PATTERN + _ASSIGNMENT_VALUE_PATTERN 

1642) 

1643_STOP_SENSITIVE_ASSIGNMENT_RE = re.compile( 

1644 _PREFIXED_ASSIGNMENT_NAME_PATTERN + _STOP_ASSIGNMENT_VALUE_PATTERN 

1645) 

1646 

1647# Exact and prefixed names in ONE pattern, so whichever assignment starts 

1648# first wins. Two readings of it run, each as a pipeline of 

1649# :func:`_redact_log_text`: 

1650# - "next-line names" (:data:`_LEFTMOST_SENSITIVE_ASSIGNMENT_RE`, first 

1651# pass only, on the original separators; also a pipeline of 

1652# :func:`redact_log_message`): the exempt flag is skipped by 

1653# the lookahead, and a name at the start of the next line followed by 

1654# ``=`` is never taken as a value: ``DB_PASSWORD=\nOPENAI_API_KEY = x`` 

1655# is an empty ``DB_PASSWORD`` and an ``OPENAI_API_KEY`` whose value is 

1656# ``x``. Read otherwise, the name is the value and `` = x`` is left 

1657# unredacted. "The next line" is any run of whitespace and 

1658# :func:`strip_control_chars` characters between the ``=`` and the name 

1659# that holds at least one of the latter (``\n``, ``\r\n``, ``\x1c``, 

1660# U+2028, ...): the indentation of a pretty-printed dump 

1661# (``DB_PASSWORD =\n OPENAI_API_KEY = x``) and an invisible character 

1662# before the name (``\n<U+200B>OPENAI_API_KEY``) included. A name after 

1663# only spaces on the same line (``password= api_key = x``) is still read 

1664# as the value. 

1665# - "flag kept whole" (:data:`_START_ORDER_SENSITIVE_ASSIGNMENT_RE`, both 

1666# passes): the exempt flag's assignment is matched like any other and 

1667# kept as it is, value included, so nothing in its value starts an 

1668# assignment (``requires_api_key=a,db_password\n=x;token=y``: the value 

1669# ``a,db_password`` is the flag's, and ``token=y`` is still found). 

1670_ANY_SENSITIVE_NAME_PATTERN = ( 

1671 r"(?=[a-z0-9])(?!" 

1672 + _EXEMPT_ASSIGNMENT_NAME_PATTERN 

1673 + r"\s*=)(?:[a-z0-9]+[._])*(?:" 

1674 + _LOG_SENSITIVE_NAME_PATTERN 

1675 + r")" 

1676) 

1677# Whitespace that :func:`strip_control_chars` keeps: a space, U+00A0, 

1678# U+3000, ... -- not a tab or a line break, which it removes. 

1679_KEPT_WHITESPACE_PATTERN = "".join( 

1680 (r"(?:(?!", _UNSAFE_CHAR_RE.pattern, r")\s)") 

1681) 

1682# The next-line guard of :data:`_LEFTMOST_SENSITIVE_ASSIGNMENT_RE`, checked 

1683# right after the ``=``: kept whitespace, one stripped character, then any 

1684# mix of whitespace and stripped characters (ONE character class -- 

1685# ``_UNSAFE_CHAR_RE`` is a single class, whose inside is reused -- so a 

1686# long run is scanned once, not re-split), then a name and ``=``. The parts 

1687# before the name are disjoint or a single class, so the check is linear in 

1688# the run it scans. 

1689_NEXT_LINE_NAME_GUARD_PATTERN = ( 

1690 r"(?!" 

1691 + _KEPT_WHITESPACE_PATTERN 

1692 + r"*" 

1693 + _UNSAFE_CHAR_RE.pattern 

1694 + r"[\s" 

1695 + _UNSAFE_CHAR_RE.pattern[1:-1] 

1696 + r"]*" 

1697 + _ANY_SENSITIVE_NAME_PATTERN 

1698 + r"\s*=)" 

1699) 

1700_LEFTMOST_SENSITIVE_ASSIGNMENT_RE = re.compile( 

1701 r"(?i)(?<![\w.?&-])(" 

1702 + _ANY_SENSITIVE_NAME_PATTERN 

1703 + r")(\s*=" 

1704 + _NEXT_LINE_NAME_GUARD_PATTERN 

1705 + r"\s*)" 

1706 + _ASSIGNMENT_VALUE_PATTERN 

1707) 

1708_START_ORDER_SENSITIVE_ASSIGNMENT_RE = re.compile( 

1709 r"(?i)(?<![\w.?&-])((?:[a-z0-9]+[._])*(?:" 

1710 + _LOG_SENSITIVE_NAME_PATTERN 

1711 + r"))" 

1712 + _ASSIGNMENT_SEPARATOR_PATTERN 

1713 + _ASSIGNMENT_VALUE_PATTERN 

1714) 

1715# Run the guarded stop as one additional reading, leaving the established 

1716# pipelines intact. Their union keeps every value the old readings masked 

1717# while the stop reading can preserve and redact a glued next name. 

1718_STOP_START_ORDER_SENSITIVE_ASSIGNMENT_RE = re.compile( 

1719 r"(?i)(?<![\w.?&-])((?:[a-z0-9]+[._])*(?:" 

1720 + _LOG_SENSITIVE_NAME_PATTERN 

1721 + r"))" 

1722 + _ASSIGNMENT_SEPARATOR_PATTERN 

1723 + _STOP_ASSIGNMENT_VALUE_PATTERN 

1724) 

1725_EXEMPT_ASSIGNMENT_NAME_RE = re.compile( 

1726 r"(?i)" + _EXEMPT_ASSIGNMENT_NAME_PATTERN + r"\Z" 

1727) 

1728 

1729 

1730def _start_order_replacement_parts(match: "re.Match[str]") -> tuple: 

1731 """Replacement for :data:`_START_ORDER_SENSITIVE_ASSIGNMENT_RE`, as parts. 

1732 

1733 The exempt flag's whole assignment is kept (group 0); any other keeps 

1734 its name and separator and redacts the value. 

1735 """ 

1736 if _EXEMPT_ASSIGNMENT_NAME_RE.match(match.group(1)): 

1737 return (0,) 

1738 return _ASSIGNMENT_REPLACEMENT_PARTS 

1739 

1740 

1741def _redact_start_order_match(match: "re.Match[str]") -> str: 

1742 """Replacement for :data:`_START_ORDER_SENSITIVE_ASSIGNMENT_RE`.""" 

1743 return _join_replacement_parts(match, _start_order_replacement_parts) 

1744 

1745 

1746# The prefixed pattern for the first pass of the "restricted" pipeline of 

1747# :func:`_redact_log_text`. It must not change how that pipeline's second 

1748# pass parses the text around the value it replaces, or that pass -- whose 

1749# bare-name half is exactly what the bare names alone would redact -- could 

1750# match less. So it only takes a value the second pass could never read 

1751# differently: 

1752# - no ``=`` and not followed by ``\s*=``, so no name inside it (one that 

1753# only becomes recognisable once invisible characters are stripped, 

1754# ``pass<U+200B>word = x``) is lost; 

1755# - followed by whitespace that survives :func:`strip_control_chars` (or by 

1756# the end of the text), so the stripped text never glues the replacement 

1757# onto the next token and makes a name there newly recognisable, whose 

1758# value could then swallow the next one; 

1759# - unquoted with no quote character in it, or quoted with no quote 

1760# character or backslash inside the quotes -- and a quoted value keeps 

1761# its quotes (``"[REDACTED]"``) -- so a quote the second pass opens 

1762# earlier still closes where it would have. The second pass then 

1763# redacts the kept quotes along with the value. 

1764# Any other value is left for the second pass, which can MISS it: on the 

1765# stripped text a name the newline separated is glued to the line before 

1766# (``-\nDB_PASSWORD=x`` reads ``-DB_PASSWORD=x``, a name after ``-``), or 

1767# the value before it runs on into it. That is why this pipeline never 

1768# runs alone: the others read the first kind on the original separators, 

1769# where the newline still separates the name. The second kind is missed by 

1770# every reading -- each reads the quoted value's start as part of the value 

1771# before it, and none redacts the rest -- when this pattern cannot take it 

1772# either: a prefixed name's quoted value followed by a line break, a tab 

1773# or another stripped character (``OPENAI_API_KEY=abc,DB_PASSWORD="a b c"`` 

1774# and ``\n`` leaves `` b c"``), by any other non-whitespace character 

1775# (``")``) or by whitespace and ``=`` (``" = x``); one whose run-on value 

1776# belongs to an exact name, which this pipeline's bare-name step has 

1777# already redacted to the end of the quote's first word 

1778# (``token=abc,DB_PASSWORD="a b c"``); or a quoted assignment glued on 

1779# with ``&``, ``?`` or ``-`` (``OPENAI_API_KEY=abc&DB_PASSWORD="a b c"``, 

1780# also with an exact name), which no reading takes as a quoted value. 

1781_FIRST_PASS_SENSITIVE_ASSIGNMENT_RE = re.compile( 

1782 _PREFIXED_ASSIGNMENT_NAME_PATTERN 

1783 + r"(?:([\"'])[^\"'\\=]*(\3|\Z)|[^\s=\"']+?)" 

1784 + r"(?=[,;]?(?:" 

1785 + _KEPT_WHITESPACE_PATTERN 

1786 + r"|\Z))(?!\s*=)" 

1787) 

1788 

1789 

1790def _first_pass_replacement_parts(match: "re.Match[str]") -> tuple: 

1791 """Replacement for :data:`_FIRST_PASS_SENSITIVE_ASSIGNMENT_RE`, as parts. 

1792 

1793 The name, the separator and any quotes are kept (group numbers); only 

1794 the value becomes :data:`REDACTION_TEXT` (a literal). See 

1795 :func:`_substitute_tracked` for the part format. 

1796 """ 

1797 parts: list = [1, 2] 

1798 if match.group(3): 

1799 parts.append(3) 

1800 parts.append(REDACTION_TEXT) 

1801 if match.group(4): 

1802 parts.append(4) 

1803 return tuple(parts) 

1804 

1805 

1806def _join_replacement_parts(match: "re.Match[str]", parts_for) -> str: 

1807 """The replacement text *parts_for* gives for *match*.""" 

1808 return "".join( 

1809 part if isinstance(part, str) else match.group(part) 

1810 for part in parts_for(match) 

1811 ) 

1812 

1813 

1814def _redact_first_pass_match(match: "re.Match[str]") -> str: 

1815 """Replacement for :data:`_FIRST_PASS_SENSITIVE_ASSIGNMENT_RE`.""" 

1816 return _join_replacement_parts(match, _first_pass_replacement_parts) 

1817 

1818 

1819def redact_sensitive_assignments(message: str) -> str: 

1820 """Redact known credential assignments with quoted or unquoted values. 

1821 

1822 Complements :func:`sanitize_error_message` (which only redacts a 

1823 ``field=value`` when it sits in a URL query string, i.e. preceded by 

1824 ``?``/``&``). The field name (``api_key=``, ``password=``, ``token=``, 

1825 ``OPENAI_API_KEY=``, ``llm.openai.api_key=``, ...) is preserved; only 

1826 its value is replaced. Returns *message* 

1827 unchanged when it is empty or contains no sensitive assignment. 

1828 """ 

1829 message = _plain_text(message) 

1830 return _scrub_to_fixed_point(_redact_sensitive_assignments_once, message) 

1831 

1832 

1833def _redact_sensitive_assignments_once(message: str) -> str: 

1834 if not message: 1834 ↛ 1835line 1834 didn't jump to line 1835 because the condition on line 1834 was never true

1835 return message 

1836 bare = _STOP_BARE_SENSITIVE_ASSIGNMENT_RE.sub( 

1837 _ASSIGNMENT_REPLACEMENT, message 

1838 ) 

1839 return _STOP_SENSITIVE_ASSIGNMENT_RE.sub(_ASSIGNMENT_REPLACEMENT, bare) 

1840 

1841 

1842# Upper bound on how much of ONE string the log-path redaction scans. The 

1843# patcher runs synchronously in the logging thread on every record (message, 

1844# every ``extra`` string leaf, every rendered exception field), so its cost 

1845# must not grow with whatever a call site hands it -- fetched page text, a 

1846# response body, a long traceback string. Text past the bound is NOT passed 

1847# through unscanned (that would ship any secret in it); it is dropped and 

1848# replaced by a marker giving the omitted length, in every sink (stderr, 

1849# file, DB, frontend). 32 KiB keeps a fetched page (the fetch tool logs up 

1850# to 10,000 characters) intact. On adversarial 32 KiB input the passes of 

1851# :func:`_redact_log_text` measured up to ~280 ms together on an idle 

1852# machine and about twice that on a loaded one, and those of 

1853# :func:`redact_log_message` up to ~240 ms idle (``pwd="a"b=c `` repeated): 

1854# a text whose pipelines disagree on every line (that one, 

1855# ``db_password = a, `` repeated, or ``DB_PASSWORD=x`` lines) runs all of 

1856# them with origins tracked, several times the cost of one pipeline. A 

1857# text with no credential name followed by ``=`` skips the name steps (see 

1858# :func:`_result_without_name_assignments`): about 10-50 ms at 32 KiB. 

1859# The cost is linear in the length scanned (8x the time for 8x the text, 

1860# measured from 4 KiB to 32 KiB), so it is the same for a 1 MB or a 10 MB 

1861# string; the cut itself is one scan of at most 32 KiB (under 0.3 ms). 

1862_LOG_REDACTION_MAX_CHARS = 32_768 

1863# The run at the end of a truncated head, matched reversed: non-whitespace 

1864# plus every character ``strip_control_chars`` removes (``\n``, ``\t``, 

1865# ``\x1c``, U+0085, U+2028, ...). ``_redact_log_text`` strips those between 

1866# its passes, which rejoins a token they split, so they are not a boundary. 

1867# Built from ``_UNSAFE_CHAR_RE`` itself so the two cannot drift apart. 

1868_LOG_TRAILING_RUN_RE = re.compile(r"(?:\S|" + _UNSAFE_CHAR_RE.pattern + r")*") 

1869# Digits bounded (ASCII, at most 19) so a forged marker cannot make 

1870# ``int()`` exceed its digit limit; the real count is far below that. 

1871_LOG_OMISSION_MARKER_RE = re.compile( 

1872 r" \[\.\.\. ([0-9]{1,19}) characters omitted from log output\]\Z" 

1873) 

1874 

1875 

1876def _truncate_for_log_redaction( 

1877 message: str, max_chars: int = _LOG_REDACTION_MAX_CHARS 

1878) -> tuple[str, str]: 

1879 """Split *message* into the part to scan and a marker for the rest. 

1880 

1881 *max_chars* is the bound (the patcher's ``_LOG_REDACTION_MAX_CHARS`` 

1882 unless a caller that shows only a short prefix of the result passes a 

1883 smaller one, see :func:`redact_and_bound_for_log`); the rule below is 

1884 the same for any bound. 

1885 

1886 The cut only lands at a boundary: a whitespace character that 

1887 :func:`strip_control_chars` keeps (a space, U+00A0, U+3000, ...). A 

1888 control or separator whitespace character (``\n``, ``\t``, ``\x1c``, 

1889 U+0085, U+2028, ...) is not one, because :func:`_redact_log_text` 

1890 strips it between its passes and so joins the text on either side. 

1891 Unless the character at the bound is a boundary, the cut moves back to 

1892 just after the last boundary before the bound, dropping the whole run 

1893 the bound falls in -- however long, so a string with no boundary in its 

1894 first *max_chars* characters keeps nothing but the 

1895 marker. A partial run could be a credential fragment its shape pattern 

1896 no longer recognises (a key cut below its minimum length, URL userinfo 

1897 cut before its ``@``, a password cut at a ``&``). A credential token 

1898 contains no boundary character -- one split by a stripped control 

1899 character is a single run here, as it is to the second pass -- so the 

1900 head keeps at most a label that the passes redact or that holds no 

1901 secret (``password = ``, ``Authorization: Basic ``, or an unterminated 

1902 quote, which the assignment pass redacts to the end of the head, so a 

1903 quoted value with spaces in it is covered too). 

1904 """ 

1905 if len(message) <= max_chars: 

1906 return message, "" 

1907 cut = max_chars 

1908 at_bound = message[cut] 

1909 if not at_bound.isspace() or _UNSAFE_CHAR_RE.match(at_bound): 

1910 # ``\S`` is exactly ``not str.isspace()``; matching the reversed head 

1911 # measures the trailing run in one linear scan. 

1912 run = _LOG_TRAILING_RUN_RE.match(message[cut - 1 :: -1]) 

1913 cut -= run.end() 

1914 omitted = len(message) - cut 

1915 return message[:cut], f" [... {omitted} characters omitted from log output]" 

1916 

1917 

1918def log_length_before_truncation(message: str) -> int: 

1919 """Length *message* had before the log-path length bound cut it. 

1920 

1921 For a string ending in the marker :func:`redact_log_message` appends, 

1922 the kept text plus the omitted count; otherwise ``len(message)``. Both 

1923 are measured after redaction, which may change the length. Never 

1924 raises: a marker the pattern does not accept (logged text can forge 

1925 one) yields ``len(message)``. 

1926 """ 

1927 marker = _LOG_OMISSION_MARKER_RE.search(message) 

1928 if marker is None: 

1929 return len(message) 

1930 try: 

1931 return marker.start() + int(marker.group(1)) 

1932 except ValueError: 

1933 return len(message) 

1934 

1935 

1936def redact_log_message(message: str) -> str: 

1937 """Full credential scrub for a log *message* string. 

1938 

1939 Runs the credential-*shape* patterns :func:`sanitize_error_message` 

1940 runs (its ``_CREDENTIAL_PATTERNS``: URL userinfo, ``?param=`` 

1941 credentials, ``Bearer``/``x-api-key`` headers, well-known key prefixes, 

1942 JWTs) -- the shape redaction the sanctioned :func:`scrub_error` applies, 

1943 run as a backstop on every record -- then ``?name=``/``&name=`` query 

1944 parameters for every :data:`LOG_SENSITIVE_KEYS` name, then 

1945 ``name=value`` assignments of those names, exact or prefixed (the 

1946 names :func:`redact_sensitive_assignments` recognises), in each reading 

1947 of :data:`_LOG_MESSAGE_PIPELINES`; what any reading redacts is 

1948 redacted. Control characters are kept (exception text is rendered 

1949 with its newlines). Non-sensitive text up to 

1950 ``_LOG_REDACTION_MAX_CHARS`` characters is returned unchanged; a longer 

1951 string is cut there (see :func:`_truncate_for_log_redaction`) and the 

1952 remainder replaced by an omitted-length marker, so the cost per string 

1953 is bounded. 

1954 """ 

1955 message = _plain_text(message) 

1956 if not message: 

1957 return _exact_str(message) 

1958 head, marker = _truncate_for_log_redaction(message) 

1959 return _scrub_to_fixed_point(_redact_bounded_log_text, head) + marker 

1960 

1961 

1962# One substitution of a redaction pass: a pattern and its replacement, 

1963# given as the string or callable ``re.sub`` takes and as PARTS -- a list 

1964# (or a callable giving one per match) of group numbers, which keep that 

1965# group's text, and literal strings, which are inserted. The parts let 

1966# :func:`_substitute_tracked` follow every kept character back to the 

1967# position it came from. 

1968_TEMPLATE_GROUP_RE = re.compile(r"\\([0-9])") 

1969 

1970 

1971def _template_parts(template: str) -> tuple: 

1972 """Parts of a ``re.sub`` template that uses only ``\\1``-style groups.""" 

1973 if "\\" in _TEMPLATE_GROUP_RE.sub("", template): 1973 ↛ 1974line 1973 didn't jump to line 1974 because the condition on line 1973 was never true

1974 raise ValueError(f"unsupported replacement template: {template!r}") 

1975 parts: list = [] 

1976 for index, piece in enumerate(_TEMPLATE_GROUP_RE.split(template)): 

1977 if index % 2: 

1978 parts.append(int(piece)) 

1979 elif piece: 

1980 parts.append(piece) 

1981 return tuple(parts) 

1982 

1983 

1984def _step(pattern: "re.Pattern[str]", replacement: Any, parts: Any = None): 

1985 if parts is None: 

1986 parts = _template_parts(replacement) 

1987 return pattern, replacement, parts 

1988 

1989 

1990# The credential-shape and query-parameter steps every pass starts with. 

1991_SHAPE_STEPS = tuple( 

1992 _step(pattern, replacement) for pattern, replacement in _CREDENTIAL_PATTERNS 

1993) + (_step(_SENSITIVE_QUERY_RE, r"\1" + REDACTION_TEXT),) 

1994_BARE_STEP = _step(_BARE_SENSITIVE_ASSIGNMENT_RE, _ASSIGNMENT_REPLACEMENT) 

1995_PREFIXED_STEP = _step(_SENSITIVE_ASSIGNMENT_RE, _ASSIGNMENT_REPLACEMENT) 

1996# The name steps of :func:`redact_log_message`'s pass before the glued 

1997# tails were closed (below). The bare names run before the prefixed ones 

1998# (see :data:`_BARE_SENSITIVE_ASSIGNMENT_RE`); the prefixed ones run twice, 

1999# because a quoted value glued to the next text (``OPENAI_API_KEY="x"tail``) 

2000# leaves ``[REDACTED]tail``, which a second scan reads as one unquoted value. 

2001# An exact name's tail (``password="x"tail``) was left. 

2002_NAME_STEPS_BEFORE_CLOSING = (_BARE_STEP, _PREFIXED_STEP, _PREFIXED_STEP) 

2003# A quoted value glued to the next text leaves that text behind when it is 

2004# redacted: ``password="x"tail`` becomes ``password=[REDACTED]tail``. This 

2005# step reads such an assignment again, as a scan for names would (the same 

2006# names, word start and separator; not the exempt flag), but only where the 

2007# value starts with ``[REDACTED]``, and redacts the tail up to the 

2008# whitespace (or ``,``/``;`` before it) where an unquoted value ends -- 

2009# unless the tail holds an ``=`` or is followed by one, i.e. is itself the 

2010# name of an assignment (``passwd=""apikey =""tail``: ``apikey`` stays, 

2011# its own ``tail`` goes). So, unlike another scan for names, it never reads 

2012# a value on into the name of an assignment whose own tail it has not yet 

2013# redacted. 

2014_GLUED_TAIL_RE = re.compile( 

2015 r"(?i)(?<![\w.?&-])(" 

2016 + _ANY_SENSITIVE_NAME_PATTERN 

2017 + r")" 

2018 + _ASSIGNMENT_SEPARATOR_PATTERN 

2019 + re.escape(REDACTION_TEXT) 

2020 + r"(?![,;]?(?:\s|$))[^\s=]+?(?=[,;]?(?:\s|$))(?!\s*=)" 

2021) 

2022_GLUED_TAIL_STEP = _step(_GLUED_TAIL_RE, _ASSIGNMENT_REPLACEMENT) 

2023# The "exact names first" name steps: the bare names, the prefixed ones, 

2024# the glued tails, then both name scans once more, the bare names last 

2025# (see :data:`_BARE_SENSITIVE_ASSIGNMENT_RE`). The last two scans redact a 

2026# tail the tail step leaves (one holding an ``=``: ``password="x"a=b``) as 

2027# part of an unquoted value. Running these steps again on their result 

2028# changes nothing (seeded fuzz: 0 of 50,000 messages). 

2029_FULL_PASS_NAME_STEPS = ( 

2030 _BARE_STEP, 

2031 _PREFIXED_STEP, 

2032 _GLUED_TAIL_STEP, 

2033 _PREFIXED_STEP, 

2034 _BARE_STEP, 

2035) 

2036_FULL_PASS_STEPS = _SHAPE_STEPS + _FULL_PASS_NAME_STEPS 

2037_RESTRICTED_FIRST_PASS_STEPS = ( 

2038 _BARE_STEP, 

2039 _step( 

2040 _FIRST_PASS_SENSITIVE_ASSIGNMENT_RE, 

2041 _redact_first_pass_match, 

2042 _first_pass_replacement_parts, 

2043 ), 

2044) 

2045_LEFTMOST_STEP = _step( 

2046 _LEFTMOST_SENSITIVE_ASSIGNMENT_RE, _ASSIGNMENT_REPLACEMENT 

2047) 

2048# The "next-line names" steps, closed the same way, so 

2049# ``DB_PASSWORD=\nOPENAI_API_KEY = "x"tail`` does not leave ``tail`` -- which 

2050# the second pass, reading ``OPENAI_API_KEY`` as ``DB_PASSWORD``'s value on 

2051# the stripped text, would not redact either. 

2052_LEFTMOST_STEPS = (_LEFTMOST_STEP, _GLUED_TAIL_STEP, _LEFTMOST_STEP) 

2053_START_ORDER_STEPS = ( 

2054 _step( 

2055 _START_ORDER_SENSITIVE_ASSIGNMENT_RE, 

2056 _redact_start_order_match, 

2057 _start_order_replacement_parts, 

2058 ), 

2059) 

2060_STOP_START_ORDER_STEPS = ( 

2061 _step( 

2062 _STOP_START_ORDER_SENSITIVE_ASSIGNMENT_RE, 

2063 _redact_start_order_match, 

2064 _start_order_replacement_parts, 

2065 ), 

2066) 

2067# The pipelines of :func:`_redact_log_text`: the name steps each runs after 

2068# :data:`_SHAPE_STEPS`, in its first pass (on the original text) and 

2069# in its second (on the text with control characters stripped). The first 

2070# pipeline's text is the one kept; the others only add redactions to it. 

2071_LOG_TEXT_PIPELINES = ( 

2072 # "exact names first": :func:`redact_log_message`'s name steps, on the 

2073 # original separators and again on the stripped text. Its result is 

2074 # the one kept. 

2075 (_FULL_PASS_NAME_STEPS, _FULL_PASS_NAME_STEPS), 

2076 # "restricted first pass": a first pass that never changes how the 

2077 # second reads the text around a value it replaces (see 

2078 # :data:`_FIRST_PASS_SENSITIVE_ASSIGNMENT_RE`). 

2079 (_RESTRICTED_FIRST_PASS_STEPS, _FULL_PASS_NAME_STEPS), 

2080 # "next-line names": names in start order, in the first pass (see 

2081 # :data:`_LEFTMOST_SENSITIVE_ASSIGNMENT_RE`). 

2082 (_LEFTMOST_STEPS, _FULL_PASS_NAME_STEPS), 

2083 # "flag kept whole": names in start order, in both passes (see 

2084 # :data:`_START_ORDER_SENSITIVE_ASSIGNMENT_RE`). 

2085 (_START_ORDER_STEPS, _START_ORDER_STEPS), 

2086 # "exact names only": the redaction as it was before the prefix rule 

2087 # (with the trailing ``,``/``;`` rule of the value pattern), so this 

2088 # one never removes less than the exact names alone would. 

2089 ((_BARE_STEP,), (_BARE_STEP,)), 

2090 # "exact names first" and "next-line names" with their first pass as 

2091 # it was before the glued tails were closed. Closing them changes the 

2092 # text the later steps of that pass read, and removes text before the 

2093 # strip, which can join what follows onto a different neighbour and 

2094 # change how the second pass reads it; with these readings kept, 

2095 # closing never shows anything that leaving it open would hide. 

2096 (_NAME_STEPS_BEFORE_CLOSING, _FULL_PASS_NAME_STEPS), 

2097 ((_LEFTMOST_STEP,), _FULL_PASS_NAME_STEPS), 

2098 # Preserve names that an earlier value would otherwise swallow. 

2099 (_STOP_START_ORDER_STEPS, _STOP_START_ORDER_STEPS), 

2100) 

2101# The pipelines of :func:`redact_log_message`, which also renders exception 

2102# text (``str()``, notes, ``SyntaxError`` fields) and so keeps every 

2103# control character: the first passes of :data:`_LOG_TEXT_PIPELINES`, in 

2104# that order, on the text as it is -- one pass each, no second pass 

2105# (``None``) -- except that the "restricted first pass" reading, which 

2106# leaves values for its second pass, runs the "exact names first" steps 

2107# after it on the same text, and the unclosed "next-line names" reading is 

2108# left out: its closed steps start with it and only run more steps after 

2109# it on the same text, so they only redact more. So an empty 

2110# ``DB_PASSWORD=`` at the end of a line does not take the name starting the 

2111# next line as its value, and ``OPENAI_API_KEY=abc,DB_PASSWORD="a b c"`` 

2112# followed by the end of the text or by a space (with no ``=`` after it) 

2113# does not leave `` b c"`` readable, in an exception's text as in a log 

2114# message (followed by a line break, or by `` = x``, it does in both; see 

2115# :data:`_FIRST_PASS_SENSITIVE_ASSIGNMENT_RE`). 

2116_LOG_MESSAGE_PIPELINES = ( 

2117 (_FULL_PASS_NAME_STEPS, None), 

2118 (_NAME_STEPS_BEFORE_CLOSING, None), 

2119 (_RESTRICTED_FIRST_PASS_STEPS + _FULL_PASS_NAME_STEPS, None), 

2120 (_LEFTMOST_STEPS, None), 

2121 (_START_ORDER_STEPS, None), 

2122 ((_BARE_STEP,), None), 

2123 (_STOP_START_ORDER_STEPS, None), 

2124) 

2125 

2126 

2127class _BarePass(NamedTuple): 

2128 """A name-based log pass and its unanchored variant for glued text.""" 

2129 

2130 pattern: "re.Pattern[str]" 

2131 replacement: str 

2132 name_group: int 

2133 value_after_group: int 

2134 unanchored: "re.Pattern[str]" 

2135 

2136 

2137def _without_name_boundary(pattern: "re.Pattern[str]") -> "re.Pattern[str]": 

2138 return re.compile(pattern.pattern.replace(r"(?<![\w.?&-])", "", 1)) 

2139 

2140 

2141def _log_patcher_bare_passes() -> tuple[_BarePass, ...]: 

2142 """Find the query and name patterns in every live log pipeline. 

2143 

2144 The control-splice guard must follow new name passes automatically; 

2145 otherwise a splice could consume a name and expose its value to a sink. 

2146 All current name passes capture the name and separator as groups 1/2. 

2147 The replacement here is used only to detect whether a prospective match 

2148 would hide anything, so a conservative value marker is sufficient for 

2149 passes with conditional replacement callbacks. 

2150 """ 

2151 passes = [ 

2152 _BarePass( 

2153 _SENSITIVE_QUERY_RE, 

2154 r"\1" + REDACTION_TEXT, 

2155 1, 

2156 1, 

2157 _SENSITIVE_QUERY_RE, 

2158 ) 

2159 ] 

2160 seen = {_SENSITIVE_QUERY_RE} 

2161 for pipelines in (_LOG_TEXT_PIPELINES, _LOG_MESSAGE_PIPELINES): 

2162 for first, second in pipelines: 

2163 for steps in (first, second or ()): 

2164 for pattern, _replacement, _parts in steps: 

2165 if pattern in seen: 

2166 continue 

2167 seen.add(pattern) 

2168 passes.append( 

2169 _BarePass( 

2170 pattern, 

2171 _ASSIGNMENT_REPLACEMENT, 

2172 1, 

2173 2, 

2174 _without_name_boundary(pattern), 

2175 ) 

2176 ) 

2177 return tuple(passes) 

2178 

2179 

2180_LOG_PATCHER_BARE_PASSES = _log_patcher_bare_passes() 

2181_LOG_PATCHER_BARE_GROUPS = { 

2182 bare.pattern: (bare.name_group, bare.value_after_group) 

2183 for bare in _LOG_PATCHER_BARE_PASSES 

2184} 

2185 

2186 

2187def _run_steps(text: str, steps) -> str: 

2188 for pattern, replacement, _parts in steps: 

2189 text = pattern.sub(replacement, text) 

2190 return text 

2191 

2192 

2193def _substitute_tracked( 

2194 pattern: "re.Pattern[str]", parts: Any, text: str, origins: list 

2195) -> tuple[str, list]: 

2196 """``pattern.sub`` on *text*, carrying each character's origin along. 

2197 

2198 *origins* gives, for every character of *text*, the position in the 

2199 original string it came from, or ``-1`` for an inserted one. The result 

2200 is the same text ``re.sub`` gives with the matching replacement, and its 

2201 origins: a kept group keeps its characters' origins, a literal part is 

2202 inserted (``-1``). 

2203 """ 

2204 pieces: list = [] 

2205 # Accumulate directly to avoid retaining one list per match fragment, 

2206 # which can trigger repeated garbage collections on dense credentials. 

2207 merged: list = [] 

2208 position = 0 

2209 for match in pattern.finditer(text): 

2210 start = match.start() 

2211 pieces.append(text[position:start]) 

2212 merged.extend(origins[position:start]) 

2213 for part in parts(match) if callable(parts) else parts: 

2214 if isinstance(part, str): 

2215 pieces.append(part) 

2216 merged.extend([-1] * len(part)) 

2217 continue 

2218 group_start, group_end = match.span(part) 

2219 if group_start < 0: 2219 ↛ 2220line 2219 didn't jump to line 2220 because the condition on line 2219 was never true

2220 continue 

2221 pieces.append(text[group_start:group_end]) 

2222 merged.extend(origins[group_start:group_end]) 

2223 position = match.end() 

2224 if not pieces: 

2225 return text, origins 

2226 pieces.append(text[position:]) 

2227 merged.extend(origins[position:]) 

2228 return "".join(pieces), merged 

2229 

2230 

2231def _strip_control_chars_tracked(text: str, origins: list) -> tuple[str, list]: 

2232 """:func:`strip_control_chars`, carrying origins as 

2233 :func:`_substitute_tracked` does.""" 

2234 kept: list = [] 

2235 kept_origins: list = [] 

2236 position = 0 

2237 for match in _UNSAFE_CHAR_RE.finditer(text): 

2238 kept.append(text[position : match.start()]) 

2239 kept_origins.extend(origins[position : match.start()]) 

2240 position = match.end() 

2241 if not kept: 

2242 return text, origins 

2243 kept.append(text[position:]) 

2244 kept_origins.extend(origins[position:]) 

2245 return "".join(kept), kept_origins 

2246 

2247 

2248def _redact_bounded_log_text(text: str) -> str: 

2249 """:func:`redact_log_message`'s pass, on text already within the bound. 

2250 

2251 Runs the pipelines of :data:`_LOG_MESSAGE_PIPELINES` and shows nothing 

2252 that any of them leaves out (see :func:`_redact_by_pipelines`). 

2253 """ 

2254 return _redact_by_pipelines(text, _LOG_MESSAGE_PIPELINES) 

2255 

2256 

2257def _redact_log_text(value: str) -> str: 

2258 """Redact, strip control characters, then redact again. 

2259 

2260 The first pass sees the original separators, so ``failure\ntoken=x`` does 

2261 not glue into an unrecognisable ``failuretoken=x``; the second catches a 

2262 name that only becomes recognisable once invisible characters are gone 

2263 (``api_<U+200B>key=x``). Used for the message and every ``extra`` string. 

2264 The length bound is applied once, up front, so the second pass never 

2265 re-truncates the first pass's output. 

2266 

2267 Several pipelines run these passes, differing only in how they redact 

2268 names (see :data:`_LOG_TEXT_PIPELINES`), because no single order is 

2269 safe on its own: a replacement made in the first pass changes how the 

2270 second pass, on the stripped text, splits the text around it, and a 

2271 value left for the second pass can be missed there 

2272 (``-\nDB_PASSWORD=x`` is stripped to ``-DB_PASSWORD=x``, a name after 

2273 ``-``). The result shows nothing that any pipeline's result leaves 

2274 out (see :func:`_redact_by_pipelines`). 

2275 """ 

2276 value = _plain_text(value) 

2277 if not value: 

2278 return _exact_str(value) 

2279 head, marker = _truncate_for_log_redaction(value) 

2280 return ( 

2281 _scrub_to_fixed_point( 

2282 lambda text: _redact_by_pipelines(text, _LOG_TEXT_PIPELINES), head 

2283 ) 

2284 + marker 

2285 ) 

2286 

2287 

2288# A credential name directly followed by ``\s*=``. Every name step's 

2289# pattern (:data:`_BARE_SENSITIVE_ASSIGNMENT_RE`, 

2290# :data:`_SENSITIVE_ASSIGNMENT_RE`, :data:`_GLUED_TAIL_RE`, 

2291# :data:`_LEFTMOST_SENSITIVE_ASSIGNMENT_RE`, 

2292# :data:`_START_ORDER_SENSITIVE_ASSIGNMENT_RE`, 

2293# :data:`_FIRST_PASS_SENSITIVE_ASSIGNMENT_RE`) ends its name with an 

2294# alternative of :data:`_LOG_SENSITIVE_NAME_PATTERN` followed by 

2295# ``\s*=``, under the same ``(?i)``, so a text this does not match is 

2296# left unchanged by every name step. 

2297_NAME_ASSIGNMENT_HINT_RE = re.compile( 

2298 "".join((r"(?i)(?:", _LOG_SENSITIVE_NAME_PATTERN, r")\s*=")) 

2299) 

2300 

2301 

2302def _has_name_assignment_hint(text: str) -> bool: 

2303 # No ``=`` at all is the common case, and a substring test is far 

2304 # cheaper than the case-insensitive scan over every name. 

2305 return "=" in text and _NAME_ASSIGNMENT_HINT_RE.search(text) is not None 

2306 

2307 

2308def _result_without_name_assignments( 

2309 shaped: str, pipelines: tuple 

2310) -> Optional[str]: 

2311 """What every pipeline gives when no name step can match, else ``None``. 

2312 

2313 *shaped* is the text after :data:`_SHAPE_STEPS`, where every first 

2314 pass starts. When neither it nor (for pipelines with a second pass) 

2315 the second pass's text after its shape steps holds a credential name 

2316 followed by ``=`` (:data:`_NAME_ASSIGNMENT_HINT_RE`), every name step 

2317 is a no-op and every pipeline gives the same text, which is returned 

2318 -- the text :func:`_redact_by_pipelines` would return, without running 

2319 the name steps of each pipeline (most log records). ``None`` means the 

2320 pipelines must run. 

2321 """ 

2322 if _has_name_assignment_hint(shaped): 

2323 return None 

2324 second_passes = {second is None for _first, second in pipelines} 

2325 if second_passes == {True}: 

2326 return shaped 

2327 if second_passes != {False}: 2327 ↛ 2328line 2327 didn't jump to line 2328 because the condition on line 2327 was never true

2328 return None 

2329 second_text = _run_steps(strip_control_chars(shaped), _SHAPE_STEPS) 

2330 if _has_name_assignment_hint(second_text): 

2331 return None 

2332 return second_text 

2333 

2334 

2335def _redact_by_pipelines(head: str, pipelines: tuple) -> str: 

2336 """*head* as every pipeline of *pipelines* redacts it, together. 

2337 

2338 Runs them untracked first. When the text holds no credential name 

2339 followed by ``=`` (before or after control characters are stripped), 

2340 no name step can match and the shape-redacted text is returned 

2341 directly (see :func:`_result_without_name_assignments`). In the usual 

2342 remaining case they all give the same text, which is returned; 

2343 otherwise :func:`_redact_log_text_union` reruns them with origins 

2344 tracked. 

2345 """ 

2346 run = _PipelineRun(head, tracked=False, pipelines=pipelines) 

2347 without_names = _result_without_name_assignments(run.shaped, pipelines) 

2348 if without_names is not None: 

2349 return without_names 

2350 indexes = range(len(pipelines)) 

2351 if len({run.first_pass(index) for index in indexes}) == 1: 

2352 # The first passes agree, so the pipelines differ at most in the 

2353 # name steps of the second. 

2354 results = {run.result(index)[0] for index in indexes} 

2355 if len(results) == 1: 

2356 return results.pop() 

2357 return _redact_log_text_union(head, run.unmatched, pipelines) 

2358 

2359 

2360class _PipelineRun: 

2361 """The pipelines of *pipelines* (by default :data:`_LOG_TEXT_PIPELINES`) 

2362 on one text. 

2363 

2364 Each pipeline is a pair: the name steps of its first pass and those of 

2365 its second, or ``None`` for a pipeline with no second pass (no control 

2366 characters stripped). Steps several pipelines share on the same text 

2367 (the shape steps of the first pass, the bare names, a second pass 

2368 reached from the same first pass) run once. *tracked* carries each 

2369 character's origin along (see :func:`_substitute_tracked`); otherwise 

2370 origins are ``None``. An untracked run records each step that matched 

2371 nothing on a text (:attr:`unmatched`); a tracked run given that set 

2372 skips those steps, which leave both the text and its origins unchanged. 

2373 """ 

2374 

2375 def __init__( 

2376 self, 

2377 head: str, 

2378 *, 

2379 tracked: bool, 

2380 unmatched: Optional[set] = None, 

2381 pipelines: Optional[tuple] = None, 

2382 credential_spans: Optional[list] = None, 

2383 origins: Optional[list] = None, 

2384 ) -> None: 

2385 self._tracked = tracked 

2386 self._pipelines = ( 

2387 _LOG_TEXT_PIPELINES if pipelines is None else pipelines 

2388 ) 

2389 self._memo: dict = {} 

2390 self.unmatched: set = set() if unmatched is None else unmatched 

2391 self._credential_spans = credential_spans 

2392 if tracked and origins is None: 

2393 origins = list(range(len(head))) 

2394 self._shared = self._run((head, origins), _SHAPE_STEPS) 

2395 

2396 @property 

2397 def shaped(self) -> str: 

2398 """The text after :data:`_SHAPE_STEPS`, where every first pass 

2399 starts.""" 

2400 return self._shared[0] 

2401 

2402 def _cached(self, key: tuple, state: tuple, compute) -> tuple: 

2403 # Keyed by text; reused only for equal origins, so two pipelines 

2404 # that reach the same text from different characters stay apart. 

2405 entries = self._memo.setdefault(key, []) 

2406 for origins, result in entries: 

2407 if origins is state[1] or origins == state[1]: 

2408 return result 

2409 result = compute() 

2410 entries.append((state[1], result)) 

2411 return result 

2412 

2413 def _substitute(self, step: tuple, state: tuple) -> tuple: 

2414 pattern, replacement, parts = step 

2415 if self._tracked: 

2416 if (state[0], step) in self.unmatched: 

2417 return state 

2418 if self._credential_spans is not None: 

2419 self._collect_credential_spans(step, state) 

2420 return _substitute_tracked(pattern, parts, *state) 

2421 text, count = pattern.subn(replacement, state[0]) 

2422 if not count: 

2423 self.unmatched.add((state[0], step)) 

2424 return text, None 

2425 

2426 def _collect_credential_spans(self, step: tuple, state: tuple) -> None: 

2427 collected = self._credential_spans 

2428 if collected is None: 2428 ↛ 2429line 2428 didn't jump to line 2429 because the condition on line 2428 was never true

2429 return 

2430 pattern, replacement, _parts = step 

2431 groups = _LOG_PATCHER_BARE_GROUPS.get(pattern) 

2432 if groups is None: 

2433 return 

2434 name_group, value_after = groups 

2435 text, origins = state 

2436 spans = [] 

2437 for match in pattern.finditer(text): 

2438 rendered = ( 

2439 replacement(match) 

2440 if callable(replacement) 

2441 else match.expand(replacement) 

2442 ) 

2443 if rendered == match.group(): 

2444 continue 

2445 name = [ 

2446 offset 

2447 for offset in origins[ 

2448 match.start(name_group) : match.end(value_after) 

2449 ] 

2450 if offset >= 0 

2451 ] 

2452 value = [ 

2453 offset 

2454 for offset in origins[match.end(value_after) : match.end()] 

2455 if offset >= 0 

2456 ] 

2457 if name and value: 2457 ↛ 2437line 2457 didn't jump to line 2437 because the condition on line 2457 was always true

2458 spans.append((min(name), min(value), max(value) + 1)) 

2459 if spans: 

2460 collected.append(spans) 

2461 

2462 def _run(self, state: tuple, steps: tuple) -> tuple: 

2463 for step in steps: 

2464 # Reuse identical steps reached through different pipelines. 

2465 # _cached also checks origins before reusing a tracked result. 

2466 state = self._cached( 

2467 (state[0], step), 

2468 state, 

2469 lambda: self._substitute(step, state), 

2470 ) 

2471 return state 

2472 

2473 def first_pass(self, index: int) -> tuple: 

2474 return self._run(self._shared, self._pipelines[index][0]) 

2475 

2476 def result(self, index: int) -> tuple: 

2477 state = self.first_pass(index) 

2478 second = self._pipelines[index][1] 

2479 if second is None: 

2480 return state 

2481 if self._tracked: 

2482 stripped = self._cached( 

2483 (state[0], "strip"), 

2484 state, 

2485 lambda: _strip_control_chars_tracked(*state), 

2486 ) 

2487 else: 

2488 stripped = (strip_control_chars(state[0]), None) 

2489 return self._run(stripped, _SHAPE_STEPS + second) 

2490 

2491 

2492_LOG_TEXT_PIPELINE_INDEXES = range(len(_LOG_TEXT_PIPELINES)) 

2493 

2494 

2495def _pipeline_credential_spans(text: str) -> list: 

2496 """Map later name/value readings back to the original input.""" 

2497 spans: list = [] 

2498 run = _PipelineRun(text, tracked=True, credential_spans=spans) 

2499 for index in _LOG_TEXT_PIPELINE_INDEXES: 

2500 run.result(index) 

2501 # A plain query replacement can expose a name that the original URL 

2502 # made part of another value. Cover the later log-pipeline readings 

2503 # after that replacement too, before extending across line breaks or 

2504 # quoted values would remove the newly exposed name. 

2505 pieces: list[str] = [] 

2506 origins: list[int] = [] 

2507 position = 0 

2508 for match in _URL_QUERY_STRING_RE.finditer(text): 

2509 pieces.append(text[position : match.start()]) 

2510 origins.extend(range(position, match.start())) 

2511 replacement = _restore_query_wrapper(match) 

2512 pieces.append(replacement) 

2513 origins.extend(range(match.start(), match.start(2))) 

2514 origins.extend([-1] * len(_URL_QUERY_MARKER)) 

2515 if len(replacement) > len(match.group(1)) + 1 + len(_URL_QUERY_MARKER): 

2516 origins.append(match.end() - 1) 

2517 position = match.end() 

2518 if pieces: 

2519 pieces.append(text[position:]) 

2520 origins.extend(range(position, len(text))) 

2521 projected = "".join(pieces) 

2522 if projected != text: 

2523 run = _PipelineRun( 

2524 projected, 

2525 tracked=True, 

2526 credential_spans=spans, 

2527 origins=origins, 

2528 ) 

2529 for index in _LOG_TEXT_PIPELINE_INDEXES: 

2530 run.result(index) 

2531 return spans 

2532 

2533 

2534def _redact_log_text_union( 

2535 head: str, unmatched: set, pipelines: Optional[tuple] = None 

2536) -> str: 

2537 """Every pipeline of *pipelines* (by default :data:`_LOG_TEXT_PIPELINES`), 

2538 with origins tracked. 

2539 

2540 Each character of a pipeline's result is followed back to the position 

2541 of *head* it came from (see :func:`_substitute_tracked`). A position 

2542 that some pipeline's result no longer holds was redacted (or stripped) 

2543 there; the first pipeline's result is returned with every run of such 

2544 characters replaced by one ``[REDACTED]``. A pipeline whose result is 

2545 the same text as the first's adds nothing and is skipped. *unmatched* 

2546 is :attr:`_PipelineRun.unmatched` of an untracked run on *head*. 

2547 """ 

2548 run = _PipelineRun( 

2549 head, tracked=True, unmatched=unmatched, pipelines=pipelines 

2550 ) 

2551 kept_text, kept_origins = run.result(0) 

2552 removed = bytearray(len(head)) 

2553 for index in range(1, len(run._pipelines)): 

2554 text, origins = run.result(index) 

2555 if text == kept_text: 

2556 # Prints exactly what the kept text prints. 

2557 continue 

2558 survived = bytearray(len(head)) 

2559 for origin in origins: 

2560 if origin >= 0: 

2561 survived[origin] = 1 

2562 for origin in kept_origins: 

2563 if origin >= 0 and not survived[origin]: 

2564 removed[origin] = 1 

2565 out: list = [] 

2566 in_run = False 

2567 width = len(REDACTION_TEXT) 

2568 for position, (character, origin) in enumerate( 

2569 zip(kept_text, kept_origins) 

2570 ): 

2571 if origin >= 0 and removed[origin]: 

2572 # A run next to a replacement the kept pipeline made already 

2573 # reads ``[REDACTED]``; do not double it. 

2574 if not in_run and not "".join(out[-width:]).endswith( 

2575 REDACTION_TEXT 

2576 ): 

2577 out.append(REDACTION_TEXT) 

2578 in_run = True 

2579 continue 

2580 if ( 

2581 in_run 

2582 and out[-1] is REDACTION_TEXT 

2583 and kept_text.startswith(REDACTION_TEXT, position) 

2584 and max(kept_origins[position : position + width]) < 0 

2585 ): 

2586 out.pop() 

2587 in_run = False 

2588 out.append(character) 

2589 return "".join(out) 

2590 

2591 

2592def _type_is_one_of(kind: Any, types: tuple) -> bool: 

2593 """True when *kind* is one of *types* (by identity). 

2594 

2595 ``kind in types`` compares with ``==``, which a metaclass ``__eq__`` of 

2596 an application class can answer ``True`` for ``str`` or ``list``. 

2597 """ 

2598 return any(kind is candidate for candidate in types) 

2599 

2600 

2601# Credential shapes the log-record path redacts on top of the passes above. 

2602# They run ONLY as the last step of :func:`redact_log_record`'s message and 

2603# ``extra`` string redaction (:func:`_redact_log_record_text`), its rendered 

2604# exception fields (:func:`_redact_exception_text`), and the exception prefix 

2605# of a database log row (``utilities.log_utils._exception_context``), whose 

2606# output goes straight to the sinks: no pass of 

2607# ``sanitize_error_message``, ``scrub_error``, the client helpers or 

2608# :func:`redact_log_message` ever runs over what these patterns write. Those 

2609# functions do not apply them, so their output is what it was before these 

2610# patterns existed. These patterns only replace part of the text they are 

2611# given with a ``[REDACTED]`` marker; whatever the earlier passes redacted 

2612# stays redacted, so where they run they can hide more, never less. 

2613# 

2614# Their value boundaries are conservative, and what may follow a value is an 

2615# allowlist. A value stops before a credential label (``_VALUE_STOP``; that 

2616# label is then matched on its own) and before a URL scheme, and it is 

2617# redacted only when what follows it ends a value in its syntax: one of the 

2618# structural followers listed below for its kind, after optional whitespace 

2619# that is not a line break, or a line break, or the end of the text 

2620# (``_cuts_a_value``, ``_API_KEY_FOLLOWER_RE``, ``_DSN_HOST_RE``). Anything 

2621# else -- a word after a space, another quote, a backslash, an opening 

2622# bracket, or a symbol a secret may contain (``&``, ``!``, ``$``, ``/``, 

2623# ``.``, ``@`` ...) -- means the value may go on past the match, so the 

2624# text is left exactly as the earlier passes left it: replacing only part 

2625# of a value would print the rest of it behind a marker that makes the 

2626# line look redacted. A 

2627# quoted value holding the other kind of quote, an unquoted value that 

2628# starts with a string prefix before a quote (``_UNQUOTED_PREFIX_STOP``) and 

2629# one that leaves a bracket it opened unclosed are left as they were too. 

2630# 

2631# What no allowlist can tell apart: a secret written raw into a quoted or 

2632# unquoted slot (JSON and a repr escape a quote inside a string; a 

2633# hand-built ``f"{{'password': '{pw}'}}"`` does not) that itself holds a 

2634# quote or a value end followed by a structural follower. ``'ab',cd'`` or 

2635# ``{'password': ab,cd}`` has exactly the shape of the value ``'ab'`` (or 

2636# ``ab``) followed by another item, so the part after the follower prints 

2637# behind the marker. The same holds for a DSN password that holds an '@' 

2638# followed by what reads as a host and its end (``_DSN_HOST_RE``). 

2639# 

2640# The structural followers, by the kind of value they follow. After a 

2641# quoted value's closing quote (a JSON or repr string, a quoted parameter): 

2642# ``,`` the next item of an object, a list, a call or a parameter list; 

2643# ``}``, ``]`` and ``)`` the end of an object, a list, a call or a tuple; 

2644# ``:`` the separator after an item in a ``name: value`` listing 

2645# (``f"{key!r}: {value!r}: {reason}"``); ``;`` the next parameter or cookie 

2646# attribute (``password="..."; Path=/``); ``&`` the next query or form 

2647# parameter; ``#`` a URL fragment or a trailing comment. 

2648_QUOTED_VALUE_FOLLOWERS = ",}]):;&#" 

2649# After an unquoted value of a quoted key (JSON or a repr, where an unquoted 

2650# value is a number, a literal, a name or a call): ``,`` the next item, and 

2651# ``}`` or ``]`` the end of the object or list. Nothing else ends such a 

2652# value: ``&`` and ``#`` never do in JSON or a repr, the run takes ``;``, 

2653# ``:`` and ``)`` in (a call ends at its own ``)``), and a word after a 

2654# space means the value may be several words (``Basic dXNl...``, 

2655# ``correct horse battery staple``). 

2656_KEY_VALUE_FOLLOWERS = ",}]" 

2657# After an unquoted ``;``/``,`` parameter value (matrix parameters, joined 

2658# lists, call arguments): ``,`` and ``;`` the next parameter, and ``)``, 

2659# ``]`` and ``}`` the end of the call, list or object the parameters are 

2660# in. ``&`` and ``#`` belong to ``?``/``&`` query syntax (its separator 

2661# and the fragment), not to these parameters, so a value that stops at one 

2662# may go on past it (``;key=ab&cd``), and a word after a space means the 

2663# value may be several words. 

2664_PARAM_VALUE_FOLLOWERS = ",;)]}" 

2665# After an ``Api-Key:`` header value (a run of the header pattern's token 

2666# class, below): ``,``, ``;``, ``)``, ``]`` and ``}`` end the list, call or 

2667# object the header sits in, and a word after a space means the value may 

2668# be several words, as above. A quote that closes the string the header 

2669# sits in counts too, when one of ``_QUOTED_VALUE_FOLLOWERS``, a line break 

2670# or the end of the text follows it (``['Api-Key: ...', ...]``), and so 

2671# does ``://`` straight after the run: a URL glued to the key, whose scheme 

2672# letters the run took in. 

2673_API_KEY_VALUE_FOLLOWERS = ",;)]}" 

2674 

2675 

2676def _followers_pattern(followers: str) -> str: 

2677 """Matches at the end of a value only when the value ends there: 

2678 optional whitespace other than a line break, then one of *followers*, 

2679 a line break (``\\n``, ``\\r``) or the end of the text.""" 

2680 return r"[^\S\r\n]*+(?:[" + re.escape(followers) + r"\r\n]|\Z)" 

2681 

2682 

2683_QUOTED_VALUE_FOLLOWER_RE = re.compile( 

2684 _followers_pattern(_QUOTED_VALUE_FOLLOWERS) 

2685) 

2686_KEY_VALUE_FOLLOWER_RE = re.compile(_followers_pattern(_KEY_VALUE_FOLLOWERS)) 

2687_PARAM_VALUE_FOLLOWER_RE = re.compile( 

2688 _followers_pattern(_PARAM_VALUE_FOLLOWERS) 

2689) 

2690_API_KEY_FOLLOWER_RE = re.compile( 

2691 "://|" 

2692 + _followers_pattern(_API_KEY_VALUE_FOLLOWERS) 

2693 + r"|[^\S\r\n]*+[\"'](?=" 

2694 + _followers_pattern(_QUOTED_VALUE_FOLLOWERS) 

2695 + ")" 

2696) 

2697# After the '@' that ends a would-be DSN's userinfo: a host (a name or an 

2698# IPv4 address, an IPv6 literal in brackets, or a ``${VAR}`` placeholder) 

2699# followed by what ends a host: whitespace, ``:`` before a port, ``/``, 

2700# ``?``, ``#``, a quote, ``<``, ``>``, ``,``, ``;``, ``)``, ``]``, ``}``, a 

2701# backslash or the end of the text. Anything else after the '@' (another 

2702# '@', a symbol, a space) means the password may hold an unescaped '@' and 

2703# go on past it. 

2704_DSN_HOST_RE = re.compile( 

2705 r"(?:[A-Za-z0-9][A-Za-z0-9.\-_~%]*+|\[[0-9A-Za-z:.%]++\]|\$\{\w++\})" 

2706 r"(?=[\s:/?#\"'<>,;)\]}\\]|\Z)" 

2707) 

2708# Credential parameter names for the ``;``/``,`` parameter pattern below: 

2709# the same names the ``?``/``&`` query-parameter pattern above redacts. 

2710_CREDENTIAL_PARAM_NAMES = ( 

2711 r"api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|" 

2712 r"auth[_-]?token|session[_-]?token|secret[_-]?key|bearer[_-]?token|" 

2713 r"subscription[_-]?key|client[_-]?secret|api[_-]?secret|" 

2714 r"app[_-]?secret|private[_-]?key|" 

2715 r"key|token|secret|password|passwd|pwd" 

2716) 

2717# A credential label: a ``name=`` that ``redact_sensitive_assignments`` 

2718# redacts (a ``LOG_SENSITIVE_KEYS`` name, optional whitespace before the 

2719# ``=``) or that the parameter pattern below redacts 

2720# (``_CREDENTIAL_PARAM_NAMES``). 

2721_CREDENTIAL_LABEL = ( 

2722 r"(?:(?i:" + _LOG_SENSITIVE_NAME_PATTERN + r")\s*=" 

2723 r"|(?i:" + _CREDENTIAL_PARAM_NAMES + r")=)" 

2724) 

2725# Checked before each character of a value matched below. A credential 

2726# label is found only after a character outside ``[\w.-]`` (the assignment 

2727# pass's left boundary; the ``,``/``;`` anchors of the parameter pattern), 

2728# so the value stops before such a character when a label follows it: the 

2729# label and its anchor stay as they were, legible, and the label's own 

2730# value is matched separately. 

2731_LABEL_STOP = "".join((r"(?![^\w.\-]", _CREDENTIAL_LABEL, r")")) 

2732# At the first character of a value: no label there either. 

2733_NO_LABEL = "".join((r"(?!", _CREDENTIAL_LABEL, r")")) 

2734# Also checked before each character: a value stops before a URL scheme 

2735# that starts a run of scheme characters (a letter, then letters, digits, 

2736# ``+``, ``.`` or ``-`` reaching ``://`` within 32 characters), so a URL 

2737# after a value stays legible; the URL-credential patterns have already 

2738# redacted its userinfo. 

2739_SCHEME_STOP = r"(?!(?<![A-Za-z0-9+.\-])[A-Za-z][A-Za-z0-9+.\-]{0,31}://)" 

2740_VALUE_STOP = _LABEL_STOP + _SCHEME_STOP 

2741# A name the assignment pass redacts, at the start of its ``name=``. 

2742_ASSIGNMENT_NAME = "".join((r"(?:(?i:", _LOG_SENSITIVE_NAME_PATTERN, r")\s*=)")) 

2743# Quoted keys (JSON, Python repr) name a credential with the names an 

2744# ``extra`` key is redacted under: ``LOG_SENSITIVE_KEYS``, through 

2745# ``_LOG_SENSITIVE_NAME_PATTERN`` (``_`` also matching ``-`` or nothing), so 

2746# the message text and the ``extra`` keys cannot drift apart. As for an 

2747# ``extra`` key (``_is_sensitive_log_key``: the last dotted segment, equal 

2748# to a name or ending in ``_`` and the name), the name may follow any 

2749# prefix that ends in a ``_``, ``.`` or ``-`` (``_QUOTED_KEY_PREFIX``): 

2750# ``"_api_key"``, ``"db__password"``, ``"csrf_token"``, the request-header 

2751# dicts the search engines send (``'X-API-KEY'``, ``'x-api-key'``, 

2752# ``'X-Api-Key'``, ``'X-Subscription-Token'``), settings dumps 

2753# (``"openai_api_key"``, ``"Ocp-Apim-Subscription-Key"``), the project's 

2754# dotted settings keys (``"llm.openai.api_key"``) and its environment 

2755# variables (``"LDR_SEARCH_ENGINE_WEB_TAVILY_API_KEY"``). A ``-`` before the 

2756# name counts too, which an ``extra`` key does not need. The name must end 

2757# the key, so ``"secret_name"`` or ``"password_hint"`` is not a credential 

2758# key; a name glued to a camelCase prefix (``"dbPassword"``) is not one 

2759# either, as for an ``extra`` key. Any key that ends in one is, so 

2760# ``"show_password"`` or ``"use_secret"`` is redacted unless its value is a 

2761# bare literal (over-redaction only); ``"requires_api_key"``, which 

2762# ``_is_sensitive_log_key`` exempts, is redacted too when its value is not 

2763# a bare literal. 

2764# 

2765# The cost stays linear in the key: the prefix cannot contain a quote, a 

2766# backslash or a newline, so the scan from one opening quote ends before 

2767# the next one starts; the prefix is lazy, so a name is tried once at each 

2768# position, and the atomic group commits to the first position whose name 

2769# reaches the closing quote (every decomposition ends at that same quote), 

2770# so the value is matched once per key. 

2771_QUOTED_CREDENTIAL_KEY_NAMES = _LOG_SENSITIVE_NAME_PATTERN 

2772_QUOTED_KEY_PREFIX = r"(?:[^\"'\n\\]*?[_.\-])?" 

2773# An unquoted value that is a whole JSON/Python literal (``true``, 

2774# ``False``, ``None``, ``null``) is not a secret; with prefixed names 

2775# (``"requires_api_key": True``) it is common, so it is left legible. 

2776_UNQUOTED_LITERAL_STOP = r"(?!(?:true|false|none|null)(?![^\s,}\]&#\"'\\]))" 

2777# A label inside a would-be DSN password, with the assignment pass's own 

2778# left boundary. 

2779_LABEL_IN_USERINFO_RE = re.compile(r"(?<![\w.?&-])" + _CREDENTIAL_LABEL) 

2780 

2781# A quoted value for the parameter and quoted-key patterns below: to its 

2782# closing quote (or the end of the text), with no credential label or URL 

2783# scheme inside, and no quote character of the other kind (escaped or 

2784# not). ``_VALUE_STOP`` is checked before every character, the one after a 

2785# backslash included: a backslash escape is two characters, and checking 

2786# only before the backslash would let an escaped separator (a backslash and 

2787# a comma) hide a quoted label's ``password =`` after it, so the value would 

2788# run on to that label's own opening quote and take the label with it. 

2789_QUOTED_VALUE = "".join( 

2790 ( 

2791 r"\"", 

2792 _NO_LABEL, 

2793 r"(?:", 

2794 _VALUE_STOP, 

2795 r"(?:\\", 

2796 _VALUE_STOP, 

2797 r"[^'\n]|[^\"'\\]))*(?:\"|\\?$)", 

2798 r"|'", 

2799 _NO_LABEL, 

2800 r"(?:", 

2801 _VALUE_STOP, 

2802 r"(?:\\", 

2803 _VALUE_STOP, 

2804 r"[^\"\n]|[^'\"\\]))*(?:'|\\?$)", 

2805 ) 

2806) 

2807# A quoted value as above, after an optional Python string prefix 

2808# (``b'...'``, ``rb"..."``, ``f'...'``), so a bytes value or a bytes-keyed 

2809# repr (``{b'password': b'...'}``) is redacted whole. 

2810# ``_redacted_value`` keeps the prefix. 

2811_PREFIXED_QUOTED_VALUE = "".join( 

2812 (r"(?:[bruf]{1,2}(?=[\"']))?(?:", _QUOTED_VALUE, r")") 

2813) 

2814# At the first character of an unquoted value: not a string prefix before a 

2815# quote. Matched as an unquoted run, ``b'secret'`` would stop at the quote, 

2816# so the marker would replace only the ``b``; it is matched whole by 

2817# ``_PREFIXED_QUOTED_VALUE`` instead, or left as it is when that refuses it. 

2818_UNQUOTED_PREFIX_STOP = r"(?![bruf]{1,2}[\"'])" 

2819# An unquoted value of a quoted key that is written as an ``Authorization`` 

2820# header's is: a scheme word (the schemes ``sanitize_error_message``'s 

2821# ``Authorization`` pattern names) and one credential token. It is taken as 

2822# one value, whatever the key, so the marker replaces both 

2823# (``{'Authorization': Basic dXNl...}``). Anything but a structural 

2824# follower after the token (a second token, the ``username=..., 

2825# response=...`` list of a ``Digest`` value) leaves the text as it was. 

2826_SCHEME_CREDENTIAL = ( 

2827 r"(?:basic|bearer|digest|negotiate|apikey|token)" 

2828 r"[^\S\r\n]++[A-Za-z0-9\-._~+/]++=*" 

2829) 

2830# Brackets an unquoted value may open and the ones that close them. 

2831_VALUE_BRACKETS = (("(", ")"), ("[", "]"), ("{", "}")) 

2832 

2833 

2834def _cuts_a_value( 

2835 text: str, 

2836 start: int, 

2837 end: int, 

2838 unquoted_followers: "re.Pattern[str]", 

2839) -> bool: 

2840 """True unless ``text[start:end]``, a value matched below, ends where 

2841 its syntax ends a value, so that replacing it cannot print the rest of 

2842 the value behind a ``[REDACTED]`` marker that makes the line look 

2843 redacted. The callers then return the text unchanged. 

2844 

2845 An allowlist: the value is taken as whole only when what follows it 

2846 matches ``_QUOTED_VALUE_FOLLOWER_RE`` (after a quoted value's closing 

2847 quote, or its end when it runs to the end of the text) or 

2848 *unquoted_followers* (after an unquoted run): a structural follower for 

2849 that kind of value, after optional whitespace that is not a line 

2850 break, or a line break, or the end of the text. Any other character 

2851 refuses it: a quote (``'''secret'''``, whose first two quotes are an 

2852 empty string, or a quote that opens a string belonging to the value, 

2853 ``SecretStr('...')``), a backslash, a word after a space, or a symbol a 

2854 secret may hold (``'Pa'$$w0rd'``, ``hunter2&rest``). An unquoted value 

2855 that leaves a bracket it opened unclosed is not whole either, whatever 

2856 follows it: a nested container or a call stops at its first separator 

2857 (``(1, '...')``, ``{0: '...'}``, ``Foo(a, '...')``), so the rest of it 

2858 is outside the match. 

2859 """ 

2860 value = text[start:end] 

2861 if _redacted_value(value) != "[REDACTED]": 

2862 # A quoted value, string prefix and all (``b'...'``). 

2863 return _QUOTED_VALUE_FOLLOWER_RE.match(text, end) is None 

2864 if unquoted_followers.match(text, end) is None: 

2865 return True 

2866 return any( 

2867 value.count(opener) > value.count(closer) 

2868 for opener, closer in _VALUE_BRACKETS 

2869 ) 

2870 

2871 

2872def _redacted_value(value: str) -> str: 

2873 """``[REDACTED]`` in place of a value matched by the patterns below. 

2874 

2875 A quoted value keeps its quotes (its closing quote only when it has 

2876 one) and its string prefix (``b'[REDACTED]'``), so the text after it 

2877 is still delimited as it was. 

2878 """ 

2879 stripped = value.lstrip("bBrRuUfF") 

2880 prefix = value[: len(value) - len(stripped)] 

2881 quote = stripped[:1] 

2882 if quote not in ('"', "'") or len(prefix) > 2: 

2883 return "[REDACTED]" 

2884 body = stripped[1:] 

2885 closed = False 

2886 if body.endswith(quote): 

2887 inner = body[:-1] 

2888 closed = (len(inner) - len(inner.rstrip("\\"))) % 2 == 0 

2889 return prefix + quote + "[REDACTED]" + (quote if closed else "") 

2890 

2891 

2892def _redact_slash_dsn(match: "re.Match[str]") -> str: 

2893 """Replacement for the slash-DSN pattern below. 

2894 

2895 A password run that holds a credential label (``/password=...``, 

2896 ``;token=...``) is not userinfo but a path running into that label's 

2897 value, up to an '@' inside it. Redacting it would remove the label the 

2898 parameter pattern below needs and print the value's tail, so the text 

2899 is returned unchanged for it. Checked here rather than inside the 

2900 regex: a per-character label lookahead made the capped scan several 

2901 times slower. 

2902 

2903 The '@' is taken as the end of the userinfo only when a host follows 

2904 it and ends where a host ends (``_DSN_HOST_RE``). Anything else, such 

2905 as a second '@' (``u:pa/ss@word@host``), a symbol or a space after it, 

2906 means the password may hold an unescaped '@', so which '@' ends the 

2907 userinfo cannot be told: the text is returned unchanged rather than 

2908 printing the password's tail behind the marker. 

2909 """ 

2910 if _LABEL_IN_USERINFO_RE.search(match.string, match.start(4), match.end(4)): 2910 ↛ 2911line 2910 didn't jump to line 2911 because the condition on line 2910 was never true

2911 return match.group(0) 

2912 if _DSN_HOST_RE.match(match.string, match.end()) is None: 

2913 return match.group(0) 

2914 return match.group(1) + match.group(2) + "[REDACTED]:[REDACTED]@" 

2915 

2916 

2917def _redact_api_key_header(match: "re.Match[str]") -> str: 

2918 """Replacement for the ``Api-Key:`` header pattern below. 

2919 

2920 The text is returned unchanged unless the value ends where a header 

2921 value ends (``_API_KEY_FOLLOWER_RE``). 

2922 """ 

2923 if _API_KEY_FOLLOWER_RE.match(match.string, match.end()) is None: 

2924 return match.group(0) 

2925 return match.group(1) + "[REDACTED]" 

2926 

2927 

2928def _redact_credential_param(match: "re.Match[str]") -> str: 

2929 """Replacement for the ``;``/``,`` credential-parameter pattern below. 

2930 

2931 The text is returned unchanged unless the matched value ends where a 

2932 value ends (:func:`_cuts_a_value`, with ``_PARAM_VALUE_FOLLOWERS`` after 

2933 an unquoted value). 

2934 """ 

2935 group = 3 if match.group(3) is not None else 5 

2936 if _cuts_a_value( 

2937 match.string, 

2938 match.start(group), 

2939 match.end(group), 

2940 _PARAM_VALUE_FOLLOWER_RE, 

2941 ): 

2942 return match.group(0) 

2943 return ( 

2944 match.group(1) 

2945 + (match.group(2) or match.group(4)) 

2946 + "=" 

2947 + _redacted_value(match.group(group)) 

2948 ) 

2949 

2950 

2951def _redact_quoted_key(match: "re.Match[str]") -> str: 

2952 """Replacement for the quoted-credential-key pattern below. 

2953 

2954 The text is returned unchanged unless the matched value ends where a 

2955 value ends (:func:`_cuts_a_value`, with ``_KEY_VALUE_FOLLOWERS`` after 

2956 an unquoted value). 

2957 """ 

2958 if _cuts_a_value( 

2959 match.string, match.start(4), match.end(4), _KEY_VALUE_FOLLOWER_RE 

2960 ): 

2961 return match.group(0) 

2962 return ( 

2963 match.group(1) 

2964 + match.group(2) 

2965 + match.group(1) 

2966 + match.group(3) 

2967 + _redacted_value(match.group(4)) 

2968 ) 

2969 

2970 

2971_ADDITIONAL_CREDENTIAL_PATTERNS: list[ 

2972 tuple[re.Pattern[str], Union[str, Callable[["re.Match[str]"], str]]] 

2973] = [ 

2974 # Slash-bearing DSN passwords — base64/base64-ish generated secrets 

2975 # pasted into a connection string routinely contain an unescaped '/', 

2976 # which the URL-credentials pattern in ``_CREDENTIAL_PATTERNS`` refuses 

2977 # by design. Admitting the slash makes one URL shape look like 

2978 # userinfo: ``host:port/path...@...`` 

2979 # (``https://api.example.com:443/users/bob@example.com``), where the 

2980 # "user" is really the host and the "password" a port followed by a 

2981 # path. A password that is a run of digits followed by ``/``, ``?`` or 

2982 # ``#`` (or that starts with one of them) is that shape, so it is 

2983 # refused; the user part also refuses ``[``/``]`` so an IPv6 literal 

2984 # host is never read as userinfo. Any host is accepted after the '@' 

2985 # (``@postgres:5432``, ``@localhost``, ``@10.0.0.5`` are the usual 

2986 # container DSNs). Known gap: a real password that is digits followed 

2987 # by '/' is left alone. 

2988 # 

2989 # Neither group crosses a JSON, list or query boundary: the user refuses 

2990 # ``"'<>,`` and ``?#&=;``, the password ``"'<>,`` and ``?#&``. A 

2991 # match whose password holds a credential label (``/password=``, 

2992 # ``;token=``, ``=api_key=``), or whose '@' is not followed by a host 

2993 # and a host's end (``_DSN_HOST_RE``; ``u:pa/ss@word@host``, where the 

2994 # password may hold an unescaped '@'), is left alone 

2995 # (``_redact_slash_dsn``). Known gap: a slash-bearing password that 

2996 # also contains one of those characters is not redacted by this 

2997 # pattern, nor one that contains an '@' unless what follows that '@' 

2998 # reads as a host and its end (``u:pa/ss@word/x@host``), which cannot 

2999 # be told from a DSN followed by more text, so the rest of such a 

3000 # password prints behind the marker. 

3001 # 

3002 # Both groups are length-capped: an uncapped password class that may 

3003 # run through '/' made matching quadratic on a whitespace-free run of 

3004 # ``scheme://:`` with no '@' (seconds of GIL-holding CPU at ~30k 

3005 # chars). The caps bound the work per start position, which keeps the 

3006 # scan linear; a longer user or password is not redacted by this 

3007 # pattern. 

3008 # 

3009 # Each ``://`` is also tried once, not once per scheme start: a run of 

3010 # scheme characters is entered only at its first character (the 

3011 # lookbehind), a lazy prefix (group 1, put back by the replacement) 

3012 # skips to the leftmost position where a scheme reaches the ``://`` -- 

3013 # where an unanchored search would have started -- and the atomic group 

3014 # commits to that scheme. Every later start before the same ``://`` 

3015 # would retry the identical user/password tail, so dropping them changes 

3016 # no result; without this, a 32-letter scheme repeated with ``://:`` 

3017 # ran up to 31 capped tails per ``://`` (about 6x the cost of the 

3018 # URL-credentials pattern on the same run). The user and password 

3019 # quantifiers are possessive: the character that must follow each is 

3020 # outside its class, so giving characters back can never succeed. 

3021 ( 

3022 re.compile( 

3023 r"(?<![A-Za-z0-9+.\-])" 

3024 r"(?>([A-Za-z0-9+.\-]*?)([A-Za-z][A-Za-z0-9+.\-]{1,31}://))" 

3025 r"([^:\s/@\[\]\"'<>,?#&=;]{0,128}+):(?![0-9]*[/?#])" 

3026 r"([^@\s\"'<>,?&#]{1,256}+)@" 

3027 ), 

3028 _redact_slash_dsn, 

3029 ), 

3030 # API-key header spellings the ``x-api-key`` pattern in 

3031 # ``_CREDENTIAL_PATTERNS`` misses — the unprefixed ``Api-Key:`` form is 

3032 # what Anthropic, Azure Cognitive Services and several search APIs 

3033 # send, plus ``x-api_key``/``x-apikey``. Like the ``x-api-key`` 

3034 # pattern, the label is a strong anchor, so any value of >=16 

3035 # characters is redacted regardless of shape; short values like 

3036 # "invalid"/"missing" stay intact. The unprefixed forms match only the 

3037 # ``:`` header-dump shape — an ``=`` assignment is prose (``api_key=...`` 

3038 # mid-sentence is not redacted by this pattern). The value stops where 

3039 # a credential label begins (``KEY/client_secret=...``) or a URL scheme 

3040 # starts a run (``api_key: postgresql+asyncpg://...``), and is not 

3041 # redacted when fewer than 16 characters come before that. It is 

3042 # redacted only when it ends where a value ends 

3043 # (``_API_KEY_FOLLOWER_RE``): one of ``_API_KEY_VALUE_FOLLOWERS``, a 

3044 # line break or the end of the text, after optional whitespace; a 

3045 # quote that closes the string the header sits in; or ``://`` glued to 

3046 # it. A key followed by a word after a space, by ``&``, ``!``, ``:``, 

3047 # a ``/`` before a label or any other character its class lacks is 

3048 # left as the earlier passes left it. 

3049 # 

3050 # Known gap: where a key glued straight to a scheme (``KEYhttps://``) 

3051 # ends and the scheme begins cannot be told apart. When the run of 

3052 # scheme characters starts at a letter within 32 characters of 

3053 # ``://``, the value stops there, so the key prints, all of it, since 

3054 # what follows the stop is a letter and not a header value's end. A run 

3055 # that starts with a digit or is longer than that is taken whole, 

3056 # scheme included. 

3057 ( 

3058 re.compile( 

3059 r"(?i)((?:x-api[_-]?key\s*[:=]|api[_-]?key\s*:)\s*)" 

3060 + _NO_LABEL 

3061 + r"(?:" 

3062 + _VALUE_STOP 

3063 + r"[A-Za-z0-9\-._~+/]){16,}+=*" 

3064 ), 

3065 _redact_api_key_header, 

3066 ), 

3067 # Credential parameters after ``;`` (matrix parameters, ``;token=...``) 

3068 # or ``,`` (joined lists, ``,password=...``), with the ``?``/``&`` 

3069 # parameter pattern's names. The value is a quoted string, optionally 

3070 # string-prefixed (``_PREFIXED_QUOTED_VALUE``), or an unquoted run that 

3071 # does not start with a string prefix before a quote 

3072 # (``_UNQUOTED_PREFIX_STOP``). For a name the assignment 

3073 # pass also redacts (``password``, ``token``, ``api_key``, ...) an 

3074 # unquoted run must reach whitespace (or the end of the text) without a 

3075 # ``&``, ``#``, quote, backslash, label or scheme on the way; any other 

3076 # value of such a name is left as the earlier passes left it. Any other 

3077 # name (``key``) takes a run that stops at whitespace, ``&``, ``#``, a 

3078 # quote, a backslash or a ``,``/``;`` starting a credential label 

3079 # (``,key=abc,password="example secret"`` redacts both). Either is 

3080 # redacted only when what follows it ends a value (``_cuts_a_value``): 

3081 # one of ``_QUOTED_VALUE_FOLLOWERS`` after a quoted value, one of 

3082 # ``_PARAM_VALUE_FOLLOWERS`` after an unquoted one, after optional 

3083 # whitespace, or a line break or the end of the text; so a run that 

3084 # stops at a space before a word (``;key=abc def``), at a quote, at a 

3085 # backslash, or at a label after any character but ``,``/``;`` is not 

3086 # redacted, nor an unquoted value that leaves a bracket it opened 

3087 # unclosed (``,key=[b'...']``). Such a value is left as the earlier 

3088 # passes left it. 

3089 ( 

3090 re.compile( 

3091 r"(?i)([,;])(?:" 

3092 r"(?=" + _ASSIGNMENT_NAME + r")(" + _CREDENTIAL_PARAM_NAMES + r")=" 

3093 r"(" 

3094 + _PREFIXED_QUOTED_VALUE 

3095 + r"|(?![\"'])" 

3096 + _UNQUOTED_PREFIX_STOP 

3097 + _NO_LABEL 

3098 + r"(?:" 

3099 + _VALUE_STOP 

3100 + r"[^\s&#\"'\\])++(?!\S))" 

3101 r"|(?!" + _ASSIGNMENT_NAME + r")(" + _CREDENTIAL_PARAM_NAMES + r")=" 

3102 r"(" 

3103 + _PREFIXED_QUOTED_VALUE 

3104 + r"|" 

3105 + _UNQUOTED_PREFIX_STOP 

3106 + _NO_LABEL 

3107 + r"(?:[,;]" 

3108 + _NO_LABEL 

3109 + r"|" 

3110 + _VALUE_STOP 

3111 + r"[^\s,;&#\"'\\])++)" 

3112 r")" 

3113 ), 

3114 _redact_credential_param, 

3115 ), 

3116 # Quoted credential keys in JSON or a Python repr ("password": "...", 

3117 # {'api-key': '...'}, {'X-API-KEY': '...'}, "llm.openai.api_key": ...). 

3118 # The value is a quoted string, optionally string-prefixed 

3119 # (``_PREFIXED_QUOTED_VALUE``: ``b'...'``), a scheme word and one 

3120 # credential token (``_SCHEME_CREDENTIAL``: ``Basic dXNl...``), or an 

3121 # unquoted run (to whitespace, ``,``, ``}``, ``]``, ``&``, ``#``, a 

3122 # quote, a backslash, a credential label or a scheme); an unquoted run 

3123 # never starts with a quote or ``[``, so an already-redacted 

3124 # ``[REDACTED]`` value is left as it is, nor with a string prefix before 

3125 # a quote (``_UNQUOTED_PREFIX_STOP``), and is never a bare 

3126 # ``true``/``false``/``None``/``null``. It is redacted only when what 

3127 # follows it ends a value (``_cuts_a_value``): one of 

3128 # ``_QUOTED_VALUE_FOLLOWERS`` after a quoted value, one of 

3129 # ``_KEY_VALUE_FOLLOWERS`` (``,``, ``}``, ``]``) after an unquoted one, 

3130 # after optional whitespace, or a line break or the end of the text; an 

3131 # unquoted value that leaves a bracket it opened unclosed (``(b'...',)``, 

3132 # ``bytearray(b'...')``, ``{0: '...'}``) is not whole either. Any other 

3133 # value is left as the earlier passes left it: ``'''...'''``, 

3134 # ``'Pa'$$w0rd'``, ``hunter2&rest``, ``correct horse battery staple``, 

3135 # ``ApiKey(key=...)``. 

3136 ( 

3137 re.compile( 

3138 r"(?i)([\"'])(?>(" 

3139 + _QUOTED_KEY_PREFIX 

3140 + r"(?:" 

3141 + _QUOTED_CREDENTIAL_KEY_NAMES 

3142 + r"))\1)(\s*:\s*)" 

3143 r"(" 

3144 + _PREFIXED_QUOTED_VALUE 

3145 + r"|" 

3146 + _SCHEME_CREDENTIAL 

3147 + r"|(?![\"'\[])" 

3148 + _UNQUOTED_PREFIX_STOP 

3149 + _UNQUOTED_LITERAL_STOP 

3150 + _NO_LABEL 

3151 + r"(?:" 

3152 + _VALUE_STOP 

3153 + r"[^\s,}\]&#\"'\\])+)" 

3154 ), 

3155 _redact_quoted_key, 

3156 ), 

3157] 

3158 

3159 

3160def _redact_additional_shapes(message: str) -> str: 

3161 """Apply ``_ADDITIONAL_CREDENTIAL_PATTERNS`` in order, as plain substitutions. 

3162 

3163 Used as the final redaction step for record text, rendered exception 

3164 fields, and the database log row's exception prefix. Callers bound the 

3165 text before applying these patterns. 

3166 """ 

3167 if not message: 

3168 return message 

3169 for pattern, replacement in _ADDITIONAL_CREDENTIAL_PATTERNS: 

3170 message = pattern.sub(replacement, message) 

3171 return message 

3172 

3173 

3174def _redact_log_record_text(value: str) -> str: 

3175 """:func:`_redact_log_text`, then ``_ADDITIONAL_CREDENTIAL_PATTERNS``. 

3176 

3177 For the log record's message and its ``extra`` string leaves, whose 

3178 redacted text goes straight to the sinks. The text is cut to the bound 

3179 first, :func:`_redact_log_text` redacts that head, and the added 

3180 patterns run on its output, so they see what its passes left and can 

3181 only replace more of it; the omission marker is appended after them. 

3182 """ 

3183 value = _plain_text(value) 

3184 if not value: 

3185 return _exact_str(value) 

3186 # Cut once here so the added patterns see a bounded string and the 

3187 # omission marker stays outside their reach. ``head`` is within the 

3188 # bound, so ``_redact_log_text`` cuts nothing more and appends no 

3189 # marker of its own: its output is exactly its two passes over 

3190 # ``head``. Calling it (rather than repeating its passes) keeps any 

3191 # change to it in force on the log-record path. 

3192 head, marker = _truncate_for_log_redaction(value) 

3193 return _redact_additional_shapes(_redact_log_text(head)) + marker 

3194 

3195 

3196def redact_for_log(value: str) -> str: 

3197 """Run the log patcher's credential redaction on *value* alone. 

3198 

3199 The same two passes and the same ``_LOG_REDACTION_MAX_CHARS`` bound the 

3200 loguru patcher applies to every record (:func:`_redact_log_text`), for a 

3201 caller that is about to shorten *value* before it reaches a log line. 

3202 Shortening first can hide a credential from the patcher: a cut that drops 

3203 ``?key=`` or the ``@`` after URL userinfo, but keeps part of the secret, 

3204 leaves text no credential pattern matches. Redacting first means any cut 

3205 afterwards only removes text the patcher already judged safe or already 

3206 replaced. Cost is bounded the same way as the patcher's. 

3207 """ 

3208 return _redact_log_text(value) 

3209 

3210 

3211# How much of one value :func:`redact_and_bound_for_log` scans: at least 

3212# this many characters, or four times the output cap if that is more, and 

3213# never more than the patcher's own ``_LOG_REDACTION_MAX_CHARS``. The 

3214# result keeps at most ``max_length`` characters, so scanning the full 

3215# 32 KiB made one call cost ~50-100 ms on adversarial input (a run of 

3216# spaces, or of ``https://a:b@ ``) for a 256-character result; 1,024 

3217# characters cost a few milliseconds. 

3218_LOG_BOUNDED_REDACTION_MIN_CHARS = 1_024 

3219 

3220 

3221def bounded_redaction_chars(max_length: int) -> int: 

3222 """How many characters of a value to redact for a *max_length* result.""" 

3223 return min( 

3224 _LOG_REDACTION_MAX_CHARS, 

3225 max(_LOG_BOUNDED_REDACTION_MIN_CHARS, 4 * max_length), 

3226 ) 

3227 

3228 

3229def redact_head_for_log(value: str, max_chars: int) -> str: 

3230 """:func:`redact_for_log` on at most *max_chars* characters of *value*. 

3231 

3232 For a caller that shows only a short prefix of the result. The value 

3233 is cut first by the patcher's own rule 

3234 (:func:`_truncate_for_log_redaction` with *max_chars* as the bound: 

3235 only at a whitespace boundary, dropping the whole run the bound falls 

3236 in), then the kept head is redacted, and the rest is replaced by the 

3237 omitted-length marker. The patcher's own argument applies: a 

3238 credential token holds no boundary character, so the head never keeps 

3239 part of a token whose label or ``@`` was cut off, and text past the 

3240 cut is never shown. The cost is bounded by *max_chars*. 

3241 """ 

3242 if not value: 

3243 return value 

3244 head, marker = _truncate_for_log_redaction(value, max_chars) 

3245 return redact_for_log(head) + marker 

3246 

3247 

3248def redact_and_bound_for_log(value: str, max_length: int) -> str: 

3249 """:func:`redact_for_log`, then :func:`sanitize_for_log` (strip + cap). 

3250 

3251 Use instead of plain ``sanitize_for_log`` for a value that a call site 

3252 previously logged in full, where the patcher saw (and redacted) the whole 

3253 value: truncating first would let it see less than it did before. 

3254 

3255 Only the first :func:`bounded_redaction_chars` characters (at least 

3256 1,024) are redacted, cut by the patcher's whitespace-boundary rule (see 

3257 :func:`redact_head_for_log`); the result keeps at most *max_length* of 

3258 them. A value whose first whitespace-free run reaches past that bound 

3259 is shown as the omitted-length marker only. 

3260 """ 

3261 return sanitize_for_log( 

3262 redact_head_for_log(value, bounded_redaction_chars(max_length)), 

3263 max_length, 

3264 ) 

3265 

3266 

3267def _is_sensitive_log_key(key: Any) -> bool: 

3268 """True when *key* names a secret under settings redaction's rules. 

3269 

3270 Delegates to ``DataSanitizer.is_sensitive_setting`` with 

3271 :data:`LOG_SENSITIVE_KEYS`: the last dotted segment matches a name exactly 

3272 or as an underscore-delimited suffix (``openai_api_key``, 

3273 ``db_password``), after invisible characters are removed from it. Message 

3274 text also recognizes prefixed and dotted assignments, and the additional 

3275 shape pass recognizes quoted credential keys. A ``str`` subclass key is 

3276 checked by its plain characters: its own ``rsplit``, ``lower`` or 

3277 ``__eq__`` could otherwise hide a name. A key longer than 

3278 ``_LOG_REDACTION_MAX_CHARS`` is treated as sensitive without scanning it, 

3279 keeping the cost per key bounded and masking its value. 

3280 """ 

3281 if not isinstance(key, str): 

3282 return False 

3283 if len(key) > _LOG_REDACTION_MAX_CHARS: 

3284 return True 

3285 return DataSanitizer.is_sensitive_setting( 

3286 _exact_str(key), sensitive_keys=LOG_SENSITIVE_KEYS 

3287 ) 

3288 

3289 

3290def _is_structural_extra_key(key: Any) -> bool: 

3291 """True when *key* is one of the routing keys, as an exact ``str``. 

3292 

3293 A ``str`` subclass can hash and compare equal to ``"username"`` while 

3294 holding (and rendering) other text, so only an exact ``str`` passes 

3295 unredacted as a routing key. 

3296 """ 

3297 return type(key) is str and key in _LOG_STRUCTURAL_EXTRA_KEYS 

3298 

3299 

3300def _is_empty_log_value(value: Any) -> bool: 

3301 """``None`` or an empty str/list/dict (left readable, like DataSanitizer). 

3302 

3303 Type-checked rather than ``value in (None, "", [], {})`` so an object with 

3304 a raising ``__eq__`` cannot abort the redaction of the whole record. 

3305 Exact types only: a subclass can override ``__len__`` (or iterate items 

3306 its storage does not hold) to look empty under a sensitive key, so a 

3307 subclass is never "empty" and is masked like any other value. 

3308 """ 

3309 if value is None: 3309 ↛ 3310line 3309 didn't jump to line 3310 because the condition on line 3309 was never true

3310 return True 

3311 return _type_is_one_of(type(value), (str, list, dict)) and len(value) == 0 

3312 

3313 

3314# Nesting bound for ``extra`` values: a container at this depth or deeper 

3315# (a value bound directly in ``extra`` is depth 1) becomes a placeholder. 

3316_LOG_EXTRA_MAX_DEPTH = 32 

3317 

3318 

3319def _scrub_log_key(key: Any) -> Any: 

3320 """Scrub a dict key in ``extra`` the way a string value is scrubbed. 

3321 

3322 A ``str`` key is redacted (:func:`_redact_log_text`). ``None``, ``bool``, 

3323 ``int`` and ``float`` keys cannot carry a credential and pass through. A 

3324 key whose text is fixed by its type (``bytes``, or a tuple or frozenset 

3325 of such values, see :func:`_is_stable_text_value`) is kept as is unless 

3326 its ``str()`` redacts differently, in which case the redacted text 

3327 replaces it. Any other key (an object with its own ``__str__`` or 

3328 ``__repr__``, a ``str`` subclass) is always replaced by its checked, 

3329 redacted text: the sinks render the key again, and an object may render 

3330 a secret the second time, or (when its ``str()`` raised here) render 

3331 text that was never checked at all. 

3332 """ 

3333 if isinstance(key, str): 

3334 return _redact_log_text(key) 

3335 if key is None or _type_is_one_of(type(key), (bool, int, float)): 3335 ↛ 3336line 3335 didn't jump to line 3336 because the condition on line 3335 was never true

3336 return key 

3337 try: 

3338 text = _exact_str(str(key)) 

3339 printable = True 

3340 except Exception: 

3341 text = _unprintable_placeholder(key) 

3342 printable = False 

3343 redacted = _redact_log_text(text) 

3344 if printable and redacted == text and _is_stable_text_value(key): 3344 ↛ 3345line 3344 didn't jump to line 3345 because the condition on line 3344 was never true

3345 return key 

3346 return redacted 

3347 

3348 

3349def _unique_log_key(out: dict, key: Any, reserved: Any = ()) -> Any: 

3350 """*key*, or ``key#2``, ``key#3``, ... when it is already taken. 

3351 

3352 Redaction can map two different keys to the same text (two URLs with 

3353 different passwords, or ``"a\\nb"`` and ``"ab"``); the second must not 

3354 overwrite the first. *reserved* holds keys that may not be produced at 

3355 all (the routing keys of the top-level ``extra``). 

3356 

3357 Keys are taken in the input's iteration order, so the first key to 

3358 produce a given text keeps it unsuffixed and later ones are suffixed; a 

3359 reserved key is never produced, even for the first key that redacts to 

3360 it (the caller keeps a routing key itself under its own name, whether 

3361 it comes before or after a key that redacts to it). 

3362 """ 

3363 if key not in out and key not in reserved: 

3364 return key 

3365 base = key if isinstance(key, str) else _safe_log_str(key) 

3366 n = 2 

3367 while f"{base}#{n}" in out or f"{base}#{n}" in reserved: 3367 ↛ 3368line 3367 didn't jump to line 3368 because the condition on line 3367 was never true

3368 n += 1 

3369 return f"{base}#{n}" 

3370 

3371 

3372def _scrub_log_bytes(value: Any) -> Any: 

3373 """Scrub a ``bytes``/``bytearray`` ``extra`` leaf. 

3374 

3375 Only the first ``4 * _LOG_REDACTION_MAX_CHARS + 4`` bytes are decoded 

3376 (UTF-8, undecodable bytes become U+FFFD, which cannot join a credential 

3377 name the way a ``\\xNN`` escape's hex digits would), so the cost does not 

3378 grow with the value. A value that decodes to at most 

3379 ``_LOG_REDACTION_MAX_CHARS`` characters is returned unchanged (a 

3380 ``bytearray`` or ``bytes`` subclass as a plain copy of itself) unless 

3381 :func:`_redact_log_text` redacts something in it, in which case the 

3382 redacted text replaces it. A longer value is always replaced by its 

3383 redacted, truncated text, ending in a marker that gives its total size 

3384 in bytes. 

3385 """ 

3386 if type(value) is not bytes: 3386 ↛ 3390line 3386 didn't jump to line 3390 because the condition on line 3386 was never true

3387 # A ``bytearray`` (or a ``bytes`` subclass) can change or render 

3388 # differently after the check; check and return a plain copy (read 

3389 # through the buffer, so a ``__bytes__`` override is not called). 

3390 value = ( 

3391 bytearray(memoryview(value)) 

3392 if isinstance(value, bytearray) 

3393 else bytes(memoryview(value)) 

3394 ) 

3395 limit = 4 * _LOG_REDACTION_MAX_CHARS + 4 

3396 text = bytes(value[:limit]).decode("utf-8", "replace") 

3397 head, marker = _truncate_for_log_redaction(text) 

3398 if not marker: 

3399 # Compared with the control-stripped text rather than ``text``: 

3400 # ``_redact_log_text`` always strips control characters, so only a 

3401 # difference beyond that strip is a redaction (``api_<U+001F>key=`` 

3402 # included), while benign bytes holding a newline stay bytes. 

3403 redacted = _redact_log_text(text) 

3404 if redacted == strip_control_chars(text): 

3405 return value 

3406 return redacted 

3407 return ( 

3408 _redact_log_text(head) 

3409 + f" [... {len(value)} bytes in total, the rest omitted from log output]" 

3410 ) 

3411 

3412 

3413def _scrub_log_leaf(value: Any, _depth: int = 0, _active: Any = None) -> Any: 

3414 """Recursively scrub credential shapes from an ``extra`` value. 

3415 

3416 Redacts by key *name* (a value under a sensitive key is masked whole) 

3417 AND by value *shape* (a credential embedded in a string leaf under any 

3418 key is scrubbed) — the two halves that a name-only or shape-only check 

3419 would each miss. Dict keys are scrubbed too (:func:`_scrub_log_key`); 

3420 sets and tuples come back as lists; a ``bytes``/``bytearray`` leaf is 

3421 handled by :func:`_scrub_log_bytes`. Other non-container leaves pass 

3422 through. 

3423 

3424 A container that contains itself (directly or further down) is rendered 

3425 as ``"<cycle>"`` and a container at depth :data:`_LOG_EXTRA_MAX_DEPTH` 

3426 or deeper as ``"<nested too deep>"``, so neither raises 

3427 ``RecursionError`` and trips :func:`redact_log_record`'s fail-closed 

3428 fallback for the whole record. 

3429 """ 

3430 if isinstance(value, str): 

3431 return _redact_log_record_text(value) 

3432 if isinstance(value, (bytes, bytearray)): 

3433 return _scrub_log_bytes(value) 

3434 if not isinstance(value, (dict, list, tuple, set, frozenset)): 

3435 return value 

3436 if _depth >= _LOG_EXTRA_MAX_DEPTH: 

3437 return "<nested too deep>" 

3438 if _active is None: 3438 ↛ 3439line 3438 didn't jump to line 3439 because the condition on line 3438 was never true

3439 _active = set() 

3440 marker = id(value) 

3441 if marker in _active: 

3442 return "<cycle>" 

3443 _active.add(marker) 

3444 try: 

3445 if isinstance(value, dict): 

3446 out: dict = {} 

3447 for k, v in value.items(): 

3448 out[_unique_log_key(out, _scrub_log_key(k))] = ( 

3449 REDACTION_TEXT 

3450 if _is_sensitive_log_key(k) and not _is_empty_log_value(v) 

3451 else _scrub_log_leaf(v, _depth + 1, _active) 

3452 ) 

3453 return out 

3454 return [_scrub_log_leaf(v, _depth + 1, _active) for v in value] 

3455 finally: 

3456 _active.discard(marker) 

3457 

3458 

3459def redact_log_extra(extra: Any) -> Any: 

3460 """Redact secrets from a loguru record's structured ``extra`` mapping. 

3461 

3462 * Keys the sinks depend on for routing (``research_id``, ``username``, 

3463 ``policy_audit``) pass through untouched -- as exact ``str`` keys only 

3464 (:func:`_is_structural_extra_key`); a ``str`` subclass key is checked 

3465 by its plain characters like any other key. 

3466 * A value under a sensitive key name (:data:`LOG_SENSITIVE_KEYS`, exact 

3467 or as an ``_``-delimited suffix such as ``openai_api_key``) is masked 

3468 whole (empty values are left readable, mirroring ``DataSanitizer``). 

3469 * Other keys are scrubbed for credential shapes like string values; a 

3470 key whose scrubbed form is a routing key or an earlier key gets a 

3471 ``#2``/``#3`` suffix instead of overwriting it. 

3472 * Every other value is recursively scrubbed for credential *shapes*, so 

3473 a token/URL/query bound under an innocuous key (the family of bug the 

3474 name-based checks miss) is still caught. String values get the same 

3475 control-character strip as the message, between two redaction passes, 

3476 and then the same ``_ADDITIONAL_CREDENTIAL_PATTERNS`` 

3477 (:func:`_redact_log_record_text`). 

3478 

3479 Returns a new dict; the input is not mutated. A non-dict ``extra`` is 

3480 returned unchanged. 

3481 """ 

3482 if not isinstance(extra, dict): 3482 ↛ 3483line 3482 didn't jump to line 3483 because the condition on line 3482 was never true

3483 return extra 

3484 out: dict = {} 

3485 # ``extra`` itself counts as an enclosing container, so a value that 

3486 # refers back to the mapping is reported as a cycle at the first level. 

3487 active = {id(extra)} 

3488 for key, value in extra.items(): 

3489 if _is_structural_extra_key(key): 

3490 out[key] = value 

3491 continue 

3492 safe_key = _unique_log_key( 

3493 out, _scrub_log_key(key), _LOG_STRUCTURAL_EXTRA_KEYS 

3494 ) 

3495 if _is_sensitive_log_key(key) and not _is_empty_log_value(value): 

3496 out[safe_key] = REDACTION_TEXT 

3497 else: 

3498 out[safe_key] = _scrub_log_leaf(value, 1, active) 

3499 return out 

3500 

3501 

3502def _unprintable_placeholder(obj: Any) -> str: 

3503 """Stand-in text for an object whose ``str()`` raised or was not a str.""" 

3504 return f"<unprintable {_type_name(type(obj))}>" 

3505 

3506 

3507def _safe_log_str(obj: Any) -> str: 

3508 """``str(obj)``, or a placeholder when that raises. 

3509 

3510 ``str()`` raises ``TypeError`` itself when ``__str__`` returns a 

3511 non-str, so one guard covers both a raising and a mistyped ``__str__``. 

3512 Only the one node's text is replaced; the rest of the exception graph 

3513 and its traceback are still rendered. A ``__str__`` may return a ``str`` 

3514 subclass, which can render differently later (its own ``__str__``, 

3515 ``__format__`` or ``__repr__``); the result is always an exact ``str``. 

3516 """ 

3517 try: 

3518 return _exact_str(str(obj)) 

3519 except Exception: 

3520 return _unprintable_placeholder(obj) 

3521 

3522 

3523def _exact_str(text: str) -> str: 

3524 """*text* as an exact ``str`` (a copy when it is a ``str`` subclass).""" 

3525 return text if type(text) is str else str.__str__(text) 

3526 

3527 

3528def _plain_text(value: Any) -> Any: 

3529 """*value* as an exact ``str`` when it is a ``str``; anything else as is. 

3530 

3531 Called before a ``not value`` shortcut: a ``str`` subclass can override 

3532 ``__bool__`` or ``__len__`` to look empty while holding text, and would 

3533 then be returned unredacted. The copy's truth value and length are its 

3534 real characters'. 

3535 """ 

3536 return _exact_str(value) if isinstance(value, str) else value 

3537 

3538 

3539# Line breaks and tabs that exception text keeps: ``\t``, ``\n`` and ``\r`` 

3540# are never drawn as nothing in a sink -- terminal, log file, and the DB log 

3541# shown in the browser -- (a tab, a line break, or for ``\r`` a line break 

3542# or, in a terminal, a return to the start of the line), so a credential 

3543# name they split is not shown as one unbroken name, and a multi-line 

3544# message keeps its lines. 

3545_EXCEPTION_KEPT_BREAKS = "\t\n\r" 

3546 

3547# Every other character ``strip_control_chars`` removes: zero-width and bidi 

3548# format characters, and all other C0/C1 controls. That deliberately 

3549# includes the ones ``str.isspace()`` calls whitespace -- ``\x1c``-``\x1f``, 

3550# U+0085, ``\v``, ``\f``, U+2028 and U+2029. Terminals draw ``\x1c``-``\x1f`` 

3551# as nothing, browsers draw U+0085 and ``\v`` as nothing, and ``\f``, 

3552# U+2028 and U+2029 are a line break in some sinks and nothing in others. 

3553# Only a character that is a visible separator in every sink may stay, since 

3554# any of these can hide a credential name (``api_<U+200B>key=``, 

3555# ``tok<U+001F>en=``) or split URL userinfo exactly like a zero-width space. 

3556# Derived from ``_UNSAFE_CHAR_RE`` so the two cannot drift apart. 

3557_EXCEPTION_INVISIBLE_RE = re.compile( 

3558 "[" 

3559 + "".join( 

3560 re.escape(char) 

3561 for char in map( 

3562 chr, 

3563 [ 

3564 *range(0xA0), 

3565 0x61C, 

3566 *range(0x2000, 0x2070), 

3567 0xFEFF, 

3568 ], 

3569 ) 

3570 if _UNSAFE_CHAR_RE.match(char) and char not in _EXCEPTION_KEPT_BREAKS 

3571 ) 

3572 + "]" 

3573) 

3574 

3575 

3576def _redact_exception_text(value: str) -> str: 

3577 """Redact one rendered exception field (value, group message or note). 

3578 

3579 The first pass sees the text as loguru will render it. The second pass 

3580 runs on that output with every character :func:`strip_control_chars` 

3581 removes, except ``\t``, ``\n`` and ``\r``, removed 

3582 (:data:`_EXCEPTION_INVISIBLE_RE`), catching a credential name or URL 

3583 those characters split; its result is used only when it redacts 

3584 something more. Tabs and line breaks are kept in both passes, so a 

3585 multi-line exception message keeps its lines, and a value the first 

3586 pass redacted at a line end is not glued onto the next line's text. 

3587 When removing those characters leaves exactly the original text (none 

3588 in it, and the first pass changed nothing), the second pass would scan 

3589 that same text again and return the first pass's result, so it is 

3590 skipped: clean text, the common case, costs one pass. The additional 

3591 credential shapes run on the selected redacted text before the omission 

3592 marker is appended. 

3593 """ 

3594 value = _plain_text(value) 

3595 if not value: 

3596 return _exact_str(value) 

3597 head, marker = _truncate_for_log_redaction(value) 

3598 first = _redact_bounded_log_text(head) 

3599 visible = _EXCEPTION_INVISIBLE_RE.sub("", first) 

3600 if visible == head: 

3601 # ``_redact_bounded_log_text(visible)`` is ``first`` itself, which 

3602 # the line below would return either way. 

3603 result = first 

3604 else: 

3605 second = _redact_bounded_log_text(visible) 

3606 result = first if second == visible else second 

3607 return _exact_str(_redact_additional_shapes(result) + marker) 

3608 

3609 

3610def _rendered_exception_notes(node: Any) -> tuple[list, bool]: 

3611 """The notes the traceback module renders for *node*, and an exactness flag. 

3612 

3613 Mirrors ``TracebackException.format_exception_only`` on Python 3.12 to 

3614 3.14 (which loguru calls): a ``__notes__`` that is a 

3615 ``collections.abc.Sequence`` other than ``str``/``bytes`` is iterated 

3616 and each item rendered with ``str()``; any other value except ``None`` 

3617 (a ``str``, a ``set``, an arbitrary object) is rendered as one note, 

3618 its ``repr()``. The flag is true only for a ``list`` or ``tuple`` of 

3619 exact ``str`` objects, which renders as exactly the returned texts as 

3620 long as reading ``__notes__`` again returns the same object; a 

3621 ``__notes__`` property or ``__getattr__`` on the class is caught by 

3622 :func:`_exception_text_is_stable` instead. 

3623 Anything else (a ``UserList``, a note object, a non-sequence) is 

3624 evaluated again when the traceback is rendered and may produce 

3625 different text from what was checked, so the caller rebuilds the node 

3626 with the checked texts. Raises when getting or iterating ``__notes__`` 

3627 raises. 

3628 """ 

3629 notes = getattr(node, "__notes__", None) 

3630 if notes is None: 

3631 return [], True 

3632 if isinstance(notes, collections.abc.Sequence) and not isinstance( 

3633 notes, (str, bytes) 

3634 ): 

3635 items = list(notes) 

3636 exact = _type_is_one_of(type(notes), (list, tuple)) and all( 

3637 type(item) is str for item in items 

3638 ) 

3639 return [_safe_log_str(item) for item in items], exact 

3640 try: 

3641 text = repr(notes) 

3642 except Exception: 

3643 text = None 

3644 if not isinstance(text, str): 3644 ↛ 3645line 3644 didn't jump to line 3645 because the condition on line 3644 was never true

3645 text = _unprintable_placeholder(notes) 

3646 return [text], False 

3647 

3648 

3649# Nodes visited per record when rebuilding an exception graph (causes, 

3650# contexts and group members together); the rest become placeholders. 

3651_LOG_EXCEPTION_NODE_BUDGET = 200 

3652 

3653# Values whose ``str()`` and ``repr()`` are fixed by their type: an 

3654# exception holding only these (in ``args`` or in a field its builtin base 

3655# renders) renders the same text every time it is rendered. 

3656_STABLE_TEXT_TYPES = (str, int, float, complex, bool, type(None), bytes) 

3657 

3658# Attributes the sinks read or call on an exception they render: its text, 

3659# chained exceptions and notes, and a group's message and members (the 

3660# traceback module), its truth value (loguru's ``if exc_value:``, which 

3661# calls ``__bool__`` or ``__len__``), ``__class__`` (the ``isinstance`` 

3662# group check falls back to it), a chained exception's ``__traceback__``, 

3663# and the pickling hooks (an ``enqueue=True`` sink pickles the record, 

3664# exception included, after rendering it and before the next sink renders 

3665# it; ``BaseException.__reduce__`` calls neither ``__getstate__`` nor 

3666# ``__getnewargs__``/``__getnewargs_ex__`` today, but a kept class defines 

3667# no pickling hook at all). A non-builtin class anywhere in the exception 

3668# type's MRO (a mixin listed after a builtin base included) that defines 

3669# one of these (a 

3670# ``__str__`` override, a ``__notes__`` or ``__cause__`` property, a 

3671# ``__getattr__`` that supplies ``__notes__``, a ``__len__`` or 

3672# ``__reduce_ex__`` that rewrites ``args``) can render, or make a later 

3673# sink render, different text from what the redactor checked. 

3674_RENDER_ATTRIBUTES = frozenset( 

3675 { 

3676 "__str__", 

3677 "__getattribute__", 

3678 "__getattr__", 

3679 "args", 

3680 "__notes__", 

3681 "__cause__", 

3682 "__context__", 

3683 "__suppress_context__", 

3684 "message", 

3685 "exceptions", 

3686 "__bool__", 

3687 "__len__", 

3688 "__class__", 

3689 "__traceback__", 

3690 "__reduce__", 

3691 "__reduce_ex__", 

3692 "__getstate__", 

3693 "__getnewargs__", 

3694 "__getnewargs_ex__", 

3695 } 

3696) 

3697 

3698# Attributes read on the exception's *type*: the traceback module renders 

3699# ``__qualname__`` and ``__module__``, pickling looks the class up by them, 

3700# and the stability check itself reads ``__mro__`` and ``__dict__``. A 

3701# non-builtin metaclass whose class dict holds anything under one of these 

3702# names (a property, a function, a callable object that is not a 

3703# descriptor) can run its code or make the attribute read something other 

3704# than the type's own value -- an entry under ``__name__`` or 

3705# ``__qualname__``, even a plain string, shadows the type's own. The one 

3706# exception is a plain ``str`` under ``__module__``, which every class 

3707# statement puts in a metaclass's dict: it is inert, because the class's 

3708# own ``__module__`` entry is found first. 

3709_METACLASS_RENDER_ATTRIBUTES = frozenset( 

3710 { 

3711 "__getattribute__", 

3712 "__getattr__", 

3713 "__name__", 

3714 "__qualname__", 

3715 "__module__", 

3716 "__mro__", 

3717 "__dict__", 

3718 } 

3719) 

3720 

3721# ``type``'s own descriptors for the attributes above: reading through them 

3722# returns the type's real MRO (the order attribute lookup uses), class dict 

3723# and names, without running a metaclass's code. 

3724_TYPE_MRO = vars(type)["__mro__"] 

3725_TYPE_DICT = vars(type)["__dict__"] 

3726_TYPE_NAME = vars(type)["__name__"] 

3727_TYPE_QUALNAME = vars(type)["__qualname__"] 

3728_TYPE_MODULE = vars(type)["__module__"] 

3729 

3730# Fields a builtin exception renders besides ``args`` (in ``str()`` or in 

3731# the traceback module's extra lines), by the builtin base that renders 

3732# them. An exception that is an instance of the base must hold only 

3733# :data:`_STABLE_TEXT_TYPES` there, and no non-builtin class in the 

3734# exception type's MRO may redefine them. ``SyntaxError._metadata`` 

3735# (Python 3.13+) holds the source the traceback module's keyword-typo 

3736# suggestion reads its replacement line from. ``AttributeError.obj`` is not 

3737# listed: it feeds only the "Did you mean" suggestion, and an exception for 

3738# which the traceback module would compute one is never kept (see 

3739# :func:`_exception_text_is_stable`). 

3740_RENDERED_FIELDS = ( 

3741 (OSError, ("errno", "strerror", "filename", "filename2")), 

3742 ( 

3743 SyntaxError, 

3744 ( 

3745 "msg", 

3746 "filename", 

3747 "lineno", 

3748 "offset", 

3749 "text", 

3750 "end_lineno", 

3751 "end_offset", 

3752 "print_file_and_line", 

3753 "_metadata", 

3754 ), 

3755 ), 

3756 (UnicodeError, ("encoding", "object", "start", "end", "reason")), 

3757 (ImportError, ("msg", "name", "path", "name_from")), 

3758 (NameError, ("name",)), 

3759 (AttributeError, ("name",)), 

3760) 

3761 

3762 

3763# ``BaseException``'s ``__dict__`` descriptor: the instance dict, read 

3764# without going through the instance's (possibly overridden) attribute 

3765# lookup. 

3766_BASE_EXCEPTION_DICT = vars(BaseException)["__dict__"] 

3767 

3768 

3769# Answers computed during the current decision phase of 

3770# :func:`redact_log_exception` (``None`` outside one), by the check and the 

3771# identity of what it was asked about (:func:`_once_per_phase`). 

3772_DECISION_MEMO: contextvars.ContextVar = contextvars.ContextVar( 

3773 "ldr_log_decision_memo", default=None 

3774) 

3775 

3776 

3777def _once_per_phase(check: Any) -> Any: 

3778 """*check*, answered once per decision phase for the same subjects. 

3779 

3780 The checks wrapped with this (whether pickling a class runs code, a 

3781 class's rendering hooks and names, the reducers registered for a type, 

3782 ``sys.modules``'s keys) depend only on the class or object they are 

3783 asked about and on global tables: class and metaclass dicts, MROs, the 

3784 picklers' dispatch tables, ``sys.modules`` and module namespaces. A 

3785 decision phase (:func:`_decision_phase`) runs no application code for 

3786 exception classes that do not hook the machinery those checks read 

3787 through, so nothing changes any of these between two nodes' checks 

3788 (another thread could, which the threat model in the module docstring 

3789 does not cover either way), and the first answer for a subject holds for 

3790 the rest of the phase: a group of many members of one class pays for 

3791 its class's checks once, not per member. The subjects are compared by 

3792 identity (``id()``, so no ``__hash__`` or ``__eq__`` of theirs runs) 

3793 and held by the memo, so an id cannot be reused while it lives. Outside 

3794 a phase, every call computes its answer. A check that raises is not 

3795 remembered. 

3796 """ 

3797 

3798 @functools.wraps(check) 

3799 def memoized(*subjects: Any) -> Any: 

3800 memo = _DECISION_MEMO.get() 

3801 if memo is None: 

3802 return check(*subjects) 

3803 key = (check, *map(id, subjects)) 

3804 entry = memo.get(key) 

3805 if entry is None: 

3806 entry = memo[key] = (subjects, check(*subjects)) 

3807 return entry[1] 

3808 

3809 return memoized 

3810 

3811 

3812@contextlib.contextmanager 

3813def _decision_phase(): 

3814 """Scope of one record's decisions, and of :func:`_once_per_phase`'s memo. 

3815 

3816 Entered by :func:`redact_log_exception` only after every step that can 

3817 run application code has run (the read phase, 

3818 :func:`_plan_redacted_exception`), so an answer remembered in it cannot 

3819 go stale through code the redaction itself runs. 

3820 """ 

3821 token = _DECISION_MEMO.set({}) 

3822 try: 

3823 yield 

3824 finally: 

3825 _DECISION_MEMO.reset(token) 

3826 

3827 

3828def _is_builtin_class(kind: Any) -> bool: 

3829 """True when *kind* is the builtin class of that name (not a lookalike). 

3830 

3831 The name is read through ``type``'s own descriptor, so a metaclass's 

3832 ``__name__`` is never called. 

3833 """ 

3834 try: 

3835 name = _TYPE_NAME.__get__(kind) 

3836 return type(name) is str and getattr(builtins, name, None) is kind 

3837 except Exception: 

3838 return False 

3839 

3840 

3841def _type_name(kind: Any, default: str = "object") -> str: 

3842 """*kind*'s own ``__name__``, redacted; *default* if it is not a type. 

3843 

3844 Read through ``type``'s descriptor (a metaclass's ``__name__`` property 

3845 is never called, and cannot return one name now and another later) and 

3846 redacted like exception text, since a class can be created under any 

3847 name (``type("token=...", (Exception,), {})``). Used wherever the 

3848 redactor writes a type's name into text a sink renders. 

3849 """ 

3850 try: 

3851 name = _exact_str(_TYPE_NAME.__get__(kind)) 

3852 return _redact_name_text(name) 

3853 except Exception: 

3854 return default 

3855 

3856 

3857# Longest name :func:`_redact_name_text` remembers; a longer one (a class 

3858# can be created under any name) is redacted on every call. 

3859_NAME_CACHE_MAX_LEN = 256 

3860 

3861 

3862def _redact_name_text(name: str) -> str: 

3863 """:func:`_redact_exception_text` of the exact ``str`` *name*, remembered. 

3864 

3865 Class, module and qualified names are redacted on every record that 

3866 renders or keeps an exception, and the same few recur. Redaction is a 

3867 function of the text alone (fixed patterns, no state), so a remembered 

3868 result cannot go stale; names longer than :data:`_NAME_CACHE_MAX_LEN` 

3869 are not remembered, which bounds the memory the cache holds. 

3870 """ 

3871 if len(name) > _NAME_CACHE_MAX_LEN: 3871 ↛ 3872line 3871 didn't jump to line 3872 because the condition on line 3871 was never true

3872 return _redact_exception_text(name) 

3873 return _redact_short_name(name) 

3874 

3875 

3876@functools.lru_cache(maxsize=1024) 

3877def _redact_short_name(name: str) -> str: 

3878 """:func:`_redact_exception_text` of *name* (:func:`_redact_name_text`).""" 

3879 return _redact_exception_text(name) 

3880 

3881 

3882def type_name_for_log(kind: Any, default: str = "Exception") -> str: 

3883 """Public form of :func:`_type_name` for sinks that print a type name. 

3884 

3885 The DB sink's row prefix (``utilities.log_utils._exception_context``) 

3886 names ``record["exception"].type``, which the patcher leaves as the 

3887 original class unless :func:`_record_type_is_safe` rejects it; this 

3888 reads and redacts that name the way the patcher's own rebuilt text does, 

3889 so the two agree. 

3890 """ 

3891 return _type_name(kind, default) 

3892 

3893 

3894def _rendered_block(header: str, notes: list) -> str: 

3895 """The text a sink renders for one exception: its header line, then notes. 

3896 

3897 ``TracebackException.format_exception_only`` writes the header line 

3898 (``TypeName: text``, see :func:`_rendered_header_tails`) and then each 

3899 note on lines of its own, so a credential label at the end of one and 

3900 its value at the start of the next are adjacent text. 

3901 """ 

3902 return "\n".join([header, *notes]) 

3903 

3904 

3905def _syntax_error_header_text(node: Any, location: bool = True) -> str: 

3906 """The text a ``SyntaxError``'s header line shows after ``TypeName: ``. 

3907 

3908 ``TracebackException._format_syntax_error`` prints ``msg`` (or 

3909 ``<no detail available>`` when it is empty), followed by `` (filename)`` 

3910 only when ``lineno`` is ``None`` and ``filename`` is not; the file and 

3911 line number otherwise go on the ``File ...`` line above it. That is not 

3912 ``str(node)``, which adds `` (basename, line N)``: a suffix the sinks do 

3913 not print between ``msg`` and the notes on the following lines. On 

3914 Python 3.13+ the keyword-typo suggestion can replace an ``invalid 

3915 syntax`` message, or one mentioning a forgotten comma, with the fixed 

3916 text ``invalid syntax. Did you mean 'kw'?`` (``kw`` a Python keyword). 

3917 With *location* false, the `` (filename)`` suffix is left out. 

3918 """ 

3919 msg = node.msg 

3920 if msg is None or (_is_stable_text_value(msg) and not msg): 

3921 text = "<no detail available>" 

3922 else: 

3923 text = _safe_log_str(msg) 

3924 if location and node.lineno is None and node.filename is not None: 

3925 text += f" ({_safe_log_str(node.filename)})" 

3926 return text 

3927 

3928 

3929def _rendered_header_tails(node: Any, text: str) -> tuple: 

3930 """What the traceback module prints after the type name for *node*. 

3931 

3932 The header line is ``TypeName: text`` (``format_exception_only``), but 

3933 only ``TypeName`` when *text* (the node's ``str()``) is empty. A 

3934 ``SyntaxError`` (the class or a subclass, as the traceback module tests 

3935 it) shows :func:`_syntax_error_header_text` instead of *text*; its form 

3936 without the `` (filename)`` suffix is returned too, so that the suffix 

3937 the sinks print between ``msg`` and the notes is not what keeps a label 

3938 ending ``msg`` apart from its value in a note. The first item is the 

3939 line the sinks print. 

3940 """ 

3941 if issubclass(type(node), SyntaxError): 

3942 return ( 

3943 f": {_syntax_error_header_text(node)}", 

3944 f": {_syntax_error_header_text(node, location=False)}", 

3945 ) 

3946 return (f": {text}" if text else "",) 

3947 

3948 

3949def _named_text(kind: Any, text: str) -> str: 

3950 """``TypeName: text`` for a rebuilt node's text, redacted. 

3951 

3952 Only ``TypeName`` when *text* is empty, as the traceback module renders 

3953 an exception whose ``str()`` is empty: ``TypeName: `` would put text 

3954 the original rendering did not have between the name and the notes on 

3955 the following lines. 

3956 """ 

3957 name = _type_name(kind) 

3958 return _redact_exception_text(f"{name}: {text}" if text else name) 

3959 

3960 

3961def _type_header_changed( 

3962 kind: Any, text: str, notes: list = (), tails: tuple = () 

3963) -> bool: 

3964 """True when a sink's rendered ``TypeName: text`` block redacts differently. 

3965 

3966 The traceback module renders ``qualname`` and the header line's rest 

3967 (*tails*, :func:`_rendered_header_tails`), with ``module.`` in front 

3968 outside ``__main__`` and ``builtins``, followed by the notes, one per 

3969 line (:func:`_rendered_block`); the DB row renders ``name: text``. Each 

3970 header form (:func:`_rendered_type_prefixes`) is checked with the notes 

3971 after it. 

3972 The name, the text and each note can be clean while the joined block is 

3973 not: a credential pattern can span them (a class named 

3974 ``InvalidAuthorization`` and the text ``abc123...`` render as an 

3975 ``Authorization: abc123...`` header; a message ending in ``password=`` 

3976 and a note holding the value render as ``password=`` and the value on 

3977 the next line). The names are read through ``type``'s own descriptors; 

3978 a name that is not an exact ``str`` counts as a change. 

3979 """ 

3980 prefixes = _rendered_type_prefixes(kind) 

3981 if prefixes is None: 3981 ↛ 3982line 3981 didn't jump to line 3982 because the condition on line 3981 was never true

3982 return True 

3983 tails = dict.fromkeys((f": {text}", *tails)) 

3984 for prefix in prefixes: 

3985 for header_tail in tails: 

3986 block = _rendered_block(prefix + header_tail, notes) 

3987 if _redact_exception_text(block) != block: 

3988 return True 

3989 return False 

3990 

3991 

3992@_once_per_phase 

3993def _rendered_type_prefixes(kind: Any) -> Optional[tuple]: 

3994 """The forms of *kind*'s name a sink renders before ``: text``. 

3995 

3996 ``name`` (the DB row), ``qualname``, and ``module.qualname`` (the 

3997 traceback module, except for a class of ``builtins`` or ``__main__``, 

3998 which it renders as ``qualname`` alone), without repeats; ``None`` when 

3999 a name is not an exact ``str``. The names are read through ``type``'s 

4000 own descriptors, so no metaclass code runs. 

4001 """ 

4002 name = _TYPE_NAME.__get__(kind) 

4003 qualname = _TYPE_QUALNAME.__get__(kind) 

4004 module = _TYPE_MODULE.__get__(kind) 

4005 if not all(type(part) is str for part in (name, qualname, module)): 4005 ↛ 4006line 4005 didn't jump to line 4006 because the condition on line 4005 was never true

4006 return None 

4007 prefixes = [name, qualname] 

4008 if module not in ("builtins", "__main__"): 

4009 prefixes.append(f"{module}.{qualname}") 

4010 return tuple(dict.fromkeys(prefixes)) 

4011 

4012 

4013def _notes_fitting_header(header: str, notes: list) -> list: 

4014 """*notes*, redacted together with *header* as the sinks render them. 

4015 

4016 *header* is a rebuilt node's own clean header line (``TypeName: text``, 

4017 see :func:`_rendered_header_tails`) and each note is clean on its own; 

4018 a credential can still span a line break between them 

4019 (:func:`_rendered_block`). When redacting the joined block 

4020 changes it but leaves the header line as it is, the redacted rest is 

4021 returned as one note (one note holding line breaks renders as the same 

4022 lines as several notes). Otherwise a placeholder note is returned, or 

4023 no notes when even that does not render cleanly after the header. 

4024 """ 

4025 if not notes: 4025 ↛ 4026line 4025 didn't jump to line 4026 because the condition on line 4025 was never true

4026 return notes 

4027 block = _rendered_block(header, notes) 

4028 redacted = _redact_exception_text(block) 

4029 if redacted == block: 

4030 return notes 

4031 start = header + "\n" 

4032 if redacted.startswith(start): 

4033 rest = [redacted[len(start) :]] 

4034 block = _rendered_block(header, rest) 

4035 if _redact_exception_text(block) == block: 4035 ↛ 4037line 4035 didn't jump to line 4037 because the condition on line 4035 was always true

4036 return rest 

4037 placeholder = ["<exception notes omitted: they joined into a credential>"] 

4038 block = _rendered_block(header, placeholder) 

4039 if _redact_exception_text(block) == block: 4039 ↛ 4041line 4039 didn't jump to line 4041 because the condition on line 4039 was always true

4040 return placeholder 

4041 return [] 

4042 

4043 

4044def _record_type_is_safe(kind: Any) -> bool: 

4045 """True when a record may keep *kind* as its exception's ``type``. 

4046 

4047 An ``enqueue=True`` sink pickles the whole record before queueing it, 

4048 and loguru's ``RecordException.__reduce__`` pickles ``type`` by 

4049 reference outside the guard it puts around ``value``. A class pickle 

4050 cannot find by its name (one defined inside a function, one made with 

4051 ``type()``) makes the put raise, and loguru's error handler then writes 

4052 ``str(record)`` and the pickling traceback to ``sys.stderr``; that 

4053 traceback's ``__context__`` is the exception being logged, unredacted, 

4054 with its whole chain. The repr of the type in ``str(record)`` shows its 

4055 module and qualified name, so these must also be clean. 

4056 

4057 Trying the pickle here (:func:`_pickles`) is not enough on its own: it 

4058 runs once, and the sink pickles again later. So *kind* is kept only 

4059 when pickling it by reference runs no code that could answer 

4060 differently the second time (:func:`_pickled_by_reference_runs_no_code`, 

4061 the check every exception kept in the rendered graph passes too), and 

4062 only then is the pickle tried. 

4063 """ 

4064 try: 

4065 if not _pickled_by_reference_runs_no_code(kind): 

4066 return False 

4067 if not _type_names_are_clean(kind): 4067 ↛ 4068line 4067 didn't jump to line 4068 because the condition on line 4067 was never true

4068 return False 

4069 qualname = _TYPE_QUALNAME.__get__(kind) 

4070 module = _TYPE_MODULE.__get__(kind) 

4071 text = "{}.{}".format(module, qualname) 

4072 if _redact_name_text(text) != text: 4072 ↛ 4073line 4072 didn't jump to line 4073 because the condition on line 4072 was never true

4073 return False 

4074 except Exception: 

4075 return False 

4076 return _pickles(kind) 

4077 

4078 

4079@_once_per_phase 

4080def _type_names_are_clean(kind: Any) -> bool: 

4081 """True when *kind*'s names are exact ``str`` that redaction leaves as is. 

4082 

4083 The names are ``__name__``, ``__qualname__`` and ``__module__``, read 

4084 through ``type``'s own descriptors, so no metaclass code runs. 

4085 """ 

4086 for read in (_TYPE_NAME, _TYPE_QUALNAME, _TYPE_MODULE): 

4087 text = read.__get__(kind) 

4088 if type(text) is not str or _redact_name_text(text) != text: 4088 ↛ 4089line 4088 didn't jump to line 4089 because the condition on line 4088 was never true

4089 return False 

4090 return True 

4091 

4092 

4093# Type attributes pickling a class, or an instance of it, reads or calls: 

4094# the class's ``__module__``, ``__qualname__`` and ``__name__`` (looked up 

4095# with ``getattr``, so through the metaclass's ``__getattribute__``, 

4096# ``__getattr__`` and data descriptors); for a class nested in another 

4097# (``Outer.Inner``), the ``__get__`` that ``getattr(Outer, "Inner")`` calls 

4098# when ``Inner``'s metaclass defines one; ``__hash__`` and ``__eq__``, with 

4099# which the pickler's dispatch-table lookup of an instance's class hashes 

4100# and compares it; and ``__reduce__`` (and ``__reduce_ex__``), which 

4101# ``object.__reduce_ex__`` reads on the instance's class to see whether it 

4102# is overridden. 

4103_PICKLE_METACLASS_ATTRIBUTES = _METACLASS_RENDER_ATTRIBUTES | { 

4104 "__get__", 

4105 "__hash__", 

4106 "__eq__", 

4107 "__reduce__", 

4108 "__reduce_ex__", 

4109} 

4110 

4111 

4112@_once_per_phase 

4113def _pickled_by_reference_runs_no_code(kind: Any) -> bool: 

4114 """True when pickling *kind*, or an instance of it, runs none of its code. 

4115 

4116 An ``enqueue=True`` sink pickles the record's ``type`` and, through 

4117 loguru's ``RecordException.__reduce__`` (``pickle.dumps(value)``), the 

4118 exception and everything its ``args`` and instance dict hold (a group's 

4119 members, through its ``args``), after that sink rendered the record and 

4120 before the next sink renders it. Pickling an object of class *kind* 

4121 looks *kind* up in the picklers' dispatch tables and saves it by module 

4122 and qualified name; code run on the way (a metaclass hook, a reducer 

4123 registered for *kind*, an enclosing class's metaclass, the module's 

4124 ``__spec__``) could change what a later sink renders, or succeed in a 

4125 trial pickle and fail in the sink's. So every exception kept in the 

4126 rendered graph (:func:`_exception_text_is_stable`), and the record's 

4127 ``type`` (:func:`_record_type_is_safe`), must pass this one check: 

4128 

4129 * *kind* is a class whose metaclass's own metaclass is ``type`` (which 

4130 hashes and dispatches the metaclass when *kind* itself is pickled); 

4131 * no non-builtin metaclass of *kind* defines 

4132 :data:`_PICKLE_METACLASS_ATTRIBUTES` (:func:`_metaclass_is_inert`; 

4133 a ``__module__`` property, say, that names the real module on the 

4134 first read and another on the next); 

4135 * no reducer is registered for *kind* or its metaclass 

4136 (:func:`_has_registered_reducer`); and 

4137 * pickle's lookup of *kind* by its module and qualified name runs no 

4138 code (:func:`_pickle_lookup_runs_no_code`): it finds *kind*, or 

4139 pickle refuses a class defined in a function before looking it up 

4140 (that pickle then fails, which loguru's ``__reduce__`` guards for a 

4141 value, and :func:`_record_type_is_safe` rejects for the ``type``). 

4142 

4143 The instance side (the exception's own ``__reduce_ex__``, ``args`` and 

4144 instance dict) is checked by :func:`_exception_text_is_stable`. 

4145 Everything is read through the builtin descriptors, so this check runs 

4146 no code of *kind*'s either. 

4147 """ 

4148 if not issubclass(type(kind), type): 4148 ↛ 4149line 4148 didn't jump to line 4149 because the condition on line 4148 was never true

4149 return False 

4150 meta = type(kind) 

4151 if type(meta) is not type: 4151 ↛ 4152line 4151 didn't jump to line 4152 because the condition on line 4151 was never true

4152 return False 

4153 if not _metaclass_is_inert(kind, _PICKLE_METACLASS_ATTRIBUTES): 

4154 return False 

4155 if _has_registered_reducer(kind) or _has_registered_reducer(meta): 

4156 return False 

4157 qualname = _TYPE_QUALNAME.__get__(kind) 

4158 module = _TYPE_MODULE.__get__(kind) 

4159 if type(qualname) is not str or type(module) is not str: 4159 ↛ 4160line 4159 didn't jump to line 4160 because the condition on line 4159 was never true

4160 return False 

4161 return _pickle_lookup_runs_no_code(kind, module, qualname) 

4162 

4163 

4164# The reducer ``copyreg`` registers for ``complex``: the only stock entry 

4165# for a type :func:`_is_plain_data` and :func:`_is_stable_text_value` 

4166# accept (the pickler saves the others without consulting the tables). 

4167_STOCK_COMPLEX_REDUCER = getattr(copyreg, "pickle_complex", None) 

4168 

4169# Metaclass attributes a dict lookup runs on a key it compares. 

4170_DICT_KEY_ATTRIBUTES = frozenset({"__hash__", "__eq__"}) 

4171 

4172 

4173@_once_per_phase 

4174def _has_registered_reducer(kind: Any, stock: Any = None) -> bool: 

4175 """True when a pickler may call a reducer other than *stock* for *kind*. 

4176 

4177 ``pickle.dumps`` (which loguru pickles an exception with) looks the 

4178 exact class of every object it saves, other than its builtin fast-path 

4179 types (``str``, ``int``, ``list`` ...), up in ``copyreg.dispatch_table``, 

4180 and the ``ForkingPickler`` the queue uses looks it up there and in its 

4181 own ``_extra_reducers``; a reducer found there runs. A table that is not 

4182 an exact ``dict``, or a key that is not a class whose metaclasses leave 

4183 ``__hash__`` and ``__eq__`` alone (one that could claim to equal *kind* 

4184 and run its own code while the lookup compares it), counts as a 

4185 reducer too. Keys are compared by identity, so nothing here runs code. 

4186 """ 

4187 try: 

4188 # What ``_metaclass_is_inert`` answers for every key whose metaclass 

4189 # is ``type`` itself, the usual case (see its fast path). 

4190 plain_classes_inert = _plain_metaclass_is_builtin() 

4191 tables = ( 

4192 copyreg.dispatch_table, 

4193 ForkingPickler._copyreg_dispatch_table, 

4194 ForkingPickler._extra_reducers, 

4195 ) 

4196 for table in tables: 

4197 if type(table) is not dict: 4197 ↛ 4198line 4197 didn't jump to line 4198 because the condition on line 4197 was never true

4198 return True 

4199 for key, reducer in dict.items(table): 

4200 if key is kind: 

4201 if reducer is not stock: 

4202 return True 

4203 elif type(key) is type: 

4204 # A plain class: ``_metaclass_is_inert``'s fast path. 

4205 if not plain_classes_inert: 4205 ↛ 4206line 4205 didn't jump to line 4206 because the condition on line 4205 was never true

4206 if not _metaclass_is_inert(key, _DICT_KEY_ATTRIBUTES): 

4207 return True 

4208 elif not ( 4208 ↛ 4212line 4208 didn't jump to line 4212 because the condition on line 4208 was never true

4209 issubclass(type(key), type) 

4210 and _metaclass_is_inert(key, _DICT_KEY_ATTRIBUTES) 

4211 ): 

4212 return True 

4213 except Exception: 

4214 return True 

4215 return False 

4216 

4217 

4218def _pickle_lookup_runs_no_code(kind: Any, module: str, qualname: str) -> bool: 

4219 """True when pickle's lookup of *kind* by name runs no code. 

4220 

4221 That is, pickle refuses the name before looking anything up (a class 

4222 defined in a function, whose qualified name has a ``<locals>`` part), 

4223 or it finds *kind* and runs no code on the way. Pickle saves a class as 

4224 its module and qualified name: it imports the module 

4225 (:func:`_module_import_runs_no_code`) and checks that following the 

4226 name (``getattr`` for each part of ``qualname``) gives back the class. Each ``getattr`` runs the code of the object it is called on: a 

4227 module that is an instance of a ``ModuleType`` subclass, or an 

4228 enclosing class whose metaclass defines ``__getattribute__``, 

4229 ``__getattr__``, ``__get__`` or an attribute of the part's name, could 

4230 find *kind* when :func:`_pickles` tries it and fail when the 

4231 ``enqueue=True`` sink pickles it, or change what a later sink renders. 

4232 So the module must already be in ``sys.modules`` as an exact 

4233 ``ModuleType``, each part must be found as the attribute lookup finds it 

4234 (no entry of that name in the lookup object's type's MRO, then the 

4235 module's dict or the first dict of the enclosing class's MRO holding 

4236 the name), through dicts whose keys are all exact ``str`` (a key of 

4237 another type could compare equal to the part by running its own code), 

4238 each enclosing class's metaclasses must be inert 

4239 (:func:`_metaclass_is_inert`), and the last part must be *kind* itself. 

4240 Everything is read through the builtin descriptors, so nothing here runs 

4241 code either. 

4242 """ 

4243 parts = qualname.split(".") 

4244 if "<locals>" in parts: 

4245 # Pickle refuses a class defined in a function by its qualified 

4246 # name alone, before it imports or looks anything up: no code runs 

4247 # (and the pickle fails, which :func:`_pickles` reports). 

4248 return True 

4249 if not _module_import_runs_no_code(module): 

4250 return False 

4251 owner = sys.modules.get(module) 

4252 if type(owner) is not types.ModuleType: 4252 ↛ 4253line 4252 didn't jump to line 4253 because the condition on line 4252 was never true

4253 return False 

4254 for index, part in enumerate(parts): 

4255 if index and not ( 

4256 issubclass(type(owner), type) 

4257 and _metaclass_is_inert(owner, _PICKLE_METACLASS_ATTRIBUTES) 

4258 ): 

4259 return False 

4260 # Any entry under ``part`` in the type of ``owner`` (the module 

4261 # type, or the enclosing class's metaclass) is found before 

4262 # ``owner``'s own: a data descriptor there wins. 

4263 for cls in _TYPE_MRO.__get__(type(owner)): 

4264 namespace = _TYPE_DICT.__get__(cls) 

4265 if not _plain_names(namespace) or part in namespace: 4265 ↛ 4266line 4265 didn't jump to line 4266 because the condition on line 4265 was never true

4266 return False 

4267 if index: 

4268 namespaces = [ 

4269 _TYPE_DICT.__get__(cls) for cls in _TYPE_MRO.__get__(owner) 

4270 ] 

4271 else: 

4272 namespaces = [_MODULE_DICT.__get__(owner)] 

4273 for namespace in namespaces: 

4274 if not _plain_names(namespace): 4274 ↛ 4275line 4274 didn't jump to line 4275 because the condition on line 4274 was never true

4275 return False 

4276 if part in namespace: 

4277 owner = namespace[part] 

4278 break 

4279 else: 

4280 return False 

4281 return owner is kind 

4282 

4283 

4284# ``ModuleType``'s ``__dict__`` descriptor: a module's own namespace. 

4285_MODULE_DICT = vars(types.ModuleType)["__dict__"] 

4286 

4287# ``ModuleSpec``'s ``__dict__`` descriptor: a spec's instance attributes. 

4288_MODULE_SPEC_DICT = vars(ModuleSpec)["__dict__"] 

4289 

4290# Attributes the import system reads on a module's ``__spec__``. 

4291_MODULE_SPEC_ATTRIBUTES = frozenset( 

4292 {"_initializing", "__getattribute__", "__getattr__"} 

4293) 

4294 

4295 

4296def _module_spec_read_is_plain(kind: Any) -> bool: 

4297 """True when reading ``__spec__`` on a module of class *kind* runs no code. 

4298 

4299 *kind* is ``ModuleType`` or a subclass whose own classes (other than 

4300 ``ModuleType`` and ``object``) define neither ``__getattribute__`` nor 

4301 an entry named ``__spec__``, so the read finds the module's own 

4302 ``__spec__`` entry through ``ModuleType``'s lookup. 

4303 """ 

4304 if not issubclass(kind, types.ModuleType): 4304 ↛ 4305line 4304 didn't jump to line 4305 because the condition on line 4304 was never true

4305 return False 

4306 for cls in _TYPE_MRO.__get__(kind): 

4307 if cls is types.ModuleType or cls is object: 4307 ↛ 4309line 4307 didn't jump to line 4309 because the condition on line 4307 was always true

4308 continue 

4309 namespace = _TYPE_DICT.__get__(cls) 

4310 if not _plain_names(namespace): 

4311 return False 

4312 if "__getattribute__" in namespace or "__spec__" in namespace: 

4313 return False 

4314 return True 

4315 

4316 

4317@_once_per_phase 

4318def _sys_modules_names_are_plain(modules: dict) -> bool: 

4319 """:func:`_plain_names` of *modules* (``sys.modules``), once per record. 

4320 

4321 :func:`_module_import_runs_no_code` runs for every class kept in the 

4322 rendered graph, and ``sys.modules`` holds thousands of entries in the 

4323 web process, so scanning it for each one made a large exception group 

4324 cost tens of milliseconds on the logging thread. Inside a decision 

4325 phase it is scanned once (:func:`_once_per_phase`); it is scanned again 

4326 if ``sys.modules`` is no longer the dict that was scanned. 

4327 """ 

4328 return _plain_names(modules) 

4329 

4330 

4331def _module_import_runs_no_code(module: str) -> bool: 

4332 """True when pickle's import of the imported module *module* runs no code. 

4333 

4334 Pickle imports the module of every class it saves by reference. For a 

4335 module already in ``sys.modules`` the import system still reads the 

4336 module's ``__spec__`` and that spec's ``_initializing`` flag (and its 

4337 truth value), and for a dotted name it does the same for the top-level 

4338 package: a ``__spec__`` object with its own ``__getattr__`` or an 

4339 ``_initializing`` property, or a module without ``__spec__`` whose 

4340 ``__getattr__`` then supplies one, runs code there. So each of those 

4341 modules must be in ``sys.modules`` (whose keys must all be exact 

4342 ``str``) as a module whose class reads ``__spec__`` from its namespace 

4343 (:func:`_module_spec_read_is_plain`; a lazy-loading ``ModuleType`` 

4344 subclass with only a ``__getattr__`` for missing names does), and that 

4345 namespace (exact ``str`` keys only) must hold ``__spec__`` as ``None`` 

4346 or an exact ``ModuleSpec`` whose class leaves those attributes alone 

4347 and whose ``_initializing``, if set, is an exact ``bool``. The module 

4348 a class is then looked up in must also be an exact ``ModuleType`` 

4349 (:func:`_pickle_lookup_runs_no_code`). ``sys.modules``'s keys are 

4350 scanned once per record, not once per call 

4351 (:func:`_sys_modules_names_are_plain`). 

4352 """ 

4353 modules = sys.modules 

4354 if type(modules) is not dict or not _sys_modules_names_are_plain(modules): 

4355 return False 

4356 for name in {module, module.partition(".")[0]}: 

4357 owner = modules.get(name) 

4358 if not _module_spec_read_is_plain(type(owner)): 4358 ↛ 4359line 4358 didn't jump to line 4359 because the condition on line 4358 was never true

4359 return False 

4360 namespace = _MODULE_DICT.__get__(owner) 

4361 if not _plain_names(namespace) or "__spec__" not in namespace: 4361 ↛ 4362line 4361 didn't jump to line 4362 because the condition on line 4361 was never true

4362 return False 

4363 spec = namespace["__spec__"] 

4364 if spec is None: 

4365 continue 

4366 if type(spec) is not ModuleSpec: 

4367 return False 

4368 for cls in _TYPE_MRO.__get__(ModuleSpec): 

4369 if _MODULE_SPEC_ATTRIBUTES.intersection(_TYPE_DICT.__get__(cls)): 

4370 # ``object`` defines ``__getattribute__``: builtin. 

4371 if not _is_builtin_class(cls): 4371 ↛ 4372line 4371 didn't jump to line 4372 because the condition on line 4371 was never true

4372 return False 

4373 state = _MODULE_SPEC_DICT.__get__(spec) 

4374 # An exact ``dict``: a subclass's ``__iter__``, ``__contains__`` or 

4375 # ``__getitem__`` would run here. 

4376 if type(state) is not dict or not _plain_names(state): 

4377 return False 

4378 if ( 4378 ↛ 4382line 4378 didn't jump to line 4382 because the condition on line 4378 was never true

4379 "_initializing" in state 

4380 and type(state["_initializing"]) is not bool 

4381 ): 

4382 return False 

4383 return True 

4384 

4385 

4386def _picklable_extra(extra: Any) -> Any: 

4387 """*extra*, with every value that is not plain data replaced. 

4388 

4389 An ``enqueue=True`` sink pickles the whole record before queueing it. 

4390 One value it cannot pickle (a lock, an open file, an instance of a class 

4391 defined in a function) makes the put raise, and loguru's error handler 

4392 then writes ``str(record)`` and the pickling traceback to 

4393 ``sys.stderr``; that traceback's ``__context__`` is the exception being 

4394 handled when the log call was made (whether or not the call logged it), 

4395 unredacted. Trying the pickle here would not settle it: an object's own 

4396 pickling code (``__reduce_ex__``, ``__getstate__``) can succeed now and 

4397 raise when the sink pickles it. So every value that is not plain data 

4398 (:func:`_is_plain_data`: an exact ``str``, ``int``, ``float``, 

4399 ``complex`` (while ``copyreg``'s own reducer pickles it), ``bool``, 

4400 ``None``, ``bytes`` or ``bytearray``, or an exact ``list``, ``tuple``, 

4401 ``dict``, ``set`` or ``frozenset`` of plain data), 

4402 whose pickling runs builtin code only, is replaced, for every sink, by 

4403 ``<unpicklable TypeName>``. If a key is not plain data, only the routing 

4404 keys (:data:`_LOG_STRUCTURAL_EXTRA_KEYS`) are kept, and ``{}`` if their 

4405 values are not plain data either. 

4406 """ 

4407 if _is_plain_data(extra): 

4408 return extra 

4409 try: 

4410 out = { 

4411 key: value 

4412 if _is_plain_data(value) 

4413 else f"<unpicklable {_type_name(type(value))}>" 

4414 for key, value in dict.items(extra) 

4415 } 

4416 except Exception: 

4417 out = None 

4418 if out is None or not _is_plain_data(out): 4418 ↛ 4419line 4418 didn't jump to line 4419 because the condition on line 4418 was never true

4419 out = _structural_extra_only(extra) 

4420 return out if _is_plain_data(out) else {} 

4421 

4422 

4423# How deep :func:`_is_plain_data` follows nested containers; a deeper one is 

4424# not counted as plain data. ``redact_log_extra`` already replaces a 

4425# container nested :data:`_LOG_EXTRA_MAX_DEPTH` deep, except under a 

4426# routing key, which it passes through as is. 

4427_PLAIN_DATA_MAX_DEPTH = _LOG_EXTRA_MAX_DEPTH + 2 

4428 

4429_PLAIN_DATA_SCALARS = ( 

4430 str, 

4431 int, 

4432 float, 

4433 complex, 

4434 bool, 

4435 type(None), 

4436 bytes, 

4437 bytearray, 

4438) 

4439_PLAIN_DATA_CONTAINERS = (list, tuple, set, frozenset) 

4440 

4441 

4442def _is_plain_data(value: Any, _depth: int = 0, _seen: Any = None) -> bool: 

4443 """True when pickling *value* runs builtin code only. 

4444 

4445 *value* is an instance of exactly one of :data:`_PLAIN_DATA_SCALARS`, or 

4446 an exact ``list``, ``tuple``, ``set``, ``frozenset`` or ``dict`` whose 

4447 items (keys and values) are plain data, nested at most 

4448 :data:`_PLAIN_DATA_MAX_DEPTH` deep (a ``complex`` only while 

4449 :func:`_complex_pickles_as_stock`). Types are compared by identity 

4450 (:func:`_type_is_one_of`), so no subclass and no metaclass ``__eq__`` 

4451 counts, and nothing here runs code of *value*'s own. A container that 

4452 contains itself is not counted as plain data; one reached again by 

4453 another path is checked once (so shared sub-containers cost linear, 

4454 not exponential, time). 

4455 """ 

4456 kind = type(value) 

4457 if _type_is_one_of(kind, _PLAIN_DATA_SCALARS): 

4458 return kind is not complex or _complex_pickles_as_stock() 

4459 if _depth >= _PLAIN_DATA_MAX_DEPTH: 4459 ↛ 4460line 4459 didn't jump to line 4460 because the condition on line 4459 was never true

4460 return False 

4461 if kind is not dict and not _type_is_one_of(kind, _PLAIN_DATA_CONTAINERS): 

4462 return False 

4463 if _seen is None: 

4464 _seen = {} 

4465 marker = id(value) 

4466 if marker in _seen: 

4467 # ``False`` while it is still being checked: a cycle. 

4468 return _seen[marker] 

4469 _seen[marker] = False 

4470 if kind is dict: 

4471 result = all( 

4472 _is_plain_data(key, _depth + 1, _seen) 

4473 and _is_plain_data(item, _depth + 1, _seen) 

4474 for key, item in dict.items(value) 

4475 ) 

4476 else: 

4477 result = all(_is_plain_data(item, _depth + 1, _seen) for item in value) 

4478 _seen[marker] = result 

4479 return result 

4480 

4481 

4482def _complex_pickles_as_stock() -> bool: 

4483 """True when pickling a ``complex`` calls ``copyreg``'s own reducer only. 

4484 

4485 The pickler saves ``str``, ``int``, ``float``, ``bool``, ``None``, 

4486 ``bytes``, ``bytearray`` and the exact builtin containers itself, but 

4487 looks ``complex`` up in its dispatch tables, where a reducer registered 

4488 in place of ``copyreg``'s would run (:func:`_has_registered_reducer`). 

4489 """ 

4490 return _STOCK_COMPLEX_REDUCER is not None and not _has_registered_reducer( 

4491 complex, _STOCK_COMPLEX_REDUCER 

4492 ) 

4493 

4494 

4495def _pickles(obj: Any) -> bool: 

4496 """True when *obj* pickles the way an ``enqueue=True`` sink pickles it. 

4497 

4498 loguru queues the record with ``multiprocessing.SimpleQueue.put``, which 

4499 serializes with :class:`multiprocessing.reduction.ForkingPickler`. That 

4500 pickler has its own reducers on top of :mod:`pickle`'s (registered, for 

4501 example, for a ``ctypes`` type once ``multiprocessing.sharedctypes`` has 

4502 created a value of it), so ``pickle.dumps`` succeeding does not mean 

4503 the put will. 

4504 """ 

4505 try: 

4506 ForkingPickler.dumps(obj) 

4507 except Exception: 

4508 return False 

4509 return True 

4510 

4511 

4512def _is_stable_text_value(value: Any, _depth: int = 0) -> bool: 

4513 """True when *value* renders the same text every time. 

4514 

4515 An exact :data:`_STABLE_TEXT_TYPES` instance, or a ``tuple`` or 

4516 ``frozenset`` (exact types) of such values. A subclass of ``str`` or 

4517 ``int``, or any other object, may render differently on each call. A 

4518 ``complex`` counts only while the reducer pickle calls for it is 

4519 ``copyreg``'s own (:func:`_complex_pickles_as_stock`): it is the one 

4520 such type the pickler dispatches through ``copyreg.dispatch_table``. 

4521 """ 

4522 kind = type(value) 

4523 if _type_is_one_of(kind, _STABLE_TEXT_TYPES): 

4524 return kind is not complex or _complex_pickles_as_stock() 

4525 if _type_is_one_of(kind, (tuple, frozenset)) and _depth < 4: 

4526 return all(_is_stable_text_value(item, _depth + 1) for item in value) 

4527 return False 

4528 

4529 

4530def _plain_names(namespace: Any) -> bool: 

4531 """True when every key of the class dict *namespace* is an exact ``str``. 

4532 

4533 ``type(name) is str`` for every key, run in C (``map`` and 

4534 ``operator.is_``) rather than as a generator: ``sys.modules`` is checked 

4535 once per record and holds thousands of keys in the web process. 

4536 """ 

4537 return all(map(operator.is_, map(type, namespace), itertools.repeat(str))) 

4538 

4539 

4540def _plain_metaclass_is_builtin() -> bool: 

4541 """True when :func:`_is_builtin_class` holds for ``type`` and ``object``. 

4542 

4543 They are the whole MRO of ``type``, the metaclass of a class statement 

4544 without one, so :func:`_metaclass_is_inert` holds for every class whose 

4545 metaclass is exactly ``type`` (it skips builtin classes). 

4546 """ 

4547 return ( 

4548 getattr(builtins, "type", None) is type 

4549 and getattr(builtins, "object", None) is object 

4550 ) 

4551 

4552 

4553def _metaclass_is_inert(kind: Any, attributes: frozenset) -> bool: 

4554 """True when no non-builtin metaclass of *kind* defines *attributes*. 

4555 

4556 Every metaclass in ``type(kind)``'s MRO that is not a builtin class must 

4557 have a class dict whose keys are all exact ``str`` (a subclass with its 

4558 own ``__hash__``/``__eq__`` could hide from the check) and that holds 

4559 none of *attributes* -- except a plain ``str`` under ``__module__``, 

4560 which every class statement puts in a metaclass's dict and which is 

4561 inert, because the class's own ``__module__`` entry is found first. The 

4562 MRO and class dicts are read through ``type``'s own descriptors, so no 

4563 metaclass code runs here. 

4564 """ 

4565 if type(kind) is type and _plain_metaclass_is_builtin(): 

4566 # The common case, answered without the loop: ``type``'s MRO is 

4567 # ``type`` and ``object``, both builtin, so the loop skips both. 

4568 return True 

4569 for meta in _TYPE_MRO.__get__(type(kind)): 

4570 if _is_builtin_class(meta): 

4571 continue 

4572 namespace = _TYPE_DICT.__get__(meta) 

4573 if not _plain_names(namespace): 4573 ↛ 4574line 4573 didn't jump to line 4574 because the condition on line 4573 was never true

4574 return False 

4575 for name in attributes.intersection(namespace): 

4576 if name == "__module__" and type(namespace[name]) is str: 

4577 continue 

4578 return False 

4579 return True 

4580 

4581 

4582def _group_args_are_its_members(node: Any) -> bool: 

4583 """True when an exception group's ``args`` are its message and members. 

4584 

4585 An ``enqueue=True`` sink pickles a group through its ``args`` (the 

4586 message and the container it was built from), not its ``exceptions``, 

4587 after that sink rendered it and before the next sink renders it. The 

4588 container is the caller's own object: a ``list`` subclass whose 

4589 ``__reduce_ex__`` rewrites a member's ``args`` would change the text 

4590 the later sinks render, and a list appended to after the group was 

4591 built would pickle objects never checked. So ``args`` must be an exact 

4592 2-tuple of the message (an exact ``str`` equal to ``message``) and an 

4593 exact ``list`` or ``tuple`` holding exactly the group's members, the 

4594 same objects in the same order; the members themselves are checked as 

4595 nodes of their own. 

4596 """ 

4597 args = node.args 

4598 if type(args) is not tuple or len(args) != 2: 4598 ↛ 4599line 4598 didn't jump to line 4599 because the condition on line 4598 was never true

4599 return False 

4600 message, members = args 

4601 if type(message) is not str or message != _exact_str(node.message): 4601 ↛ 4602line 4601 didn't jump to line 4602 because the condition on line 4601 was never true

4602 return False 

4603 if not _type_is_one_of(type(members), (list, tuple)): 

4604 return False 

4605 exceptions = node.exceptions 

4606 return len(members) == len(exceptions) and all( 

4607 member is exception for member, exception in zip(members, exceptions) 

4608 ) 

4609 

4610 

4611def _exception_text_is_stable(node: Any, is_group: bool) -> bool: 

4612 """True when rendering *node* again yields exactly the text checked. 

4613 

4614 The redactor reads an exception's text once and keeps the original 

4615 object only when that text was clean, but the sinks render the object 

4616 again (loguru's ``format_exception_only``, the DB row's ``str()``) and 

4617 run its code while doing so. Keeping it is safe only when no code the 

4618 exception's own classes define runs then, and the values the builtin 

4619 rendering reads cannot render other text. That holds when: 

4620 

4621 * every non-builtin class in ``type(node).__mro__`` (including a mixin 

4622 listed after a builtin base) leaves the rendering attributes alone 

4623 (:data:`_RENDER_ATTRIBUTES` and the base's :data:`_RENDERED_FIELDS`); 

4624 * the type is one that pickling by reference runs no code of 

4625 (:func:`_pickled_by_reference_runs_no_code`, the check the record's 

4626 ``type`` passes too): an ``enqueue=True`` sink pickles every kept 

4627 node an exception's ``args`` reach (a group's members) after it 

4628 rendered the record and before the next sink renders it, and the 

4629 check is applied to every kept node, so one node can be kept or 

4630 rebuilt only one way wherever it appears in the graph. Among other 

4631 things, no non-builtin metaclass of the type defines the type 

4632 attributes the sinks, the pickler and this check read 

4633 (:data:`_PICKLE_METACLASS_ATTRIBUTES`, which include 

4634 :data:`_METACLASS_RENDER_ATTRIBUTES`; the MRO, class dicts and names 

4635 are read here through ``type``'s own descriptors, so a metaclass 

4636 cannot hide a class or override from this check), and no class or 

4637 module pickle looks it up through runs code; 

4638 * the type's own ``__name__``, ``__qualname__`` and ``__module__`` 

4639 (which the traceback module and the DB row render) are plain ``str`` 

4640 that redaction leaves unchanged; 

4641 * the values the builtin rendering reads (``args``, a group's 

4642 ``message``, the base's fields) and the instance attributes an 

4643 ``enqueue=True`` sink pickles are of :data:`_STABLE_TEXT_TYPES` 

4644 (``__notes__`` aside, which the caller checks), and a group's 

4645 ``args``, which that sink pickles in place of its ``exceptions``, 

4646 hold nothing but its message and a plain container of exactly its 

4647 members (:func:`_group_args_are_its_members`); and 

4648 * the traceback module computes no "Did you mean" suggestion for it (an 

4649 ``AttributeError`` or ``NameError`` with a ``str`` ``name``, an 

4650 ``ImportError`` with a ``str`` ``name_from``): that runs code of 

4651 other objects -- ``obj.__dir__()``, importing the named module and 

4652 calling its ``__dir__``, and, for a renderer that passes the 

4653 traceback (loguru does not), ``hasattr`` on the failing frame's 

4654 ``self`` -- which can change the exception after its text was 

4655 checked. A rebuilt node has no ``name``, so its traceback shows no 

4656 suggestion. 

4657 

4658 Otherwise (a ``__str__`` that returns benign text first and a secret 

4659 later, a ``__notes__`` property, a ``__len__`` that rewrites ``args``, 

4660 an argument object with its own ``__str__``, a class nested in one 

4661 whose metaclass hooks attribute lookup) the caller rebuilds the node 

4662 from the text it checked. Code that changes the exception from outside 

4663 its rendering and pickling is not covered: another thread; the class's 

4664 own construction code (``__init__``, ``__new__``, ``__setattr__``, a 

4665 metaclass ``__call__``), which runs when an ``enqueue=True`` sink 

4666 unpickles its own copy in its writer thread; a reducer registered for 

4667 a builtin exception type, which also runs for the rebuilt nodes; and 

4668 patches to the pickling or import machinery itself. Nor is a class or 

4669 object written to defeat this check (see the module docstring's threat 

4670 model): the reads here are made through builtin descriptors and exact 

4671 type tests (an instance ``__dict__`` must be an exact ``dict``, a 

4672 ``name`` an exact ``str``) as defence in depth, not as a guarantee. 

4673 """ 

4674 kind = type(node) 

4675 # Everything that depends on the class alone, checked once per class in 

4676 # a decision phase. 

4677 if not _exception_class_is_stable(kind): 

4678 return False 

4679 # ``type(...) is str``, not ``isinstance``: the latter falls back to a 

4680 # non-``str`` value's ``__class__``, which runs its code. Any value 

4681 # other than an exact ``str`` or ``None`` fails the field check below. 

4682 if issubclass(kind, ImportError): 

4683 if type(getattr(node, "name_from", None)) is str: 

4684 return False 

4685 if issubclass(kind, (NameError, AttributeError)): 

4686 if type(getattr(node, "name", None)) is str: 

4687 return False 

4688 # Read through ``BaseException``'s own descriptor: the attribute lookup 

4689 # is guarded (:func:`_exception_class_is_stable`), and this is the dict 

4690 # pickling copies. It must be an exact ``dict`` 

4691 # (``exc.__dict__ = SomeDictSubclass()`` is accepted), read with 

4692 # ``dict``'s own methods, so no ``__len__`` or ``items`` of the node's 

4693 # runs here or in the pickler. 

4694 state = _BASE_EXCEPTION_DICT.__get__(node) 

4695 if type(state) is not dict: 

4696 return False 

4697 if state: 

4698 for name, value in dict.items(state): 

4699 if type(name) is not str: 4699 ↛ 4700line 4699 didn't jump to line 4700 because the condition on line 4699 was never true

4700 return False 

4701 if name != "__notes__" and not _is_stable_text_value(value): 

4702 return False 

4703 if is_group: 

4704 if type(node.message) is not str: 4704 ↛ 4705line 4704 didn't jump to line 4705 because the condition on line 4704 was never true

4705 return False 

4706 if not _group_args_are_its_members(node): 

4707 return False 

4708 elif not _is_stable_text_value(node.args): 

4709 return False 

4710 return all( 

4711 _is_stable_text_value(getattr(node, name, None)) 

4712 for name in _rendered_fields(kind) 

4713 ) 

4714 

4715 

4716def _rendered_fields(kind: Any) -> list: 

4717 """The :data:`_RENDERED_FIELDS` of the builtin bases *kind* derives from.""" 

4718 # ``issubclass`` on the type, not ``isinstance`` on the node: the latter 

4719 # falls back to the node's ``__class__``, which runs its code. 

4720 return [ 

4721 name 

4722 for base, names in _RENDERED_FIELDS 

4723 if issubclass(kind, base) 

4724 for name in names 

4725 ] 

4726 

4727 

4728@_once_per_phase 

4729def _exception_class_is_stable(kind: Any) -> bool: 

4730 """The class-level conditions of :func:`_exception_text_is_stable`. 

4731 

4732 No non-builtin class in *kind*'s MRO defines a rendering attribute, 

4733 pickling *kind* by reference runs no code, and its names are clean. 

4734 These depend on the class alone, so a decision phase checks them once 

4735 per class (:func:`_once_per_phase`). 

4736 """ 

4737 guarded = _RENDER_ATTRIBUTES.union(_rendered_fields(kind)) 

4738 # Every non-builtin class in the MRO counts, not only those before the 

4739 # first builtin: in ``class E(ValueError, Mixin)`` the order is E, 

4740 # ValueError, Exception, BaseException, Mixin, object, and the builtins 

4741 # do not define ``__str__``, ``__notes__``, ``__getattr__`` or 

4742 # ``__getattribute__`` themselves, so Python still finds Mixin's. 

4743 # The MRO and class dicts are read through ``type``'s own descriptors: 

4744 # ``kind.__mro__`` and ``vars(cls)`` go through the metaclass, which 

4745 # could report builtin bases only, or an empty dict. 

4746 # A class dict key that is not an exact ``str`` (a subclass with its 

4747 # own ``__hash__``/``__eq__``) could hide from the name checks. 

4748 for cls in _TYPE_MRO.__get__(kind): 

4749 if _is_builtin_class(cls): 

4750 continue 

4751 namespace = _TYPE_DICT.__get__(cls) 

4752 if not _plain_names(namespace) or guarded.intersection(namespace): 

4753 return False 

4754 # The sinks read the type's names and an ``enqueue=True`` sink pickles 

4755 # the kept node, so its class must be one that rendering and pickling 

4756 # by reference run no code of (a superset of the metaclass attributes 

4757 # rendering alone reads). 

4758 if not _pickled_by_reference_runs_no_code(kind): 

4759 return False 

4760 return _type_names_are_clean(kind) 

4761 

4762 

4763# A ``SyntaxError``'s fields the interpreter fills, by the exact type it 

4764# fills them with (or ``None``). Only a node whose fields all have these 

4765# types is kept; anything else (a ``str`` line number, ``bytes`` source 

4766# text, a ``str`` offset) is rendered differently by each Python version's 

4767# traceback module, and on Python 3.12 a ``bytes`` text or non-``int`` 

4768# offset makes it raise, so loguru's error handler prints the context of 

4769# that error -- the exception being handled -- unredacted to stderr. The 

4770# ``int`` fields must also be in the accepted ranges 

4771# (:func:`_syntax_error_fields_out_of_range`). 

4772_SYNTAX_ERROR_FIELD_TYPES = ( 

4773 ("msg", str), 

4774 ("filename", str), 

4775 ("text", str), 

4776 ("lineno", int), 

4777 ("offset", int), 

4778 ("end_lineno", int), 

4779 ("end_offset", int), 

4780) 

4781 

4782# The largest line number a kept ``SyntaxError`` may carry: the interpreter 

4783# fills ``lineno`` and ``end_lineno`` from a C ``int``. A larger one is not 

4784# an interpreter value, and one past ``sys.get_int_max_str_digits()`` digits 

4785# makes the ``File ...`` line raise ``ValueError`` when it is printed. 

4786_SYNTAX_ERROR_MAX_LINENO = 2**31 - 1 

4787 

4788# How far past the UTF-8 length of ``text`` (plus one) a kept 

4789# ``SyntaxError``'s ``offset`` and ``end_offset`` may run, in columns. The 

4790# interpreter counts columns in UTF-8 bytes of the reported line, not in 

4791# characters: the tokenizer passes raw byte offsets, and the compiler and 

4792# symtable stages (errors raised when a file is compiled or imported) report 

4793# byte offsets too. Those stages also report the ``end_offset`` of a span 

4794# that ends on a later line as a column of that later line, which ``text`` 

4795# does not hold and which can be longer than it. This slack covers such a 

4796# later line of up to this many bytes; a span ending further right than that 

4797# is not kept (the caller rebuilds the node). It also bounds the caret line 

4798# Python 3.12 draws (``'^' * (end_offset - offset)``), so a value like 

4799# ``10**9`` is rejected before anything is rendered 

4800# (:func:`_syntax_error_fields_out_of_range`). 

4801_SYNTAX_ERROR_SPAN_END_SLACK = 2**16 

4802 

4803# How much longer than the text it prints verbatim (``msg``, ``filename`` 

4804# and ``text``, each counted three times) plus its caret line (at most its 

4805# largest offset, when ``text`` is a ``str``) a kept ``SyntaxError``'s 

4806# rendering may be, in characters. On Python 3.13+ the keyword-typo 

4807# suggestion may replace ``msg`` and the source line with ones from at most 

4808# 1024 characters of source. Anything longer is not a rendering of these 

4809# fields, and the caller rebuilds the node 

4810# (:func:`_syntax_error_renders_bounded`). 

4811_SYNTAX_ERROR_RENDER_SLACK = 4096 

4812 

4813 

4814def _syntax_error_fields_out_of_range(node: Any) -> bool: 

4815 """True when a ``SyntaxError``'s ``int`` fields are not interpreter values. 

4816 

4817 Called only for fields of the canonical types 

4818 (:data:`_SYNTAX_ERROR_FIELD_TYPES`) and a ``text`` of at most 

4819 ``_LOG_REDACTION_MAX_CHARS`` characters, so encoding it is bounded. The traceback module does 

4820 arithmetic on the offsets to draw the caret line (``'^' * (end - start)`` 

4821 on Python 3.12, and on 3.14 too when ``text`` is empty), so an offset 

4822 near ``2**63`` makes it raise ``OverflowError`` or ``MemoryError``, and a 

4823 moderate one (``10**9``) makes every sink build a caret line that long. 

4824 Loguru's error handler then prints the context of that error -- the 

4825 exception being handled -- unredacted to stderr. The interpreter 

4826 reports ``lineno`` and ``end_lineno`` between ``0`` and 

4827 :data:`_SYNTAX_ERROR_MAX_LINENO`. It reports ``offset`` and 

4828 ``end_offset`` as ``-1`` (unknown) or as columns counted in 

4829 UTF-8 bytes, not characters, so on a line with non-ASCII text they can 

4830 exceed ``len(text) + 1``; and for an error raised when a file is 

4831 compiled or imported, an ``end_offset`` on a later line than 

4832 ``lineno`` is a column of that later line, not of ``text``. The 

4833 accepted range is therefore ``-1`` to the UTF-8 length of ``text`` 

4834 plus ``1`` plus :data:`_SYNTAX_ERROR_SPAN_END_SLACK`, or up to 

4835 :data:`_SYNTAX_ERROR_MAX_LINENO` when ``text`` is ``None`` (the offsets 

4836 are then not drawn). Anything outside these ranges makes the caller 

4837 rebuild the node, which drops these fields; that includes a real error 

4838 whose span ends more than :data:`_SYNTAX_ERROR_SPAN_END_SLACK` bytes 

4839 into a later line. 

4840 """ 

4841 for value in (node.lineno, node.end_lineno): 

4842 if value is not None and not 0 <= value <= _SYNTAX_ERROR_MAX_LINENO: 

4843 return True 

4844 limit = ( 

4845 _SYNTAX_ERROR_MAX_LINENO 

4846 if node.text is None 

4847 else len(node.text.encode("utf-8", "surrogatepass")) 

4848 + 1 

4849 + _SYNTAX_ERROR_SPAN_END_SLACK 

4850 ) 

4851 for value in (node.offset, node.end_offset): 

4852 if value is not None and not -1 <= value <= limit: 

4853 return True 

4854 return False 

4855 

4856 

4857def _syntax_error_renders_bounded(node: Any) -> bool: 

4858 """True when the traceback module renders *node*'s lines within bounds. 

4859 

4860 Runs ``traceback.format_exception_only`` -- what loguru's formatter 

4861 calls -- on a detached builtin ``SyntaxError`` carrying *node*'s 

4862 rendered fields (``msg``, ``filename``, ``lineno``, ``offset``, 

4863 ``text``, ``end_lineno``, ``end_offset`` and ``_metadata``), not on 

4864 *node* itself, so no chained exception is formatted and no frame's 

4865 source is read. Called only for a node that is about to be kept, whose 

4866 fields have passed :func:`_syntax_error_details_changed` (canonical 

4867 types, in range, and a keyword-typo source that is an exact ``str``, so 

4868 the rendering reads no file). False when that rendering raises 

4869 (``OverflowError``, ``MemoryError``, ``ValueError`` or any other 

4870 ``Exception`` a sink would hit, whose error handler prints the 

4871 exception being handled unredacted) or is longer than 

4872 :data:`_SYNTAX_ERROR_RENDER_SLACK` allows; the caller then rebuilds the 

4873 node. ``KeyboardInterrupt`` and ``SystemExit`` propagate. 

4874 """ 

4875 import traceback 

4876 

4877 try: 

4878 probe = SyntaxError() 

4879 verbatim = 0 

4880 for name, _kind in _SYNTAX_ERROR_FIELD_TYPES: 

4881 value = getattr(node, name) 

4882 setattr(probe, name, value) 

4883 if type(value) is str: 

4884 verbatim += len(value) 

4885 metadata = getattr(node, "_metadata", None) 

4886 if metadata is not None: 

4887 probe._metadata = metadata 

4888 size = sum(len(line) for line in traceback.format_exception_only(probe)) 

4889 # Python 3.12 draws ``end_offset - offset`` carets, unclamped. 

4890 caret = 0 

4891 if type(node.text) is str: 

4892 caret = max(0, node.offset or 0, node.end_offset or 0) 

4893 except Exception: 

4894 return False 

4895 return size <= 3 * verbatim + caret + _SYNTAX_ERROR_RENDER_SLACK 

4896 

4897 

4898def _syntax_error_location_block(node: Any) -> str: 

4899 """The ``File ...`` and source lines the traceback prints for *node*. 

4900 

4901 ``TracebackException._format_syntax_error`` prints 

4902 `` File "<filename>", line <lineno>`` (only when ``lineno`` is not 

4903 ``None``; ``<string>`` for an empty or missing file name) and, on the 

4904 next line, the source ``text`` without its trailing newlines and 

4905 leading spaces, so a label ending one line and a value starting the 

4906 next are adjacent text. Called only for fields of the canonical types 

4907 (:data:`_SYNTAX_ERROR_FIELD_TYPES`). 

4908 """ 

4909 lines = [] 

4910 if node.lineno is not None: 

4911 lines.append( 

4912 f' File "{node.filename or "<string>"}", line {node.lineno}' 

4913 ) 

4914 if node.text is not None: 

4915 lines.append(" " + node.text.rstrip("\n").lstrip(" \n\f")) 

4916 return "\n".join(lines) 

4917 

4918 

4919def _syntax_error_details_changed(node: Any) -> bool: 

4920 """True when a ``SyntaxError``'s extra traceback lines need redacting. 

4921 

4922 The traceback module renders ``filename``, ``lineno``, ``text`` and 

4923 ``msg`` outside ``str(node)``. A field that is not ``None`` or of the 

4924 exact type the interpreter fills it with 

4925 (:data:`_SYNTAX_ERROR_FIELD_TYPES`: ``str`` for ``msg``, ``filename`` 

4926 and ``text``, ``int`` -- not ``bool`` -- for ``lineno``, ``offset``, 

4927 ``end_lineno`` and ``end_offset``) makes the caller rebuild the node, 

4928 and so does a ``text`` longer than ``_LOG_REDACTION_MAX_CHARS`` (which 

4929 redaction would cut) or an ``int`` field outside the accepted ranges 

4930 (:func:`_syntax_error_fields_out_of_range`). Otherwise each of ``msg``, ``filename``, ``lineno`` and ``text`` is 

4931 checked on its own, and the ``File ...`` line joined with the source 

4932 line below it (:func:`_syntax_error_location_block`). On Python 3.13+ 

4933 the keyword-typo suggestion may also replace the shown line with one 

4934 from the source in ``_metadata``, or from the file ``filename`` names 

4935 when that source is ``None``; that happens only when ``msg`` is 

4936 ``"invalid syntax"`` or mentions a forgotten comma. Any of these that 

4937 redacts differently, a source that is not an exact ``str``, or a file 

4938 the sink would read makes the caller rebuild the node, which drops 

4939 these details. 

4940 """ 

4941 for name, kind in _SYNTAX_ERROR_FIELD_TYPES: 

4942 value = getattr(node, name, None) 

4943 if value is not None and type(value) is not kind: 

4944 return True 

4945 # Redaction cuts a longer ``text`` and appends a marker, so it changes 

4946 # anyway; returning here also bounds what the range check encodes. 

4947 if node.text is not None and len(node.text) > _LOG_REDACTION_MAX_CHARS: 4947 ↛ 4948line 4947 didn't jump to line 4948 because the condition on line 4947 was never true

4948 return True 

4949 if _syntax_error_fields_out_of_range(node): 

4950 return True 

4951 for value in (node.msg, node.filename, node.lineno, node.text): 

4952 if value is None: 

4953 continue 

4954 text = _safe_log_str(value) 

4955 if _redact_exception_text(text) != text: 

4956 return True 

4957 block = _syntax_error_location_block(node) 

4958 if _redact_exception_text(block) != block: 4958 ↛ 4959line 4958 didn't jump to line 4959 because the condition on line 4958 was never true

4959 return True 

4960 metadata = getattr(node, "_metadata", None) 

4961 if metadata is None: 

4962 return False 

4963 # Plain characters: a ``str`` subclass's own ``__ne__`` or 

4964 # ``__contains__`` could answer differently here than in the sink. 

4965 msg = _plain_text(node.msg) 

4966 if not isinstance(node.text, str) or ( 

4967 isinstance(msg, str) 

4968 and msg != "invalid syntax" 

4969 and "Perhaps you forgot a comma" not in msg 

4970 ): 

4971 return False 

4972 source = ( 

4973 metadata[2] if type(metadata) is tuple and len(metadata) == 3 else None 

4974 ) 

4975 if type(source) is not str or len(source) > _LOG_REDACTION_MAX_CHARS: 4975 ↛ 4976line 4975 didn't jump to line 4976 because the condition on line 4975 was never true

4976 return True 

4977 return any( 

4978 _redact_exception_text(text) != text 

4979 for text in ( 

4980 source, 

4981 *source.splitlines(), 

4982 *(line.lstrip() for line in source.splitlines()), 

4983 ) 

4984 ) 

4985 

4986 

4987def redact_log_exception(record) -> None: 

4988 """Give sinks a scrubbed exception graph without changing live exceptions. 

4989 

4990 Loguru renders exception values, notes and group messages independently 

4991 of record["message"]. Rebuild changed nodes using builtin exceptions only; 

4992 custom constructors, copy hooks and group derive methods are never called. 

4993 Custom non-group types retain their name in a RuntimeError message; group 

4994 subclasses become builtin groups. Application-specific attributes are not 

4995 copied to log output. Sensitive SyntaxError source details are omitted, 

4996 as are those of a SyntaxError whose fields are not of the types the 

4997 interpreter fills them with (:data:`_SYNTAX_ERROR_FIELD_TYPES`), not in 

4998 the accepted ranges (:func:`_syntax_error_fields_out_of_range`), or whose 

4999 rendering by the traceback module raises or runs long 

5000 (:func:`_syntax_error_renders_bounded`). 

5001 Tracebacks and safe notes are retained. Cycles and traversal limits become 

5002 explicit placeholders instead of links back to unsanitized objects. An 

5003 exception, cause, context, group member or note whose ``str()`` fails is 

5004 rendered as ``<unprintable TypeName>`` rather than aborting the rebuild. 

5005 An exception is kept as the original object only when its text was clean 

5006 and no code its classes define, and no "Did you mean" suggestion, runs 

5007 when the sinks render it again (:func:`_exception_text_is_stable` lists 

5008 the conditions and what they do not cover) and its type's name joined 

5009 with its text and notes (the ``TypeName: text`` header line, as the 

5010 sinks render it, and the notes on the lines after it) is clean too; 

5011 otherwise the sinks get a rebuilt node carrying exactly the text that 

5012 was checked. The record's ``type`` stays the original class only when an 

5013 ``enqueue=True`` sink can pickle it and its names are clean 

5014 (:func:`_record_type_is_safe`); otherwise the top node is rebuilt even 

5015 when it could be kept, and ``type`` becomes the rebuilt node's builtin 

5016 type, so that sink never fails and dumps the unredacted original. A 

5017 record with a ``type`` but no value (``None``) gets the same check: a 

5018 ``type`` it rejects becomes ``RuntimeError``, with a value naming the 

5019 original class (``RuntimeError: TypeName``). 

5020 

5021 Application code the redaction itself runs (an exception's or a note 

5022 object's ``__str__``, a ``__notes__`` or ``__cause__`` property) runs 

5023 only while the graph is read (:func:`_plan_redacted_exception`), for 

5024 every node, before anything is decided: such code can change any 

5025 object, including one already read. Every decision -- which nodes are 

5026 kept, and whether the record's ``type`` is -- is made afterwards, on 

5027 the objects as they are then, by code that runs no application code 

5028 for exception classes that do not hook the type, descriptor and 

5029 instance-dict machinery those checks read through 

5030 (:func:`_build_redacted_exception`), so for them nothing changes 

5031 between those checks and the sinks. A class written to defeat this 

5032 is outside the threat model (see the module docstring). 

5033 """ 

5034 exc = record.get("exception") if record else None 

5035 if not exc: 5035 ↛ 5036line 5035 didn't jump to line 5036 because the condition on line 5035 was never true

5036 return 

5037 value = getattr(exc, "value", None) 

5038 if value is None and isinstance(exc, tuple) and len(exc) >= 2: 

5039 value = exc[1] 

5040 

5041 kind = getattr(exc, "type", None) 

5042 if kind is None and isinstance(exc, tuple) and len(exc) >= 1: 

5043 kind = exc[0] 

5044 if value is None: 

5045 # No value to redact (``logger.opt(exception=(Type, None, tb))``), 

5046 # but the sink still pickles ``type``: one it cannot keep is 

5047 # replaced by a ``RuntimeError`` naming it, as the traceback module 

5048 # renders a type with no value. 

5049 if kind is None: 5049 ↛ 5050line 5049 didn't jump to line 5050 because the condition on line 5049 was never true

5050 return 

5051 with _decision_phase(): 

5052 kind_is_safe = _record_type_is_safe(kind) 

5053 if kind_is_safe: 

5054 return 

5055 new_value = RuntimeError(_named_text(kind, "")) 

5056 traceback = getattr(exc, "traceback", None) 

5057 if traceback is None and isinstance(exc, tuple) and len(exc) >= 3: 5057 ↛ 5059line 5057 didn't jump to line 5059 because the condition on line 5057 was always true

5058 traceback = exc[2] 

5059 new_value.__traceback__ = traceback 

5060 _replace_record_exception(record, exc, RuntimeError, new_value) 

5061 return 

5062 

5063 plans: dict = {} 

5064 root = _plan_redacted_exception( 

5065 value, plans, set(), [_LOG_EXCEPTION_NODE_BUDGET] 

5066 ) 

5067 # Every step that can run application code has run. Everything below 

5068 # reads the objects as they are now and runs builtin code only, so no 

5069 # code can change a node, or the record's type, after it was checked. 

5070 # Checks that depend on a class alone (its pickling, rendering hooks 

5071 # and names, and ``sys.modules``'s keys) are answered once per class 

5072 # for all of them (:func:`_once_per_phase`). 

5073 with _decision_phase(): 

5074 kind_is_safe = _record_type_is_safe(kind) 

5075 # A top node whose type cannot stay in the record 

5076 # (:func:`_record_type_is_safe`) is rebuilt even when its text is 

5077 # clean, so that its builtin type replaces it. 

5078 new_value = _build_redacted_exception( 

5079 root, plans, {}, force=None if kind_is_safe else root 

5080 ) 

5081 if new_value is value: 

5082 return 

5083 # ``type`` stays the original class when it is safe to keep (the DB 

5084 # row's prefix names it); otherwise it becomes the rebuilt value's 

5085 # builtin type. 

5086 new_kind = kind if kind_is_safe else type(new_value) 

5087 _replace_record_exception(record, exc, new_kind, new_value) 

5088 

5089 

5090def _replace_record_exception( 

5091 record: Any, exc: Any, kind: Any, value: Any 

5092) -> None: 

5093 """Set *record*'s exception to *exc* with *kind* and *value* replaced.""" 

5094 if hasattr(exc, "_replace"): 

5095 record["exception"] = exc._replace(type=kind, value=value) 

5096 elif isinstance(exc, tuple) and len(exc) >= 3: 5096 ↛ 5099line 5096 didn't jump to line 5099 because the condition on line 5096 was always true

5097 record["exception"] = (kind, value, exc[2]) 

5098 else: 

5099 record["exception"] = None 

5100 

5101 

5102def _exception_redaction_failed(exc: Any) -> Any: 

5103 """Replacement for a record exception whose redaction raised. 

5104 

5105 Keeps the traceback (frames and source lines carry no exception text) 

5106 and replaces the value with a fixed placeholder, so the record neither 

5107 ships unredacted exception text nor silently loses where the error 

5108 happened. ``None`` when even that cannot be built. 

5109 """ 

5110 try: 

5111 traceback = getattr(exc, "traceback", None) 

5112 kind = getattr(exc, "type", None) 

5113 name = _type_name(kind, "Exception") 

5114 placeholder = RuntimeError( 

5115 f"{name}: <exception text omitted: redaction failed>" 

5116 ) 

5117 placeholder.__traceback__ = traceback 

5118 if not _record_type_is_safe(kind): 

5119 kind = RuntimeError 

5120 return exc._replace(type=kind, value=placeholder, traceback=traceback) 

5121 except Exception: 

5122 return None 

5123 

5124 

5125# ``BaseException``'s own ``__traceback__`` descriptor: the traceback a node 

5126# carries, read without running anything its class defines. 

5127_BASE_EXCEPTION_TRACEBACK = vars(BaseException)["__traceback__"] 

5128 

5129 

5130@dataclasses.dataclass(eq=False) 

5131class _ExceptionNodePlan: 

5132 """What was read from one exception node and redacted, before any decision. 

5133 

5134 :func:`_plan_redacted_exception` fills one per node; it is the pass 

5135 that runs every step that can run application code. 

5136 :func:`_build_redacted_exception` then decides, from this and from the 

5137 node as it is by then, whether the original can be kept, and otherwise 

5138 rebuilds it from the texts recorded here. A child is referred to by the 

5139 ``id()`` of the original (a key of the plans, which hold the originals, 

5140 so the ids stay unique), by a placeholder text, or by ``None``. 

5141 """ 

5142 

5143 node: Any 

5144 kind: Any 

5145 is_group: bool 

5146 cause: Any 

5147 cause_ref: Any 

5148 context: Any 

5149 context_ref: Any 

5150 suppress_context: Any 

5151 subs: tuple 

5152 sub_refs: list 

5153 traceback: Any 

5154 rendered_message: str 

5155 message: str 

5156 safe_message: str 

5157 note_texts: list 

5158 safe_notes: list 

5159 # The texts read here were clean, as one would render them: printable, 

5160 # unchanged by redaction, with notes that are a list or tuple of exact 

5161 # ``str``. A node is kept only when this holds and it is unchanged. 

5162 text_clean: bool 

5163 

5164 

5165def _plan_redacted_exception( 

5166 node: Any, plans: dict, in_progress: set, budget: list 

5167) -> Any: 

5168 """Read and redact the exception graph under *node*, deciding nothing. 

5169 

5170 Every step of the redaction that can run application code runs here, 

5171 for every node, before anything is decided: an exception's or a note 

5172 object's ``str()``, a ``__notes__``, ``__cause__``, ``__context__``, 

5173 ``exceptions`` or ``message`` that a class overrides, a group's 

5174 message. That code can change any object, a node already read here 

5175 included, so :func:`_build_redacted_exception` makes every decision 

5176 afterwards, against the objects as they are by then. Each node's texts 

5177 are read once and recorded (:class:`_ExceptionNodePlan`), and a node 

5178 that is rebuilt carries exactly those texts. 

5179 

5180 Returns the reference to *node*'s plan: its ``id()``, a placeholder 

5181 text (the traversal budget ran out, which bounds both depth and group 

5182 width, or the node is its own ancestor), or ``None``. 

5183 """ 

5184 if node is None: 

5185 return None 

5186 if budget[0] <= 0: 

5187 return "<exception traversal limit reached>" 

5188 # Cached children still consume work and space in a group's output. 

5189 budget[0] -= 1 

5190 key = id(node) 

5191 if key in plans: 

5192 return key 

5193 if key in in_progress: 

5194 return "<exception cycle omitted>" 

5195 in_progress.add(key) 

5196 try: 

5197 cause = node.__cause__ 

5198 cause_ref = _plan_redacted_exception(cause, plans, in_progress, budget) 

5199 suppress_context = node.__suppress_context__ 

5200 # Not only when there is no cause: the sinks render each exception 

5201 # once, so a cause already rendered elsewhere in the graph (shared 

5202 # by two group members) is skipped, and an unsuppressed context is 

5203 # rendered in its place. 

5204 context = node.__context__ if not suppress_context else None 

5205 context_ref = _plan_redacted_exception( 

5206 context, plans, in_progress, budget 

5207 ) 

5208 

5209 is_group = isinstance(node, BaseExceptionGroup) 

5210 subs = tuple(node.exceptions) if is_group else () 

5211 sub_refs: list = [] 

5212 for child in subs: 

5213 if budget[0] <= 0: 

5214 sub_refs.append("<remaining exceptions omitted>") 

5215 break 

5216 sub_refs.append( 

5217 _plan_redacted_exception(child, plans, in_progress, budget) 

5218 ) 

5219 

5220 # The text is read once here and, if the node is rebuilt, the 

5221 # rebuilt node carries exactly what was checked. A ``str()`` that 

5222 # raised (the placeholder below is not what a sink would render) 

5223 # rules out keeping the original. 

5224 try: 

5225 rendered_message = _exact_str(str(node)) 

5226 printable = True 

5227 except Exception: 

5228 rendered_message = _unprintable_placeholder(node) 

5229 printable = False 

5230 if is_group: 

5231 try: 

5232 message = node.message 

5233 except Exception: 

5234 message = None 

5235 if isinstance(message, str): 5235 ↛ 5240line 5235 didn't jump to line 5240 because the condition on line 5235 was always true

5236 # A ``str`` subclass compares and renders on its own terms; 

5237 # check, compare and rebuild with its plain characters. 

5238 message = _exact_str(message) 

5239 else: 

5240 message = _unprintable_placeholder(node) 

5241 elif printable and issubclass(type(node), SyntaxError): 

5242 # The header line shows ``msg``, not ``str(node)`` with its 

5243 # `` (file, line N)`` suffix (:func:`_syntax_error_header_text`): 

5244 # a rebuilt node carries ``msg`` alone (the file and line are 

5245 # details it drops), redacted as one with the notes printed 

5246 # right after it. 

5247 try: 

5248 message = _syntax_error_header_text(node, location=False) 

5249 except Exception: 

5250 message = rendered_message 

5251 else: 

5252 message = rendered_message 

5253 safe_message = _redact_exception_text(message) 

5254 # A group renders ``str(node)`` (message plus sub-exception count), 

5255 # and a ``SyntaxError``'s ``str()`` (which the DB row renders) is 

5256 # not its header text; for any other exception ``str(node)`` is 

5257 # ``message`` itself, so there this check would only repeat 

5258 # ``safe_message != message`` below: the guard only saves that 

5259 # second redaction pass (dropping it is an equivalent mutant, not a 

5260 # missing check). 

5261 rendered_changed = ( 

5262 is_group or message is not rendered_message 

5263 ) and _redact_exception_text(rendered_message) != rendered_message 

5264 # A ``__notes__`` property that raises: the traceback module reports 

5265 # it with the error's repr ("Ignored error getting __notes__: ..."), 

5266 # which is not redacted, so the node is rebuilt with a placeholder 

5267 # note instead (the placeholder alone marks the node as changed). 

5268 # The same placeholder covers notes that raise while being iterated. 

5269 notes_failed = False 

5270 try: 

5271 note_texts, notes_exact = _rendered_exception_notes(node) 

5272 except Exception: 

5273 notes_failed = True 

5274 note_texts, notes_exact = [], True 

5275 safe_notes = [_redact_exception_text(note) for note in note_texts] 

5276 if notes_failed: 

5277 safe_notes = ["<exception notes omitted: __notes__ raised>"] 

5278 plans[key] = _ExceptionNodePlan( 

5279 node=node, 

5280 kind=type(node), 

5281 is_group=is_group, 

5282 cause=cause, 

5283 cause_ref=cause_ref, 

5284 context=context, 

5285 context_ref=context_ref, 

5286 suppress_context=suppress_context, 

5287 subs=subs, 

5288 sub_refs=sub_refs, 

5289 traceback=_BASE_EXCEPTION_TRACEBACK.__get__(node), 

5290 rendered_message=rendered_message, 

5291 message=message, 

5292 safe_message=safe_message, 

5293 note_texts=note_texts, 

5294 safe_notes=safe_notes, 

5295 text_clean=( 

5296 printable 

5297 and not notes_failed 

5298 and notes_exact 

5299 and safe_message == message 

5300 and not rendered_changed 

5301 and safe_notes == note_texts 

5302 ), 

5303 ) 

5304 return key 

5305 finally: 

5306 in_progress.discard(key) 

5307 

5308 

5309def _build_redacted_exception( 

5310 ref: Any, plans: dict, built: dict, force: Any = None 

5311) -> Any: 

5312 """The graph the sinks get for plan *ref*: originals kept, the rest rebuilt. 

5313 

5314 Runs after :func:`_plan_redacted_exception` has read the whole graph, 

5315 and runs no application code itself for exception classes and objects 

5316 that do not hook the machinery it reads through (see the module 

5317 docstring's threat model), so for those nothing can change a node 

5318 between the decision to keep it and the sinks rendering it. A node 

5319 retains identity only when its recorded text was clean, it is still 

5320 the node that text was read from (:func:`_node_is_kept_as_planned`), 

5321 and every chained exception and member it links to is kept too; any 

5322 other node is rebuilt from the texts recorded for it, so the text, 

5323 notes and chained exceptions a sink renders for it are the ones checked 

5324 (a node for which the traceback module would compute a "Did you mean" 

5325 suggestion is rebuilt, so no suggestion is rendered for it). Rebuilt 

5326 nodes are builtin exceptions (a class counts as builtin when 

5327 :mod:`builtins` holds it under its name): application constructors, 

5328 copy hooks and group derive methods are never called. Omitted branches (traversal 

5329 budget, cycles) become placeholders. *force* is the reference of a node 

5330 rebuilt even when it could be kept (see :func:`redact_log_exception`); 

5331 its chained exceptions and members are not forced. 

5332 """ 

5333 if ref is None: 

5334 return None 

5335 if type(ref) is str: 

5336 return RuntimeError(ref) 

5337 if ref in built: 

5338 return built[ref] 

5339 plan = plans[ref] 

5340 new_cause = _build_redacted_exception(plan.cause_ref, plans, built) 

5341 new_context = _build_redacted_exception(plan.context_ref, plans, built) 

5342 new_subs = [ 

5343 _build_redacted_exception(sub_ref, plans, built) 

5344 for sub_ref in plan.sub_refs 

5345 ] 

5346 keep = ( 

5347 ref != force 

5348 and new_cause is plan.cause 

5349 and new_context is plan.context 

5350 and len(new_subs) == len(plan.subs) 

5351 and all(new is old for new, old in zip(new_subs, plan.subs)) 

5352 and _node_is_kept_as_planned(plan) 

5353 ) 

5354 if keep: 

5355 new_node = plan.node 

5356 else: 

5357 new_node = _rebuilt_exception(plan, new_cause, new_context, new_subs) 

5358 built[ref] = new_node 

5359 return new_node 

5360 

5361 

5362def _exact_note_texts(node: Any) -> Optional[list]: 

5363 """*node*'s notes when they are a ``list`` or ``tuple`` of exact ``str``. 

5364 

5365 ``[]`` for no notes, ``None`` for notes of any other form. Called only 

5366 for a node :func:`_exception_text_is_stable` accepts, for which the 

5367 lookup and the iteration run builtin code only. 

5368 """ 

5369 notes = getattr(node, "__notes__", None) 

5370 if notes is None: 

5371 return [] 

5372 if not _type_is_one_of(type(notes), (list, tuple)): 5372 ↛ 5373line 5372 didn't jump to line 5373 because the condition on line 5372 was never true

5373 return None 

5374 items = list(notes) 

5375 if not all(type(item) is str for item in items): 5375 ↛ 5376line 5375 didn't jump to line 5376 because the condition on line 5375 was never true

5376 return None 

5377 return items 

5378 

5379 

5380def _node_is_kept_as_planned(plan: _ExceptionNodePlan) -> bool: 

5381 """True when *plan*'s original node may go to the sinks as it is now. 

5382 

5383 Its recorded texts were clean, and it is still what they were read 

5384 from: the same class; its rendering and pickling still run no code of 

5385 its own classes (:func:`_exception_text_is_stable`, checked first, by 

5386 code that runs none of the node's); and its ``str()``, notes, chained 

5387 exceptions, suppress flag, a group's message and members, a 

5388 ``SyntaxError``'s rendered details, and its type's name joined with 

5389 its text and notes (:func:`_type_header_changed`) are as recorded and 

5390 clean. Application code that ran while the graph was read (a parent's 

5391 or a note object's ``__str__``, say) may have changed any of these 

5392 after the node itself was read. Once the stability check passes, every 

5393 read and call below runs builtin code only, so nothing changes between 

5394 this check and the sinks -- for classes and objects that do not hook 

5395 the machinery these checks read through; the module docstring's threat 

5396 model says why one that does is not covered. 

5397 """ 

5398 if not plan.text_clean: 

5399 return False 

5400 node = plan.node 

5401 try: 

5402 if type(node) is not plan.kind: 

5403 return False 

5404 if not _exception_text_is_stable(node, plan.is_group): 

5405 return False 

5406 if node.__cause__ is not plan.cause: 

5407 return False 

5408 suppress_context = node.__suppress_context__ 

5409 if suppress_context is not plan.suppress_context: 5409 ↛ 5410line 5409 didn't jump to line 5410 because the condition on line 5409 was never true

5410 return False 

5411 context = None if suppress_context else node.__context__ 

5412 if context is not plan.context: 5412 ↛ 5413line 5412 didn't jump to line 5413 because the condition on line 5412 was never true

5413 return False 

5414 if plan.is_group: 

5415 exceptions = node.exceptions 

5416 if len(exceptions) != len(plan.subs) or not all( 5416 ↛ 5419line 5416 didn't jump to line 5419 because the condition on line 5416 was never true

5417 live is old for live, old in zip(exceptions, plan.subs) 

5418 ): 

5419 return False 

5420 if node.message != plan.message: 5420 ↛ 5421line 5420 didn't jump to line 5421 because the condition on line 5420 was never true

5421 return False 

5422 rendered = str(node) 

5423 if type(rendered) is not str or rendered != plan.rendered_message: 

5424 return False 

5425 if _exact_note_texts(node) != plan.note_texts: 

5426 return False 

5427 is_syntax_error = issubclass(plan.kind, SyntaxError) 

5428 if is_syntax_error: 

5429 if _syntax_error_header_text(node, location=False) != plan.message: 5429 ↛ 5430line 5429 didn't jump to line 5430 because the condition on line 5429 was never true

5430 return False 

5431 if _syntax_error_details_changed(node): 

5432 return False 

5433 # The sinks render the type's name, the text and the notes joined, 

5434 # so a credential can span them; checked last, as it costs one more 

5435 # redaction pass per name form (:func:`_rendered_type_prefixes`: one 

5436 # for a builtin class, two for one defined at module level, three 

5437 # for a nested one) and header form (two for an empty text, up to 

5438 # three for a ``SyntaxError``, one otherwise). 

5439 if _type_header_changed( 

5440 plan.kind, 

5441 rendered, 

5442 plan.note_texts, 

5443 _rendered_header_tails(node, rendered), 

5444 ): 

5445 return False 

5446 # Rendered as the sinks will, last: the caret line is drawn from the 

5447 # offsets, and a rendering that raises or runs long makes the sinks' 

5448 # error handler print the exception being handled. 

5449 if is_syntax_error and not _syntax_error_renders_bounded(node): 

5450 return False 

5451 except Exception: 

5452 return False 

5453 return True 

5454 

5455 

5456def _rebuilt_exception( 

5457 plan: _ExceptionNodePlan, new_cause: Any, new_context: Any, new_subs: list 

5458) -> BaseException: 

5459 """A builtin exception carrying the texts recorded in *plan*.""" 

5460 kind = plan.kind 

5461 safe_message = plan.safe_message 

5462 if plan.is_group: 

5463 # The builtin constructor rebuilds the read-only message and 

5464 # members, without trusting a subclass's derive implementation. 

5465 # The type's name is read through ``type``'s descriptor, and 

5466 # redacted (:func:`_type_name`). 

5467 if not _is_builtin_class(kind): 

5468 # Keep a group subclass's name visible in the rebuilt group. 

5469 safe_message = _redact_exception_text( 

5470 f"{_type_name(kind)}: {safe_message}" 

5471 ) 

5472 new_node = BaseExceptionGroup(safe_message, new_subs) 

5473 elif _is_builtin_class(kind): 

5474 try: 

5475 new_node = kind(safe_message) 

5476 except Exception: 

5477 new_node = RuntimeError(_named_text(kind, safe_message)) 

5478 else: 

5479 new_node = RuntimeError(_named_text(kind, safe_message)) 

5480 new_node.__traceback__ = plan.traceback 

5481 new_node.__cause__ = new_cause 

5482 new_node.__context__ = new_context 

5483 new_node.__suppress_context__ = plan.suppress_context 

5484 safe_notes = plan.safe_notes 

5485 if safe_notes: 

5486 # Each note was redacted on its own; a credential can still span 

5487 # the rebuilt header line and the notes. 

5488 header = ( 

5489 _TYPE_NAME.__get__(type(new_node)) 

5490 + _rendered_header_tails(new_node, _exact_str(str(new_node)))[0] 

5491 ) 

5492 safe_notes = _notes_fitting_header(header, safe_notes) 

5493 if safe_notes: 

5494 new_node.__notes__ = safe_notes 

5495 return new_node 

5496 

5497 

5498def redact_log_record(record) -> None: 

5499 """loguru patcher: control-char strip + credential redaction of a record. 

5500 

5501 Scrubs the message and bound ``extra`` values (:func:`_redact_log_record_text`, 

5502 :func:`redact_log_extra`) and the rendered exception graph 

5503 (:func:`redact_log_exception`), so a credential that reaches a record 

5504 without going through ``scrub_error`` first -- bare ``field=value`` in 

5505 the message, a secret bound via ``logger.bind()``, or one embedded in an 

5506 exception's text -- is caught before any sink renders it. Kept here, 

5507 rather than in ``log_utils``, because that module imports the web stack 

5508 at module scope and the MCP subprocess (``mcp/server.py``'s 

5509 ``configure_mcp_logging``) must not pull that in: this is the patcher 

5510 both processes install (the web process through 

5511 ``utilities.log_utils._sanitize_record``). 

5512 

5513 Fail-closed per field: a field that fails to redact is replaced rather 

5514 than passed through as-is. Routing keys in ``extra`` 

5515 (:data:`_LOG_STRUCTURAL_EXTRA_KEYS`) are preserved so a failed field 

5516 does not change the destination of a database log record. An exception 

5517 that fails to redact keeps its traceback with a placeholder value. 

5518 

5519 The result is always picklable: an ``enqueue=True`` sink pickles the 

5520 record, and a pickling failure makes loguru print the original exception 

5521 to stderr. Non-plain ``extra`` values and unsafe exception types are 

5522 replaced before reaching a sink. Kept exception nodes are checked for 

5523 pickling hooks that could change later rendering, within the threat 

5524 model in the module docstring. 

5525 

5526 The message and every ``extra`` string leaf use 

5527 :func:`_redact_log_record_text`, which adds the unprefixed ``Api-Key:`` 

5528 header, slash-bearing DSN password, separator-delimited credential 

5529 parameter, and quoted credential-key patterns after the ordinary 

5530 redaction passes. Exception values use :func:`redact_log_exception`, 

5531 which applies the patterns to rendered fields too; the database row's 

5532 exception prefix applies them separately. Application code invoked while 

5533 reading an exception graph runs before the keep decisions and 

5534 picklability checks. 

5535 """ 

5536 try: 

5537 # Preserve credential boundaries that control stripping may remove, 

5538 # then catch names that become recognizable after normalization. 

5539 record["message"] = _redact_log_record_text(record["message"]) 

5540 except Exception: 

5541 record["message"] = "<log message redacted: sanitization error>" 

5542 

5543 extra = record.get("extra") 

5544 if extra: 

5545 try: 

5546 extra = redact_log_extra(extra) 

5547 except Exception: 

5548 extra = _structural_extra_only(extra) 

5549 record["extra"] = extra 

5550 else: 

5551 extra = None 

5552 

5553 exc = record.get("exception") 

5554 if exc: 

5555 try: 

5556 redact_log_exception(record) 

5557 except Exception: 

5558 record["exception"] = _exception_redaction_failed(exc) 

5559 

5560 if extra is not None: 

5561 # Last, after the exception's redaction, which runs application code 

5562 # (:func:`redact_log_exception`) that could change a value passed 

5563 # through above (a routing key's); this check runs none. 

5564 record["extra"] = _picklable_extra(extra) 

5565 

5566 

5567def _structural_extra_only(extra: Any) -> dict: 

5568 """The routing keys of *extra* alone; ``{}`` if even that fails. 

5569 

5570 Taken from the keys *extra* holds, by :func:`_is_structural_extra_key`, 

5571 rather than looked up by name: ``extra["username"]`` would also find a 

5572 ``str`` subclass key that only compares equal to ``"username"``. 

5573 """ 

5574 try: 

5575 return { 

5576 key: value 

5577 for key, value in dict.items(extra) 

5578 if _is_structural_extra_key(key) 

5579 } 

5580 except Exception: 

5581 return {}