Skip to content

Commit 774651f

Browse files
🛡️ Sentinel: [SECURITY] Enforce secure URL validation and file permissions
Co-authored-by: abhimehro <[email protected]>
1 parent 74918c3 commit 774651f

File tree

3 files changed

+98
-3
lines changed

3 files changed

+98
-3
lines changed

‎.jules/sentinel.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Sentinel's Journal
2+
3+
## 2025-02-07 - Secure File Creation Pattern
4+
**Vulnerability:** Default `open()` creates files with user umask, which might allow group/world read access for sensitive files like `plan.json`.
5+
**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.
6+
**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()`.

‎main.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,12 @@ def validate_folder_url(url: str) -> bool:
340340
if not hostname:
341341
return False
342342

343+
if parsed.username or parsed.password:
344+
log.warning(
345+
f"Skipping unsafe URL (credentials detected): {sanitize_for_log(url)}"
346+
)
347+
return False
348+
343349
# Check for potentially malicious hostnames
344350
if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
345351
log.warning(
@@ -1370,9 +1376,14 @@ def validate_profile_input(value: str) -> bool:
13701376
)
13711377

13721378
if args.plan_json:
1373-
with open(args.plan_json, "w", encoding="utf-8") as f:
1374-
json.dump(plan, f, indent=2)
1375-
log.info("Plan written to %s", args.plan_json)
1379+
try:
1380+
# Securely create file with 600 permissions
1381+
fd = os.open(args.plan_json, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
1382+
with os.fdopen(fd, "w", encoding="utf-8") as f:
1383+
json.dump(plan, f, indent=2)
1384+
log.info("Plan written to %s", args.plan_json)
1385+
except OSError as e:
1386+
log.error(f"Failed to write plan to {args.plan_json}: {e}")
13761387

13771388
# Print Summary Table
13781389
# Determine the width for the Profile ID column (min 25)

‎test_main.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import importlib
22
import os
3+
import stat
34
import sys
45
from unittest.mock import MagicMock, call, patch
56

@@ -510,3 +511,80 @@ def test_render_progress_bar(monkeypatch):
510511
# Color codes (accessing instance Colors or m.Colors)
511512
assert m.Colors.CYAN in combined
512513
assert m.Colors.ENDC in combined
514+
515+
516+
# Case 14: validate_folder_url rejects URLs with credentials
517+
def test_validate_folder_url_rejects_credentials(monkeypatch):
518+
m = reload_main_with_env(monkeypatch)
519+
mock_log = MagicMock()
520+
monkeypatch.setattr(m, "log", mock_log)
521+
522+
# Use a mock for socket.getaddrinfo so we don't hit network or fail DNS
523+
# But validate_folder_url logic for credentials happens BEFORE DNS.
524+
# So we don't strictly need to mock DNS if it returns early.
525+
# However, if it falls through (fails logic), it hits DNS.
526+
# To be safe and isolated, mock getaddrinfo.
527+
with patch("socket.getaddrinfo") as mock_dns:
528+
mock_dns.return_value = [(2, 1, 6, "", ("1.1.1.1", 443))]
529+
# URL with credentials
530+
url = "https://user:[email protected]/list.json"
531+
assert m.validate_folder_url(url) is False
532+
assert mock_log.warning.called
533+
# Check that warning message contains "credentials detected"
534+
# call_args is (args, kwargs)
535+
args, _ = mock_log.warning.call_args
536+
assert "credentials detected" in args[0]
537+
538+
539+
# Case 15: plan.json is created with secure permissions
540+
def test_plan_json_secure_permissions(monkeypatch, tmp_path, capsys):
541+
# Prepare environment
542+
monkeypatch.setenv("TOKEN", "dummy")
543+
m = reload_main_with_env(monkeypatch)
544+
545+
# Mock args
546+
plan_file = tmp_path / "plan.json"
547+
mock_args = MagicMock()
548+
mock_args.plan_json = str(plan_file)
549+
mock_args.dry_run = True
550+
mock_args.profiles = "p1"
551+
# Empty folder_url to avoid fetch logic
552+
mock_args.folder_url = []
553+
mock_args.no_delete = False
554+
555+
monkeypatch.setattr(m, "parse_args", lambda: mock_args)
556+
m.TOKEN = "dummy"
557+
558+
# Mock warm_up_cache to avoid actual network
559+
monkeypatch.setattr(m, "warm_up_cache", MagicMock())
560+
561+
# Mock sync_profile so it returns True and populates plan
562+
def mock_sync(pid, urls, dry_run, no_delete, plan_accumulator=None):
563+
if plan_accumulator is not None:
564+
# Add a dummy plan entry
565+
plan_accumulator.append({
566+
"profile": pid,
567+
"folders": [{"name": "F1", "rules": 10, "action": 0, "status": 1}]
568+
})
569+
return True
570+
571+
monkeypatch.setattr(m, "sync_profile", mock_sync)
572+
573+
# Run main, expect SystemExit(0) on success
574+
with pytest.raises(SystemExit) as e:
575+
m.main()
576+
assert e.value.code == 0
577+
578+
# Verify file exists
579+
assert plan_file.exists()
580+
581+
# Verify permissions on POSIX
582+
if os.name != "nt":
583+
mode = os.stat(plan_file).st_mode
584+
# Check permissions are 600 (rw-------)
585+
# S_IRWXG = 0o070 (group rwx)
586+
# S_IRWXO = 0o007 (other rwx)
587+
assert not (mode & stat.S_IRWXG), "Group should have no permissions"
588+
assert not (mode & stat.S_IRWXO), "Others should have no permissions"
589+
assert mode & stat.S_IRUSR, "Owner should have read"
590+
assert mode & stat.S_IWUSR, "Owner should have write"

0 commit comments

Comments
 (0)