1 Commits

Author SHA1 Message Date
Кокос Артем Николаевич
3accb1212c fix: use OS trust store for TLS verification via truststore
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled
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
2026-07-17 18:39:53 +07:00
6 changed files with 97 additions and 9 deletions

View File

@@ -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 и ничего в нём не изменяет.

View File

@@ -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` — точность периода

View File

@@ -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",
]

View File

@@ -1 +1 @@
__version__ = "1.11.0"
__version__ = "1.11.1"

View File

@@ -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,
},
)

View File

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