Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3accb1212c |
@@ -96,7 +96,7 @@ CI — Gitea Actions (`.gitea/workflows/checks.yaml`): все шесть про
|
|||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- `REDMINE_URL` обязан использовать HTTPS: валидация отклоняет остальное, API-ключ передаётся в заголовках.
|
- `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`; при более широких правах выводится предупреждение.
|
- Конфиг создаётся с правами `0600`, директория — `0700`; при более широких правах выводится предупреждение.
|
||||||
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
|
- Секреты храните через `${VAR}` в YAML или в переменных окружения, не в открытом виде.
|
||||||
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.
|
- Инструмент только читает данные из Redmine и ничего в нём не изменяет.
|
||||||
|
|||||||
@@ -103,14 +103,15 @@ email:
|
|||||||
|
|
||||||
| Значение | Поведение |
|
| Значение | Поведение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `true` (по умолчанию) | Стандартная проверка TLS средствами requests (системные CA / certifi) |
|
| `true` (по умолчанию) | Проверка по системному хранилищу CA операционной системы (через truststore) — корпоративные CA, добавленные в ОС, работают без настройки |
|
||||||
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
|
| `false` | Проверка отключена — при запуске выводится предупреждение о риске MITM |
|
||||||
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
|
| строка с путём, например `/etc/ssl/my-ca.pem` | Путь к собственному CA-bundle, передаётся в requests как есть |
|
||||||
|
|
||||||
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
|
До версии с унификацией `verify_ssl: true` подставлял захардкоженный путь
|
||||||
`/etc/ssl/certs/ca-certificates.crt`, который отсутствует на части
|
`/etc/ssl/certs/ca-certificates.crt`, который существует только в
|
||||||
дистрибутивов. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
|
Debian/Ubuntu. Теперь `true` в YAML и `REDMINE_VERIFY=true` в env работают
|
||||||
одинаково — оба включают стандартную проверку без привязки к конкретному пути.
|
одинаково — оба включают проверку по системному хранилищу CA на любой ОС
|
||||||
|
(через truststore), без привязки к конкретному пути.
|
||||||
|
|
||||||
### `period.precision` — точность периода
|
### `period.precision` — точность периода
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "redmine-reporter"
|
name = "redmine-reporter"
|
||||||
version = "1.11.0"
|
version = "1.11.1"
|
||||||
description = "Redmine time-entry based issue reporter for internal use"
|
description = "Redmine time-entry based issue reporter for internal use"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
authors = [{ name = "Artem Kokos", email = "artem-kokos@mail.ru" }]
|
||||||
@@ -25,6 +25,7 @@ dependencies = [
|
|||||||
"openpyxl>=3.1.0",
|
"openpyxl>=3.1.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
|
"truststore>=0.10",
|
||||||
"urllib3>=1.26",
|
"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
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import truststore
|
||||||
from redminelib import Redmine
|
from redminelib import Redmine
|
||||||
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
from redminelib.exceptions import AuthError, ForbiddenError, ResourceNotFoundError
|
||||||
from redminelib.resources import Issue
|
from redminelib.resources import Issue
|
||||||
@@ -49,12 +50,21 @@ def _make_retry_adapter() -> requests.adapters.HTTPAdapter:
|
|||||||
|
|
||||||
|
|
||||||
def _create_redmine() -> Redmine:
|
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(
|
redmine = Redmine(
|
||||||
Config.get_redmine_url(),
|
Config.get_redmine_url(),
|
||||||
**_get_redmine_auth_kwargs(),
|
**_get_redmine_auth_kwargs(),
|
||||||
requests={
|
requests={
|
||||||
"verify": Config.get_redmine_verify(),
|
"verify": verify,
|
||||||
"timeout": REQUEST_TIMEOUT,
|
"timeout": REQUEST_TIMEOUT,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ def _configure_current_user(mock_redmine, user_id=1):
|
|||||||
mock_redmine.user.get.return_value = mock_user
|
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.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
|
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"
|
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.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.client.Redmine")
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
|
def test_fetch_raises_redmine_api_error_on_auth(mock_redmine_class):
|
||||||
|
|||||||
Reference in New Issue
Block a user