Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.13
3.13
6 changes: 6 additions & 0 deletions fix_env.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import re
import stat

def fix_env():
try:
Expand Down Expand Up @@ -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()
39 changes: 39 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import argparse
import json
import os
import stat
import logging
import sys
import time
Expand Down Expand Up @@ -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")

# --------------------------------------------------------------------------- #
Expand Down
156 changes: 156 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
@@ -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}"