diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95d343e..327af4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,7 @@ jobs: - name: Run security scan env: SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} + DATABASE_URL: 'sqlite:///:memory:' FLASK_DEBUG: 'false' run: python app/supporting_scripts/security_scan.py --skip-http diff --git a/app/supporting_scripts/security_scan.py b/app/supporting_scripts/security_scan.py index 32632c2..54bb4e0 100644 --- a/app/supporting_scripts/security_scan.py +++ b/app/supporting_scripts/security_scan.py @@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate: - Debug mode status - HTTPS configuration - Dependency vulnerabilities -- Database connectivity +- Required database configuration Usage: python security_scan.py [--url http://localhost:5000] @@ -19,6 +19,10 @@ 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(): @@ -31,8 +35,8 @@ def check_environment(): print('1. ENVIRONMENT VARIABLES CHECK') print('=' * 60) - critical_vars = ['SECRET_KEY'] - recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS'] + critical_vars = ['SECRET_KEY', 'DATABASE_URL'] + recommended_vars = ['CORS_ALLOWED_ORIGINS'] all_ok = True for var in critical_vars: @@ -58,7 +62,8 @@ def check_environment(): # Check FLASK_DEBUG debug = os.getenv('FLASK_DEBUG', 'false').lower() if debug == 'true': - print('[WARN] FLASK_DEBUG is enabled! Should be disabled in production.') + print('[FAIL] FLASK_DEBUG is enabled! It must be disabled in production.') + all_ok = False else: print('[OK] FLASK_DEBUG is disabled') @@ -91,10 +96,11 @@ def check_https_headers(url): all_ok = True try: - # Create a context that doesn't verify SSL (for local testing) + # 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() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE req = urllib.request.Request(url, method='HEAD') @@ -148,9 +154,9 @@ def check_https_headers(url): 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 + 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 @@ -167,7 +173,15 @@ def check_dependencies(): try: result = subprocess.run( - [sys.executable, '-m', 'pip_audit', '--format', 'json'], + [ + sys.executable, + '-m', + 'pip_audit', + '--requirement', + str(REQUIREMENTS_FILE), + '--format', + 'json', + ], capture_output=True, text=True, timeout=60, @@ -195,14 +209,16 @@ def check_dependencies(): if result.stdout: print(f'[INFO] {result.stdout.strip()}') if result.stderr: - print(f'[WARN] {result.stderr.strip()}') - return True + print(f'[FAIL] {result.stderr.strip()}') + else: + print(f'[FAIL] pip-audit exited with status {result.returncode}.') + return False except FileNotFoundError: - print('[SKIP] pip-audit not installed. Run: pip install pip-audit') - return True + print('[FAIL] pip-audit not installed. Run: pip install pip-audit') + return False except subprocess.TimeoutExpired: - print('[WARN] pip-audit timed out') - return True + print('[FAIL] pip-audit timed out') + return False def check_file_permissions(): diff --git a/app/templates/pages/match_form.html b/app/templates/pages/match_form.html index a589205..d1a0e21 100644 --- a/app/templates/pages/match_form.html +++ b/app/templates/pages/match_form.html @@ -661,7 +661,7 @@ function renderMergedDisponibilityGrid() { } dayRow += '
' + + 'data-action="toggle-time-slot">' + slotData.display + '' + count + '' + '
'; @@ -1120,6 +1120,11 @@ function togglePresence(matchId, participantId, badgeEl) { registerActions({ 'toggle-match-type': toggleMatchType, 'clear-time-selection': clearTimeSelection, + 'toggle-time-slot': function (element) { + toggleTimeSlot(parseInt(element.getAttribute('data-day'), 10), + element.getAttribute('data-time'), + element); + }, 'update-randomize-preview': updateRandomizePreview, 'randomize-teams': randomizeTeams, 'return-to-pool': returnToPool, diff --git a/tests/test_csp.py b/tests/test_csp.py index 3002992..c268ee7 100644 --- a/tests/test_csp.py +++ b/tests/test_csp.py @@ -31,6 +31,14 @@ INLINE_HANDLER = re.compile( re.I, ) +# An event attribute assembled inside a JavaScript string is absent from the +# template DOM, so the expression above cannot see it. Once assigned through +# innerHTML it is still an inline handler and the CSP still refuses to run it. +DYNAMIC_INLINE_HANDLER = re.compile( + r'''["']on(?:click|change|submit|input|load|keyup|keydown|mouseover|focus|blur)\s*=''', + re.I, +) + #: Remaining inline handlers, per template. Lower these as you migrate; #: never raise one. Templates absent from this map must have none. #: No template may carry an inline event handler. The migration is done; @@ -52,7 +60,8 @@ def _templates(): def _count_handlers(path): with open(path, encoding='utf-8') as handle: - return len(INLINE_HANDLER.findall(handle.read())) + content = handle.read() + return len(INLINE_HANDLER.findall(content)) + len(DYNAMIC_INLINE_HANDLER.findall(content)) class TestPolicyHeader: @@ -102,6 +111,12 @@ class TestPolicyHeader: class TestInlineHandlerRatchet: + def test_a_handler_built_inside_a_javascript_string_is_counted(self, tmp_path): + template = tmp_path / 'dynamic-handler.html' + template.write_text("html += '