Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Sentinel's Journal

## 2025-02-07 - Secure File Creation Pattern
**Vulnerability:** Default `open()` creates files with user umask, which might allow group/world read access for sensitive files like `plan.json`.
**Learning:** Python's `open()` mode `w` respects `umask` but doesn't enforce restrictive permissions by default. `os.chmod` after creation leaves a small race condition window.
**Prevention:** Use `os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)` to create the file descriptor with correct permissions atomically, then wrap with `os.fdopen()`.
17 changes: 14 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,12 @@
if not hostname:
return False

if parsed.username or parsed.password:
log.warning(

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
f"Skipping unsafe URL (credentials detected): {sanitize_for_log(url)}"
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning message includes sanitize_for_log(url), but sanitize_for_log only redacts TOKEN and escapes control chars; it does not remove URL credentials. For a URL like https://user:pass@host/..., this will leak user:pass into logs. Avoid logging the raw URL here (log only hostname) or strip credentials from the URL before calling sanitize_for_log (or enhance sanitize_for_log to scrub userinfo@).

Suggested change
f"Skipping unsafe URL (credentials detected): {sanitize_for_log(url)}"
f"Skipping unsafe URL (credentials detected for host {hostname})"

Copilot uses AI. Check for mistakes.
)
return False

# Check for potentially malicious hostnames
if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
log.warning(
Expand Down Expand Up @@ -514,7 +520,7 @@
response = request_func()
response.raise_for_status()
return response
except (httpx.HTTPError, httpx.TimeoutException) as e:

Check warning

Code scanning / Prospector (reported by Codacy)

Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning

Use lazy % formatting in logging functions (logging-fstring-interpolation)
if attempt == max_retries - 1:
if hasattr(e, "response") and e.response is not None:
log.debug(f"Response content: {sanitize_for_log(e.response.text)}")
Expand Down Expand Up @@ -1370,9 +1376,14 @@
)

if args.plan_json:
with open(args.plan_json, "w", encoding="utf-8") as f:
json.dump(plan, f, indent=2)
log.info("Plan written to %s", args.plan_json)
try:
# Securely create file with 600 permissions
fd = os.open(args.plan_json, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "fd" doesn't conform to snake_case naming style Warning

Variable name "fd" doesn't conform to snake_case naming style

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "fd" doesn't conform to snake_case naming style Warning

Variable name "fd" doesn't conform to snake_case naming style
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating the file with mode 0o600 only guarantees restrictive permissions when the file is newly created; if args.plan_json already exists with broader permissions, os.open(...|O_TRUNC, 0o600) will not tighten them. Consider applying os.fchmod(fd, 0o600) immediately after opening, or writing to a secure temp file (0600) and os.replace it into place to ensure the final file is always private.

Suggested change
fd = os.open(args.plan_json, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
fd = os.open(args.plan_json, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
# Ensure restrictive permissions even if file already existed
os.fchmod(fd, 0o600)
except OSError:
# If we cannot adjust permissions, proceed as before
pass

Copilot uses AI. Check for mistakes.
with os.fdopen(fd, "w", encoding="utf-8") as f:

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "f" doesn't conform to snake_case naming style Warning

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 Warning

Variable name "f" doesn't conform to snake_case naming style
json.dump(plan, f, indent=2)
log.info("Plan written to %s", args.plan_json)
except OSError as e:

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "e" doesn't conform to snake_case naming style Warning

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 Warning

Variable name "e" doesn't conform to snake_case naming style
log.error(f"Failed to write plan to {args.plan_json}: {e}")

Check warning

Code scanning / Prospector (reported by Codacy)

Use lazy % formatting in logging functions (logging-fstring-interpolation) Warning

Use lazy % formatting in logging functions (logging-fstring-interpolation)

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Use lazy % formatting in logging functions Note

Use lazy % formatting in logging functions
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This except OSError logs the failure to write --plan-json but then continues; the process can still exit 0 even though the user explicitly requested an output file. Consider failing the run (e.g., exit(1)/re-raise) when --plan-json is provided but cannot be written.

Suggested change
log.error(f"Failed to write plan to {args.plan_json}: {e}")
log.error(f"Failed to write plan to {args.plan_json}: {e}")
sys.exit(1)

Copilot uses AI. Check for mistakes.
Comment on lines +1384 to +1386
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error log uses an f-string with the raw args.plan_json path. Since this codebase uses sanitize_for_log to escape control characters and prevent log injection, consider logging sanitize_for_log(args.plan_json) (and sanitize_for_log(e)) and/or using logger formatting (log.error("... %s", ...)) to avoid embedding untrusted strings directly.

Suggested change
log.info("Plan written to %s", args.plan_json)
except OSError as e:
log.error(f"Failed to write plan to {args.plan_json}: {e}")
log.info("Plan written to %s", sanitize_for_log(args.plan_json))
except OSError as e:
log.error(
"Failed to write plan to %s: %s",
sanitize_for_log(args.plan_json),
sanitize_for_log(e),
)

Copilot uses AI. Check for mistakes.

# Print Summary Table
# Determine the width for the Profile ID column (min 25)
Expand Down
78 changes: 78 additions & 0 deletions test_main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import importlib
import os
import stat
import sys
from unittest.mock import MagicMock, call, patch

Expand Down Expand Up @@ -74,7 +75,7 @@


# Case 3: push_rules updates data dictionary with pre-calculated batch keys correctly
def test_push_rules_updates_data_with_batch_keys(monkeypatch):

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "m" doesn't conform to snake_case naming style Warning test

Variable name "m" doesn't conform to snake_case naming style
m = reload_main_with_env(monkeypatch)
mock_client = MagicMock()
mock_post_form = MagicMock()
Expand Down Expand Up @@ -257,7 +258,7 @@
assert m.check_api_access(mock_client, "valid_profile") is True


def test_check_api_access_401(monkeypatch):

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "m" doesn't conform to snake_case naming style Warning test

Variable name "m" doesn't conform to snake_case naming style
m = reload_main_with_env(monkeypatch)
mock_client = MagicMock()

Expand Down Expand Up @@ -320,7 +321,7 @@
assert "Profile Not Found" in str(mock_log.critical.call_args_list)


def test_check_api_access_generic_http_error(monkeypatch):

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "m" doesn't conform to snake_case naming style Warning test

Variable name "m" doesn't conform to snake_case naming style
m = reload_main_with_env(monkeypatch)
mock_client = MagicMock()

Expand Down Expand Up @@ -510,3 +511,80 @@
# Color codes (accessing instance Colors or m.Colors)
assert m.Colors.CYAN in combined
assert m.Colors.ENDC in combined


# Case 14: validate_folder_url rejects URLs with credentials
def test_validate_folder_url_rejects_credentials(monkeypatch):

Check warning

Code scanning / Pylint (reported by Codacy)

Missing function docstring Warning test

Missing function docstring

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing function or method docstring Warning test

Missing function or method docstring
m = reload_main_with_env(monkeypatch)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Variable name "m" doesn't conform to snake_case naming style Warning test

Variable name "m" doesn't conform to snake_case naming style
mock_log = MagicMock()
monkeypatch.setattr(m, "log", mock_log)

# Use a mock for socket.getaddrinfo so we don't hit network or fail DNS
# But validate_folder_url logic for credentials happens BEFORE DNS.
# So we don't strictly need to mock DNS if it returns early.
# However, if it falls through (fails logic), it hits DNS.
# To be safe and isolated, mock getaddrinfo.
with patch("socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(2, 1, 6, "", ("1.1.1.1", 443))]
# URL with credentials
url = "https://user:pass@example.com/list.json"
assert m.validate_folder_url(url) is False

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert mock_log.warning.called

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
# Check that warning message contains "credentials detected"
# call_args is (args, kwargs)
args, _ = mock_log.warning.call_args
assert "credentials detected" in args[0]

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts that a warning was logged, but it doesn't verify that credentials were not included in the logged message. Since the new behavior is security-motivated, add an assertion that the warning text does not contain user:pass (or the substring before @) to prevent regressions that leak credentials via logs.

Suggested change
assert "credentials detected" in args[0]
assert "credentials detected" in args[0]
# Ensure that raw credentials are not leaked in the warning message
assert "user:pass" not in args[0]

Copilot uses AI. Check for mistakes.


# Case 15: plan.json is created with secure permissions
def test_plan_json_secure_permissions(monkeypatch, tmp_path, capsys):

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'capsys' (unused-argument) Warning test

Unused argument 'capsys' (unused-argument)

Check warning

Code scanning / Pylint (reported by Codacy)

Missing function docstring Warning test

Missing function docstring

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'capsys' Note test

Unused argument 'capsys'

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

Missing function or method docstring Warning test

Missing function or method docstring

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'capsys' Note test

Unused argument 'capsys'
# Prepare environment
monkeypatch.setenv("TOKEN", "dummy")
m = reload_main_with_env(monkeypatch)

# Mock args
plan_file = tmp_path / "plan.json"
mock_args = MagicMock()
mock_args.plan_json = str(plan_file)
mock_args.dry_run = True
mock_args.profiles = "p1"
# Empty folder_url to avoid fetch logic
mock_args.folder_url = []
mock_args.no_delete = False

monkeypatch.setattr(m, "parse_args", lambda: mock_args)
m.TOKEN = "dummy"

Check notice

Code scanning / Bandit

Possible hardcoded password: 'dummy' Note test

Possible hardcoded password: 'dummy'

# Mock warm_up_cache to avoid actual network
monkeypatch.setattr(m, "warm_up_cache", MagicMock())

# Mock sync_profile so it returns True and populates plan
def mock_sync(pid, urls, dry_run, no_delete, plan_accumulator=None):

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'no_delete' (unused-argument) Warning test

Unused argument 'no_delete' (unused-argument)

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'urls' (unused-argument) Warning test

Unused argument 'urls' (unused-argument)

Check warning

Code scanning / Prospector (reported by Codacy)

Unused argument 'dry_run' (unused-argument) Warning test

Unused argument 'dry_run' (unused-argument)

Check warning

Code scanning / Pylint (reported by Codacy)

Missing function docstring Warning test

Missing function docstring

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'urls' Note test

Unused argument 'urls'

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'dry_run' Note test

Unused argument 'dry_run'

Check notice

Code scanning / Pylint (reported by Codacy)

Unused argument 'no_delete' Note test

Unused argument 'no_delete'

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'no_delete' Note test

Unused argument 'no_delete'

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'urls' Note test

Unused argument 'urls'

Check notice

Code scanning / Pylintpython3 (reported by Codacy)

Unused argument 'dry_run' Note test

Unused argument 'dry_run'
if plan_accumulator is not None:
# Add a dummy plan entry
plan_accumulator.append({
"profile": pid,
"folders": [{"name": "F1", "rules": 10, "action": 0, "status": 1}]
})
return True

monkeypatch.setattr(m, "sync_profile", mock_sync)

# Run main, expect SystemExit(0) on success
with pytest.raises(SystemExit) as e:

Check warning

Code scanning / Pylint (reported by Codacy)

Variable name "e" doesn't conform to snake_case naming style Warning test

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 Warning test

Variable name "e" doesn't conform to snake_case naming style
m.main()
assert e.value.code == 0

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Verify file exists
assert plan_file.exists()

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

# Verify permissions on POSIX
if os.name != "nt":
mode = os.stat(plan_file).st_mode
# Check permissions are 600 (rw-------)
# S_IRWXG = 0o070 (group rwx)
# S_IRWXO = 0o007 (other rwx)
assert not (mode & stat.S_IRWXG), "Group should have no permissions"

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert not (mode & stat.S_IRWXO), "Others should have no permissions"

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert mode & stat.S_IRUSR, "Owner should have read"

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
assert mode & stat.S_IWUSR, "Owner should have write"

Check notice

Code scanning / Bandit

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.

Check notice

Code scanning / Bandit (reported by Codacy)

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code. Note test

Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
Comment on lines +583 to +590

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current permission check is good, but it could be more robust. It verifies that owner-read and owner-write permissions are set, and group/other permissions are not, but it doesn't ensure that other permission bits (like owner-execute) are unset. A more precise approach is to check that the file mode is exactly 0o600.

Suggested change
mode = os.stat(plan_file).st_mode
# Check permissions are 600 (rw-------)
# S_IRWXG = 0o070 (group rwx)
# S_IRWXO = 0o007 (other rwx)
assert not (mode & stat.S_IRWXG), "Group should have no permissions"
assert not (mode & stat.S_IRWXO), "Others should have no permissions"
assert mode & stat.S_IRUSR, "Owner should have read"
assert mode & stat.S_IWUSR, "Owner should have write"
permissions = stat.S_IMODE(os.stat(plan_file).st_mode)
assert permissions == 0o600, f"Expected file permissions to be 0o600, but got {oct(permissions)}"

Loading