Coverage for src/local_deep_research/security/url_redaction.py: 95%

118 statements  

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

1"""Redact credentials embedded in a configured URL before it is logged. 

2 

3Operators sometimes put an API key straight into an LLM base URL -- as 

4userinfo (``https://user:key@host/v1``), as a query or fragment parameter 

5(``?api_key=...``, ``#access_token=...``), as a ``;`` path parameter 

6(``/v1;api_key=...``), or as a bare path segment (``/sk-.../v1``). 

7:func:`redact_url_credentials` removes or masks those while keeping the URL 

8legible for troubleshooting. 

9 

10Dependency-free on purpose (``re`` / ``urllib`` only), so provider, router 

11and error-handling modules can all import it without an import cycle. 

12""" 

13 

14from __future__ import annotations 

15 

16import re 

17from urllib.parse import unquote_plus, urlsplit, urlunsplit 

18 

19REDACTED = "REDACTED" 

20 

21# A parameter NAME is a credential name when any of its PARTS is. The 

22# parts of a PHP/Rails-style array name are the text around its ``[`` / 

23# ``]`` brackets (``api_key[0]`` -> ``api_key``; ``auth[token]`` -> 

24# ``auth``, ``token``; ``api_key[prod]`` -> ``api_key``, ``prod``); an 

25# all-digit or empty part (an index) is ignored, and a stray bracket 

26# (``api_key]``) simply ends a part. A part is normalised by splitting 

27# camelCase (``accessToken`` -> ``access_token``, ``XAPIKey`` -> 

28# ``XAPI_Key``), lower-casing, and splitting into words on every character 

29# that is not an ASCII letter or digit (``_``, ``-``, ``.``, whitespace -- 

30# ``api+key`` decodes to ``api key`` -- and also ``%00``, ``:`` and the 

31# like). A part is a credential name when EITHER 

32# * its last word is in ``_CREDENTIAL_SUFFIX_WORDS`` (so ``api_key``, 

33# ``x-api-key``, ``subscription-key``, ``access_token``, ``authToken``, 

34# ``clientSecret``, ``x-amz-signature``, ``db.password``, 

35# ``passphrase``, and the OAuth grant secrets ``assertion``, 

36# ``client_assertion`` (RFC 7521/7523), ``code_verifier`` (RFC 7636) 

37# and ``oauth_verifier`` (RFC 5849) all match), OR 

38# * its last word is a run-together compound: it ends in one of 

39# ``_RUN_TOGETHER_SUFFIXES`` (``authtoken``, ``APITOKEN``, 

40# ``clientsecret``, ``clientassertion``, ``dbpassword``, 

41# ``keypassphrase``) or in one of the ``...key`` words of 

42# ``_RUN_TOGETHER_KEY_NAMES`` (``apikey``, ``xapikey``, ``secretkey``, 

43# ``privatekey``). ``key`` itself is NOT a run-together suffix, because 

44# ordinary words end in it (``monkey``, ``turkey``, ``hockey``); a 

45# ``...key`` compound outside that list (``fookey``) is not matched, OR 

46# * its words, joined without separators, are one of 

47# ``_CREDENTIAL_EXACT_NAMES`` -- credential names that cannot be 

48# matched by their ending without also matching ordinary names: 

49# ``code`` (``error_code``), ``auth`` (``oauth``), ``refresh`` 

50# (``force_refresh``), ``device_code`` (RFC 8628; ``country_code``), 

51# ``id_token_hint`` (it carries an OpenID Connect ID token; 

52# ``login_hint``) and the OAuth authorization code ``auth_code`` / 

53# ``authorization_code`` (RFC 6749). Joining makes ``device_code``, 

54# ``deviceCode`` and the run-together ``devicecode`` / ``DEVICECODE`` 

55# one name. 

56# Each of these tests is also run with a trailing number or version taken 

57# off the part -- final words that are digits or ``v`` + digits are 

58# dropped, then trailing digits (and a ``v`` just before them) come off the 

59# last word -- and once more with a plural ``s`` also taken off the last 

60# word (not when it ends in ``ss``). So ``api_key_2``, ``apiKey2``, 

61# ``key1``, ``token2``, ``access_token_v2``, ``x-api-key-v2`` and 

62# ``api_keys`` match, while ``page2``, ``items`` and ``v2`` do not; this 

63# only ever adds matches. 

64# Other words match only as a whole word, so ``tokenizer``, ``passthrough``, 

65# ``monkey`` and ``keyboard`` never match. Errs towards over-redaction: a 

66# non-secret ``sort_key``, ``sort_keys``, ``max_tokens`` or 

67# ``api_key[prod]`` is redacted too, and a name longer than 

68# ``_MAX_PARAM_NAME_LEN`` is treated as a credential name without being 

69# inspected (fail closed, and bounded work). 

70_CREDENTIAL_SUFFIX_WORDS = frozenset( 

71 { 

72 "key", 

73 "apikey", 

74 "token", 

75 "secret", 

76 "password", 

77 "passwd", 

78 "pwd", 

79 "pass", 

80 "passphrase", 

81 "signature", 

82 "sig", 

83 "credential", 

84 "credentials", 

85 "jwt", 

86 "session", 

87 "assertion", 

88 "verifier", 

89 } 

90) 

91_RUN_TOGETHER_SUFFIXES = ( 

92 "token", 

93 "secret", 

94 "password", 

95 "passwd", 

96 "passphrase", 

97 "assertion", 

98 "verifier", 

99) 

100_RUN_TOGETHER_KEY_NAMES = ( 

101 "apikey", 

102 "appkey", 

103 "accesskey", 

104 "authkey", 

105 "clientkey", 

106 "encryptionkey", 

107 "licensekey", 

108 "masterkey", 

109 "passkey", 

110 "privatekey", 

111 "secretkey", 

112 "sessionkey", 

113 "signingkey", 

114 "subscriptionkey", 

115) 

116# Stored without separators: compared with a part's words joined by "". 

117_CREDENTIAL_EXACT_NAMES = frozenset( 

118 name.replace("_", "") 

119 for name in ( 

120 "sas", 

121 "auth", 

122 "authorization", 

123 "bearer", 

124 "code", 

125 "refresh", 

126 "device_code", 

127 "id_token_hint", 

128 "auth_code", 

129 "authorization_code", 

130 ) 

131) 

132# A final name word that is a number or a version (``2``, ``v2``). 

133_NUMBER_OR_VERSION_WORD_RE = re.compile(r"v?[0-9]+") 

134# Longest parameter name inspected word by word; a longer one is treated 

135# as a credential name. Real parameter names are far shorter. 

136_MAX_PARAM_NAME_LEN = 256 

137# Longest value :func:`redact_url_credentials` parses; a longer one is 

138# logged as ``<unknown>``. A configured base URL is far shorter; the cap 

139# bounds the work done in (often unconditional) log lines on a value a 

140# user can supply. 

141MAX_REDACTABLE_URL_LEN = 4096 

142_CAMEL_BOUNDARY_RE = re.compile( 

143 r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])" 

144) 

145_NAME_WORD_SEPARATOR_RE = re.compile(r"[^a-z0-9]+") 

146_ARRAY_BRACKET_RE = re.compile(r"[\[\]]") 

147# ``?`` is a separator too: ``?x=1?api_key=...`` (a repeated ``?``) and a 

148# value that embeds a URL (``next=https://h/p?token=...``) both carry their 

149# credential after a ``?`` that ``urlsplit`` leaves inside the query. 

150_PARAM_SEPARATORS_RE = re.compile(r"([&;?])") 

151# A value-less query/fragment item at least this long (``?sk-...``) is 

152# assumed to be a bare credential. 

153_BARE_ITEM_REDACT_MIN_LEN = 17 

154# Path segments shaped like a provider key: 

155# * ``sk-`` / ``key-`` in any letter case (``SK-``, ``Key-``) + 16 or 

156# more base64url characters, or 

157# * 32+ characters of ``[A-Za-z0-9_]`` -- NO ``-`` -- containing both a 

158# letter and a digit. 

159# Excluding ``-`` keeps hyphenated model and deployment names 

160# (``text-embedding-3-large-2024-preview-model``, UUIDs) legible. The 

161# trade-offs: a dash-bearing key without an ``sk-``/``key-`` prefix (some 

162# Google keys) is not caught, and a 32-hex id such as a Cloudflare account 

163# id IS redacted (over-redaction, the safe direction). 

164_KEY_LIKE_SEGMENT_RE = re.compile( 

165 r"^(?:(?:sk|key)-[A-Za-z0-9_\-]{16,}" 

166 r"|(?=[A-Za-z0-9_]*[A-Za-z])(?=[A-Za-z0-9_]*[0-9])[A-Za-z0-9_]{32,})$", 

167 # ASCII: without it, IGNORECASE lets the classes match four non-ASCII 

168 # letters too (U+0130, U+0131, U+017F, U+212A). 

169 re.IGNORECASE | re.ASCII, 

170) 

171_PORT_RE = re.compile(r"[0-9]+") 

172# Characters that never belong in a host we log: a ``;`` / ``&`` / ``=`` 

173# after the host means parameters (``https://host;api_key=...``) ended up 

174# inside the parsed authority, where they would not be redacted. 

175_AUTHORITY_PARAM_CHARS_RE = re.compile(r"[;&=]") 

176# End of a scheme-less value's leading authority-like part. 

177_AUTHORITY_END_RE = re.compile(r"[/?#]") 

178_HTTP_SCHEMES = frozenset({"http", "https"}) 

179 

180 

181def _name_variants(words: list[str]) -> list[list[str]]: 

182 """``words`` as given, without a trailing number or version, and 

183 without that and a plural ``s`` (see the rule above). A last word that 

184 the stripping would leave empty (``v2``, ``s``) is kept as it was.""" 

185 trimmed = list(words) 

186 while len(trimmed) > 1 and _NUMBER_OR_VERSION_WORD_RE.fullmatch( 

187 trimmed[-1] 

188 ): 

189 trimmed.pop() 

190 last = trimmed[-1] 

191 stem = last.rstrip("0123456789") 

192 if stem != last and stem.endswith("v"): 

193 stem = stem[:-1] 

194 if stem: 

195 trimmed[-1] = stem 

196 variants = [words, trimmed] 

197 last = trimmed[-1] 

198 if len(last) > 1 and last.endswith("s") and not last.endswith("ss"): 

199 variants.append([*trimmed[:-1], last[:-1]]) 

200 return variants 

201 

202 

203def _words_name_a_credential(words: list[str]) -> bool: 

204 """The three tests of the rule above, for one spelling of a part.""" 

205 if "".join(words) in _CREDENTIAL_EXACT_NAMES: 

206 return True 

207 last = words[-1] 

208 return ( 

209 last in _CREDENTIAL_SUFFIX_WORDS 

210 or last.endswith(_RUN_TOGETHER_SUFFIXES) 

211 or last.endswith(_RUN_TOGETHER_KEY_NAMES) 

212 ) 

213 

214 

215def _is_credential_name_part(part: str) -> bool: 

216 """The rule above for one bracket-free part of a parameter name.""" 

217 words = [ 

218 w 

219 for w in _NAME_WORD_SEPARATOR_RE.split( 

220 _CAMEL_BOUNDARY_RE.sub("_", part).lower() 

221 ) 

222 if w 

223 ] 

224 if not words: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 return False 

226 return any(_words_name_a_credential(v) for v in _name_variants(words)) 

227 

228 

229def is_credential_param_name(raw_name: str) -> bool: 

230 """Return True when a URL parameter name names a credential. 

231 

232 Linear in the length of the name, and a name longer than 

233 ``_MAX_PARAM_NAME_LEN`` is reported 

234 as a credential name without further inspection.""" 

235 if len(raw_name) > _MAX_PARAM_NAME_LEN: 

236 return True 

237 return any( 

238 _is_credential_name_part(part) 

239 for part in _ARRAY_BRACKET_RE.split(unquote_plus(raw_name)) 

240 if part.strip() and not part.strip().isdigit() 

241 ) 

242 

243 

244def _redact_param_list(params: str, *, redact_bare: bool) -> str: 

245 """Redact credential values in a ``&`` / ``;`` / ``?`` separated list, 

246 keeping every other parameter and separator unchanged.""" 

247 if not params: 

248 return params 

249 out = [] 

250 for piece in _PARAM_SEPARATORS_RE.split(params): 

251 if piece in ("&", ";", "?"): 

252 out.append(piece) 

253 continue 

254 name, sep, _value = piece.partition("=") 

255 if sep and is_credential_param_name(name): 

256 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: URL parameter text 

257 piece = f"{name}={REDACTED}" 

258 elif ( 

259 not sep and redact_bare and len(piece) >= _BARE_ITEM_REDACT_MIN_LEN 

260 ): 

261 piece = REDACTED 

262 out.append(piece) 

263 return "".join(out) 

264 

265 

266def _redact_path(path: str) -> str: 

267 """Redact ``;name=value`` path parameters and key-shaped segments.""" 

268 segments = [] 

269 for segment in path.split("/"): 

270 head, sep, params = segment.partition(";") 

271 if _KEY_LIKE_SEGMENT_RE.match(head): 

272 head = REDACTED 

273 if sep: 

274 params = _redact_param_list(params, redact_bare=False) 

275 segments.append(f"{head}{sep}{params}") 

276 return "/".join(segments) 

277 

278 

279def _drop_schemeless_userinfo(url: str) -> str: 

280 """``user:secret@host/v1`` (no ``//``) parses with ``user`` as the 

281 scheme and the secret in the path; drop everything up to the ``@`` of 

282 the leading authority-like part, which ends at the first ``/``, ``?`` 

283 or ``#``. An ``@`` only later in the value (a password containing one 

284 of those such as ``user:Abc/123@host``, or an ``@`` in the query or 

285 fragment such as ``host?x=a@b``) is left for the caller's fail-closed 

286 ``@`` check.""" 

287 head = _AUTHORITY_END_RE.split(url, maxsplit=1)[0] 

288 if "@" in head: 

289 return url[head.rindex("@") + 1 :] 

290 return url 

291 

292 

293def _authority_is_well_formed(authority: str) -> bool: 

294 """True when ``host[:port]`` (or ``[v6][:port]``) has an empty or 

295 all-digit port. A non-digit "port" means ``urlsplit`` cut the 

296 authority short -- typically at a ``/``, ``?`` or ``#`` inside an 

297 unencoded password -- so the real userinfo was never isolated. Also 

298 False when the authority holds a ``;``, ``&`` or ``=`` 

299 (``host;api_key=...``): parameters there are not redacted.""" 

300 if _AUTHORITY_PARAM_CHARS_RE.search(authority): 

301 return False 

302 if authority.startswith("["): 

303 close = authority.find("]") 

304 if close == -1: 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true

305 return False 

306 rest = authority[close + 1 :] 

307 if not rest: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true

308 return True 

309 if not rest.startswith(":"): 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true

310 return False 

311 port = rest[1:] 

312 else: 

313 port = authority.rpartition(":")[2] if ":" in authority else "" 

314 return not port or bool(_PORT_RE.fullmatch(port)) 

315 

316 

317def redact_url_credentials(url: object) -> str: 

318 """Return ``url`` with embedded credentials removed, for logging. 

319 

320 * userinfo (``user:pass@``) is dropped -- also for a scheme-less 

321 ``user:pass@host/v1``; 

322 * in the query string, the fragment, and ``;`` path parameters, the 

323 value of every credential-named parameter (see the name rule above; 

324 ``&``, ``;`` and ``?`` all separate parameters) becomes ``REDACTED``; 

325 * a value-less query or fragment item of 17+ characters (``?sk-...``) 

326 becomes ``REDACTED``; 

327 * a path segment shaped like a provider key (see 

328 ``_KEY_LIKE_SEGMENT_RE``) becomes ``REDACTED``. 

329 

330 Fails closed -- returns ``"<unknown>"`` -- when the userinfo cannot be 

331 isolated reliably: any ``@`` in the path, query or fragment (so the 

332 count of ``@`` in the value differs from the count in the parsed 

333 authority -- this includes a harmless ``?email=a@example.com``), or an 

334 authority whose port is not all digits (an unencoded ``/``, ``?`` or 

335 ``#`` in a password ends the authority early), or an authority that 

336 contains ``;``, ``&`` or ``=`` (``https://host;api_key=...``). 

337 Non-string and empty inputs, and any value longer than 

338 ``MAX_REDACTABLE_URL_LEN`` characters, also return ``"<unknown>"``; 

339 the function never raises, and its work is linear in the input. 

340 

341 The scheme is lower-cased (``urlsplit`` does that); the host (with its 

342 original case) and the port are kept as given; the rest of the URL is 

343 kept unchanged except for the replacements above (``urlsplit`` also 

344 drops an empty trailing ``?`` or ``#``). 

345 

346 Limits: a credential under a name the rule does not recognise, a short 

347 or non key-shaped path segment, and percent-encoded separators inside a 

348 value are not detected. 

349 """ 

350 if not isinstance(url, str) or not url or len(url) > MAX_REDACTABLE_URL_LEN: 

351 return "<unknown>" 

352 try: 

353 parsed = urlsplit(url) 

354 if not parsed.netloc and parsed.scheme.lower() not in _HTTP_SCHEMES: 

355 stripped = _drop_schemeless_userinfo(url) 

356 if stripped != url: 

357 url = stripped 

358 parsed = urlsplit(url) 

359 except ValueError: 

360 return "<unknown>" 

361 if url.count("@") != parsed.netloc.count("@"): 

362 # An @ outside the authority (path, query or fragment): either the 

363 # userinfo was not isolated -- a password containing ``@`` and then 

364 # ``/``, ``?`` or ``#`` splits across the authority boundary -- or a 

365 # scheme-less value still carries one after stripping. Also covers 

366 # an innocent ``?email=a@example.com``: withheld rather than guessed. 

367 return "<unknown>" 

368 redacted = parsed._replace( 

369 path=_redact_path(parsed.path), 

370 query=_redact_param_list(parsed.query, redact_bare=True), 

371 fragment=_redact_param_list(parsed.fragment, redact_bare=True), 

372 ) 

373 if parsed.netloc: 

374 authority = parsed.netloc.rpartition("@")[2] 

375 if not _authority_is_well_formed(authority): 

376 return "<unknown>" 

377 redacted = redacted._replace(netloc=authority) 

378 elif redacted == parsed: 

379 # Nothing to redact: pass a scheme-less value through as given. 

380 return url or "<unknown>" 

381 return urlunsplit(redacted) or "<unknown>"