Files
VoiceStudio/backend/core/scrub.py
T
7c0b0c2572 fix(diagnostics): harden the bug-report scrubber (5 audited leak/correctness gaps) (#856)
* fix(diagnostics): harden the bug-report scrubber against 5 audited leak/correctness gaps

Audit of the (already-on-main) diagnostics/bug-report feature found the opt-in/
no-telemetry contract clean but 5 real gaps in the redaction + URL assembly.
Fixed in both scrub twins (backend/core/scrub.py + frontend utils/bugReport.js):

- Windows home paths with lowercase 'users' now redact (case-insensitive) — a
  spec-level PII leak: c:\users\john\… kept the username verbatim.
- Broadened credential shapes (JWT/Bearer, Google AIza, Slack xox, AWS AKIA) +
  a URL query-secret pass (?token=/?api_key=… → value redacted, name kept) so a
  secret propagated from a backend error into error.message/.stack can't reach a
  public issue. The webview has no env backstop, so these shapes are its only
  defense.
- Boundary-safe $HOME replace: a home of /Users/john no longer rewrites
  /Users/johnny to '~ny' (fragment leak + path mangling).
- Bug-report URL now bounds the URL-ENCODED body length (~7k), not the raw
  length — a dense 6k markdown body encoded to ~9k and blew past GitHub's ceiling
  (silent truncation / failed open). Message body is capped too.

- 9 new scrub regressions (backend) + 9 (frontend); all green. No API/behavior
  change beyond stricter redaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): note the bug-report scrubber hardening in [0.3.8] (#856)

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:26:38 +05:30

137 lines
5.7 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
# A backend error can carry a secret from *any* provider (the LLM-providers
# feature ships a dozen), so match the common credential shapes too, not
# just the four vendors above — a leaked key in a public issue is real harm.
re.compile(r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{6,}"), # JWT (Bearer)
re.compile(r"AIza[0-9A-Za-z_\-]{35}"), # Google API key
re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}"), # Slack token
re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key id
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{16,}"), # opaque bearer tokens
)
# Secrets carried in a URL query string (`?token=…`, `&api_key=…`). Redact the
# VALUE while keeping the param name + separator so the URL stays legible. Bare
# `key=` is intentionally excluded — too common in non-secret text; shaped keys
# are already caught above and named env vars by the sweep below.
_URL_SECRET_RE = re.compile(
r"((?:access[_-]?token|api[_-]?key|apikey|auth[_-]?token|token|secret|password|passwd|pwd)=)"
r"([^&\s\"'#]{6,})",
re.IGNORECASE,
)
# 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.
# IGNORECASE because Windows is case-insensitive and tools routinely emit the
# lowercase `c:\users\<name>` form, which the CLAUDE.md redaction spec still
# requires to become `~`. `Users`/`users`, `Home`/`home` all match.
_HOME_PATTERNS = (
# Windows-with-forward-slashes must run BEFORE the bare macOS shape, or
# `/Users/<name>` inside `C:/Users/<name>` gets eaten first, leaving `C:~`.
re.compile(r"[A-Za-z]:/Users/[^/\s\"']+", re.IGNORECASE), # Windows, forward slashes
re.compile(r"/Users/[^/\s\"']+", re.IGNORECASE), # macOS
re.compile(r"/home/[^/\s\"']+", re.IGNORECASE), # Linux
re.compile(r"[A-Za-z]:\\Users\\[^\\\s\"']+", re.IGNORECASE), # Windows, backslashes
)
# 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 + URL query secrets.
for pat in _TOKEN_PATTERNS:
try:
s = pat.sub(REDACTED, s)
except Exception:
pass
try:
s = _URL_SECRET_RE.sub(lambda m: m.group(1) + 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. Boundary-aware so
# a home of `/Users/john` doesn't rewrite `/Users/johnny` to `~ny`
# (leaking the fragment + mangling the path).
try:
home = os.path.expanduser("~")
if home and home not in ("/", "~"):
s = re.sub(re.escape(home) + r"(?=[/\\\s\"']|$)", "~", s)
except Exception:
pass
for pat in _HOME_PATTERNS:
try:
s = pat.sub("~", s)
except Exception:
pass
return s