Coverage for src/local_deep_research/security/data_sanitizer.py: 100%

89 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1"""Security module for sanitizing sensitive data from data structures. 

2 

3This module ensures that sensitive information like API keys, passwords, and tokens 

4are not accidentally leaked in logs, files, or API responses. 

5 

6Includes helpers for filtering research metadata in API responses to prevent 

7settings_snapshot (which contains all application settings including API keys) 

8from being sent to the frontend. 

9""" 

10 

11import json 

12from typing import Any, Set 

13 

14 

15# The placeholder a redacted value is replaced with. Single source of truth 

16# so that write-back guards (which must treat this sentinel as a no-op to 

17# avoid persisting it over a real secret on a redacted GET round-trip) 

18# cannot drift from what the redactor actually emits. 

19REDACTION_TEXT = "[REDACTED]" 

20 

21# Unicode 16.0 DerivedCoreProperties.txt, Default_Ignorable_Code_Point. 

22# Most Unicode Default_Ignorable_Code_Point characters are non-printable and 

23# are removed by str.isprintable(). These exceptions are printable combining 

24# marks or letter-category fillers, so list them explicitly. Do not drop Mn as 

25# a category: ordinary combining marks are visible parts of legitimate names. 

26_PRINTABLE_DEFAULT_IGNORABLES = ( 

27 frozenset({0x034F}) # COMBINING GRAPHEME JOINER 

28 | frozenset(range(0x115F, 0x1161)) # Hangul fillers 

29 | frozenset(range(0x17B4, 0x17B6)) # Khmer inherent vowels 

30 | frozenset(range(0x180B, 0x1810)) # Mongolian variation separators 

31 | frozenset({0x3164, 0xFFA0}) # Hangul fillers 

32 | frozenset(range(0xFE00, 0xFE10)) # Variation Selectors 

33 | frozenset(range(0xE0100, 0xE01F0)) # Variation Selectors Supplement 

34) 

35 

36# Printable characters that Unicode specifies as rendering blank but that are 

37# not default ignorables. They pass str.isprintable() and setting-key validation, 

38# so they can otherwise disguise a sensitive leaf in the bulk settings endpoint. 

39_PRINTABLE_BLANKS = frozenset( 

40 { 

41 0x2800, # BRAILLE PATTERN BLANK 

42 0x13441, # EGYPTIAN HIEROGLYPH FULL BLANK 

43 0x13442, # EGYPTIAN HIEROGLYPH HALF BLANK 

44 0x1D159, # MUSICAL SYMBOL NULL NOTEHEAD 

45 } 

46) 

47 

48 

49def _visible_leaf(key: str) -> str: 

50 """The key's last dotted segment, normalized for the sensitive-name check. 

51 

52 Defense in depth for the bulk-settings GET, which lacks the 

53 authoritative ``ui_element == "password"`` signal and falls back to the key 

54 name. Drops non-printable chars (control, format \u2014 zero-width/BOM/bidi/tag 

55 chars/soft hyphen \u2014 and non-ASCII spaces) plus printable Unicode default 

56 ignorables (variation selectors, combining grapheme joiner, and fillers) 

57 and explicitly known printable blank characters, then strips ASCII space 

58 and lowercases. Ordinary visible combining marks are retained. This 

59 intentionally fails closed: removing invisible padding can collapse a 

60 distinct key onto a sensitive name and over-redact its value, which is 

61 safer than exposing a potentially disguised secret. 

62 """ 

63 leaf = key.rsplit(".", 1)[-1] 

64 leaf = "".join( 

65 ch 

66 for ch in leaf 

67 if ch.isprintable() 

68 and ord(ch) not in _PRINTABLE_DEFAULT_IGNORABLES 

69 and ord(ch) not in _PRINTABLE_BLANKS 

70 ) 

71 return leaf.strip().lower() 

72 

73 

74class DataSanitizer: 

75 """Utility class for removing sensitive information from data structures.""" 

76 

77 # Public alias of the module-level sentinel (see REDACTION_TEXT above). 

78 REDACTION_TEXT: str = REDACTION_TEXT 

79 

80 # Default set of sensitive key names to redact 

81 DEFAULT_SENSITIVE_KEYS: Set[str] = { 

82 "api_key", 

83 "apikey", 

84 "password", 

85 "secret", 

86 "access_token", 

87 "refresh_token", 

88 "private_key", 

89 "auth_token", 

90 "session_token", 

91 "csrf_token", 

92 # Additional unambiguous secret leaf-names. The predicate is 

93 # exact-match on the last dotted segment, so these matter especially 

94 # for the bulk settings GET, which can only use the key-name heuristic 

95 # (it passes no ui_element). All are unambiguously secrets and no 

96 # current setting key uses them for non-secret data. 

97 "client_secret", 

98 "secret_key", 

99 "bearer_token", 

100 "api_secret", 

101 "app_secret", 

102 } 

103 

104 @staticmethod 

105 def is_sensitive_setting( 

106 key: str, 

107 ui_element: str | None = None, 

108 sensitive_keys: Set[str] | None = None, 

109 ) -> bool: 

110 """True when a setting holds a secret: it is ``ui_element == 

111 "password"`` OR the last dotted segment of its key is a sensitive 

112 name (``llm.openai.api_key`` -> ``api_key``). 

113 

114 Single source of truth for "is this a secret" so the GET redactor 

115 and the write-back no-op guards apply the SAME predicate — a value 

116 the redactor masks to the sentinel must also be one the guards 

117 refuse to overwrite, or a redacted GET could round-trip the 

118 sentinel back over the real secret. 

119 """ 

120 if ui_element == "password": 

121 return True 

122 sens = { 

123 k.lower() 

124 for k in (sensitive_keys or DataSanitizer.DEFAULT_SENSITIVE_KEYS) 

125 } 

126 # _visible_leaf normalizes away invisible/whitespace padding so a key 

127 # like "api_key " or "api_key<zero-width>" still matches (see its 

128 # docstring). Keep the raw-leaf match as well: callers may provide a 

129 # custom sensitive name containing one of those characters, and an 

130 # exact match must remain sensitive for backward compatibility. 

131 raw_leaf = key.rsplit(".", 1)[-1].lower() 

132 return raw_leaf in sens or _visible_leaf(key) in sens 

133 

134 @staticmethod 

135 def redact_value( 

136 key: str, 

137 ui_element: str | None = None, 

138 value: Any = None, 

139 sensitive_keys: Set[str] | None = None, 

140 redaction_text: str = REDACTION_TEXT, 

141 ) -> Any: 

142 """Redact a single setting's value when it holds a set secret. 

143 

144 The single-value counterpart of ``redact_settings_snapshot``: it 

145 applies the SAME ``is_sensitive_setting`` predicate and the SAME 

146 empty-value rule, so every read path that ships a setting to the 

147 browser (the bulk GET, the singular GET, the run-time snapshot) 

148 masks identically. Returns ``redaction_text`` for a non-empty 

149 sensitive value, otherwise ``value`` unchanged. 

150 

151 Empty values (``None``, ``""``, ``[]``, ``{}``) are left readable so 

152 the UI can tell "configured" from "not configured" without leaking 

153 that a secret is set. 

154 

155 Nested containers are redacted recursively: a subtree request such as 

156 ``get_setting("llm")`` returns ``{"openai.api_key": "sk-…", …}`` where 

157 the outer key (``llm``) is not sensitive but inner keys are, and a JSON 

158 setting value may be a list of dicts (e.g. ``[{"api_key": "sk-…"}]``). 

159 Without the recursion, ``GET /settings/api/bulk?keys[]=llm`` would ship 

160 those nested secrets in the clear. A sensitive OUTER key still masks its 

161 whole value wholesale (the check below runs first), so plain lists under 

162 a non-sensitive key pass through untouched. 

163 """ 

164 if DataSanitizer.is_sensitive_setting( 

165 key, ui_element, sensitive_keys 

166 ) and value not in (None, "", [], {}): 

167 return redaction_text 

168 if isinstance(value, dict): 

169 redacted: dict = {} 

170 for sub_key, sub_val in value.items(): 

171 nested_key = f"{key}.{sub_key}" if key else sub_key 

172 redacted[sub_key] = DataSanitizer.redact_value( 

173 nested_key, None, sub_val, sensitive_keys, redaction_text 

174 ) 

175 return redacted 

176 if isinstance(value, list): 

177 # List items reuse the parent key; a dict item is caught by the 

178 # branch above (its own sensitive leaves get masked), a plain 

179 # scalar item passes through. 

180 return [ 

181 DataSanitizer.redact_value( 

182 key, None, item, sensitive_keys, redaction_text 

183 ) 

184 for item in value 

185 ] 

186 return value 

187 

188 @staticmethod 

189 def sanitize(data: Any, sensitive_keys: Set[str] | None = None) -> Any: 

190 """ 

191 Recursively remove sensitive keys from data structures. 

192 

193 This method traverses dictionaries and lists, removing any keys that match 

194 the sensitive keys list (case-insensitive). This prevents accidental 

195 credential leakage in optimization results, logs, or API responses. 

196 

197 Args: 

198 data: The data structure to sanitize (dict, list, or primitive) 

199 sensitive_keys: Set of key names to remove (case-insensitive). 

200 If None, uses DEFAULT_SENSITIVE_KEYS. 

201 

202 Returns: 

203 Sanitized copy of the data with sensitive keys removed 

204 

205 Example: 

206 >>> sanitizer = DataSanitizer() 

207 >>> data = {"username": "user", "api_key": "secret123"} 

208 >>> sanitizer.sanitize(data) 

209 {"username": "user"} 

210 """ 

211 if sensitive_keys is None: 

212 sensitive_keys = DataSanitizer.DEFAULT_SENSITIVE_KEYS 

213 

214 # Convert to lowercase for case-insensitive comparison 

215 sensitive_keys_lower = {key.lower() for key in sensitive_keys} 

216 

217 if isinstance(data, dict): 

218 return { 

219 k: DataSanitizer.sanitize(v, sensitive_keys) 

220 for k, v in data.items() 

221 if k.lower() not in sensitive_keys_lower 

222 } 

223 if isinstance(data, list): 

224 return [ 

225 DataSanitizer.sanitize(item, sensitive_keys) for item in data 

226 ] 

227 # Return primitives unchanged 

228 return data 

229 

230 @staticmethod 

231 def redact( 

232 data: Any, 

233 sensitive_keys: Set[str] | None = None, 

234 redaction_text: str = REDACTION_TEXT, 

235 ) -> Any: 

236 """ 

237 Recursively redact (replace with placeholder) sensitive values in data structures. 

238 

239 Unlike sanitize() which removes keys entirely, this method replaces their 

240 values with a redaction placeholder, preserving the structure. 

241 

242 Args: 

243 data: The data structure to redact (dict, list, or primitive) 

244 sensitive_keys: Set of key names to redact (case-insensitive). 

245 If None, uses DEFAULT_SENSITIVE_KEYS. 

246 redaction_text: Text to replace sensitive values with 

247 

248 Returns: 

249 Copy of the data with sensitive values redacted 

250 

251 Example: 

252 >>> sanitizer = DataSanitizer() 

253 >>> data = {"username": "user", "api_key": "secret123"} 

254 >>> sanitizer.redact(data) 

255 {"username": "user", "api_key": "[REDACTED]"} 

256 """ 

257 if sensitive_keys is None: 

258 sensitive_keys = DataSanitizer.DEFAULT_SENSITIVE_KEYS 

259 

260 # Convert to lowercase for case-insensitive comparison 

261 sensitive_keys_lower = {key.lower() for key in sensitive_keys} 

262 

263 if isinstance(data, dict): 

264 return { 

265 k: ( 

266 redaction_text 

267 if k.lower() in sensitive_keys_lower 

268 else DataSanitizer.redact(v, sensitive_keys, redaction_text) 

269 ) 

270 for k, v in data.items() 

271 } 

272 if isinstance(data, list): 

273 return [ 

274 DataSanitizer.redact(item, sensitive_keys, redaction_text) 

275 for item in data 

276 ] 

277 # Return primitives unchanged 

278 return data 

279 

280 @staticmethod 

281 def redact_settings_snapshot( 

282 snapshot: Any, 

283 sensitive_keys: Set[str] | None = None, 

284 redaction_text: str = REDACTION_TEXT, 

285 ) -> Any: 

286 """Redact secret values in a settings snapshot while preserving metadata. 

287 

288 A settings snapshot from ``SettingsManager.get_all_settings()`` has the 

289 nested-with-metadata shape ``{dotted_key: {"value": ..., "ui_element": 

290 ..., "type": ..., ...}}``. The ordinary ``redact()`` method does not 

291 catch secrets in this shape: the outer dotted key (e.g. 

292 ``"llm.openai.api_key"``) is not in the sensitive-name set (only the 

293 suffix ``"api_key"`` is), and the inner key ``"value"`` is not 

294 sensitive — so the secret survives unredacted. 

295 ``redact_settings_snapshot`` handles the shape correctly: 

296 

297 - Replaces ``entry["value"]`` with ``redaction_text`` when the entry 

298 is sensitive (``ui_element == "password"`` OR the last dotted 

299 segment of the outer key matches a sensitive name). 

300 - Preserves all metadata (``ui_element``, ``type``, ``description``, 

301 etc.) so YAML diffs can still show "this key existed." 

302 - Leaves empty values (``None``, ``""``, ``[]``, ``{}``) unredacted 

303 so diffs of "unset" settings stay readable. 

304 - Pure function: does not mutate the input. 

305 

306 Entries that don't have the metadata-wrapper shape (e.g. mixed 

307 snapshots that contain bare values) are passed through untouched — 

308 this is intentional so the helper is safe to call on any dict 

309 without crashing. 

310 

311 Args: 

312 snapshot: A settings snapshot dict. 

313 sensitive_keys: Override the default set of sensitive name 

314 suffixes. Defaults to ``DataSanitizer.DEFAULT_SENSITIVE_KEYS``. 

315 redaction_text: Replacement string for redacted values. 

316 

317 Returns: 

318 New dict with secret values replaced. 

319 

320 Example: 

321 >>> snap = {"llm.openai.api_key": {"value": "sk-x", "ui_element": "password"}} 

322 >>> DataSanitizer.redact_settings_snapshot(snap) 

323 {'llm.openai.api_key': {'value': '[REDACTED]', 'ui_element': 'password'}} 

324 """ 

325 if not isinstance(snapshot, dict): 

326 return snapshot 

327 

328 out: dict = {} 

329 for key, entry in snapshot.items(): 

330 if not isinstance(entry, dict) or "value" not in entry: 

331 out[key] = entry 

332 continue 

333 new_entry = dict(entry) # shallow copy preserves metadata 

334 # Delegate the per-value rule to redact_value so the snapshot, 

335 # the singular GET and the bulk GET can never mask differently. 

336 new_entry["value"] = DataSanitizer.redact_value( 

337 key, 

338 entry.get("ui_element"), 

339 entry.get("value"), 

340 sensitive_keys, 

341 redaction_text, 

342 ) 

343 out[key] = new_entry 

344 return out 

345 

346 

347# Convenience functions for direct use 

348def sanitize_data(data: Any, sensitive_keys: Set[str] | None = None) -> Any: 

349 """ 

350 Remove sensitive keys from data structures. 

351 

352 Convenience function that calls DataSanitizer.sanitize(). 

353 

354 Args: 

355 data: The data structure to sanitize 

356 sensitive_keys: Optional set of sensitive key names 

357 

358 Returns: 

359 Sanitized copy of the data 

360 """ 

361 return DataSanitizer.sanitize(data, sensitive_keys) 

362 

363 

364def redact_data( 

365 data: Any, 

366 sensitive_keys: Set[str] | None = None, 

367 redaction_text: str = REDACTION_TEXT, 

368) -> Any: 

369 """ 

370 Redact (replace) sensitive values in data structures. 

371 

372 Convenience function that calls DataSanitizer.redact(). 

373 

374 Args: 

375 data: The data structure to redact 

376 sensitive_keys: Optional set of sensitive key names 

377 redaction_text: Text to replace sensitive values with 

378 

379 Returns: 

380 Copy of the data with sensitive values redacted 

381 """ 

382 return DataSanitizer.redact(data, sensitive_keys, redaction_text) 

383 

384 

385def filter_research_metadata(research_meta: Any) -> dict: 

386 """Filter research_meta to only safe fields for history list API responses. 

387 

388 Uses an allowlist approach to prevent leaking settings_snapshot 

389 (which contains API keys, passwords, tokens) to the frontend. 

390 History list consumers only need is_news_search from metadata. 

391 

392 Args: 

393 research_meta: Raw research metadata (dict, JSON string, or None) 

394 

395 Returns: 

396 dict with only safe fields extracted (currently: is_news_search) 

397 """ 

398 try: 

399 meta = research_meta or {} 

400 if isinstance(meta, str): 

401 meta = json.loads(meta) 

402 if not isinstance(meta, dict): 

403 return {"is_news_search": False} 

404 return { 

405 "is_news_search": bool(meta.get("is_news_search", False)), 

406 } 

407 except (json.JSONDecodeError, TypeError, AttributeError): 

408 return {"is_news_search": False} 

409 

410 

411def strip_settings_snapshot(research_meta: Any) -> dict: 

412 """Remove settings_snapshot from research_meta for API responses. 

413 

414 settings_snapshot contains all application settings including API keys. 

415 This strips it while preserving all other metadata fields that the 

416 frontend needs (phase, error_type, processed_query, mode, duration, etc.). 

417 

418 Args: 

419 research_meta: Raw research metadata (dict, JSON string, or None) 

420 

421 Returns: 

422 Copy of the dict with settings_snapshot removed 

423 """ 

424 try: 

425 meta = research_meta or {} 

426 if isinstance(meta, str): 

427 meta = json.loads(meta) 

428 if not isinstance(meta, dict): 

429 return {} 

430 return {k: v for k, v in meta.items() if k != "settings_snapshot"} 

431 except (json.JSONDecodeError, TypeError, AttributeError): 

432 return {}