diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index a018e7d..e3ee63b 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -17,6 +17,26 @@ from .mailer import send_report from .report_builder import build_grouped_report, calculate_summary from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config +# Маскируем API-ключ Redmine в тексте ошибок (#55): +# - query-параметр key=<значение> в URL (?key=... / &key=...); +# - заголовок X-Redmine-API-Key: <значение>. +_SANITIZE_PATTERNS = [ + (re.compile(r"([?&]key=)[^\s&\"')]+", re.IGNORECASE), r"\1***"), + ( + re.compile( + r"(X-Redmine-API-Key[\"']?\s*[:=]\s*[\"']?)[^\s\"',}]+", re.IGNORECASE + ), + r"\1***", + ), +] + + +def _sanitize_error_text(text: str) -> str: + """Маскирует API-ключ Redmine в тексте ошибки перед выводом в stderr (#55).""" + for pattern, replacement in _SANITIZE_PATTERNS: + text = pattern.sub(replacement, text) + return text + def parse_date_range(date_arg: str) -> tuple[str, str]: if "--" not in date_arg: @@ -226,7 +246,7 @@ def _save_and_maybe_send( ) print(f"📧 Report sent to {', '.join(email_config.to)}") except RedmineAPIError as e: - print(f"❌ {e.message}", file=sys.stderr) + print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr) return 1 return 0 @@ -392,12 +412,14 @@ def main(argv: Optional[List[str]] = None) -> int: dedup_before=_compute_dedup_cutoff(), ) except RedmineAPIError as e: - print(f"❌ {e.message}", file=sys.stderr) - if args.debug and e.original is not None: + print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr) + if (args.verbose or args.debug) and e.original is not None: logging.exception("Original Redmine API error") return 1 except Exception as e: - print(f"❌ Unexpected error: {e}", file=sys.stderr) + print(f"❌ Unexpected error: {_sanitize_error_text(str(e))}", file=sys.stderr) + if args.verbose or args.debug: + logging.exception("Unexpected error") return 1 if issue_hours is None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 95c6610..e2fdb2d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1452,3 +1452,119 @@ def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path): # Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59 assert from_date == "2026-07-01T00:00:00" assert to_date == "2026-07-30T23:59:59" + + +# --------------------------------------------------------------------------- +# #55: sanitize Redmine exception output in CLI +# --------------------------------------------------------------------------- + + +class TestSanitizeErrorOutput: + """CLI не должен выводить API-ключ из текста исключений в stderr (#55).""" + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_api_key_in_url_is_masked(self, mock_fetch, capsys): + """key=SECRET в URL внутри сообщения исключения маскируется.""" + from redmine_reporter.client import RedmineAPIError + + mock_fetch.side_effect = RedmineAPIError( + "Network error while calling Redmine: " + "HTTPSConnectionPool(host='red.example.com', port=443): " + "Failed https://red.example.com/issues.json?key=SECRET123&limit=100" + ) + code = main(["--date", "2026-01-01--2026-01-31"]) + captured = capsys.readouterr() + assert code == 1 + assert "SECRET123" not in captured.err + assert "key=***" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_api_key_header_is_masked(self, mock_fetch, capsys): + """Заголовок X-Redmine-API-Key в тексте исключения маскируется.""" + from redmine_reporter.client import RedmineAPIError + + mock_fetch.side_effect = RedmineAPIError( + "Request failed: headers {'X-Redmine-API-Key': 'SECRET123'}" + ) + code = main(["--date", "2026-01-01--2026-01-31"]) + captured = capsys.readouterr() + assert code == 1 + assert "SECRET123" not in captured.err + assert "X-Redmine-API-Key" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_unexpected_exception_is_masked(self, mock_fetch, capsys): + """Общее исключение с key=SECRET в тексте тоже санитизируется.""" + mock_fetch.side_effect = RuntimeError( + "boom while requesting https://red.example.com/t.json?key=SECRET123" + ) + code = main(["--date", "2026-01-01--2026-01-31"]) + captured = capsys.readouterr() + assert code == 1 + assert "SECRET123" not in captured.err + assert "Unexpected error" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_error_message_readability_preserved(self, mock_fetch, capsys): + """Санитизация не ломает обычные сообщения об ошибках.""" + from redmine_reporter.client import RedmineAPIError + + mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key") + code = main(["--date", "2026-01-01--2026-01-31"]) + captured = capsys.readouterr() + assert code == 1 + assert "Authentication failed: bad key" in captured.err + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_debug_shows_full_traceback(self, mock_fetch, caplog): + """--debug выводит полный стектрейс исходного исключения.""" + import logging as _logging + + from redmine_reporter.client import RedmineAPIError + + original = ValueError("raw details https://red.example.com?key=SECRET123") + error = RedmineAPIError("safe message", original=original) + error.__cause__ = original + mock_fetch.side_effect = error + with caplog.at_level(_logging.ERROR): + code = main(["--date", "2026-01-01--2026-01-31", "--debug"]) + assert code == 1 + assert "raw details" in caplog.text + assert "Traceback" in caplog.text + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_verbose_shows_full_traceback(self, mock_fetch, caplog): + """--verbose тоже даёт доступ к деталям/стектрейсу исходной ошибки.""" + import logging as _logging + + from redmine_reporter.client import RedmineAPIError + + original = ValueError("raw details https://red.example.com?key=SECRET123") + error = RedmineAPIError("safe message", original=original) + error.__cause__ = original + mock_fetch.side_effect = error + with caplog.at_level(_logging.INFO): + code = main(["--date", "2026-01-01--2026-01-31", "--verbose"]) + assert code == 1 + assert "raw details" in caplog.text + + @mock.patch.dict(os.environ, VALID_ENV, clear=True) + @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") + def test_no_flags_hides_original_details(self, mock_fetch, caplog, capsys): + """Без флагов детали исходного исключения не печатаются.""" + from redmine_reporter.client import RedmineAPIError + + original = ValueError("raw details https://red.example.com?key=SECRET123") + mock_fetch.side_effect = RedmineAPIError("safe message", original=original) + code = main(["--date", "2026-01-01--2026-01-31"]) + captured = capsys.readouterr() + assert code == 1 + assert "raw details" not in captured.err + assert "raw details" not in caplog.text + assert "safe message" in captured.err