Coverage for utils/log_sanitize.py: 100.00%
122 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 15:30 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-03 15:30 +0000
1import json
2import re
3from typing import Any
4from urllib.parse import parse_qs
6REDACTED = "[REDACTED]"
7ELLIPSIS = "..."
8PREVIEW_CHARS = 8192
9JWT_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$")
10SENSITIVE_TEXT_RE = re.compile(
11 r"(?i)\b(password|passwd|pwd|secret|token|authorization|api[_-]?key)\b\s*[:=]\s*\S+"
12)
13SENSITIVE_KEY_MARKERS = (
14 "password",
15 "passwd",
16 "pwd",
17 "secret",
18 "token",
19 "authorization",
20 "cookie",
21 "apikey",
22 "setcookie",
23 "privatekey",
24 "csrf",
25 "creditcard",
26 "cardnumber",
27 "cvv",
28 "ssn",
29)
30SKIP_CONTENT_TYPES = (
31 "multipart/",
32 "image/",
33 "audio/",
34 "video/",
35 "octet-stream",
36 "gzip",
37 "zip",
38 "text/event-stream",
39)
42def _normalize_key(key: str) -> str:
43 return re.sub(r"[^a-z0-9]", "", key.lower())
46def is_sensitive_key(key: str) -> bool:
47 normalized = _normalize_key(str(key))
48 return any(marker in normalized for marker in SENSITIVE_KEY_MARKERS)
51def should_omit_body(content_type: str) -> bool:
52 lowered = (content_type or "").lower()
53 return any(kind in lowered for kind in SKIP_CONTENT_TYPES)
56def quote_log_field(value: str) -> str:
57 escaped = value.replace("\\", "\\\\").replace('"', '\\"')
58 return f'"{escaped}"'
61def format_log_value(value: str) -> str:
62 """Wrap values so Grafana can extract original JSON, including spaces."""
63 compact = value.replace("\n", "\\n").replace("\r", "").replace(">>", "\\u003e\\u003e")
64 return f"<<{compact}>>"
67def _redact_string(value: str) -> str:
68 stripped = value.strip()
69 if stripped.lower().startswith("bearer ") or JWT_RE.match(stripped):
70 return REDACTED
71 return value
74def redact(value: Any) -> Any:
75 if isinstance(value, dict):
76 return {
77 key: REDACTED if is_sensitive_key(str(key)) else redact(item)
78 for key, item in value.items()
79 }
80 if isinstance(value, list):
81 return [redact(item) for item in value]
82 if isinstance(value, str):
83 return _redact_string(value)
84 return value
87def _redact_loose_text(text: str) -> str:
88 return SENSITIVE_TEXT_RE.sub(lambda match: f"{match.group(1)}={REDACTED}", text)
91def _decode(raw: bytes, max_bytes: int) -> tuple[str, bool]:
92 truncated = len(raw) > max_bytes
93 return raw[:max_bytes].decode("utf-8", errors="replace"), truncated
96def _loads_json_prefix(text: str) -> Any | None:
97 try:
98 return json.loads(text)
99 except json.JSONDecodeError:
100 pass
102 in_string = False
103 escape = False
104 stack: list[str] = []
105 for char in text:
106 if in_string:
107 if escape:
108 escape = False
109 elif char == "\\":
110 escape = True
111 elif char == '"':
112 in_string = False
113 continue
114 if char == '"':
115 in_string = True
116 elif char == "{":
117 stack.append("}")
118 elif char == "[":
119 stack.append("]")
120 elif stack and char == stack[-1]:
121 stack.pop()
123 candidate = text.rstrip()
124 if in_string:
125 candidate += '"'
126 candidate = re.sub(r",\s*$", "", candidate)
127 candidate += "".join(reversed(stack))
128 try:
129 return json.loads(candidate)
130 except json.JSONDecodeError:
131 return None
134def _with_ellipsis(rendered: str, limit: int = PREVIEW_CHARS) -> str:
135 if len(rendered) <= limit:
136 return rendered + ELLIPSIS
137 cutoff = max(16, limit - len(ELLIPSIS))
138 preview = rendered[:cutoff]
139 for separator in ("},", "],", ",", "}", "]"):
140 index = preview.rfind(separator)
141 if index >= cutoff // 3:
142 preview = preview[: index + len(separator)].rstrip(",")
143 break
144 return preview + ELLIPSIS
147def sanitize_body(raw: bytes, content_type: str, max_bytes: int = 8192) -> str:
148 if not raw:
149 return ""
150 if should_omit_body(content_type):
151 return "[omitted]"
153 text, truncated = _decode(raw, max_bytes)
154 lowered = (content_type or "").lower()
155 payload: Any = None
156 parsed = False
158 if "application/json" in lowered or text[:1] in "{[":
159 parsed_json = _loads_json_prefix(text)
160 if parsed_json is not None:
161 payload = redact(parsed_json)
162 parsed = True
163 elif "application/x-www-form-urlencoded" in lowered:
164 parsed_form = {
165 key: values[-1] if values else ""
166 for key, values in parse_qs(text, keep_blank_values=True).items()
167 }
168 payload = redact(parsed_form)
169 parsed = True
171 if not parsed:
172 preview = _redact_loose_text(text).replace("\n", "\\n")
173 if truncated:
174 return _with_ellipsis(preview)
175 return preview
177 if isinstance(payload, (dict, list)):
178 rendered = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
179 else:
180 rendered = str(payload).replace("\n", "\\n")
182 if truncated or len(rendered) > PREVIEW_CHARS:
183 return _with_ellipsis(rendered)
184 return rendered
187def sanitize_query(query_params: Any) -> str:
188 data = {str(key): value for key, value in dict(query_params).items()}
189 if not data:
190 return ""
191 return json.dumps(redact(data), ensure_ascii=False, separators=(",", ":"))