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
This commit is contained in:
@@ -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 и ничего в нём не изменяет.
|
||||
|
||||
@@ -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` — точность периода
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.11.0"
|
||||
__version__ = "1.11.1"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user