Files
VoiceStudio/backend/core/scrub.py
T
debpalashandClaude Opus 4.8 ee08f56e1b feat(diagnostics): local self-check + scrubbed bug-report pipeline
Closes the gap between 'something broke' and 'a useful GitHub issue
exists' — entirely within the local-first constraint: the only outbound
path remains the user's own browser opening a prefilled issues/new URL.

Backend:
- core/scrub.py: privacy scrubber for anything leaving the machine —
  env-var secret values (*TOKEN*|*KEY*|*SECRET*|*PASSWORD*), credential
  shapes (hf_/ghp_/github_pat_/sk-), home dirs on all three OSes
- core/diagnose.py: 9-check self-check (device+GPU, ffmpeg, HF token,
  disk, data-dir writability, RAM, engine registry, hub reachability),
  pre-scrubbed, ASCII-safe output
- GET /system/diagnose + 'python main.py --diagnose' (exit 0/1)
- /system/info: hardware inventory (os_version, cpu_model, cpu_count,
  ram_total_gb, gpu_name, vram_total_gb, disk_free_gb), cached statics

Frontend:
- utils/bugReport.js: single source for the prefilled-URL builder —
  scrubText twin, hardware context capture, scrubbed error+stack embed,
  URL-length cap; ReportBugButton refactored onto it
- ErrorBoundary 'Report this bug' action with the error attached
- utils/errorToast.jsx toastErrorWithReport(); wired into export toasts
- Settings > About 'Run self-check' with per-check status badges

Tests: 27 pytest (scrub, diagnose) + 15 vitest (bugReport); existing
suites green; verified live (--diagnose, TestClient, vite build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:43:03 +05:30

107 lines
3.9 KiB
Python

"""Privacy scrubber for diagnostic text that may leave the machine.
Everything OmniVoice renders into a bug report or diagnostic dump goes
through ``scrub_text()`` before it can reach a prefilled GitHub Issues URL
(the only outbound path — see CLAUDE.md Capability 2). The scrubber is the
backend twin of ``frontend/src/utils/bugReport.js``'s ``scrubText`` and
must stay at least as strict:
- home directories → ``~`` (macOS ``/Users/<name>``, Linux ``/home/<name>``,
Windows ``C:\\Users\\<name>``, plus the *actual* ``$HOME`` of this process)
- credential-shaped substrings → ``***REDACTED***`` (HF tokens, GitHub
PATs, OpenAI-style ``sk-`` keys)
- values of env vars whose NAME matches ``*TOKEN*|*KEY*|*SECRET*|
*PASSWORD*|*CREDENTIAL*`` — so a stack trace that interpolated a real
secret still comes out clean
Unlike ``core.logging_filter`` (which rewrites log records in-flight and
must stay cheap), this module runs on report-sized strings at report time,
so it can afford the env-var sweep.
"""
from __future__ import annotations
import os
import re
REDACTED = "***REDACTED***"
# Env-var NAMES whose values must never appear in scrubbed output.
_SECRET_NAME_RE = re.compile(r"TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL", re.IGNORECASE)
# Credential-shaped substrings, independent of where they came from.
# Thresholds mirror core.logging_filter: long enough that identifiers like
# `hf_hub` or `sk-learn` survive, short enough that real tokens never do.
_TOKEN_PATTERNS = (
re.compile(r"hf_[A-Za-z0-9]{30,}"), # HuggingFace
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), # GitHub fine-grained PAT
re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}"), # GitHub classic tokens
re.compile(r"sk-[A-Za-z0-9_\-]{20,}"), # OpenAI-style API keys
)
# Home-directory shapes for all three supported platforms. Matched
# pattern-wise (not just this machine's $HOME) so paths quoted from a
# user's pasted log on another OS get cleaned too.
_HOME_PATTERNS = (
re.compile(r"/Users/[^/\s\"']+"), # macOS
re.compile(r"/home/[^/\s\"']+"), # Linux
re.compile(r"[A-Za-z]:\\Users\\[^\\\s\"']+"), # Windows
)
# Values shorter than this are too entropy-poor to be real secrets and too
# likely to shred unrelated text (e.g. PASSWORD_MIN_LENGTH=8 would otherwise
# turn every "8" in the report into ***REDACTED***).
_MIN_SECRET_LEN = 8
def _env_secret_values() -> list[str]:
"""Values of secret-named env vars, longest first so overlapping
values (e.g. a token and its prefix) redact cleanly."""
vals = [
v
for k, v in os.environ.items()
if _SECRET_NAME_RE.search(k) and v and len(v) >= _MIN_SECRET_LEN
]
return sorted(vals, key=len, reverse=True)
def scrub_text(text: str | None) -> str:
"""Return ``text`` with secrets and home paths redacted.
Never raises — scrubbing failure must not block a bug report, and a
partially-scrubbed string is still better than an unscrubbed one, so
each pass is independent.
"""
if not text:
return "" if text is None else str(text)
s = str(text)
# 1. Exact env-var secret values (most specific — run first).
try:
for val in _env_secret_values():
s = s.replace(val, REDACTED)
except Exception:
pass
# 2. Credential-shaped substrings.
for pat in _TOKEN_PATTERNS:
try:
s = pat.sub(REDACTED, s)
except Exception:
pass
# 3. This process's real home dir (covers symlinked/nonstandard homes
# the generic patterns miss), then the per-OS shapes.
try:
home = os.path.expanduser("~")
if home and home not in ("/", "~"):
s = s.replace(home, "~")
except Exception:
pass
for pat in _HOME_PATTERNS:
try:
s = pat.sub("~", s)
except Exception:
pass
return s