forked from username/flaskpaste
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
This commit is contained in:
284
tests/security/cli_security_audit.py
Normal file
284
tests/security/cli_security_audit.py
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user