-
Notifications
You must be signed in to change notification settings - Fork 1
π‘οΈ Sentinel: [HIGH] Fix Symlink Vulnerability in .env Handling #185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -59,12 +59,28 @@ def escape_val(val): | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Write back with standard quotes | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| new_content = f'TOKEN="{escape_val(real_token)}"\nPROFILE="{escape_val(real_profiles)}"\n' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+62
to
+65
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 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) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Check warningCode scanning / Pylint (reported by Codacy) Variable name "fd" doesn't conform to snake_case naming style
Variable name "fd" doesn't conform to snake_case naming style
Check warningCode scanning / Pylintpython3 (reported by Codacy) Variable name "fd" doesn't conform to snake_case naming style
Variable name "fd" doesn't conform to snake_case naming style
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| with os.fdopen(fd, 'w') as f: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Check warningCode scanning / Pylint (reported by Codacy) Variable name "f" doesn't conform to snake_case naming style
Variable name "f" doesn't conform to snake_case naming style
Check warningCode scanning / Pylintpython3 (reported by Codacy) Variable name "f" doesn't conform to snake_case naming style
Variable name "f" doesn't conform to snake_case naming style
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f.write(new_content) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # Enforce permissions on the file descriptor directly (safe against race conditions) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if os.name != 'nt': | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| os.chmod(fd, mode) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+71
to
+79
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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) |
Check warning
Code scanning / Pylint (reported by Codacy)
Variable name "e" doesn't conform to snake_case naming style
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Variable name "e" doesn't conform to snake_case naming style
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -116,6 +116,15 @@ def check_env_permissions(env_path: str = ".env") -> None: | |
| 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 | ||
|
Comment on lines
116
to
+126
|
||
|
|
||
| # Windows doesn't have Unix permissions | ||
| if os.name == "nt": | ||
| # Just warn on Windows, can't auto-fix | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,80 @@ | ||||||||||||||||||||
| import os | ||||||||||||||||||||
Check warningCode scanning / Pylint (reported by Codacy) Missing module docstring
Missing module docstring
Check warningCode scanning / Pylintpython3 (reported by Codacy) Missing module docstring
Missing module docstring
|
||||||||||||||||||||
| import stat | ||||||||||||||||||||
Check warningCode scanning / Prospector (reported by Codacy) Unused import stat (unused-import)
Unused import stat (unused-import)
Check noticeCode scanning / Pylint (reported by Codacy) Unused import stat
Unused import stat
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Unused import stat
Unused import stat
|
||||||||||||||||||||
| import pytest | ||||||||||||||||||||
Check warningCode scanning / Prospector (reported by Codacy) Unable to import 'pytest' (import-error)
Unable to import 'pytest' (import-error)
|
||||||||||||||||||||
| from unittest.mock import MagicMock, patch | ||||||||||||||||||||
Check warningCode scanning / Prospector (reported by Codacy) Unused MagicMock imported from unittest.mock (unused-import)
Unused MagicMock imported from unittest.mock (unused-import)
Check warningCode scanning / Pylint (reported by Codacy) standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
Check noticeCode scanning / Pylint (reported by Codacy) Unused MagicMock imported from unittest.mock
Unused MagicMock imported from unittest.mock
Check warningCode scanning / Pylintpython3 (reported by Codacy) standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Unused MagicMock imported from unittest.mock
Unused MagicMock imported from unittest.mock
Comment on lines
+2
to
+4
|
||||||||||||||||||||
| import stat | |
| import pytest | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from unittest.mock import patch |
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Fixed
Show fixed
Hide fixed
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.") |
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit
Possible hardcoded password: 'api.1234567890abcdef' Note test
Check warning
Code scanning / Pylint (reported by Codacy)
Variable name "f" doesn't conform to snake_case naming style
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Variable name "f" doesn't conform to snake_case naming style
Check warning
Code scanning / Pylint (reported by Codacy)
Variable name "st" doesn't conform to snake_case naming style
Check warning
Code scanning / Pylintpython3 (reported by Codacy)
Variable name "st" doesn't conform to snake_case naming style
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
open('.env').read() leaves the file handle unclosed. Use a context manager (with open(...) as f) to avoid resource warnings and keep the pattern consistent with the rest of the test.
| content = open(".env").read() | |
| with open(".env") as f: | |
| content = f.read() |
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,8 @@ | ||||||||||||||||||
| """Tests for the print_plan_details dry-run output function.""" | ||||||||||||||||||
|
|
||||||||||||||||||
| import importlib | ||||||||||||||||||
| import os | ||||||||||||||||||
| import sys | ||||||||||||||||||
| from unittest.mock import patch | ||||||||||||||||||
|
|
||||||||||||||||||
| import main | ||||||||||||||||||
|
|
@@ -42,16 +45,31 @@ | |||||||||||||||||
|
|
||||||||||||||||||
| def test_print_plan_details_with_colors(capsys): | ||||||||||||||||||
| """Test print_plan_details output when colors are enabled.""" | ||||||||||||||||||
| with patch("main.USE_COLORS", True): | ||||||||||||||||||
| plan_entry = { | ||||||||||||||||||
| "profile": "test_profile", | ||||||||||||||||||
| "folders": [{"name": "Folder A", "rules": 10}], | ||||||||||||||||||
| } | ||||||||||||||||||
| main.print_plan_details(plan_entry) | ||||||||||||||||||
| # Force USE_COLORS=True for this test, but also ensure Colors class is populated | ||||||||||||||||||
| # The Colors class is defined at import time based on USE_COLORS. | ||||||||||||||||||
| # If main was imported previously with USE_COLORS=False, Colors attributes are empty strings. | ||||||||||||||||||
| # We must reload main with an environment that forces USE_COLORS=True, or mock Colors. | ||||||||||||||||||
|
|
||||||||||||||||||
| 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 warningCode scanning / Pylint (reported by Codacy) Line too long (106/100)
Line too long (106/100)
Check warningCode scanning / Pylintpython3 (reported by Codacy) Line too long (106/100)
Line too long (106/100)
|
||||||||||||||||||
| # Robust reload: handle case where main module reference is stale | ||||||||||||||||||
|
||||||||||||||||||
| if "main" in sys.modules: | ||||||||||||||||||
|
||||||||||||||||||
| importlib.reload(sys.modules["main"]) | ||||||||||||||||||
|
||||||||||||||||||
| else: | ||||||||||||||||||
| import main | ||||||||||||||||||
Check warningCode scanning / Prospector (reported by Codacy) Reimport 'main' (imported line 8) (reimported)
Reimport 'main' (imported line 8) (reimported)
Check warningCode scanning / Prospector (reported by Codacy) Import outside toplevel (main) (import-outside-toplevel)
Import outside toplevel (main) (import-outside-toplevel)
Check warningCode scanning / Pylintpython3 (reported by Codacy) Import outside toplevel (main)
Import outside toplevel (main)
Check noticeCode scanning / Pylint (reported by Codacy) Redefining name 'main' from outer scope (line 8)
Redefining name 'main' from outer scope (line 8)
Check noticeCode scanning / Pylint (reported by Codacy) Reimport 'main' (imported line 8)
Reimport 'main' (imported line 8)
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Reimport 'main' (imported line 8)
Reimport 'main' (imported line 8)
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Redefining name 'main' from outer scope (line 8)
Redefining name 'main' from outer scope (line 8)
|
||||||||||||||||||
| importlib.reload(main) | ||||||||||||||||||
|
Comment on lines
+55
to
+60
|
||||||||||||||||||
| # 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,67 @@ | ||||||||||||
| import os | ||||||||||||
Check warningCode scanning / Pylint (reported by Codacy) Missing module docstring
Missing module docstring
Check warningCode scanning / Pylintpython3 (reported by Codacy) Missing module docstring
Missing module docstring
|
||||||||||||
| import stat | ||||||||||||
Check warningCode scanning / Prospector (reported by Codacy) Unused import stat (unused-import)
Unused import stat (unused-import)
Check noticeCode scanning / Pylint (reported by Codacy) Unused import stat
Unused import stat
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Unused import stat
Unused import stat
|
||||||||||||
| import pytest | ||||||||||||
Check warningCode scanning / Prospector (reported by Codacy) Unable to import 'pytest' (import-error)
Unable to import 'pytest' (import-error)
|
||||||||||||
| from unittest.mock import MagicMock, patch | ||||||||||||
Check warningCode scanning / Pylint (reported by Codacy) standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
Check warningCode scanning / Prospector (reported by Codacy) Unused MagicMock imported from unittest.mock (unused-import)
Unused MagicMock imported from unittest.mock (unused-import)
Check warningCode scanning / Pylintpython3 (reported by Codacy) standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
standard import "from unittest.mock import MagicMock, patch" should be placed before "import pytest"
Check noticeCode scanning / Pylint (reported by Codacy) Unused MagicMock imported from unittest.mock
Unused MagicMock imported from unittest.mock
Check noticeCode scanning / Pylintpython3 (reported by Codacy) Unused MagicMock imported from unittest.mock
Unused MagicMock imported from unittest.mock
Comment on lines
+2
to
+4
|
||||||||||||
| import stat | |
| import pytest | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from unittest.mock import patch |
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Feb 10, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit (reported by Codacy)
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Fixed
Show fixed
Hide fixed
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Fixed
Show fixed
Hide fixed
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
Check warning
Code scanning / Prospector (reported by Codacy)
Unused variable 'mock_stderr' (unused-variable)
Check notice
Code scanning / Pylint (reported by Codacy)
Unused variable 'mock_stderr'
Check notice
Code scanning / Pylintpython3 (reported by Codacy)
Unused variable 'mock_stderr'
Fixed
Show fixed
Hide fixed
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check notice
Code scanning / Bandit
Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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_NOFOLLOWwhere supported and/orfstat-based validation of a regular file). Please update this write-up to reflect the need forO_NOFOLLOW/fstat(and ideallyos.fchmod) so the documented guidance matches the actual secure pattern.