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>
106 lines
3.6 KiB
Python
106 lines
3.6 KiB
Python
"""core.scrub — privacy scrubber for diagnostic/bug-report text.
|
|
|
|
The scrubber is the last gate before text can reach a prefilled GitHub
|
|
Issues URL, so these tests pin the exact redaction behavior per platform
|
|
path style and per credential shape.
|
|
"""
|
|
import os
|
|
|
|
import pytest
|
|
|
|
from core.scrub import scrub_text, REDACTED
|
|
|
|
|
|
# ── Home directory redaction ──────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw,expected",
|
|
[
|
|
("/Users/alice/Library/Logs/app.log", "~/Library/Logs/app.log"),
|
|
("/home/bob/.omnivoice/omnivoice.log", "~/.omnivoice/omnivoice.log"),
|
|
(r"C:\Users\carol\AppData\Roaming\OmniVoice", r"~\AppData\Roaming\OmniVoice"),
|
|
(r"D:\Users\dave\models", r"~\models"),
|
|
],
|
|
)
|
|
def test_home_paths_redacted(raw, expected):
|
|
assert scrub_text(raw) == expected
|
|
|
|
|
|
def test_actual_process_home_redacted():
|
|
home = os.path.expanduser("~")
|
|
assert home not in scrub_text(f"failed to open {home}/some/file.wav")
|
|
|
|
|
|
def test_home_redaction_inside_traceback():
|
|
tb = (
|
|
'Traceback (most recent call last):\n'
|
|
' File "/home/eve/OmniVoice/backend/main.py", line 42, in synth\n'
|
|
"FileNotFoundError: /Users/eve/voice.wav not found"
|
|
)
|
|
out = scrub_text(tb)
|
|
assert "/home/eve" not in out
|
|
assert "/Users/eve" not in out
|
|
assert 'File "~/OmniVoice/backend/main.py"' in out
|
|
|
|
|
|
# ── Credential-shaped substrings ──────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"secret",
|
|
[
|
|
"hf_" + "A" * 34, # HuggingFace token
|
|
"ghp_" + "B" * 36, # GitHub classic PAT
|
|
"github_pat_" + "C" * 22, # GitHub fine-grained PAT
|
|
"sk-" + "d" * 40, # OpenAI-style key
|
|
],
|
|
)
|
|
def test_tokens_redacted(secret):
|
|
out = scrub_text(f"auth failed with token={secret} (401)")
|
|
assert secret not in out
|
|
assert REDACTED in out
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"benign",
|
|
["hf_hub", "hf_pipeline_load", "sk-learn", "ghp_x"],
|
|
)
|
|
def test_short_identifiers_survive(benign):
|
|
# Identifiers shorter than real-token length must NOT be clobbered —
|
|
# they're exactly what makes a stack trace debuggable.
|
|
assert benign in scrub_text(f"import error in {benign} module")
|
|
|
|
|
|
# ── Env-var secret values ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_env_secret_value_redacted(monkeypatch):
|
|
monkeypatch.setenv("TRANSLATE_API_KEY", "super-secret-value-123")
|
|
out = scrub_text("request failed: api_key=super-secret-value-123 rejected")
|
|
assert "super-secret-value-123" not in out
|
|
assert REDACTED in out
|
|
|
|
|
|
def test_env_secret_short_value_not_swept(monkeypatch):
|
|
# A short value would shred unrelated text (every "yes" in the report).
|
|
monkeypatch.setenv("SOME_PASSWORD", "yes")
|
|
assert scrub_text("yes, the export worked") == "yes, the export worked"
|
|
|
|
|
|
def test_env_non_secret_name_untouched(monkeypatch):
|
|
monkeypatch.setenv("OMNIVOICE_MODEL", "k2-fsa/OmniVoice")
|
|
assert "k2-fsa/OmniVoice" in scrub_text("loading k2-fsa/OmniVoice")
|
|
|
|
|
|
# ── Robustness ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_none_and_empty():
|
|
assert scrub_text(None) == ""
|
|
assert scrub_text("") == ""
|
|
|
|
|
|
def test_non_string_coerced():
|
|
assert scrub_text(42) == "42"
|