From 3accb1212ce2934e8dadd76a274d4a240c0bf073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Fri, 17 Jul 2026 18:39:53 +0700 Subject: [PATCH] fix: use OS trust store for TLS verification via truststore Regression from #62: verify_ssl true used to resolve to the system CA bundle path, so corporate CAs installed in the OS worked; after the unification true became requests' default (certifi), breaking setups with a corporate CA in the system store. Now verify_ssl true injects truststore, so requests verifies against the OS trust store on any platform. verify_ssl false / custom CA path behavior is unchanged. Tests mock truststore via an autouse fixture to keep the pytest process free of global ssl mutation. Refs #62 --- README.md | 2 +- docs/CONFIG.md | 9 +++-- pyproject.toml | 3 +- redmine_reporter/__init__.py | 2 +- redmine_reporter/client.py | 14 ++++++- tests/test_client.py | 76 ++++++++++++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3af39fc..4e5f96b 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть про ## Безопасность - `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках. -- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle. +- `verify_ssl` / `REDMINE_VERIFY`: `true` (по умолчанию — проверка по системному хранилищу CA операционной системы через truststore, корпоративные CA из ОС работают), `false` (предупреждение о MITM-риске при старте) или путь к CA-bundle. - Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение. - Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде. - Инструмент только читает данные из Redmine и ничего в нём не изменяет. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 282a383..5b9acfa 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -103,14 +103,15 @@ email: | Значение | Поведение | |---|---| -| `true` (по умолчанию) | Стандартная проверка TLS средствами requests (системные CA / certifi) | +| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки | | `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM | | строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть | До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь -`/etc/ssl/certs/ca-certificates.crt`, который отсутствует на части -дистрибутивов. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают -одинаково — оба включают стандартную проверку без привязки к конкретному пути. +`/etc/ssl/certs/ca-certificates.crt`, который существует только в +Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают +одинаково — оба включают проверку по системному хранилищу CA на любой ОС +(через truststore), без привязки к конкретному пути. ### `period.precision` — точность периода diff --git a/pyproject.toml b/pyproject.toml index abc27c4..ce5abe3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "redmine-reporter" -version = "1.11.0" +version = "1.11.1" description = "Redmine time-entry based issue reporter for internal use" readme = "README.md" authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }] @@ -25,6 +25,7 @@ dependencies = [ "openpyxl>=3.1.0", "pyyaml>=6.0", "requests>=2.31", + "truststore>=0.10", "urllib3>=1.26", ] diff --git a/redmine_reporter/__init__.py b/redmine_reporter/__init__.py index f84c53b..c3fa782 100644 --- a/redmine_reporter/__init__.py +++ b/redmine_reporter/__init__.py @@ -1 +1 @@ -__version__ = "1.11.0" +__version__ = "1.11.1" diff --git a/redmine_reporter/client.py b/redmine_reporter/client.py index aba017d..87a3d20 100644 --- a/redmine_reporter/client.py +++ b/redmine_reporter/client.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple, Union import requests +import truststore from redminelib import Redmine from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError from redminelib.resources import Issue @@ -49,12 +50,21 @@ def _make_retry_adapter() -> requests.adapters.HTTPAdapter: def _create_redmine() -> Redmine: - """Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24).""" + """Создаёт Redmine-клиент с таймаутом и retry-адаптером (#24). + + При verify=True подключает системное хранилище CA ОС через + truststore.inject_into_ssl() (#62). + """ + verify = Config.get_redmine_verify() + if verify is True: + # verify_ssl: true — проверка по системному хранилищу CA ОС (truststore), + # а не по certifi: корпоративные CA из ОС продолжают работать (#62). + truststore.inject_into_ssl() redmine = Redmine( Config.get_redmine_url(), **_get_redmine_auth_kwargs(), requests={ - "verify": Config.get_redmine_verify(), + "verify": verify, "timeout": REQUEST_TIMEOUT, }, ) diff --git a/tests/test_client.py b/tests/test_client.py index 35accff..19ab5ad 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,6 +19,18 @@ def _configure_current_user(mock_redmine, user_id=1): mock_redmine.user.get.return_value = mock_user +@pytest.fixture(autouse=True) +def mock_truststore(): + """Мок truststore для всех тестов клиента. + + При verify=True _create_redmine() вызывает truststore.inject_into_ssl(), + который глобально подменяет ssl.SSLContext без восстановления (#62). + Не даём реальной инъекции выполниться в pytest-процессе. + """ + with mock.patch("redmine_reporter.client.truststore") as m: + yield m + + @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_aggregates_hours_per_issue(mock_redmine_class): @@ -172,6 +184,70 @@ def test_fetch_uses_custom_verify_path(mock_redmine_class): assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem" +@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "true"}, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_verify_true_injects_system_trust_store(mock_redmine_class, mock_truststore): + """verify=True → truststore.inject_into_ssl() вызван, verify=True в Redmine (#62).""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31") + + mock_truststore.inject_into_ssl.assert_called_once_with() + _, kwargs = mock_redmine_class.call_args + assert kwargs["requests"]["verify"] is True + + +@mock.patch.dict(os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "false"}, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_verify_false_skips_truststore_injection(mock_redmine_class, mock_truststore): + """verify=False → truststore.inject_into_ssl() не вызывается.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31") + + mock_truststore.inject_into_ssl.assert_not_called() + _, kwargs = mock_redmine_class.call_args + assert kwargs["requests"]["verify"] is False + + +@mock.patch.dict( + os.environ, {**PASSWORD_ENV, "REDMINE_VERIFY": "/tmp/redmine-ca.pem"}, clear=True +) +@mock.patch("redmine_reporter.client.Redmine") +def test_verify_custom_path_skips_truststore_injection( + mock_redmine_class, mock_truststore +): + """verify=<путь к CA-bundle> → truststore.inject_into_ssl() не вызывается.""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31") + + mock_truststore.inject_into_ssl.assert_not_called() + _, kwargs = mock_redmine_class.call_args + assert kwargs["requests"]["verify"] == "/tmp/redmine-ca.pem" + + +@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) +@mock.patch("redmine_reporter.client.Redmine") +def test_verify_default_injects_system_trust_store(mock_redmine_class, mock_truststore): + """Без REDMINE_VERIFY verify по умолчанию True → inject вызван (#62).""" + mock_redmine = mock_redmine_class.return_value + _configure_current_user(mock_redmine) + mock_redmine.time_entry.filter.return_value = [] + + fetch_issues_with_spent_time("2026-01-01", "2026-01-31") + + mock_truststore.inject_into_ssl.assert_called_once_with() + _, kwargs = mock_redmine_class.call_args + assert kwargs["requests"]["verify"] is True + + @mock.patch.dict(os.environ, PASSWORD_ENV, clear=True) @mock.patch("redmine_reporter.client.Redmine") def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):