fix: sanitize Redmine exception output in CLI

Redmine/requests exceptions may embed the request URL containing the
API key (?key=SECRET) or the X-Redmine-API-Key header value. The CLI
printed the raw exception text to stderr, leaking the key into
terminals and logs.

Add _sanitize_error_text() which masks 'key=<value>' query params and
the X-Redmine-API-Key header as 'key=***' / 'X-Redmine-API-Key: ***',
and apply it to every exception-derived message printed to stderr
(fetch errors in main() and SMTP errors in _save_and_maybe_send()).

Full traceback with original exception details is now available under
both --verbose and --debug (previously only --debug, only for
RedmineAPIError). Exit codes and error-handling structure unchanged.

Closes #55
This commit is contained in:
Кокос Артем Николаевич
2026-07-17 12:51:23 +07:00
parent 594db90227
commit fb1ca1d9b8
2 changed files with 142 additions and 4 deletions

View File

@@ -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:

View File

@@ -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