diff --git a/.gitignore b/.gitignore index 01c7ecf5..91950cd1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ old-events/ polldevs.cf zino-state.json zino.toml + +# avoid accidental commit of the secrets file +secrets diff --git a/changelog.d/280.added.md b/changelog.d/280.added.md new file mode 100644 index 00000000..d05eb031 --- /dev/null +++ b/changelog.d/280.added.md @@ -0,0 +1 @@ +Log warning if `secrets` file is world-readable diff --git a/src/zino/utils.py b/src/zino/utils.py index a841c4fb..66a82e4b 100644 --- a/src/zino/utils.py +++ b/src/zino/utils.py @@ -1,5 +1,7 @@ import asyncio import logging +import os +import stat from functools import wraps from ipaddress import ip_address from time import time @@ -63,3 +65,10 @@ def wrapper(*args, **kwargs): return wrapper return actual_decorator + + +def file_is_world_readable(file: str) -> bool: + """Returns a boolean value indicating if a file is readable by other users than its owner""" + st_mode = getattr(os.stat(path=file), "st_mode", None) + + return bool(st_mode & stat.S_IROTH) diff --git a/src/zino/zino.py b/src/zino/zino.py index 444d83d9..e739984b 100644 --- a/src/zino/zino.py +++ b/src/zino/zino.py @@ -30,6 +30,7 @@ link_traps, logged_traps, ) +from zino.utils import file_is_world_readable STATE_DUMP_JOB_ID = "zino.dump_state" # Never try to dump state more often than this: @@ -46,6 +47,17 @@ def main(): ) state.config = load_config(args) apply_logging_config(state.config.logging) + + try: + secrets_file = state.config.authentication.file + if file_is_world_readable(secrets_file): + _log.warning( + f"Secrets file {secrets_file} is world-readable. Please ensure that it is only readable by the user that runs the zino process." + ) + except OSError as e: + _log.fatal(e) + sys.exit(1) + state.state = state.ZinoState.load_state_from_file(state.config.persistence.file) or state.ZinoState() init_event_loop(args) diff --git a/tests/utils_test.py b/tests/utils_test.py index 963d125a..e8acd7de 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -1,11 +1,13 @@ import logging +import os +import stat from ipaddress import IPv4Address, IPv6Address from unittest.mock import AsyncMock, MagicMock import aiodns import pytest -from zino.utils import log_time_spent, parse_ip, reverse_dns +from zino.utils import file_is_world_readable, log_time_spent, parse_ip, reverse_dns class TestParseIP: @@ -102,3 +104,16 @@ def mock_dnsresolver(monkeypatch) -> AsyncMock: mock_dnsresolver = AsyncMock() monkeypatch.setattr("zino.utils.aiodns.DNSResolver", lambda loop: mock_dnsresolver) return mock_dnsresolver + + +class TestFileIsReadableByOthers: + def test_return_true_if_file_is_world_readable(self, secrets_file): + assert file_is_world_readable(secrets_file) + + def test_return_if_file_is_only_readable_by_owner(self, tmp_path): + name = tmp_path / "owner-secrets" + with open(name, "w") as conf: + conf.write("""user1 password123""") + os.chmod(name, mode=stat.S_IRWXU) + + assert not file_is_world_readable(name)