Coverage for src/qdrant_loader_core/logging_filters.py: 78%
83 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:12 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-20 10:12 +0000
1"""Logging filters for redaction and noise suppression."""
3from __future__ import annotations
5import logging
6import re
7from urllib.parse import unquote_plus
10class QdrantVersionFilter(logging.Filter):
11 def filter(self, record: logging.LogRecord) -> bool:
12 try:
13 return "version check" not in record.getMessage().lower()
14 except Exception:
15 return True
18class ApplicationFilter(logging.Filter):
19 def filter(self, record: logging.LogRecord) -> bool:
20 # Allow all logs by default; app packages may add their own filters
21 return True
24class UvicornAccessRedactFilter(logging.Filter):
25 """Redacts secret/token query-string values from uvicorn's access log line.
27 uvicorn logs the raw request line (including the query string) via
28 ``record.args`` on the "uvicorn.access" logger, e.g.
29 ``args = (client_addr, method, "/path?token=abc123", http_version, status)``.
30 That logger has ``propagate=False`` in uvicorn's default logging config, so
31 it never reaches the root logger's handlers/filters (including
32 :class:`RedactionFilter`) — the secret would otherwise be written to the
33 access log in plaintext. Attach this filter directly to the
34 "uvicorn.access" logger; per-logger filters run in ``Logger.handle()``
35 before any handler, so this applies regardless of what handlers uvicorn's
36 own dictConfig installs (dictConfig only replaces handlers, not filters).
37 """
39 _SENSITIVE_QUERY_KEYS = {
40 "token",
41 "secret",
42 "signature",
43 "password",
44 "authorization",
45 "api_key",
46 "api-key",
47 "access_key",
48 "access-key",
49 "private_key",
50 "private-key",
51 "access_token",
52 "access-token",
53 }
54 _QUERY_PAIR = re.compile(r'([?&])([^=&\s"]+)=([^&\s"]*)')
56 @classmethod
57 def _redact_query_pair(cls, match: re.Match[str]) -> str:
58 sep, raw_key, _value = match.groups()
59 # uvicorn logs the raw, still percent-encoded request line, so a key
60 # like "sec%72et" would slip past a literal match while Starlette/
61 # FastAPI (which percent-decodes query keys during parsing) still
62 # resolves it to "secret" and accepts it as the webhook secret.
63 # Decode before comparing so encoded keys are caught too.
64 if unquote_plus(raw_key).lower() in cls._SENSITIVE_QUERY_KEYS:
65 return f"{sep}{raw_key}=***REDACTED***"
66 return match.group(0)
68 def filter(self, record: logging.LogRecord) -> bool:
69 try:
70 if isinstance(record.args, tuple) and len(record.args) >= 3:
71 path = record.args[2]
72 if isinstance(path, str) and "?" in path:
73 redacted = self._QUERY_PAIR.sub(self._redact_query_pair, path)
74 if redacted != path:
75 record.args = (
76 record.args[0],
77 record.args[1],
78 redacted,
79 *record.args[3:],
80 )
81 except Exception:
82 pass
83 return True
86class RedactionFilter(logging.Filter):
87 """Redacts obvious secrets from stdlib log records."""
89 # Heuristics for tokens/keys in plain strings
90 TOKEN_PATTERNS = [
91 re.compile(r"sk-[A-Za-z0-9_\-]{6,}"),
92 re.compile(r"tok-[A-Za-z0-9_\-]{6,}"),
93 re.compile(
94 r"(?i)(api_key|authorization|token|access_token|secret|password)\s*[:=]\s*([^\s]+)"
95 ),
96 re.compile(r"Bearer\s+[A-Za-z0-9_\-\.]+"),
97 ]
99 # Keys commonly used for secrets in structlog event dictionaries
100 SENSITIVE_KEYS = {
101 "api_key",
102 "llm_api_key",
103 "authorization",
104 "Authorization",
105 "token",
106 "access_token",
107 "secret",
108 "password",
109 }
111 def _redact_text(self, text: str) -> str:
112 def mask(m: re.Match[str]) -> str:
113 s = m.group(0)
114 if len(s) <= 8:
115 return "***REDACTED***"
116 return s[:2] + "***REDACTED***" + s[-2:]
118 redacted = text
119 for pat in self.TOKEN_PATTERNS:
120 redacted = pat.sub(mask, redacted)
121 return redacted
123 def filter(self, record: logging.LogRecord) -> bool:
124 try:
125 redaction_detected = False
127 # Args may contain secrets; best-effort mask strings and detect changes
128 if isinstance(record.args, tuple):
129 new_args = []
130 for a in record.args:
131 if isinstance(a, str):
132 red_a = self._redact_text(a)
133 if red_a != a:
134 redaction_detected = True
135 new_args.append(red_a)
136 else:
137 new_args.append(a)
138 record.args = tuple(new_args)
140 # Redact raw message only when it contains no formatting placeholders
141 # to avoid interfering with %-style or {}-style formatting
142 if isinstance(record.msg, str):
143 try:
144 has_placeholders = ("%" in record.msg) or ("{" in record.msg)
145 except Exception:
146 has_placeholders = True
147 if not has_placeholders:
148 red_msg = self._redact_text(record.msg)
149 if red_msg != record.msg:
150 record.msg = red_msg
151 redaction_detected = True
153 # If structlog extras contain sensitive keys, mark as redacted
154 try:
155 if any(
156 (k in self.SENSITIVE_KEYS and bool(record.__dict__.get(k)))
157 for k in record.__dict__.keys()
158 ):
159 redaction_detected = True
160 except Exception:
161 pass
163 # Ensure a visible redaction marker appears in the captured message
164 if redaction_detected:
165 try:
166 if (
167 isinstance(record.msg, str)
168 and "***REDACTED***" not in record.msg
169 ):
170 # Append a marker in a way that won't interfere with %-formatting
171 record.msg = f"{record.msg} ***REDACTED***"
172 except Exception:
173 pass
174 except Exception:
175 pass
176 return True