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
72 changes: 53 additions & 19 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,37 @@
API_BASE = "https://api.controld.com/profiles"
USER_AGENT = "Control-D-Sync/0.1.0"

# Pre-compiled regex patterns for hot-path validation (>2x speedup on 10k+ items)
PROFILE_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
RULE_PATTERN = re.compile(r"^[a-zA-Z0-9.\-_:*\/]+$")

# Pre-compiled patterns for log sanitization
_BASIC_AUTH_PATTERN = re.compile(r"://[^/@]+@")
_SENSITIVE_PARAM_PATTERN = re.compile(
r"([?&#])(token|key|secret|password|auth|access_token|api_key)=[^&#\s]*",
flags=re.IGNORECASE,
)


def sanitize_for_log(text: Any) -> str:
"""Sanitize text for logging, ensuring TOKEN is redacted and control chars are escaped."""
"""Sanitize text for logging.

Redacts:
- TOKEN values
- Basic Auth credentials in URLs (e.g. https://user:pass@host)
- Sensitive query parameters (token, key, secret, password, auth, access_token, api_key)
- Control characters (prevents log injection and terminal hijacking)
"""
s = str(text)
if TOKEN and TOKEN in s:
s = s.replace(TOKEN, "[REDACTED]")

# Redact Basic Auth in URLs (e.g. https://user:pass@host)
s = _BASIC_AUTH_PATTERN.sub("://[REDACTED]@", s)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

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

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

Check warning

Code scanning / Pylint (reported by Codacy)

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

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

# Redact sensitive query parameters (handles ?, &, and # separators)
s = _SENSITIVE_PARAM_PATTERN.sub(r"\1\2=[REDACTED]", s)

Check warning

Code scanning / Pylintpython3 (reported by Codacy)

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

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

Check warning

Code scanning / Pylint (reported by Codacy)

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

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

# repr() safely escapes control characters (e.g., \n -> \\n, \x1b -> \\x1b)
# This prevents log injection and terminal hijacking.
safe = repr(s)
Expand All @@ -159,23 +184,32 @@
return safe


def render_progress_bar(
current: int, total: int, label: str, prefix: str = "🚀"
) -> None:
if not USE_COLORS or total == 0:
def print_plan_details(plan_entry: Dict[str, Any]) -> None:
"""Pretty-print the folder-level breakdown during a dry-run."""
profile = sanitize_for_log(plan_entry.get("profile", "unknown"))
folders = plan_entry.get("folders", [])

if USE_COLORS:
print(f"\n{Colors.HEADER}📝 Plan Details for {profile}:{Colors.ENDC}")
else:
print(f"\nPlan Details for {profile}:")

if not folders:
if USE_COLORS:
print(f" {Colors.WARNING}No folders to sync.{Colors.ENDC}")
else:
print(" No folders to sync.")
return

width = 20
progress = min(1.0, current / total)
filled = int(width * progress)
bar = "█" * filled + "░" * (width - filled)
percent = int(progress * 100)
for folder in sorted(folders, key=lambda f: f.get("name", "")):
name = sanitize_for_log(folder.get("name", "Unknown"))
rules_count = folder.get("rules", 0)
if USE_COLORS:
print(f" • {Colors.BOLD}{name}{Colors.ENDC}: {rules_count} rules")
else:
print(f" - {name}: {rules_count} rules")

# Use \033[K to clear line residue
sys.stderr.write(
f"\r\033[K{Colors.CYAN}{prefix} {label}: [{bar}] {percent}% ({current}/{total}){Colors.ENDC}"
)
sys.stderr.flush()
print("")


def countdown_timer(seconds: int, message: str = "Waiting") -> None:
Expand Down Expand Up @@ -398,7 +432,7 @@


def is_valid_profile_id_format(profile_id: str) -> bool:
if not re.match(r"^[a-zA-Z0-9_-]+$", profile_id):
if not PROFILE_ID_PATTERN.match(profile_id):
return False
if len(profile_id) > 64:
return False
Expand All @@ -408,7 +442,7 @@
def validate_profile_id(profile_id: str, log_errors: bool = True) -> bool:
if not is_valid_profile_id_format(profile_id):
if log_errors:
if not re.match(r"^[a-zA-Z0-9_-]+$", profile_id):
if not PROFILE_ID_PATTERN.match(profile_id):
log.error("Invalid profile ID format (contains unsafe characters)")
elif len(profile_id) > 64:
log.error("Invalid profile ID length (max 64 chars)")
Expand All @@ -426,8 +460,7 @@
return False

# Strict whitelist to prevent injection
# ^[a-zA-Z0-9.\-_:*\/]+$
if not re.match(r"^[a-zA-Z0-9.\-_:*\/]+$", rule):
if not RULE_PATTERN.match(rule):
return False

return True
Expand Down Expand Up @@ -1129,6 +1162,7 @@
plan_accumulator.append(plan_entry)

if dry_run:
print_plan_details(plan_entry)
log.info("Dry-run complete: no API calls were made.")
return True

Expand Down
30 changes: 30 additions & 0 deletions tests/test_log_sanitization.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,35 @@ def test_create_folder_logs_unsafe_name(self, mock_get, mock_post, mock_sleep, m
self.assertTrue(found_sanitized, "Should find sanitized name in logs")
self.assertFalse(found_raw, "Should not find raw name in logs")

def test_sanitize_for_log_redacts_basic_auth(self):
"""Test that sanitize_for_log redacts Basic Auth credentials in URLs."""
url = "https://user:password123@example.com/folder.json"
sanitized = main.sanitize_for_log(url)
self.assertNotIn("password123", sanitized)
self.assertIn("[REDACTED]", sanitized)

def test_sanitize_for_log_redacts_query_params(self):
"""Test that sanitize_for_log redacts sensitive query parameters."""
url = "https://example.com/api?secret=mysecretkey"
sanitized = main.sanitize_for_log(url)
self.assertNotIn("mysecretkey", sanitized)
self.assertIn("secret=[REDACTED]", sanitized)

def test_sanitize_for_log_redacts_multiple_params(self):
"""Test redaction of multiple sensitive params while preserving safe ones."""
url = "https://example.com/api?id=123&token=abc&name=user&api_key=def"
sanitized = main.sanitize_for_log(url)
self.assertIn("id=123", sanitized)
self.assertIn("name=user", sanitized)
self.assertIn("token=[REDACTED]", sanitized)
self.assertIn("api_key=[REDACTED]", sanitized)

def test_sanitize_for_log_case_insensitive(self):
"""Test that query param redaction is case-insensitive."""
url = "https://example.com/api?TOKEN=mytoken"
sanitized = main.sanitize_for_log(url)
self.assertNotIn("mytoken", sanitized)
self.assertIn("[REDACTED]", sanitized)

if __name__ == '__main__':
unittest.main()
57 changes: 57 additions & 0 deletions tests/test_plan_details.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for the print_plan_details dry-run output function."""

from unittest.mock import patch

import main


def test_print_plan_details_no_colors(capsys):
"""Test print_plan_details output when colors are disabled."""
with patch("main.USE_COLORS", False):
plan_entry = {
"profile": "test_profile",
"folders": [
{"name": "Folder B", "rules": 5},
{"name": "Folder A", "rules": 10},
],
}
main.print_plan_details(plan_entry)

captured = capsys.readouterr()
output = captured.out

assert "Plan Details for test_profile:" in output

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 " - Folder A: 10 rules" in output

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 " - Folder B: 5 rules" in output

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 alphabetical ordering (A before B)
assert output.index("Folder A") < output.index("Folder B")

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.


def test_print_plan_details_empty_folders(capsys):
"""Test print_plan_details with no folders."""
with patch("main.USE_COLORS", False):
plan_entry = {"profile": "test_profile", "folders": []}
main.print_plan_details(plan_entry)

captured = capsys.readouterr()
output = captured.out

assert "Plan Details for test_profile:" in output

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 "No folders to sync." in output

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.


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)

captured = capsys.readouterr()
output = captured.out

assert "📝 Plan Details for test_profile:" in output

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 "Folder A" in output

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 "10 rules" in output

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.
Loading