Files
team-tryouts/app/supporting_scripts/security_scan.py
T
GGThedandClaude Opus 5 7cec18c139 style: formater le depot avec ruff format
QUA-002, premiere moitie. **Ce commit ne fait que reformater** : aucun
changement de comportement, aucune ligne de logique touchee. 72 fichiers,
4 restaient deja conformes. Il est isole exprès, pour que `git log -p` sur
les commits voisins reste lisible.

`quote-style = "preserve"` etait deja pose dans pyproject.toml, ce qui
evite le brassage guillemets simples / doubles : le diff porte sur les
retours a la ligne, l indentation des appels longs et les virgules
finales, pas sur le style de chaine.

Verification : 263 tests passent avant et apres, ruff check propre.

L activation en CI arrive dans le commit suivant, separement, pour que ce
diff-ci ne contienne rien d autre.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:53:10 -04:00

412 lines
13 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
- 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 <application_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())