🛡️ Sentinel: [HIGH] Fix Symlink Vulnerability in .env Handling#185
🛡️ Sentinel: [HIGH] Fix Symlink Vulnerability in .env Handling#185
Conversation
|
👋 Jules, reporting for duty! I'm here to lend a hand with this pull request. When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down. I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job! For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with New to Jules? Learn more at jules.google/docs. For security, I will only act on instructions from the user who triggered this task. |
|
Merging to
|
|
👋 Development Partner is reviewing this PR. Will provide feedback shortly. |
Summary of ChangesHello @abhimehro, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses a high-severity symlink vulnerability in the handling of Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
| @@ -0,0 +1,78 @@ | |||
| import os | |||
| import stat | |||
Check warning
Code scanning / Prospector (reported by Codacy)
Unused import stat (unused-import)
| @@ -0,0 +1,78 @@ | |||
| import os | |||
| import stat | |||
| import pytest | |||
Check warning
Code scanning / Prospector (reported by Codacy)
Unable to import 'pytest' (import-error)
| import os | ||
| import stat | ||
| import pytest | ||
| from unittest.mock import MagicMock, patch |
Check warning
Code scanning / Prospector (reported by Codacy)
Unused MagicMock imported from unittest.mock (unused-import)
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main |
Check warning
Code scanning / Prospector (reported by Codacy)
Reimport 'main' (imported line 8) (reimported)
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main |
Check warning
Code scanning / Prospector (reported by Codacy)
Import outside toplevel (main) (import-outside-toplevel)
| @@ -0,0 +1,67 @@ | |||
| import os | |||
| import stat | |||
Check warning
Code scanning / Prospector (reported by Codacy)
Unused import stat (unused-import)
| @@ -0,0 +1,67 @@ | |||
| import os | |||
| import stat | |||
| import pytest | |||
Check warning
Code scanning / Prospector (reported by Codacy)
Unable to import 'pytest' (import-error)
| import os | ||
| import stat | ||
| import pytest | ||
| from unittest.mock import MagicMock, patch |
Check warning
Code scanning / Prospector (reported by Codacy)
Unused MagicMock imported from unittest.mock (unused-import)
| env_file.chmod(0o777) | ||
|
|
||
| # Run check_env_permissions | ||
| with patch("sys.stderr") as mock_stderr: |
Check warning
Code scanning / Prospector (reported by Codacy)
Unused variable 'mock_stderr' (unused-variable)
| fix_env.fix_env() | ||
|
|
||
| # Verify warning was printed | ||
| assert mock_print.called |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
| if "Security Warning" in msg and "symlink" in msg: | ||
| found = True | ||
| break | ||
| assert found, "Warning about symlink not found" |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
| assert found, "Warning about symlink not found" | ||
|
|
||
| # Verify target file content is UNCHANGED | ||
| assert target_file.read_text() == "TOKEN=foo\nPROFILE=bar" |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
|
|
||
| # Verify permissions are 600 | ||
| st = os.stat(".env") | ||
| assert (st.st_mode & 0o777) == 0o600 |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
| # Verify warning was logged | ||
| assert mock_stderr.write.called | ||
| warning_msg = mock_stderr.write.call_args[0][0] | ||
| assert "Security Warning" in warning_msg |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
| assert mock_stderr.write.called | ||
| warning_msg = mock_stderr.write.call_args[0][0] | ||
| assert "Security Warning" in warning_msg | ||
| assert "is a symlink" in warning_msg |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
|
|
||
| # Verify target permissions are UNCHANGED | ||
| final_mode = target_file.stat().st_mode | ||
| assert final_mode == initial_mode |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
|
|
||
| # Verify permissions are fixed to 600 | ||
| final_mode = env_file.stat().st_mode | ||
| assert (final_mode & 0o777) == 0o600 |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
| import os | ||
| import stat | ||
| import pytest | ||
| from unittest.mock import MagicMock, patch |
Check warning
Code scanning / Pylint (reported by Codacy)
standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
| @@ -0,0 +1,67 @@ | |||
| import os | |||
Check warning
Code scanning / Pylint (reported by Codacy)
Missing module docstring
| captured = capsys.readouterr() | ||
| output = captured.out | ||
| with patch.dict(os.environ, {"NO_COLOR": ""}): | ||
| with patch("sys.stderr.isatty", return_value=True), patch("sys.stdout.isatty", return_value=True): |
Check warning
Code scanning / Pylint (reported by Codacy)
Line too long (106/100)
| fix_env.fix_env() | ||
|
|
||
| # Verify permissions are 600 | ||
| st = os.stat(".env") |
Check warning
Code scanning / Pylint (reported by Codacy)
Variable name "st" doesn't conform to snake_case naming style
| token = "api.1234567890abcdef" | ||
| profile = "12345abc" | ||
|
|
||
| with open(".env", "w") as f: |
Check warning
Code scanning / Pylint (reported by Codacy)
Variable name "f" doesn't conform to snake_case naming style
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main |
Check notice
Code scanning / Pylint (reported by Codacy)
Redefining name 'main' from outer scope (line 8)
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main |
Check notice
Code scanning / Pylint (reported by Codacy)
Reimport 'main' (imported line 8)
| @@ -0,0 +1,67 @@ | |||
| import os | |||
| import stat | |||
Check notice
Code scanning / Pylint (reported by Codacy)
Unused import stat
| import os | ||
| import stat | ||
| import pytest | ||
| from unittest.mock import MagicMock, patch |
Check notice
Code scanning / Pylint (reported by Codacy)
Unused MagicMock imported from unittest.mock
| env_file.chmod(0o777) | ||
|
|
||
| # Run check_env_permissions | ||
| with patch("sys.stderr") as mock_stderr: |
Check notice
Code scanning / Pylint (reported by Codacy)
Unused variable 'mock_stderr'
| import os | ||
| import stat | ||
| import pytest | ||
| from unittest.mock import MagicMock, patch |
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
| @@ -0,0 +1,67 @@ | |||
| import os | |||
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Missing module docstring
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main |
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Import outside toplevel (main)
| captured = capsys.readouterr() | ||
| output = captured.out | ||
| with patch.dict(os.environ, {"NO_COLOR": ""}): | ||
| with patch("sys.stderr.isatty", return_value=True), patch("sys.stdout.isatty", return_value=True): |
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Line too long (106/100)
| fix_env.fix_env() | ||
|
|
||
| # Verify permissions are 600 | ||
| st = os.stat(".env") |
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Variable name "st" doesn't conform to snake_case naming style
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Reimport 'main' (imported line 8)
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Redefining name 'main' from outer scope (line 8)
| @@ -0,0 +1,67 @@ | |||
| import os | |||
| import stat | |||
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Unused import stat
| import os | ||
| import stat | ||
| import pytest | ||
| from unittest.mock import MagicMock, patch |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Unused MagicMock imported from unittest.mock
| env_file.chmod(0o777) | ||
|
|
||
| # Run check_env_permissions | ||
| with patch("sys.stderr") as mock_stderr: |
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Unused variable 'mock_stderr'
|
👋 Development Partner is reviewing this PR. Will provide feedback shortly. |
| # Verify target permissions are UNCHANGED | ||
| final_mode = target_file.stat().st_mode | ||
| assert final_mode == initial_mode | ||
| assert (final_mode & 0o777) == 0o644 # Still 644, not 600 |
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
There was a problem hiding this comment.
Pull request overview
Addresses a local symlink attack risk around .env permission fixing and rewriting, aiming to prevent chmod/write operations from following attacker-controlled symlinks.
Changes:
- Add symlink detection to
check_env_permissions()to skip permission fixes on symlinks. - Update
fix_env.pyto abort on.envsymlinks and write viaos.openwith an intended secure permission flow. - Add/adjust tests for symlink handling and fix a color-output test by reloading
mainunder a forced “colors enabled” environment.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| main.py | Skips permission fixing when the .env path is a symlink. |
| fix_env.py | Adds symlink guard and switches .env rewriting to os.open + fd-based handling. |
| tests/test_symlink.py | New integration-style tests for symlink skipping and permission fixing. |
| tests/test_fix_env.py | New tests validating fix_env symlink skipping and 600 perms. |
| tests/test_plan_details.py | Reloads main under forced TTY + NO_COLOR settings to test colored output. |
| .jules/sentinel.md | Documents the symlink vulnerability and proposed prevention guidance. |
| import main | ||
|
|
There was a problem hiding this comment.
main is imported at module import time. Other tests in this repo import main inside the test function to avoid import-time side effects (e.g., load_dotenv() / TTY-derived globals) during test collection. Please move the import main into the individual tests (or import check_env_permissions inside the tests) to keep collection side-effect free and reduce order-dependence.
| with patch.dict(os.environ, {"NO_COLOR": ""}): | ||
| with patch("sys.stderr.isatty", return_value=True), patch("sys.stdout.isatty", return_value=True): | ||
| # Robust reload: handle case where main module reference is stale |
There was a problem hiding this comment.
patch("sys.stderr.isatty", ...) / patch("sys.stdout.isatty", ...) is likely to raise AttributeError because sys.stderr/sys.stdout are typically io.TextIOWrapper instances that don’t allow setting arbitrary attributes (no instance __dict__). Patch sys.stderr/sys.stdout themselves with objects that implement isatty() (or patch main.USE_COLORS/main.Colors directly) to make this test reliable.
| def test_check_env_permissions_skips_symlink(tmp_path): | ||
| """ | ||
| Verify that check_env_permissions skips symlinks and logs a warning. | ||
| This prevents modifying permissions of the symlink target. | ||
| """ | ||
| # Create a target file | ||
| target_file = tmp_path / "target_file" | ||
| target_file.write_text("target content") | ||
|
|
||
| # Set permissions to 644 (world-readable) | ||
| target_file.chmod(0o644) | ||
| initial_mode = target_file.stat().st_mode |
There was a problem hiding this comment.
This test mutates real filesystem permissions (chmod(0o777) / mode assertions) but doesn’t skip on Windows. On Windows, chmod semantics differ and symlink creation may require privileges, making this test flaky. Consider skipping on os.name == 'nt' (similar to the second test) or rewriting it to mock os.stat/os.chmod like tests/test_env_permissions.py does.
| import stat | ||
| import pytest | ||
| from unittest.mock import MagicMock, patch |
There was a problem hiding this comment.
There are unused imports here (stat, MagicMock) which can fail linting in CI and make the test harder to read. Please remove any imports that aren’t used.
| import stat | |
| import pytest | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from unittest.mock import patch |
| import stat | ||
| import pytest | ||
| from unittest.mock import MagicMock, patch |
There was a problem hiding this comment.
There are unused imports here (stat, MagicMock) which can fail linting in CI and add noise. Please remove any imports that aren’t used.
| import stat | |
| import pytest | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from unittest.mock import patch |
|
|
||
| **Learning:** `os.chmod` and `open()` follow symlinks by default in Python (and most POSIX environments). | ||
|
|
||
| **Prevention:** Always use `os.path.islink()` to check for symlinks before modifying file metadata or content if the path is user-controlled or in a writable directory. Use `os.open` with `O_CREAT | O_WRONLY | O_TRUNC` and `os.chmod(fd)` on the file descriptor to avoid race conditions (TOCTOU) and ensure operations apply to the file itself, not a symlink target. |
There was a problem hiding this comment.
The “Prevention” guidance suggests that os.open(..., O_CREAT|O_WRONLY|O_TRUNC) + os.chmod(fd) avoids TOCTOU/symlink attacks, but that isn’t sufficient unless symlink-following is explicitly prevented (e.g., O_NOFOLLOW where supported and/or fstat-based validation of a regular file). Please update this write-up to reflect the need for O_NOFOLLOW/fstat (and ideally os.fchmod) so the documented guidance matches the actual secure pattern.
| **Prevention:** Always use `os.path.islink()` to check for symlinks before modifying file metadata or content if the path is user-controlled or in a writable directory. Use `os.open` with `O_CREAT | O_WRONLY | O_TRUNC` and `os.chmod(fd)` on the file descriptor to avoid race conditions (TOCTOU) and ensure operations apply to the file itself, not a symlink target. | |
| **Prevention:** Treat any path in a user-writable location as attacker-controlled. `os.path.islink()` can be used as an initial guard, but it is not sufficient on its own because of TOCTOU races. For creating or overwriting files like `.env`, open them with `os.open` using `O_CREAT | O_WRONLY | O_TRUNC` combined with `O_NOFOLLOW` where supported, then use `os.fstat(fd)` to verify you actually opened a regular file (and not a symlink or special file), and finally call `os.fchmod(fd, 0o600)` (or your desired mode) on the file descriptor. This descriptor-based pattern avoids following symlinks and prevents race conditions between path resolution and permission changes. |
| if not os.path.exists(env_path): | ||
| return | ||
|
|
||
| # Security: Don't follow symlinks when checking/fixing permissions | ||
| # This prevents attacks where .env is symlinked to a system file (e.g., /etc/passwd) | ||
| if os.path.islink(env_path): | ||
| sys.stderr.write( | ||
| f"{Colors.WARNING}⚠️ Security Warning: {env_path} is a symlink. " | ||
| f"Skipping permission fix to avoid damaging target file.{Colors.ENDC}\n" | ||
| ) | ||
| return |
There was a problem hiding this comment.
os.path.exists() follows symlinks, so a dangling .env symlink will return False and skip the new symlink warning entirely. Also, checking islink() and then later calling os.stat/os.chmod on the path is still vulnerable to a TOCTOU swap (symlink can be replaced after the check). Consider using os.path.lexists() and performing the symlink check before the existence check, and/or switching to an FD-based approach (e.g., os.open with O_NOFOLLOW where available + os.fstat/os.fchmod, or os.chmod(..., follow_symlinks=False) as a fallback) to ensure the target cannot be replaced between check and chmod.
| # Security: Check for symlinks to prevent overwriting arbitrary files | ||
| if os.path.islink('.env'): | ||
| print("Security Warning: .env is a symlink. Aborting to avoid overwriting target.") | ||
| return |
There was a problem hiding this comment.
The symlink check happens only right before writing, but the function reads .env at the top via open('.env', 'r'), which still follows symlinks. If .env is a symlink, this will read the target file (unexpected and potentially sensitive) before aborting. Move the symlink check (and ideally a regular-file check) before any read of .env.
| flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | ||
| mode = stat.S_IRUSR | stat.S_IWUSR # 0o600 | ||
|
|
||
| fd = os.open('.env', flags, mode) | ||
| with os.fdopen(fd, 'w') as f: | ||
| f.write(new_content) | ||
| # Enforce permissions on the file descriptor directly (safe against race conditions) | ||
| if os.name != 'nt': | ||
| os.chmod(fd, mode) |
There was a problem hiding this comment.
os.path.islink() alone doesn’t prevent a race: an attacker can swap .env to a symlink between the check and os.open(..., O_TRUNC), still allowing overwrite of an arbitrary target. To actually prevent symlink-following, include O_NOFOLLOW when available (via getattr(os, 'O_NOFOLLOW', 0)) and/or validate os.fstat(fd) is a regular file before writing; also prefer os.fchmod(fd, mode) over os.chmod(fd, mode) for clarity/portability on POSIX.
| flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | |
| mode = stat.S_IRUSR | stat.S_IWUSR # 0o600 | |
| fd = os.open('.env', flags, mode) | |
| with os.fdopen(fd, 'w') as f: | |
| f.write(new_content) | |
| # Enforce permissions on the file descriptor directly (safe against race conditions) | |
| if os.name != 'nt': | |
| os.chmod(fd, mode) | |
| # Include O_NOFOLLOW when available to prevent following symlinks at open-time | |
| flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, 'O_NOFOLLOW', 0) | |
| mode = stat.S_IRUSR | stat.S_IWUSR # 0o600 | |
| fd = os.open('.env', flags, mode) | |
| # Additional safety: verify the opened target is a regular file, not a symlink/device/FIFO | |
| st = os.fstat(fd) | |
| if not stat.S_ISREG(st.st_mode): | |
| # Avoid writing to anything that isn't a normal file | |
| os.close(fd) | |
| print("Security Warning: .env is not a regular file. Aborting write.") | |
| return | |
| with os.fdopen(fd, 'w') as f: | |
| f.write(new_content) | |
| # Enforce permissions on the file descriptor directly (safe against race conditions) | |
| if os.name != 'nt' and hasattr(os, 'fchmod'): | |
| os.fchmod(fd, mode) |
| # Robust reload: handle case where main module reference is stale | ||
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main | ||
| importlib.reload(main) |
There was a problem hiding this comment.
This import of module main is redundant, as it was previously imported on line 8.
| # Robust reload: handle case where main module reference is stale | |
| if "main" in sys.modules: | |
| importlib.reload(sys.modules["main"]) | |
| else: | |
| import main | |
| importlib.reload(main) | |
| # Reload main so that the Colors configuration is recomputed | |
| importlib.reload(main) |
There was a problem hiding this comment.
Code Review
This pull request introduces crucial security enhancements by addressing symlink vulnerabilities in .env file handling. The changes correctly implement os.path.islink checks before file operations and utilize os.open with file descriptor-based chmod to prevent Time-of-check to Time-of-use (TOCTOU) race conditions. The accompanying tests are comprehensive, covering both symlink skipping and secure file creation/permission setting. The documentation in sentinel.md is also updated to reflect this vulnerability and its prevention.
| if os.path.islink('.env'): | ||
| print("Security Warning: .env is a symlink. Aborting to avoid overwriting target.") | ||
| return |
| # Security: Write using os.open to ensure 600 permissions at creation time | ||
| # This prevents a race condition where the file is world-readable before chmod | ||
| try: | ||
| # O_TRUNC to overwrite if exists, O_CREAT to create if not | ||
| flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | ||
| mode = stat.S_IRUSR | stat.S_IWUSR # 0o600 | ||
|
|
||
| fd = os.open('.env', flags, mode) | ||
| with os.fdopen(fd, 'w') as f: | ||
| f.write(new_content) | ||
| # Enforce permissions on the file descriptor directly (safe against race conditions) | ||
| if os.name != 'nt': | ||
| os.chmod(fd, mode) | ||
|
|
||
| except OSError as e: | ||
| print(f"Error writing .env: {e}") | ||
| return |
There was a problem hiding this comment.
The use of os.open with O_CREAT | O_WRONLY | O_TRUNC and subsequent os.chmod(fd, mode) is a robust way to prevent TOCTOU race conditions. This ensures that the file is created with the correct permissions atomically and that operations are performed on the intended file, not a symlink target. This is a significant security improvement.
| # Security: Don't follow symlinks when checking/fixing permissions | ||
| # This prevents attacks where .env is symlinked to a system file (e.g., /etc/passwd) | ||
| if os.path.islink(env_path): | ||
| sys.stderr.write( | ||
| f"{Colors.WARNING}⚠️ Security Warning: {env_path} is a symlink. " | ||
| f"Skipping permission fix to avoid damaging target file.{Colors.ENDC}\n" | ||
| ) | ||
| return |
| def test_check_env_permissions_skips_symlink(tmp_path): | ||
| """ | ||
| Verify that check_env_permissions skips symlinks and logs a warning. | ||
| This prevents modifying permissions of the symlink target. | ||
| """ | ||
| # Create a target file | ||
| target_file = tmp_path / "target_file" | ||
| target_file.write_text("target content") | ||
|
|
||
| # Set permissions to 644 (world-readable) | ||
| target_file.chmod(0o644) | ||
| initial_mode = target_file.stat().st_mode | ||
|
|
||
| # Create a symlink to the target | ||
| symlink = tmp_path / ".env_symlink" | ||
| try: | ||
| os.symlink(target_file, symlink) | ||
| except OSError: | ||
| pytest.skip("Symlinks not supported on this platform") | ||
|
|
||
| # Mock stderr to verify warning | ||
| with patch("sys.stderr") as mock_stderr: | ||
| # Run check_env_permissions on the symlink | ||
| main.check_env_permissions(str(symlink)) | ||
|
|
||
| # Verify warning was logged | ||
| assert mock_stderr.write.called | ||
| warning_msg = mock_stderr.write.call_args[0][0] | ||
| assert "Security Warning" in warning_msg | ||
| assert "is a symlink" in warning_msg | ||
|
|
||
| # Verify target permissions are UNCHANGED | ||
| final_mode = target_file.stat().st_mode | ||
| assert final_mode == initial_mode | ||
| assert (final_mode & 0o777) == 0o644 # Still 644, not 600 |
| def test_check_env_permissions_fixes_file(tmp_path): | ||
| """ | ||
| Verify that check_env_permissions fixes permissions for a regular file. | ||
| """ | ||
| if os.name == 'nt': | ||
| pytest.skip("Permission fix not supported on Windows") | ||
|
|
||
| # Create a regular file | ||
| env_file = tmp_path / ".env_file" | ||
| env_file.write_text("content") | ||
|
|
||
| # Set permissions to 644 (world-readable) | ||
| env_file.chmod(0o644) | ||
|
|
||
| # Run check_env_permissions | ||
| with patch("sys.stderr") as mock_stderr: | ||
| main.check_env_permissions(str(env_file)) | ||
|
|
||
| # Verify success message (or at least no warning about symlink) | ||
| # Note: Depending on implementation, it might log "Fixed .env permissions" | ||
| # We can check permissions directly. | ||
|
|
||
| # Verify permissions are fixed to 600 | ||
| final_mode = env_file.stat().st_mode | ||
| assert (final_mode & 0o777) == 0o600 |
| assert mock_print.called | ||
| found = False | ||
| for call_args in mock_print.call_args_list: | ||
| msg = call_args[0][0] | ||
| if "Security Warning" in msg and "symlink" in msg: | ||
| found = True | ||
| break | ||
| assert found, "Warning about symlink not found" |
There was a problem hiding this comment.
The assertion for the warning message can be made more concise and readable using mock_print.assert_any_call. This directly checks if the expected warning message was printed.
| assert mock_print.called | |
| found = False | |
| for call_args in mock_print.call_args_list: | |
| msg = call_args[0][0] | |
| if "Security Warning" in msg and "symlink" in msg: | |
| found = True | |
| break | |
| assert found, "Warning about symlink not found" | |
| mock_print.assert_any_call("Security Warning: .env is a symlink. Aborting to avoid overwriting target.") |
| if "main" in sys.modules: | ||
| importlib.reload(sys.modules["main"]) | ||
| else: | ||
| import main | ||
| importlib.reload(main) |
- Prevent `check_env_permissions` in `main.py` from following symlinks, mitigating potential local DoS attacks where an attacker symlinks `.env` to a system file. - Prevent `fix_env.py` from following symlinks when writing to `.env`. - Use `os.open` with `O_CREAT | O_WRONLY | O_TRUNC` and mode `0o600` in `fix_env.py` for atomic, secure file creation. - Use `os.chmod(fd)` on the file descriptor to ensure permissions are applied to the correct file, avoiding TOCTOU race conditions. - Add comprehensive tests for symlink handling in `tests/test_symlink.py` and `tests/test_fix_env.py`. - Update `tests/test_plan_details.py` to correctly reload modules for isolated testing. - Document vulnerability and prevention in `.jules/sentinel.md`. Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com>
- Prevent `check_env_permissions` in `main.py` from following symlinks, mitigating potential local DoS attacks where an attacker symlinks `.env` to a system file. - Prevent `fix_env.py` from following symlinks when writing to `.env`. - Use `os.open` with `O_CREAT | O_WRONLY | O_TRUNC` and mode `0o600` in `fix_env.py` for atomic, secure file creation. - Use `os.chmod(fd)` on the file descriptor to ensure permissions are applied to the correct file, avoiding TOCTOU race conditions. - Add comprehensive tests for symlink handling in `tests/test_symlink.py` and `tests/test_fix_env.py`. - Update `tests/test_plan_details.py` to correctly reload modules for isolated testing. - Document vulnerability and prevention in `.jules/sentinel.md`. - Fix linting issues (Bandit B103) in tests by using safer permission masks. Co-authored-by: abhimehro <84992105+abhimehro@users.noreply.github.com>
9c89905 to
2ae9319
Compare
|
👋 Development Partner is reviewing this PR. Will provide feedback shortly. |
| fix_env.fix_env() | ||
|
|
||
| # Verify warning was printed | ||
| assert mock_print.called |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
| if "Security Warning" in msg and "symlink" in msg: | ||
| found = True | ||
| break | ||
| assert found, "Warning about symlink not found" |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
| assert found, "Warning about symlink not found" | ||
|
|
||
| # Verify target file content is UNCHANGED | ||
| assert target_file.read_text() == "TOKEN=foo\nPROFILE=bar" |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
| try: | ||
| # Use realistic token (starts with api. or long) | ||
| # nosec - testing token, not real secret | ||
| token = "api.1234567890abcdef" |
Check notice
Code scanning / Bandit
Possible hardcoded password: 'api.1234567890abcdef' Note test
|
|
||
| # Verify permissions are 600 | ||
| st = os.stat(".env") | ||
| assert (st.st_mode & 0o777) == 0o600 |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
| # Verify warning was logged | ||
| assert mock_stderr.write.called | ||
| warning_msg = mock_stderr.write.call_args[0][0] | ||
| assert "Security Warning" in warning_msg |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
| assert mock_stderr.write.called | ||
| warning_msg = mock_stderr.write.call_args[0][0] | ||
| assert "Security Warning" in warning_msg | ||
| assert "is a symlink" in warning_msg |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
|
|
||
| # Verify target permissions are UNCHANGED | ||
| final_mode = target_file.stat().st_mode | ||
| assert final_mode == initial_mode |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
| # Verify target permissions are UNCHANGED | ||
| final_mode = target_file.stat().st_mode | ||
| assert final_mode == initial_mode | ||
| assert (final_mode & 0o777) == 0o644 # Still 644, not 600 |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
|
|
||
| # Verify permissions are fixed to 600 | ||
| final_mode = env_file.stat().st_mode | ||
| assert (final_mode & 0o777) == 0o600 |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
🛡️ Sentinel: [CRITICAL/HIGH] Fix Symlink Vulnerability in .env Handling
🚨 Severity: HIGH
💡 Vulnerability: Insecure file operations (chmod/write) on
.envfile without checking for symlinks.🎯 Impact: An attacker with local write access could create a symlink named
.envpointing to sensitive system files (e.g.,/etc/passwdor critical binaries), causing the script to change their permissions to 600 (DoS) or overwrite them (Data Loss/Corruption).🔧 Fix: Added
os.path.islinkchecks before operations. Switched toos.openwith secure flags and file descriptor-basedchmodto prevent race conditions.✅ Verification: Added unit tests verifying symlinks are skipped and files are created with correct permissions. Ran reproduction script to confirm fix. All 75 tests passed.
PR created automatically by Jules for task 6511438001568482826 started by @abhimehro