Files
flaskpaste/tests/security/cli_security_audit.py
Username bd75f81afd
Some checks failed
CI / Lint & Format (push) Failing after 15s
CI / Unit Tests (push) Has been skipped
CI / Memory Leak Check (push) Has been skipped
CI / SBOM Generation (push) Has been skipped
CI / Security Scan (push) Successful in 19s
CI / Security Tests (push) Has been skipped
add security testing suite and update docs
- tests/security/pentest_session.py: comprehensive 10-phase pentest
- tests/security/profiled_server.py: cProfile-enabled server
- tests/security/cli_security_audit.py: CLI security checks
- tests/security/dos_memory_test.py: memory exhaustion tests
- tests/security/race_condition_test.py: concurrency tests
- docs: add pentest results, profiling analysis, new test commands
2025-12-26 00:39:33 +01:00

285 lines
9.0 KiB
Python

#!/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("/home/user/git/flaskpaste/fpaste")
fpaste_globals = {"__name__": "fpaste", "__file__": str(fpaste_path)}
exec(compile(fpaste_path.read_text(), fpaste_path, "exec"), fpaste_globals)
# 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"),
("/home/user/bin/xclip", False, "user bin"),
("./xclip", False, "current directory"),
("/var/tmp/malicious", False, "var tmp"),
("/home/attacker/.local/bin/xclip", False, "user local"),
]
for path, expected, desc in trusted_tests + untrusted_tests:
result = is_trusted_clipboard_path(path)
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")
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}"
# Try to find clipboard command
cmd = find_clipboard_command(CLIPBOARD_READ_COMMANDS)
# 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":
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("/home/user/git/flaskpaste/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:
if not call.strip().startswith("[") and not call.strip().startswith("cmd"):
if "cmd" not in call: # Allow variable names like 'cmd'
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)
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)
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("/home/user/git/flaskpaste/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("/home/user/git/flaskpaste/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())