QUA-002, seconde moitie. Le depot etant formate, l elargissement porte sur
des defauts et non sur du brassage.
Ajoute a la selection : B (bugbear), C4, RET, SIM, UP. Le lot entier n a
produit que 24 signalements sur 76 fichiers -- le code etait plus propre
que l audit ne le craignait. Neuf corriges automatiquement, quinze a la
main.
SIM108 est ignore : forcer un ternaire se lit moins bien que le if/else
qu il remplace, au seul endroit ou il se declenche.
isort (I) n est PAS active. Il reordonnerait les imports de 48 fichiers,
soit une seconde passe de pur brassage juste apres le commit de formatage.
A faire, mais seul.
Deux vrais defauts trouves par les nouvelles regles
- team_matches.edit_match faisait `except ValueError: pass` sur l heure de
debut et l heure de fin, trois lignes sous un champ date qui, lui,
signale et redirige. Une heure mal saisie etait donc acceptee par le
formulaire, jetee, l ancienne valeur conservee -- et la page annoncait
la reussite. Meme traitement que la date desormais.
- backup.py levait BackupError depuis deux blocs `except` sans `from`,
ce qui perdait la cause d origine dans la trace.
Ainsi que : un `return` explicite dans force_https, `%`-formatage remplace
dans log_auth_event (operations de chaine avant journalisation, pas des
gabarits de logger -- la redaction n est pas affectee), une compréhension
inutile, un `set(...)` en compréhension d ensemble, `open(..., 'r')`, une
variable de boucle inutilisee, et `contextlib.suppress` dans conftest.
CI : `ruff format --check` remplace le commentaire qui expliquait pourquoi
il etait absent.
263 tests passent. Les deux nouveaux messages sont traduits ; attention,
pybabel les avait apparies en `fuzzy` avec des entrees « date » existantes,
et une entree fuzzy est ignoree a l execution.
Co-Authored-By: Claude Opus 5 <[email protected]>
409 lines
13 KiB
Python
409 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
|
|
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'[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) 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
|
|
print(f'\n[WARN] {failed} check(s) failed. Review the output above.')
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|