"""Security validation script for the Team Tryouts application. This script performs pre-deployment security checks to validate: - HTTP security headers - Cookie security attributes - Debug mode status - HTTPS configuration - Dependency vulnerabilities - Database connectivity Usage: python security_scan.py [--url http://localhost:5000] """ import os import sys import json import subprocess import urllib.request import ssl from datetime import datetime def check_environment(): """Check required environment variables are set. Returns: bool: True if all critical variables are set. """ print('=' * 60) print('1. ENVIRONMENT VARIABLES CHECK') print('=' * 60) critical_vars = ['SECRET_KEY'] recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS'] all_ok = True for var in critical_vars: value = os.getenv(var) if value: # Check SECRET_KEY is not a default/weak value if var == 'SECRET_KEY' and len(value) < 32: print(f'[WARN] {var} is set but too short (less than 32 chars)') all_ok = False else: print(f'[OK] {var} is set') else: print(f'[FAIL] {var} is not set!') all_ok = False for var in recommended_vars: value = os.getenv(var) if value: print(f'[OK] {var} is set') else: print(f'[INFO] {var} is not set (using default)') # Check FLASK_DEBUG debug = os.getenv('FLASK_DEBUG', 'false').lower() if debug == 'true': print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.') else: print('[OK] FLASK_DEBUG is disabled') return all_ok def check_https_headers(url): """Check HTTP security headers from a running application. Args: url: The base URL of the application to check. Returns: bool: True if all critical headers are present. """ print('\n' + '=' * 60) print('2. HTTP SECURITY HEADERS CHECK') print('=' * 60) required_headers = { 'Strict-Transport-Security': 'HSTS enabled', 'X-Content-Type-Options': 'Prevents MIME sniffing', 'X-Frame-Options': 'Prevents clickjacking', 'Content-Security-Policy': 'CSP configured', 'Referrer-Policy': 'Referrer control', 'Permissions-Policy': 'Permissions control', 'Cross-Origin-Opener-Policy': 'Cross-origin isolation', } all_ok = True try: # Create a context that doesn't verify SSL (for local testing) ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE req = urllib.request.Request(url, method='HEAD') try: with urllib.request.urlopen(req, context=ctx, timeout=10) as response: headers = response.headers status = response.status print(f'[INFO] Response status: {status}') for header, description in required_headers.items(): if header in headers: print(f'[OK] {header}: {description}') else: print(f'[FAIL] {header} is missing: {description}') all_ok = False # Check cookie attributes if any set-cookie headers exist if 'Set-Cookie' in headers: cookie = headers['Set-Cookie'] if 'Secure' in cookie: print('[OK] Cookies have Secure flag') else: print('[WARN] Cookies missing Secure flag') all_ok = False if 'HttpOnly' in cookie: print('[OK] Cookies have HttpOnly flag') else: print('[WARN] Cookies missing HttpOnly flag') all_ok = False if 'SameSite' in cookie: print(f'[OK] Cookies have SameSite={cookie.split("SameSite=")[1].split(";")[0] if "SameSite=" in cookie else "?"}') else: print('[WARN] Cookies missing SameSite attribute') all_ok = False else: print('[INFO] No Set-Cookie headers in response') except urllib.error.HTTPError as e: print(f'[INFO] Got HTTP {e.code} (may need authentication)') # Still check headers even on error responses for header, description in required_headers.items(): if header in e.headers: print(f'[OK] {header}: {description}') else: print(f'[FAIL] {header} is missing: {description}') all_ok = False except urllib.error.URLError as e: print(f'[SKIP] Cannot connect to {url}: {e.reason}') print('[SKIP] Run with --url to check headers') return True # Not a failure, just can't check return all_ok def check_dependencies(): """Run pip-audit to check for known vulnerabilities. Returns: bool: True if no critical vulnerabilities found. """ print('\n' + '=' * 60) print('3. DEPENDENCY VULNERABILITY SCAN') print('=' * 60) try: result = subprocess.run( [sys.executable, '-m', 'pip_audit', '--format', 'json'], capture_output=True, text=True, timeout=60 ) if result.returncode == 0: print('[OK] No known vulnerabilities found') return True else: try: data = json.loads(result.stdout) # pip-audit's "dependencies" array lists EVERY dependency, each # carrying a "vulns" list that is empty when the package is # clean. Treating the array itself as the vulnerability list # reported all ~45 installed packages as vulnerable on every # run, which is why this check was pure noise. affected = [ dep for dep in data.get('dependencies', []) if dep.get('vulns') ] if affected: for dep in affected: ids = ', '.join( v.get('id', '?') for v in dep.get('vulns', []) ) print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}') return False else: print('[OK] No vulnerabilities found') return True except json.JSONDecodeError: if result.stdout: print(f'[INFO] {result.stdout.strip()}') if result.stderr: print(f'[WARN] {result.stderr.strip()}') return True except FileNotFoundError: print('[SKIP] pip-audit not installed. Run: pip install pip-audit') return True except subprocess.TimeoutExpired: print('[WARN] pip-audit timed out') return True def check_file_permissions(): """Check for common security issues in the project structure. Returns: bool: True if no critical issues found. """ print('\n' + '=' * 60) print('4. PROJECT FILES CHECK') print('=' * 60) all_ok = True # Check .gitignore exists and contains important patterns gitignore_path = os.path.join(os.getcwd(), '.gitignore') if os.path.exists(gitignore_path): required_patterns = ['.env', 'instance/', '*.db', '*.log'] with open(gitignore_path, 'r') as f: content = f.read() for pattern in required_patterns: if pattern in content: print(f'[OK] .gitignore contains: {pattern}') else: print(f'[WARN] .gitignore missing: {pattern}') all_ok = False else: print('[FAIL] .gitignore file not found!') all_ok = False # Check for .env in working directory (should NOT be committed) env_path = os.path.join(os.getcwd(), '.env') if os.path.exists(env_path): print('[INFO] .env file exists (ensure it is NOT committed)') else: print('[WARN] No .env file found') # Check for leftover .pyc or __pycache__ pycache_count = 0 for root, dirs, files in os.walk(os.getcwd()): if '__pycache__' in dirs: pycache_count += 1 for f in files: if f.endswith('.pyc'): pycache_count += 1 if pycache_count == 0: print('[OK] No __pycache__ or .pyc files found') else: print(f'[INFO] Found {pycache_count} cache files/dirs (should be in .gitignore)') return all_ok def check_flask_config(): """Check Flask application configuration for security. Returns: bool: True if configuration looks secure. """ print('\n' + '=' * 60) print('5. FLASK CONFIGURATION CHECK') print('=' * 60) all_ok = True try: # The script lives two levels below the project root; without this the # import fails and the whole check was silently skipped. root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) if root not in sys.path: sys.path.insert(0, root) from app.app import create_app # Inspect configuration only: no schema creation, no Discord bot. app = create_app({ 'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:', 'AUTO_CREATE_TABLES': False, 'ENABLE_DISCORD_BOT': False, }) # Check session cookie settings cookie_checks = [ ('SESSION_COOKIE_SECURE', True, 'Secure cookies'), ('SESSION_COOKIE_HTTPONLY', True, 'HttpOnly cookies'), ('PERMANENT_SESSION_LIFETIME', 3600, 'Session timeout'), ] for config_key, expected, description in cookie_checks: value = app.config.get(config_key) if config_key == 'PERMANENT_SESSION_LIFETIME': if value and value <= 3600: print(f'[OK] {description}: {value}s') else: print(f'[WARN] {description}: {value}s (should be <= 1 hour)') all_ok = False elif value == expected: print(f'[OK] {description}: enabled') else: print(f'[FAIL] {description}: {value}') all_ok = False # Check MAX_CONTENT_LENGTH max_content = app.config.get('MAX_CONTENT_LENGTH') if max_content: mb = max_content / (1024 * 1024) print(f'[OK] MAX_CONTENT_LENGTH: {mb}MB') else: print('[WARN] MAX_CONTENT_LENGTH not set (unlimited uploads)') all_ok = False # Check CSRF csrf_enabled = app.config.get('WTF_CSRF_ENABLED') if csrf_enabled: print('[OK] CSRF protection: enabled') else: print('[FAIL] CSRF protection: disabled') all_ok = False # Check if app is in DEBUG mode if app.debug: print('[FAIL] DEBUG mode is enabled!') all_ok = False else: print('[OK] DEBUG mode: disabled') except Exception as e: # Returning all_ok (still True) here meant that failing to load the # application at all was counted as a passing check — the most # important section of the report silently never ran. print(f'[FAIL] Cannot check Flask config: {e}') return False return all_ok def main(): """Run all security checks and produce a summary report. Returns: int: 0 if all checks pass, 1 if any fail. """ import argparse parser = argparse.ArgumentParser(description='Security validation scanner') parser.add_argument('--url', default='http://localhost:5000', help='Application URL to check headers (default: http://localhost:5000)') parser.add_argument('--skip-http', action='store_true', help='Skip the live HTTP header check (no server running, e.g. in CI)') args = parser.parse_args() # Plain ASCII: the box-drawing characters this banner used crashed the # script outright on a cp1252 Windows console, which is the platform the # project is developed and deployed on. print('=' * 60) print('TEAM TRYOUTS - SECURITY VALIDATION SCANNER') print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') print('=' * 60) checks = [check_environment] if args.skip_http: print('\n[SKIP] HTTP header check disabled via --skip-http') else: checks.append(lambda: check_https_headers(args.url)) checks += [ check_dependencies, check_file_permissions, check_flask_config, ] results = [] for check in checks: results.append(check()) print('\n' + '=' * 60) print('SUMMARY') print('=' * 60) passed = sum(1 for r in results if r) failed = sum(1 for r in results if not r) total = len(results) print(f'Passed: {passed}/{total}') print(f'Failed: {failed}/{total}') if failed == 0: print('\n[OK] All security checks passed!') return 0 else: print(f'\n[WARN] {failed} check(s) failed. Review the output above.') return 1 if __name__ == '__main__': sys.exit(main())