fix(audit): durcir les validations de securite
This commit is contained in:
@@ -94,6 +94,7 @@ jobs:
|
|||||||
- name: Run security scan
|
- name: Run security scan
|
||||||
env:
|
env:
|
||||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }}
|
||||||
|
DATABASE_URL: 'sqlite:///:memory:'
|
||||||
FLASK_DEBUG: 'false'
|
FLASK_DEBUG: 'false'
|
||||||
run: python app/supporting_scripts/security_scan.py --skip-http
|
run: python app/supporting_scripts/security_scan.py --skip-http
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This script performs pre-deployment security checks to validate:
|
|||||||
- Debug mode status
|
- Debug mode status
|
||||||
- HTTPS configuration
|
- HTTPS configuration
|
||||||
- Dependency vulnerabilities
|
- Dependency vulnerabilities
|
||||||
- Database connectivity
|
- Required database configuration
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python security_scan.py [--url http://localhost:5000]
|
python security_scan.py [--url http://localhost:5000]
|
||||||
@@ -19,6 +19,10 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REQUIREMENTS_FILE = PROJECT_ROOT / 'requirements.txt'
|
||||||
|
|
||||||
|
|
||||||
def check_environment():
|
def check_environment():
|
||||||
@@ -31,8 +35,8 @@ def check_environment():
|
|||||||
print('1. ENVIRONMENT VARIABLES CHECK')
|
print('1. ENVIRONMENT VARIABLES CHECK')
|
||||||
print('=' * 60)
|
print('=' * 60)
|
||||||
|
|
||||||
critical_vars = ['SECRET_KEY']
|
critical_vars = ['SECRET_KEY', 'DATABASE_URL']
|
||||||
recommended_vars = ['DATABASE_URL', 'CORS_ALLOWED_ORIGINS']
|
recommended_vars = ['CORS_ALLOWED_ORIGINS']
|
||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
for var in critical_vars:
|
for var in critical_vars:
|
||||||
@@ -58,7 +62,8 @@ def check_environment():
|
|||||||
# Check FLASK_DEBUG
|
# Check FLASK_DEBUG
|
||||||
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
debug = os.getenv('FLASK_DEBUG', 'false').lower()
|
||||||
if debug == 'true':
|
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:
|
else:
|
||||||
print('[OK] FLASK_DEBUG is disabled')
|
print('[OK] FLASK_DEBUG is disabled')
|
||||||
|
|
||||||
@@ -91,10 +96,11 @@ def check_https_headers(url):
|
|||||||
all_ok = True
|
all_ok = True
|
||||||
|
|
||||||
try:
|
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 = ssl.create_default_context()
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, method='HEAD')
|
req = urllib.request.Request(url, method='HEAD')
|
||||||
|
|
||||||
@@ -148,9 +154,9 @@ def check_https_headers(url):
|
|||||||
all_ok = False
|
all_ok = False
|
||||||
|
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
print(f'[SKIP] Cannot connect to {url}: {e.reason}')
|
print(f'[FAIL] Cannot connect to {url}: {e.reason}')
|
||||||
print('[SKIP] Run with --url <application_url> to check headers')
|
print('[INFO] Use --skip-http only when the live check is intentionally out of scope.')
|
||||||
return True # Not a failure, just can't check
|
return False
|
||||||
|
|
||||||
return all_ok
|
return all_ok
|
||||||
|
|
||||||
@@ -167,7 +173,15 @@ def check_dependencies():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[sys.executable, '-m', 'pip_audit', '--format', 'json'],
|
[
|
||||||
|
sys.executable,
|
||||||
|
'-m',
|
||||||
|
'pip_audit',
|
||||||
|
'--requirement',
|
||||||
|
str(REQUIREMENTS_FILE),
|
||||||
|
'--format',
|
||||||
|
'json',
|
||||||
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
@@ -195,14 +209,16 @@ def check_dependencies():
|
|||||||
if result.stdout:
|
if result.stdout:
|
||||||
print(f'[INFO] {result.stdout.strip()}')
|
print(f'[INFO] {result.stdout.strip()}')
|
||||||
if result.stderr:
|
if result.stderr:
|
||||||
print(f'[WARN] {result.stderr.strip()}')
|
print(f'[FAIL] {result.stderr.strip()}')
|
||||||
return True
|
else:
|
||||||
|
print(f'[FAIL] pip-audit exited with status {result.returncode}.')
|
||||||
|
return False
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print('[SKIP] pip-audit not installed. Run: pip install pip-audit')
|
print('[FAIL] pip-audit not installed. Run: pip install pip-audit')
|
||||||
return True
|
return False
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
print('[WARN] pip-audit timed out')
|
print('[FAIL] pip-audit timed out')
|
||||||
return True
|
return False
|
||||||
|
|
||||||
|
|
||||||
def check_file_permissions():
|
def check_file_permissions():
|
||||||
|
|||||||
@@ -661,7 +661,7 @@ function renderMergedDisponibilityGrid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
|
dayRow += '<div class="' + cssClass + '" data-day="' + day.value + '" data-time="' + slotData.time + '" ' +
|
||||||
'onclick="toggleTimeSlot(' + day.value + ', \'' + slotData.time + '\', this)">' +
|
'data-action="toggle-time-slot">' +
|
||||||
slotData.display +
|
slotData.display +
|
||||||
'<span class="merged-disponibility-count">' + count + '</span>' +
|
'<span class="merged-disponibility-count">' + count + '</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -1120,6 +1120,11 @@ function togglePresence(matchId, participantId, badgeEl) {
|
|||||||
registerActions({
|
registerActions({
|
||||||
'toggle-match-type': toggleMatchType,
|
'toggle-match-type': toggleMatchType,
|
||||||
'clear-time-selection': clearTimeSelection,
|
'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,
|
'update-randomize-preview': updateRandomizePreview,
|
||||||
'randomize-teams': randomizeTeams,
|
'randomize-teams': randomizeTeams,
|
||||||
'return-to-pool': returnToPool,
|
'return-to-pool': returnToPool,
|
||||||
|
|||||||
+16
-1
@@ -31,6 +31,14 @@ INLINE_HANDLER = re.compile(
|
|||||||
re.I,
|
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;
|
#: Remaining inline handlers, per template. Lower these as you migrate;
|
||||||
#: never raise one. Templates absent from this map must have none.
|
#: never raise one. Templates absent from this map must have none.
|
||||||
#: No template may carry an inline event handler. The migration is done;
|
#: No template may carry an inline event handler. The migration is done;
|
||||||
@@ -52,7 +60,8 @@ def _templates():
|
|||||||
|
|
||||||
def _count_handlers(path):
|
def _count_handlers(path):
|
||||||
with open(path, encoding='utf-8') as handle:
|
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:
|
class TestPolicyHeader:
|
||||||
@@ -102,6 +111,12 @@ class TestPolicyHeader:
|
|||||||
|
|
||||||
|
|
||||||
class TestInlineHandlerRatchet:
|
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 += '<button onclick=\"work()\">';", encoding='utf-8')
|
||||||
|
|
||||||
|
assert _count_handlers(template) == 1
|
||||||
|
|
||||||
@pytest.mark.parametrize('relative,full', list(_templates()))
|
@pytest.mark.parametrize('relative,full', list(_templates()))
|
||||||
def test_a_template_never_gains_an_inline_handler(self, relative, full):
|
def test_a_template_never_gains_an_inline_handler(self, relative, full):
|
||||||
allowed = HANDLER_BUDGET.get(relative, 0)
|
allowed = HANDLER_BUDGET.get(relative, 0)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""The security scanner must fail closed when its dependency audit cannot run."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
from app.supporting_scripts import security_scan
|
||||||
|
|
||||||
|
|
||||||
|
def _result(returncode, stdout='', stderr=''):
|
||||||
|
return subprocess.CompletedProcess([], returncode, stdout=stdout, stderr=stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_mode_fails_the_environment_check(monkeypatch):
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'x' * 32)
|
||||||
|
monkeypatch.setenv('DATABASE_URL', 'sqlite:///:memory:')
|
||||||
|
monkeypatch.setenv('FLASK_DEBUG', 'true')
|
||||||
|
|
||||||
|
assert security_scan.check_environment() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_database_url_fails_the_environment_check(monkeypatch):
|
||||||
|
monkeypatch.setenv('SECRET_KEY', 'x' * 32)
|
||||||
|
monkeypatch.delenv('DATABASE_URL', raising=False)
|
||||||
|
monkeypatch.setenv('FLASK_DEBUG', 'false')
|
||||||
|
|
||||||
|
assert security_scan.check_environment() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreachable_http_target_cannot_report_success(monkeypatch):
|
||||||
|
def unreachable(*args, **kwargs):
|
||||||
|
raise urllib.error.URLError('connection refused')
|
||||||
|
|
||||||
|
monkeypatch.setattr(security_scan.urllib.request, 'urlopen', unreachable)
|
||||||
|
|
||||||
|
assert security_scan.check_https_headers('https://example.test') is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_clean_dependency_audit_passes(monkeypatch):
|
||||||
|
command = []
|
||||||
|
|
||||||
|
def clean_audit(args, **kwargs):
|
||||||
|
command.extend(args)
|
||||||
|
return _result(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_scan.subprocess,
|
||||||
|
'run',
|
||||||
|
clean_audit,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert security_scan.check_dependencies() is True
|
||||||
|
requirement_flag = command.index('--requirement')
|
||||||
|
assert command[requirement_flag + 1] == str(security_scan.REQUIREMENTS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reported_vulnerabilities_fail_the_scan(monkeypatch):
|
||||||
|
report = {
|
||||||
|
'dependencies': [
|
||||||
|
{
|
||||||
|
'name': 'example',
|
||||||
|
'version': '1.0',
|
||||||
|
'vulns': [{'id': 'PYSEC-TEST'}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_scan.subprocess,
|
||||||
|
'run',
|
||||||
|
lambda *args, **kwargs: _result(1, stdout=json.dumps(report)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert security_scan.check_dependencies() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_audit_that_crashes_cannot_report_success(monkeypatch, capsys):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
security_scan.subprocess,
|
||||||
|
'run',
|
||||||
|
lambda *args, **kwargs: _result(1, stderr='audit unavailable'),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert security_scan.check_dependencies() is False
|
||||||
|
assert '[FAIL] audit unavailable' in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_audit_timeout_cannot_report_success(monkeypatch):
|
||||||
|
def timeout(*args, **kwargs):
|
||||||
|
raise subprocess.TimeoutExpired('pip-audit', 60)
|
||||||
|
|
||||||
|
monkeypatch.setattr(security_scan.subprocess, 'run', timeout)
|
||||||
|
|
||||||
|
assert security_scan.check_dependencies() is False
|
||||||
Reference in New Issue
Block a user