430 lines
14 KiB
Python
430 lines
14 KiB
Python
"""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
|
|
- Required database configuration
|
|
|
|
Usage:
|
|
python security_scan.py [--url http://localhost:5000]
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
import urllib.request
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
|
|
|
|
|
|
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', 'DATABASE_URL']
|
|
recommended_vars = ['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('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.')
|
|
all_ok = False
|
|
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:
|
|
# Keep the default certificate and hostname verification. A scanner
|
|
# that accepts an invalid certificate can validate headers while the
|
|
# transport itself is impersonated. Local runs without TLS should use
|
|
# http:// explicitly or opt out with --skip-http.
|
|
ctx = ssl.create_default_context()
|
|
|
|
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'[FAIL] Cannot connect to {url}: {e.reason}')
|
|
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
|
|
return False
|
|
|
|
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',
|
|
'--requirement',
|
|
str(REQUIREMENTS_FILE),
|
|
'--format',
|
|
'json',
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=60,
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
print('[OK] No known vulnerabilities found')
|
|
return True
|
|
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
|
|
print('[OK] No vulnerabilities found')
|
|
return True
|
|
except json.JSONDecodeError:
|
|
if result.stdout:
|
|
print(f'[INFO] {result.stdout.strip()}')
|
|
if result.stderr:
|
|
print(f'[FAIL] {result.stderr.strip()}')
|
|
else:
|
|
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
|
|
return False
|
|
except FileNotFoundError:
|
|
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
|
|
return False
|
|
except subprocess.TimeoutExpired:
|
|
print('[FAIL] pip-audit timed out')
|
|
return False
|
|
|
|
|
|
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) 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: # noqa: BLE001 — any failure to load the app is a failed check
|
|
# 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.
|
|
#
|
|
# The breadth is the point: this section's question is "does the
|
|
# application load with a safe configuration", and every way of not
|
|
# loading answers it the same way. Reported on stdout because this
|
|
# script is read by a CI job, not by a log collector.
|
|
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
|
|
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|