Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions app/blitztext_linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
sys.path.insert(0, PROJECT_DIR)

from app.config import Config, DEFAULTS, VALID_HOTKEY_KEYS
from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS
from app.llm_service import LLMService, WorkflowType, LLM_WORKFLOWS, sanitize_external_error
from app.writing_presets import (
CUSTOM_PRESET_KEY,
WRITING_PRESET_KEYS,
Expand All @@ -52,6 +52,9 @@
# Set up module logger
logger = logging.getLogger("blitztext.main")

_WORKER_ERROR_LOG_PREFIX = "Worker error: "
_MAX_ERROR_LOG_LENGTH = 240


def _configure_qt_platform() -> None:
"""Prefer native Wayland when a Wayland session is available."""
Expand Down Expand Up @@ -1145,8 +1148,12 @@ def _on_no_speech(self) -> None:

@pyqtSlot(str)
def _on_worker_error(self, err_msg: str) -> None:
logger.error("Worker error: %s", err_msg)
self._finish_worker_with_error(err_msg, "worker error")
logger.debug("Raw worker error: %s", err_msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid logging unsanitized provider errors in debug mode

When BLITZTEXT_DEBUG is enabled, this writes the complete external exception to stderr before sanitization, including any API keys, bearer tokens, URL credentials, control characters, or unbounded provider response content that the new sanitizer is intended to suppress. Debug output is commonly captured for troubleshooting and shared, so sanitize this record as well (or omit it) rather than preserving the raw error.

Useful? React with 👍 / 👎.

safe_error = sanitize_external_error(
err_msg, max_length=_MAX_ERROR_LOG_LENGTH - len(_WORKER_ERROR_LOG_PREFIX)
)
logger.error("%s%s", _WORKER_ERROR_LOG_PREFIX, safe_error)
self._finish_worker_with_error(safe_error, "worker error")

def _finish_worker_with_error(self, err_msg: str, reason: str) -> None:
self.show_tray_error(t("notify.error.title"), err_msg)
Expand Down
14 changes: 7 additions & 7 deletions app/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,22 @@
"tts.export.dialog_title": "Audiodatei exportieren",
"tts.export.default_filename_prefix": "Blitztext-Audio_",
"tts.error.piper_not_found": "Piper nicht gefunden. Installieren: pip install piper-tts und Stimmen nach ~/.local/share/piper-voices legen.",
"tts.error.openai_not_available": "OpenAI Cloud-TTS ist nicht verfuegbar. Bitte OPENAI_API_KEY in ~/.config/blitztext-linux/secrets.env setzen.",
"tts.error.openai_not_available": "OpenAI Cloud-TTS ist nicht verfügbar. Bitte OPENAI_API_KEY in ~/.config/blitztext-linux/secrets.env setzen.",
"tts.consent.title": "OpenAI Cloud-TTS aktivieren?",
"tts.consent.message": "OpenAI Cloud-TTS sendet den eingegebenen Text zur Sprachsynthese an die OpenAI-Server. Lokale Texte verlassen damit deinen Rechner.\n\nMoechtest du Cloud-TTS aktivieren?",
"tts.consent.message": "OpenAI Cloud-TTS sendet den eingegebenen Text zur Sprachsynthese an die OpenAI-Server. Lokale Texte verlassen damit deinen Rechner.\n\nMöchtest du Cloud-TTS aktivieren?",
"tts.status.openai_ready": "OpenAI Cloud-TTS bereit.",
"tts.status.piper_ready": "Piper bereit.",
"tts.status.playback": "Wiedergabe…",
"tts.status.paused": "Pausiert",
"tts.status.no_text": "Kein Text.",
"tts.status.no_voice_path": "Keine Stimme in ~/.local/share/piper-voices gefunden.",
"tts.status.synthesis": "Synthese…",
"tts.status.openai_not_confirmed": "OpenAI Cloud-TTS wurde nicht bestaetigt.",
"tts.status.openai_not_confirmed": "OpenAI Cloud-TTS wurde nicht bestätigt.",
"tts.status.cloud_synthesis": "Cloud-Synthese…",
"tts.status.exporting": "Exportiere Audiodatei…",
"tts.status.export_done": "Audiodatei exportiert.",
"tts.status.export_ffmpeg_missing": "ffmpeg nicht gefunden. Audio-Export ist nicht verfuegbar.",
"tts.status.export_format_unsupported": "Exportformat nicht unterstuetzt.",
"tts.status.export_ffmpeg_missing": "ffmpeg nicht gefunden. Audio-Export ist nicht verfügbar.",
"tts.status.export_format_unsupported": "Exportformat nicht unterstützt.",
"tts.status.missing_wav_output": "WAV-Ausgabe fehlt.",
"tts.status.cancelled": "Abgebrochen.",
"tts.status.error": "Fehler: {message}",
Expand All @@ -136,8 +136,8 @@
"history.status.copied": "✓ Kopiert",
"history.entry.meta": "{timestamp} · {count} Wörter",
"history.note.heading": "Diktat {heading}",
"history.note.merged_heading": "Diktat (zusammengefuehrt) {heading}",
"history.note.merged_filename_prefix": "Diktat-zusammengefuehrt_",
"history.note.merged_heading": "Diktat (zusammengeführt) {heading}",
"history.note.merged_filename_prefix": "Diktat-zusammengeführt_",
"tray.show_window": "Fenster anzeigen",
"tray.compose": "Text verfassen…",
"tray.writing_preset": "Text-Aktion",
Expand Down
95 changes: 95 additions & 0 deletions app/llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from __future__ import annotations

import logging
import re
import unicodedata
from typing import Any, Optional

from app.config import DEFAULTS
Expand All @@ -19,6 +21,31 @@
LLM_WORKFLOWS = {WorkflowType.TEXT_IMPROVER, WorkflowType.DAMPF_ABLASSEN, WorkflowType.EMOJI_TEXT}
DEFAULT_LLM_MODEL = DEFAULTS["llm_model"]

_REDACTED = "[REDACTED]"
_URL_USERINFO_PATTERN = re.compile(
r"\b([A-Za-z][A-Za-z0-9+.-]*://)[^\s/@]*:[^\s/@]+@", re.IGNORECASE
)
_BEARER_TOKEN_VALUE_PATTERN = r"[A-Za-z0-9._~+/=-]{12,}"
_BEARER_TOKEN_PATTERN = re.compile(
rf"\bBearer\s+{_BEARER_TOKEN_VALUE_PATTERN}(?![A-Za-z0-9._~+/=-])", re.IGNORECASE
)
_OBFUSCATED_BEARER_DELIMITER_PATTERN = re.compile(
rf"\bBearer{_BEARER_TOKEN_VALUE_PATTERN}(?![A-Za-z0-9._~+/=-])", re.IGNORECASE
)
_SK_KEY_PATTERN = re.compile(r"\bsk-[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])")
_NAMED_SECRET_PATTERN = re.compile(
r"\b(?P<name>api_key|apikey|token|secret|password)(?P<quote>[\"']?)\s*"
r"(?P<separator>[:=])\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)",
re.IGNORECASE,
)
_EMPTY_EXTERNAL_ERROR = "Unbekannter Fehler"
_SECRET_PATTERNS = (
_URL_USERINFO_PATTERN,
_BEARER_TOKEN_PATTERN,
_SK_KEY_PATTERN,
_NAMED_SECRET_PATTERN,
)

_DAMPF_SYSTEM = (
"Du erhältst ein emotional gesprochenes Transkript. Erkenne zuerst das eigentliche "
"Ziel, Anliegen und den wahren Frust der Person. Formuliere daraus eine klare, "
Expand Down Expand Up @@ -61,6 +88,74 @@
)


def _canonicalize_external_error(text: str) -> str:
return "".join(
character
if character == "\u200d" or not unicodedata.category(character).startswith("C")
else " " if unicodedata.category(character) == "Cc" else ""
for character in text
)


def _contains_control_obfuscated_secret(text: str) -> bool:
compact_characters: list[str] = []
source_positions: list[int] = []
for position, character in enumerate(text):
if unicodedata.category(character).startswith("C"):
continue
compact_characters.append(character)
source_positions.append(position)

if len(compact_characters) == len(text):
return False

compact_text = "".join(compact_characters)
for pattern in _SECRET_PATTERNS:
for match in pattern.finditer(compact_text):
source_start = source_positions[match.start()]
source_end = source_positions[match.end() - 1] + 1
source_text = text[source_start:source_end]
if (
any(unicodedata.category(character).startswith("C") for character in source_text)
and pattern.fullmatch(_canonicalize_external_error(source_text)) is None
):
return True

for match in _OBFUSCATED_BEARER_DELIMITER_PATTERN.finditer(compact_text):
bearer_end = source_positions[match.start() + len("Bearer") - 1] + 1
token_start = source_positions[match.start() + len("Bearer")]
if any(
unicodedata.category(character) == "Cf"
for character in text[bearer_end:token_start]
):
return True
return False


def sanitize_external_error(message: object, max_length: int = 240) -> str:
"""Return a safe, compact representation of an external error message."""
raw_text = str(message)
if _contains_control_obfuscated_secret(raw_text):
text = _EMPTY_EXTERNAL_ERROR
else:
text = _canonicalize_external_error(raw_text)
text = _URL_USERINFO_PATTERN.sub(rf"\1{_REDACTED}@", text)
text = _BEARER_TOKEN_PATTERN.sub(f"Bearer {_REDACTED}", text)
text = _SK_KEY_PATTERN.sub(_REDACTED, text)
text = _NAMED_SECRET_PATTERN.sub(
lambda match: (
f"{match.group('name')}{match.group('quote')}"
f"{match.group('separator')}{_REDACTED}"
),
text,
)
text = " ".join(text.split()) or _EMPTY_EXTERNAL_ERROR
limit = max(1, int(max_length))
if len(text) <= limit:
return text
return text[: limit - 1] + "…"


class LLMServiceError(Exception):
"""Raised when an LLM call fails."""

Expand Down
5 changes: 0 additions & 5 deletions app/paste_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,6 @@ def _detect_active_window_class() -> Optional[str]:
return window_class or None


def _is_terminal_active() -> bool:
window_class = _detect_active_window_class()
return bool(window_class and window_class in _KNOWN_TERMINAL_WINDOW_CLASSES)


class PasteServiceError(Exception):
"""Raised when clipboard write or key injection fails hard."""

Expand Down
94 changes: 93 additions & 1 deletion tests/test_llm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest

from app.config import DEFAULTS
from app.llm_service import LLMService, LLMServiceError, _NullLLMClient
from app.llm_service import LLMService, LLMServiceError, _NullLLMClient, sanitize_external_error
from app.workflows import WorkflowType
from app.writing_presets import WRITING_PRESETS

Expand Down Expand Up @@ -38,6 +38,98 @@ def service(mock_client):
)


class TestExternalErrorSanitizing:
def test_sanitize_external_error_masks_secrets_and_normalizes_spacing(self):
hostile_error = (
" request\x00 failed\tBearer DUMMY_BEARER_TOKEN_123456\n"
"sk-DUMMYKEY1234567890\rapi_key=DUMMY_API_KEY_123456\x1f"
"https://alice:dummy-password@example.invalid/v1 "
)

assert sanitize_external_error(hostile_error) == (
"request failed Bearer [REDACTED] [REDACTED] api_key=[REDACTED] "
"https://[REDACTED]@example.invalid/v1"
)

def test_sanitize_external_error_truncates_within_default_limit(self):
result = sanitize_external_error("x" * 300)

assert len(result) <= 240
assert result.endswith("…")

def test_sanitize_external_error_canonicalizes_hidden_characters_before_masking(self):
hostile_error = (
"Bearer\x00DUMMY_BEARER_TOKEN_123456 "
"api_key\u200b=\u200bDUMMY_API_KEY_123456"
)

assert sanitize_external_error(hostile_error) == (
"Bearer [REDACTED] api_key=[REDACTED]"
)

@pytest.mark.parametrize(
("hostile_error", "expected"),
[
(
'{"api_key": "DUMMY_API_KEY_123456"}',
'{"api_key":[REDACTED]}',
),
(
"postgresql://alice:dummy-password@example.invalid/db",
"postgresql://[REDACTED]@example.invalid/db",
),
(
"https://:dummy-password@example.invalid/v1",
"https://[REDACTED]@example.invalid/v1",
),
],
)
def test_sanitize_external_error_masks_common_secret_representations(
self, hostile_error, expected
):
assert sanitize_external_error(hostile_error) == expected

@pytest.mark.parametrize(
"message",
[
"Bearer status",
"Unsupported locale sk-SK",
"Provider returned 👨\u200d💻 error",
],
)
def test_sanitize_external_error_preserves_legitimate_non_secrets(self, message):
assert sanitize_external_error(message) == message

@pytest.mark.parametrize(
"hostile_error",
[
"Bear\x00er DUMMY_BEARER_TOKEN_123456",
"api\x00_key=DUMMY_API_KEY_123456",
"Bear\u200der DUMMY_BEARER_TOKEN_123456",
"api\u200d_key=DUMMY_API_KEY_123456",
"postgresql\x00://user:dummy-password@example.invalid/db",
"postgresql:/\x00/user:dummy-password@example.invalid/db",
"postgresql://us\x00er:dummy-password@example.invalid/db",
],
)
def test_sanitize_external_error_replaces_control_obfuscated_secret_atoms(
self, hostile_error
):
assert sanitize_external_error(hostile_error) == "Unbekannter Fehler"

@pytest.mark.parametrize(
"hostile_error",
[
"Bearer\u200bDUMMY_BEARER_TOKEN_123456",
"Bearer\u200dDUMMY_BEARER_TOKEN_123456",
],
)
def test_sanitize_external_error_replaces_format_obfuscated_bearer_delimiters(
self, hostile_error
):
assert sanitize_external_error(hostile_error) == "Unbekannter Fehler"


class TestLLMServiceInit:
def test_empty_api_key_is_not_available(self, mock_client):
service = LLMService(api_key="", client=mock_client, api_key_env="CUSTOM_OPENAI_KEY")
Expand Down
15 changes: 0 additions & 15 deletions tests/test_paste_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
PasteService,
PasteServiceError,
_detect_active_window_class,
_is_terminal_active,
)


Expand Down Expand Up @@ -81,20 +80,6 @@ def test_returns_none_if_display_not_set(self):
run_mock.assert_not_called()


class TestIsTerminalActive:
def test_returns_true_for_terminal_window(self):
with patch("app.paste_service._detect_active_window_class", return_value="konsole"):
assert _is_terminal_active() is True

def test_returns_false_for_non_terminal_window(self):
with patch("app.paste_service._detect_active_window_class", return_value="firefox"):
assert _is_terminal_active() is False

def test_returns_false_when_detection_is_none(self):
with patch("app.paste_service._detect_active_window_class", return_value=None):
assert _is_terminal_active() is False


class TestYdotoolPaste:
def test_sends_ctrl_shift_v_when_terminal_active(self):
service = PasteService(autopaste=True, key_delay_ms=135)
Expand Down
45 changes: 45 additions & 0 deletions tests/test_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,51 @@ def gui_app():

@gui_only
class TestStateMachine:
def test_worker_error_sanitizes_log_tray_and_desktop_notification(self, gui_app, caplog):
hostile_error = (
" request\x00 failed\tBearer DUMMY_BEARER_TOKEN_123456\n"
"sk-DUMMYKEY1234567890\rapi_key=DUMMY_API_KEY_123456\x1f"
"https://alice:dummy-password@example.invalid/v1 "
)
expected_message = (
"request failed Bearer [REDACTED] [REDACTED] api_key=[REDACTED] "
"https://[REDACTED]@example.invalid/v1"
)
caplog.set_level(logging.DEBUG, logger="blitztext.main")

with patch.object(gui_app, "show_tray_error") as tray_error, \
patch("app.blitztext_linux.notify_service.notify") as notify:
gui_app._on_worker_error(hostile_error)

error_messages = [
record.getMessage() for record in caplog.records if record.levelno == logging.ERROR
]
debug_messages = [
record.getMessage() for record in caplog.records if record.levelno == logging.DEBUG
]
assert error_messages == [f"Worker error: {expected_message}"]
assert any(hostile_error in message for message in debug_messages)
assert all(hostile_error not in record.getMessage() for record in caplog.records if record.levelno != logging.DEBUG)
assert tray_error.call_args.args[1] == expected_message
assert notify.call_args.args[1] == expected_message

def test_worker_error_limits_the_complete_error_log_record(self, gui_app, caplog):
hostile_error = "x" * 300
expected_message = "x" * 225 + "…"
caplog.set_level(logging.DEBUG, logger="blitztext.main")

with patch.object(gui_app, "show_tray_error") as tray_error, \
patch("app.blitztext_linux.notify_service.notify") as notify:
gui_app._on_worker_error(hostile_error)

error_messages = [
record.getMessage() for record in caplog.records if record.levelno == logging.ERROR
]
assert error_messages == [f"Worker error: {expected_message}"]
assert len(error_messages[0]) <= 240
assert tray_error.call_args.args[1] == expected_message
assert notify.call_args.args[1] == expected_message

def test_state_returns_to_idle_after_result(self, gui_app):
gui_app.state = "LLM_REWRITING"
gui_app.current_workflow = WorkflowType.TEXT_IMPROVER
Expand Down