@@ -16,6 +16,226 @@ _live_report_retained_log() {
1616 fi
1717}
1818
19+ _live_debug_sanitize_file () {
20+ local path=" $1 "
21+ local mode=" ${2:- text} "
22+ local max_lines=" ${3:- ${LOCALITY_LIVE_DEBUG_TAIL_LINES:- 160} } "
23+
24+ if [[ ! -s " $path " ]]; then
25+ return 0
26+ fi
27+
28+ python3 - " $path " " $mode " " $max_lines " << 'PY '
29+ import json
30+ import os
31+ import pathlib
32+ import re
33+ import sys
34+
35+ path = pathlib.Path(sys.argv[1])
36+ mode = sys.argv[2]
37+ max_lines = int(sys.argv[3])
38+
39+ SENSITIVE_ENV_NAME = re.compile(
40+ r"(TOKEN|SECRET|CREDENTIAL|PASSWORD|API_KEY|AUTH|ISSUE_ID|"
41+ r"CONVERSATION_ID|TO_EMAIL|EMAIL|REFRESH)",
42+ re.IGNORECASE,
43+ )
44+ SENSITIVE_KEY_FRAGMENTS = (
45+ "token",
46+ "secret",
47+ "credential",
48+ "authorization",
49+ "api_key",
50+ "password",
51+ "refresh",
52+ "client_secret",
53+ "cookie",
54+ )
55+ IDENTIFIER_KEYS = {
56+ "id",
57+ "remote_id",
58+ "remote_ids",
59+ "changed_remote_ids",
60+ "message_id",
61+ "thread_id",
62+ "draft_id",
63+ "conversation_id",
64+ "issue_id",
65+ "document_id",
66+ "event_id",
67+ "channel_id",
68+ }
69+ PII_KEYS = {
70+ "email",
71+ "to",
72+ "from",
73+ "cc",
74+ "bcc",
75+ "recipient",
76+ "recipients",
77+ "account_label",
78+ }
79+
80+
81+ def sensitive_env_values():
82+ values = []
83+ for name, value in os.environ.items():
84+ if not value or not SENSITIVE_ENV_NAME.search(name):
85+ continue
86+ value = str(value)
87+ if len(value) < 4 or value in {"true", "false", "none", "null"}:
88+ continue
89+ values.append(value)
90+ values.sort(key=len, reverse=True)
91+ return values
92+
93+
94+ ENV_VALUES = sensitive_env_values()
95+
96+
97+ def redact_text(text):
98+ if not text:
99+ return text
100+ for value in ENV_VALUES:
101+ text = text.replace(value, "<redacted>")
102+ replacements = [
103+ (r"(?i)(authorization:\s*bearer\s+)[^\s]+", r"\1<redacted>"),
104+ (r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+", r"\1<redacted>"),
105+ (r"lin_api_[A-Za-z0-9]+", "<redacted>"),
106+ (r"ya29\.[A-Za-z0-9._-]+", "<redacted>"),
107+ (r"xox[a-zA-Z]-[A-Za-z0-9-]+", "<redacted>"),
108+ (r"gh[pousr]_[A-Za-z0-9_]+", "<redacted>"),
109+ (r"AIza[0-9A-Za-z_-]{20,}", "<redacted>"),
110+ (
111+ r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b",
112+ "<redacted>",
113+ ),
114+ (
115+ r'(?i)("?(?:access_token|refresh_token_handle|api_key|token|secret|credential|authorization|client_secret|password)"?\s*[:=]\s*)("[^"]*"|[^,\s}]+)',
116+ r"\1<redacted>",
117+ ),
118+ ]
119+ for pattern, replacement in replacements:
120+ text = re.sub(pattern, replacement, text)
121+ return text
122+
123+
124+ def is_sensitive_key(key):
125+ lowered = str(key).lower()
126+ return any(fragment in lowered for fragment in SENSITIVE_KEY_FRAGMENTS)
127+
128+
129+ def sanitize_json(value, key=""):
130+ key_lower = str(key).lower()
131+ if isinstance(value, dict):
132+ return {k: sanitize_json(v, k) for k, v in value.items()}
133+ if isinstance(value, list):
134+ if key_lower in IDENTIFIER_KEYS or key_lower in PII_KEYS or is_sensitive_key(key):
135+ return ["<redacted>" for _ in value]
136+ return [sanitize_json(item, key) for item in value]
137+ if isinstance(value, str):
138+ if key_lower in IDENTIFIER_KEYS or key_lower in PII_KEYS or is_sensitive_key(key):
139+ return "<redacted>"
140+ return redact_text(value)
141+ return value
142+
143+
144+ try:
145+ raw_text = path.read_text(encoding="utf-8", errors="replace")
146+ except OSError as error:
147+ print(f"could not read {path}: {error}", file=sys.stderr)
148+ raise SystemExit(0)
149+
150+ if mode == "json":
151+ try:
152+ parsed = json.loads(raw_text)
153+ except Exception:
154+ text = raw_text
155+ else:
156+ text = json.dumps(sanitize_json(parsed), indent=2, sort_keys=True)
157+ max_bytes = int(os.environ.get("LOCALITY_LIVE_DEBUG_JSON_BYTES", "20000"))
158+ if len(text.encode("utf-8")) > max_bytes:
159+ text = text.encode("utf-8")[:max_bytes].decode("utf-8", errors="replace")
160+ text += "\n... <truncated>"
161+ else:
162+ lines = raw_text.splitlines()
163+ if max_lines > 0 and len(lines) > max_lines:
164+ text = f"... showing last {max_lines} of {len(lines)} lines ...\n"
165+ text += "\n".join(lines[-max_lines:])
166+ else:
167+ text = "\n".join(lines)
168+
169+ print(redact_text(text))
170+ PY
171+ }
172+
173+ _live_debug_emit_file () {
174+ local label=" $1 "
175+ local path=" $2 "
176+ local mode=" ${3:- text} "
177+ local max_lines=" ${4:- ${LOCALITY_LIVE_DEBUG_TAIL_LINES:- 160} } "
178+
179+ if [[ -z " $path " || ! -s " $path " ]]; then
180+ return 0
181+ fi
182+
183+ {
184+ echo " ::group::privacy-safe diagnostics: $label "
185+ echo " path: $path "
186+ _live_debug_sanitize_file " $path " " $mode " " $max_lines "
187+ echo " ::endgroup::"
188+ } >&2
189+ }
190+
191+ _live_debug_emit_var_file () {
192+ local var_name=" $1 "
193+ local label=" $2 "
194+ local mode=" ${3:- text} "
195+ local max_lines=" ${4:- ${LOCALITY_LIVE_DEBUG_TAIL_LINES:- 160} } "
196+ local path=" ${! var_name:- } "
197+
198+ _live_debug_emit_file " $label " " $path " " $mode " " $max_lines "
199+ }
200+
201+ emit_live_debug_diagnostics () {
202+ local label=" ${1:- live connector} "
203+ local report_var
204+ local log_var
205+
206+ echo " privacy-safe diagnostics: $label " >&2
207+ for report_var in \
208+ mount_report \
209+ connect_report \
210+ initial_pull_report \
211+ first_pull_report \
212+ second_pull_report \
213+ diff_report \
214+ push_report \
215+ pull_after_push_report \
216+ edit_diff_report \
217+ edit_push_report \
218+ pull_after_edit_report \
219+ restore_diff_report \
220+ restore_push_report \
221+ cleanup_diff_report \
222+ cleanup_push_report \
223+ cleanup_pull_report \
224+ status_report \
225+ info_report \
226+ doctor_report \
227+ drive_search_report \
228+ calendar_search_report \
229+ drafts_list_report \
230+ draft_get_report; do
231+ _live_debug_emit_var_file " $report_var " " $report_var " json
232+ done
233+
234+ for log_var in command_log daemon_log fuse_log; do
235+ _live_debug_emit_var_file " $log_var " " $log_var " text
236+ done
237+ }
238+
19239require_live_env () {
20240 local name
21241 for name in " $@ " ; do
0 commit comments