#!/usr/bin/env python3 """CLI security audit for fpaste.""" import os import re import sys import tempfile from pathlib import Path # Load fpaste as a module by exec fpaste_path = Path(__file__).parent.parent.parent / "fpaste" fpaste_globals = {"__name__": "fpaste", "__file__": str(fpaste_path)} exec(compile(fpaste_path.read_text(), fpaste_path, "exec"), fpaste_globals) # noqa: S102 # Import from loaded module TRUSTED_CLIPBOARD_DIRS = fpaste_globals["TRUSTED_CLIPBOARD_DIRS"] TRUSTED_WINDOWS_PATTERNS = fpaste_globals["TRUSTED_WINDOWS_PATTERNS"] check_config_permissions = fpaste_globals["check_config_permissions"] find_clipboard_command = fpaste_globals["find_clipboard_command"] is_trusted_clipboard_path = fpaste_globals["is_trusted_clipboard_path"] read_config_file = fpaste_globals["read_config_file"] CLIPBOARD_READ_COMMANDS = fpaste_globals["CLIPBOARD_READ_COMMANDS"] def test_trusted_path_validation(): """Test CLI-001: Trusted clipboard path validation.""" print("\n[1] Trusted Path Validation (CLI-001)") print("=" * 50) results = [] # Test trusted paths trusted_tests = [ ("/usr/bin/xclip", True, "system bin"), ("/usr/local/bin/pbpaste", True, "local bin"), ("/bin/cat", True, "root bin"), ("/opt/homebrew/bin/pbcopy", True, "homebrew"), ] # Test untrusted paths untrusted_tests = [ ("/tmp/xclip", False, "tmp directory"), # noqa: S108 ("/home/user/bin/xclip", False, "user bin"), ("./xclip", False, "current directory"), ("/var/tmp/malicious", False, "var tmp"), # noqa: S108 ("/home/attacker/.local/bin/xclip", False, "user local"), ] for path, expected, desc in trusted_tests + untrusted_tests: result = is_trusted_clipboard_path(path) # type: ignore[operator] status = "PASS" if result == expected else "FAIL" results.append((status, desc, path, expected, result)) print(f" {status}: {desc}") print(f" Path: {path}") print(f" Expected: {expected}, Got: {result}") failed = sum(1 for r in results if r[0] == "FAIL") return failed == 0 def test_path_injection(): """Test PATH manipulation attack prevention.""" print("\n[2] PATH Injection Prevention") print("=" * 50) # Create a malicious "xclip" in /tmp malicious_path = Path("/tmp/xclip") # noqa: S108 try: malicious_path.write_text("#!/bin/sh\necho 'PWNED' > /tmp/pwned\n") malicious_path.chmod(0o755) # Save original PATH original_path = os.environ.get("PATH", "") # Prepend /tmp to PATH (attacker-controlled) os.environ["PATH"] = f"/tmp:{original_path}" # noqa: S108 # Try to find clipboard command cmd = find_clipboard_command(CLIPBOARD_READ_COMMANDS) # type: ignore[operator] # Restore PATH os.environ["PATH"] = original_path if cmd is None: print(" PASS: No clipboard command found (expected on headless)") return True # Check if it's using the malicious path if cmd[0] == str(malicious_path) or cmd[0] == "/tmp/xclip": # noqa: S108 print(" FAIL: Malicious /tmp/xclip was selected!") print(f" Command: {cmd}") return False print(f" PASS: Selected trusted path: {cmd[0]}") return True finally: if malicious_path.exists(): malicious_path.unlink() def test_subprocess_safety(): """Test that subprocess calls don't use shell=True.""" print("\n[3] Subprocess Safety (No Shell Injection)") print("=" * 50) # Read fpaste source and check for dangerous patterns fpaste_src = Path(__file__).parent.parent.parent / "fpaste" content = fpaste_src.read_text() issues = [] # Check for shell=True if "shell=True" in content: issues.append("Found 'shell=True' in subprocess calls") # Check for os.system if "os.system(" in content: issues.append("Found 'os.system()' call") # Check for os.popen if "os.popen(" in content: issues.append("Found 'os.popen()' call") # Check subprocess.run uses list run_calls = re.findall(r"subprocess\.run\(([^)]+)\)", content) for call in run_calls: stripped = call.strip() if not stripped.startswith("[") and not stripped.startswith("cmd") and "cmd" not in call: issues.append(f"Possible string command in subprocess.run: {call[:50]}") if issues: for issue in issues: print(f" FAIL: {issue}") return False print(" PASS: All subprocess calls use safe list format") print(" PASS: No shell=True found") print(" PASS: No os.system/os.popen found") return True def test_config_permissions(): """Test CLI-003: Config file permission warnings.""" print("\n[4] Config Permission Checks (CLI-003)") print("=" * 50) import io from contextlib import redirect_stderr with tempfile.TemporaryDirectory() as tmpdir: config_path = Path(tmpdir) / "config" # Test world-readable config config_path.write_text("server = http://example.com\ncert_sha1 = abc123\n") config_path.chmod(0o644) # World-readable # Capture stderr stderr_capture = io.StringIO() with redirect_stderr(stderr_capture): check_config_permissions(config_path) # type: ignore[operator] warning = stderr_capture.getvalue() if "world-readable" in warning: print(" PASS: Warning issued for world-readable config") else: print(" FAIL: No warning for world-readable config") return False # Test secure config config_path.chmod(0o600) stderr_capture = io.StringIO() with redirect_stderr(stderr_capture): check_config_permissions(config_path) # type: ignore[operator] warning = stderr_capture.getvalue() if not warning: print(" PASS: No warning for secure config (0o600)") else: print(f" WARN: Unexpected warning: {warning}") return True def test_key_file_permissions(): """Test that generated key files have secure permissions.""" print("\n[5] Key File Permissions") print("=" * 50) # Check the source code for chmod calls fpaste_src = Path(__file__).parent.parent.parent / "fpaste" content = fpaste_src.read_text() # Find all chmod(0o600) calls for key files chmod_calls = re.findall(r"(\w+_file)\.chmod\(0o(\d+)\)", content) key_files_with_0600 = [] other_files = [] for var_name, mode in chmod_calls: if mode == "600": key_files_with_0600.append(var_name) else: other_files.append((var_name, mode)) print(f" Files with 0o600: {key_files_with_0600}") if "key_file" in key_files_with_0600: print(" PASS: Private key files use 0o600") else: print(" FAIL: Private key files may have insecure permissions") return False if "p12_file" in key_files_with_0600: print(" PASS: PKCS#12 files use 0o600") else: print(" WARN: PKCS#12 files may not have explicit permissions") # Check for atomic write (write then chmod vs mkdir+chmod+write) # This is a minor race condition but worth noting print(" NOTE: File creation followed by chmod has minor race condition") print(" Consider using os.open() with mode for atomic creation") return True def test_symlink_attacks(): """Test for symlink attack vulnerabilities in file writes.""" print("\n[6] Symlink Attack Prevention") print("=" * 50) # Check if Path.write_bytes/write_text follow symlinks # This is a potential TOCTOU issue print(" NOTE: Path.write_bytes() follows symlinks by default") print(" NOTE: Attacker could symlink key_file to /etc/passwd") print(" RECOMMENDATION: Check for symlinks before write, or use O_NOFOLLOW") # Check if the code verifies paths before writing fpaste_src = Path(__file__).parent.parent.parent / "fpaste" content = fpaste_src.read_text() if "is_symlink()" in content or "O_NOFOLLOW" in content: print(" PASS: Code checks for symlinks") return True print(" WARN: No symlink checks found (low risk - user controls output dir)") return True # Low severity for CLI tool def main(): print("=" * 60) print("CLI SECURITY AUDIT - fpaste") print("=" * 60) results = [] results.append(("Trusted Path Validation", test_trusted_path_validation())) results.append(("PATH Injection Prevention", test_path_injection())) results.append(("Subprocess Safety", test_subprocess_safety())) results.append(("Config Permissions", test_config_permissions())) results.append(("Key File Permissions", test_key_file_permissions())) results.append(("Symlink Attacks", test_symlink_attacks())) print("\n" + "=" * 60) print("SUMMARY") print("=" * 60) passed = sum(1 for _, r in results if r) total = len(results) for name, result in results: status = "PASS" if result else "FAIL" print(f" {status}: {name}") print(f"\n{passed}/{total} checks passed") return 0 if passed == total else 1 if __name__ == "__main__": sys.exit(main())