diff --git a/.python-version b/.python-version index 24ee5b1b..3a4f41ef 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13 +3.13 \ No newline at end of file diff --git a/fix_env.py b/fix_env.py index 50dc9a5f..4f975cb6 100644 --- a/fix_env.py +++ b/fix_env.py @@ -1,5 +1,6 @@ import os import re +import stat def fix_env(): try: @@ -61,7 +62,12 @@ def escape_val(val): with open('.env', 'w') as f: f.write(new_content) + # SECURITY: Ensure .env is only readable by the owner (600) on Unix-like systems + if os.name != 'nt': + os.chmod('.env', stat.S_IRUSR | stat.S_IWUSR) + print("Fixed .env file: standardized quotes and corrected variable assignments.") + print("Security: .env permissions set to 600 (read/write only by owner).") if __name__ == "__main__": fix_env() diff --git a/main.py b/main.py index 9f324efe..5a493e65 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ import argparse import json import os +import stat import logging import sys import time @@ -92,6 +93,44 @@ def format(self, record): handler.setFormatter(ColoredFormatter()) logging.basicConfig(level=logging.INFO, handlers=[handler]) logging.getLogger("httpx").setLevel(logging.WARNING) + + +def check_env_permissions(env_path: str = ".env") -> None: + """ + Check .env file permissions and warn if readable by others. + + Args: + env_path: Path to the .env file to check (default: ".env") + """ + if not os.path.exists(env_path): + return + + try: + file_stat = os.stat(env_path) + # Check if group or others have any permission + if file_stat.st_mode & (stat.S_IRWXG | stat.S_IRWXO): + platform_hint = ( + "Please secure your .env file so it is only readable by " + "the owner." + ) + if os.name != "nt": + platform_hint += " For example: 'chmod 600 .env'." + perms = format(stat.S_IMODE(file_stat.st_mode), '03o') + sys.stderr.write( + f"{Colors.WARNING}⚠️ Security Warning: .env file is " + f"readable by others ({perms})! {platform_hint}" + f"{Colors.ENDC}\n" + ) + except Exception as error: + exception_type = type(error).__name__ + sys.stderr.write( + f"{Colors.WARNING}⚠️ Security Warning: Could not check .env " + f"permissions ({exception_type}: {error}){Colors.ENDC}\n" + ) + + +# SECURITY: Check .env permissions (after Colors is defined for NO_COLOR support) +check_env_permissions() log = logging.getLogger("control-d-sync") # --------------------------------------------------------------------------- # diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 00000000..b82f2c2c --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,156 @@ +""" +Tests for security features in main.py and fix_env.py +""" +import os +import stat +import sys +from unittest.mock import MagicMock +import pytest +import fix_env + + +@pytest.mark.skipif(os.name == 'nt', reason="Unix permissions not applicable on Windows") +def test_env_permission_check_warns_on_insecure_permissions(monkeypatch, tmp_path): + """Test that insecure .env permissions trigger a warning.""" + # Import main to get access to check_env_permissions and Colors + import main + + # Create a temporary .env file + env_file = tmp_path / ".env" + env_file.write_text("TOKEN=test") + os.chmod(env_file, 0o644) + + # Mock sys.stderr to capture warnings + mock_stderr = MagicMock() + monkeypatch.setattr(sys, "stderr", mock_stderr) + + # Run the permission check logic + main.check_env_permissions(str(env_file)) + + # Verify warning was written + mock_stderr.write.assert_called() + call_args = mock_stderr.write.call_args[0][0] + assert "Security Warning" in call_args + assert "readable by others" in call_args + assert "644" in call_args + + +@pytest.mark.skipif(os.name == 'nt', reason="Unix permissions not applicable on Windows") +def test_env_permission_check_no_warn_on_secure_permissions(monkeypatch, tmp_path): + """Test that secure .env permissions do not trigger a warning.""" + # Import main to get access to check_env_permissions + import main + + # Create a temporary .env file + env_file = tmp_path / ".env" + env_file.write_text("TOKEN=test") + os.chmod(env_file, 0o600) + + # Mock sys.stderr to capture warnings + mock_stderr = MagicMock() + monkeypatch.setattr(sys, "stderr", mock_stderr) + + # Run the permission check logic + main.check_env_permissions(str(env_file)) + + # Verify no warning was written + mock_stderr.write.assert_not_called() + + +def test_env_permission_check_handles_stat_error(monkeypatch): + """Test that permission check handles stat() errors gracefully.""" + # Import main to get access to check_env_permissions + import main + + # Mock sys.stderr to capture warnings + mock_stderr = MagicMock() + monkeypatch.setattr(sys, "stderr", mock_stderr) + + # Mock os.stat to raise an exception + def mock_stat(path): + raise PermissionError("Cannot access file") + + monkeypatch.setattr(os, "stat", mock_stat) + # Mock os.path.exists to return True so the check proceeds + monkeypatch.setattr(os.path, "exists", lambda x: True) + + # Run the permission check - should handle the error gracefully + main.check_env_permissions(".env") + + # Verify error warning was written + mock_stderr.write.assert_called() + call_args = mock_stderr.write.call_args[0][0] + assert "Security Warning" in call_args + assert "Could not check .env permissions" in call_args + assert "PermissionError" in call_args + + +@pytest.mark.skipif(os.name == 'nt', reason="Unix permissions not applicable on Windows") +def test_fix_env_sets_secure_permissions(tmp_path, monkeypatch): + """Test that fix_env.py sets secure permissions on .env file.""" + # Change to temp directory + monkeypatch.chdir(tmp_path) + + # Create a .env file with insecure content + env_file = tmp_path / ".env" + env_file.write_text("TOKEN='test_token'\nPROFILE='test_profile'\n") + + # Set insecure permissions initially + os.chmod(env_file, 0o644) + + # Run fix_env + fix_env.fix_env() + + # Check that permissions are now secure + file_stat = os.stat(env_file) + mode = stat.S_IMODE(file_stat.st_mode) + + # Verify permissions are 600 (read/write for owner only) + assert mode == 0o600, f"Expected 600, got {oct(mode)}" + + # Verify no group or other permissions + assert not (file_stat.st_mode & stat.S_IRWXG), "Group has permissions" + assert not (file_stat.st_mode & stat.S_IRWXO), "Others have permissions" + + +def test_fix_env_skips_chmod_on_windows(tmp_path, monkeypatch): + """Test that fix_env.py skips chmod on Windows.""" + # Change to temp directory + monkeypatch.chdir(tmp_path) + + # Create a .env file + env_file = tmp_path / ".env" + env_file.write_text("TOKEN='test_token'\nPROFILE='test_profile'\n") + + # Mock os.name to simulate Windows + monkeypatch.setattr(os, "name", "nt") + + # Mock os.chmod to verify it's not called + mock_chmod = MagicMock() + monkeypatch.setattr(os, "chmod", mock_chmod) + + # Run fix_env + fix_env.fix_env() + + # Verify chmod was not called on Windows + mock_chmod.assert_not_called() + + +def test_octal_permission_format(): + """Test that octal permission formatting is robust.""" + # Test various permission modes + test_modes = [ + 0o644, # rw-r--r-- + 0o600, # rw------- + 0o755, # rwxr-xr-x + 0o000, # --------- + ] + + for mode in test_modes: + # The robust way + result = format(stat.S_IMODE(mode), '03o') + # Should always be 3 digits + assert len(result) == 3, f"Expected 3 digits for {oct(mode)}, got {result}" + # Should be numeric + assert result.isdigit(), f"Expected numeric for {oct(mode)}, got {result}" +