Regle BLE de ruff activee. Ce qu'elle enforce n'est pas "ne jamais attraper large" : elle se satisfait d'un logger.exception. C'est exactement la discipline visee — une frontiere peut tout avaler, a condition de laisser de quoi distinguer un defaut d'une panne. Les cinq noqa que j'avais prepares d'avance etaient donc inertes ; la raison reste en commentaire simple. Ce que la regle a trouve, une fois activee : app.py, demarrage du bot — les deux facons d'echouer, jeton invalide et import casse, se lisaient a l'identique sur une seule ligne et aucune n'etait diagnosticable. Passe en error avec exc_info : un club qui ne recoit plus aucun rappel a perdu une fonctionnalite, et warning mettait ca a cote des avis de depreciation. services/notifications.py — le bloc webhook attrapait large autour d'un requests.post. RequestException couvre toutes les facons dont un appel HTTP echoue ; le reste est un defaut. La branche DM et la branche webhook etaient en plus imbriquees dans un seul try alors qu'elles s'excluent. logging_config.py et les deux scripts CLI gardent leur largeur, avec la raison sur la ligne. Le filtre de journalisation est le cas ou la trace que BLE001 reclame est precisement ce qu'il ne faut pas produire : journaliser depuis un filtre rentre dans le meme filtre. RUF100 (noqa inutile) n'est volontairement pas active : il ferait remonter des directives preexistantes sans rapport avec ce chantier. Co-Authored-By: Claude Opus 5 <[email protected]>
414 lines
14 KiB
Python
414 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
|
|
- Database connectivity
|
|
|
|
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
|
|
|
|
|
|
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: # 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())
|