Merge branch 'fix/55-cli-sanitize'
This commit is contained in:
@@ -17,6 +17,26 @@ from .mailer import send_report
|
|||||||
from .report_builder import build_grouped_report, calculate_summary
|
from .report_builder import build_grouped_report, calculate_summary
|
||||||
from .yaml_config import ensure_config_dir, resolve_output_path, save_period_to_config
|
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]:
|
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
||||||
if "--" not in date_arg:
|
if "--" not in date_arg:
|
||||||
@@ -226,7 +246,7 @@ def _save_and_maybe_send(
|
|||||||
)
|
)
|
||||||
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
print(f"📧 Report sent to {', '.join(email_config.to)}")
|
||||||
except RedmineAPIError as e:
|
except RedmineAPIError as e:
|
||||||
print(f"❌ {e.message}", file=sys.stderr)
|
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
@@ -392,12 +412,14 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
dedup_before=_compute_dedup_cutoff(),
|
dedup_before=_compute_dedup_cutoff(),
|
||||||
)
|
)
|
||||||
except RedmineAPIError as e:
|
except RedmineAPIError as e:
|
||||||
print(f"❌ {e.message}", file=sys.stderr)
|
print(f"❌ {_sanitize_error_text(e.message)}", file=sys.stderr)
|
||||||
if args.debug and e.original is not None:
|
if (args.verbose or args.debug) and e.original is not None:
|
||||||
logging.exception("Original Redmine API error")
|
logging.exception("Original Redmine API error")
|
||||||
return 1
|
return 1
|
||||||
except Exception as e:
|
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
|
return 1
|
||||||
|
|
||||||
if issue_hours is None:
|
if issue_hours is None:
|
||||||
|
|||||||
@@ -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
|
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
|
||||||
assert from_date == "2026-07-01T00:00:00"
|
assert from_date == "2026-07-01T00:00:00"
|
||||||
assert to_date == "2026-07-30T23:59:59"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user