Merge branch 'fix/54-57-config-security'

This commit is contained in:
Кокос Артем Николаевич
2026-07-17 12:57:43 +07:00
3 changed files with 107 additions and 1 deletions

View File

@@ -317,3 +317,5 @@ mypy redmine_reporter
- Рекомендуется хранить секреты через `${VAR}`, а не plaintext. - Рекомендуется хранить секреты через `${VAR}`, а не plaintext.
- Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач. - Используйте аккаунт с минимальными правами, достаточными для чтения time entries и задач.
- Инструмент работает только в режиме чтения и не изменяет данные в Redmine. - Инструмент работает только в режиме чтения и не изменяет данные в Redmine.
- `REDMINE_URL` обязан использовать HTTPS: API-ключ передаётся в заголовках запроса, и без TLS он может быть перехвачен.
- `REDMINE_VERIFY=false` (или `verify_ssl: false`) отключает проверку TLS-сертификата — соединение уязвимо для MITM-атак; при старте выводится предупреждение. Используйте только в доверенной сети.

View File

@@ -1,5 +1,6 @@
import logging import logging
import os import os
import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path from pathlib import Path
@@ -398,8 +399,21 @@ class Config:
@classmethod @classmethod
def validate(cls) -> None: def validate(cls) -> None:
if not cls.get_redmine_url(): url = cls.get_redmine_url()
if not url:
raise ValueError("REDMINE_URL is required (set via env or .env)") raise ValueError("REDMINE_URL is required (set via env or .env)")
if not url.lower().startswith("https://"):
raise ValueError(
"REDMINE_URL must use HTTPS: the API key is sent in request "
"headers and requires TLS"
)
if cls.get_redmine_verify() is False:
print(
"⚠️ TLS certificate verification is disabled "
"(REDMINE_VERIFY=false / verify_ssl: false): connection is "
"vulnerable to MITM attacks",
file=sys.stderr,
)
if cls.get_redmine_api_key(): if cls.get_redmine_api_key():
return return
if not (cls.get_redmine_user() and cls.get_redmine_password()): if not (cls.get_redmine_user() and cls.get_redmine_password()):

View File

@@ -126,6 +126,96 @@ def test_get_redmine_verify_custom_path():
assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem" assert Config.get_redmine_verify() == "/tmp/redmine-ca.pem"
# -- #54: REDMINE_URL обязан использовать HTTPS --
class TestConfigRequiresHttpsUrl:
"""Config.validate() отклоняет URL без TLS: API-ключ идёт в заголовках."""
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "http://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_http_url_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "red.eltex.loc", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_url_without_scheme_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "ftp://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_non_http_scheme_rejected(self):
with pytest.raises(ValueError, match="HTTPS"):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_https_url_accepted(self):
Config.validate()
@mock.patch.dict(
os.environ,
{"REDMINE_URL": "HTTPS://red.eltex.loc/", "REDMINE_API_KEY": "token"},
clear=True,
)
def test_uppercase_scheme_accepted(self):
Config.validate()
# -- #57: предупреждение при отключённой проверке TLS --
class TestWarnWhenTlsVerificationDisabled:
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
_ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"}
def _validate_env(self, verify: str):
return {**self._ENV, "REDMINE_VERIFY": verify}
def test_verify_false_warns_in_stderr(self, capsys):
with mock.patch.dict(os.environ, self._validate_env("false"), clear=True):
Config.validate()
err = capsys.readouterr().err
assert "⚠️" in err
assert "TLS" in err or "SSL" in err
def test_verify_true_no_warning(self, capsys):
with mock.patch.dict(os.environ, self._validate_env("true"), clear=True):
Config.validate()
assert capsys.readouterr().err == ""
def test_verify_ca_path_no_warning(self, capsys):
with mock.patch.dict(
os.environ, self._validate_env("/tmp/redmine-ca.pem"), clear=True
):
Config.validate()
assert capsys.readouterr().err == ""
def test_verify_default_no_warning(self, capsys):
with mock.patch.dict(os.environ, self._ENV, clear=True):
Config.validate()
assert capsys.readouterr().err == ""
# -- #15: .env не должен переопределять переменные окружения -- # -- #15: .env не должен переопределять переменные окружения --