diff --git a/application/controllers/AuthController.php b/application/controllers/AuthController.php index ed2b5f9..d0465d7 100644 --- a/application/controllers/AuthController.php +++ b/application/controllers/AuthController.php @@ -10,15 +10,34 @@ require_once APP_PATH . '/services/RateLimitService.php'; class AuthController extends BaseController { + /** + * Restituisce l'IP reale del client, gestendo proxy/nginx. + */ + private function getClientIP(): string + { + $remoteAddr = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + // Fidati di X-Forwarded-For solo se la richiesta arriva da localhost (nginx proxy) + if (in_array($remoteAddr, ['127.0.0.1', '::1', 'unknown']) + && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $xForwardedFor = $_SERVER['HTTP_X_FORWARDED_FOR']; + $ips = array_map('trim', explode(',', $xForwardedFor)); + $firstIp = filter_var($ips[0], FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); + if ($firstIp !== false) { + return $firstIp; + } + } + return $remoteAddr; + } + /** * POST /api/auth/register */ public function register(): void { // Rate limiting - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $ip = $this->getClientIP(); RateLimitService::check("register:{$ip}", RATE_LIMIT_AUTH_REGISTER); - RateLimitService::increment("register:{$ip}"); + RateLimitService::increment("register:{$ip}"); // $ip defined above via getClientIP() $this->validateRequired(['email', 'password', 'full_name']); @@ -87,7 +106,7 @@ class AuthController extends BaseController public function login(): void { // Rate limiting - $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $ip = $this->getClientIP(); RateLimitService::check("login:{$ip}", RATE_LIMIT_AUTH_LOGIN); RateLimitService::increment("login:{$ip}"); @@ -167,24 +186,34 @@ class AuthController extends BaseController $refreshToken = $this->getParam('refresh_token'); $hashedToken = hash('sha256', $refreshToken); - // Verifica refresh token - $tokenRecord = Database::fetchOne( - 'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW()', - [$hashedToken] - ); + // Transazione atomica per evitare race condition (double-spend del refresh token) + Database::beginTransaction(); + try { + // SELECT FOR UPDATE: blocca il record per tutta la transazione + $tokenRecord = Database::fetchOne( + 'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW() FOR UPDATE', + [$hashedToken] + ); - if (!$tokenRecord) { - $this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN'); + if (!$tokenRecord) { + Database::rollback(); + $this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN'); + } + + // Elimina vecchio token atomicamente + Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]); + + // Genera nuovi tokens + $userId = (int) $tokenRecord['user_id']; + $accessToken = $this->generateJWT($userId); + $newRefreshToken = $this->generateRefreshToken($userId); + + Database::commit(); + } catch (Throwable $e) { + Database::rollback(); + throw $e; } - // Elimina vecchio token - Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]); - - // Genera nuovi tokens - $userId = (int) $tokenRecord['user_id']; - $accessToken = $this->generateJWT($userId); - $newRefreshToken = $this->generateRefreshToken($userId); - $this->jsonSuccess([ 'access_token' => $accessToken, 'refresh_token' => $newRefreshToken, diff --git a/application/services/AIService.php b/application/services/AIService.php index 8af62cd..3fb386d 100644 --- a/application/services/AIService.php +++ b/application/services/AIService.php @@ -35,17 +35,18 @@ class AIService { $responseSummary = $this->summarizeResponses($responses); + // Anonimizzazione: non inviare nome org né fatturato esatto ad API esterna + $employeeRange = $this->employeeRange((int)($organization['employee_count'] ?? 0)); + $prompt = <<1000 dipendenti)'; + } + /** * Riassume le risposte dell'assessment per il prompt AI */ diff --git a/docs/sql/006_security_improvements.sql b/docs/sql/006_security_improvements.sql new file mode 100644 index 0000000..f8cea2a --- /dev/null +++ b/docs/sql/006_security_improvements.sql @@ -0,0 +1,85 @@ +-- ============================================================ +-- NIS2 Agile - Migration 006: Security & Performance Improvements +-- Data: 2026-02-20 +-- Eseguire come: mysql -u root -p nis2_agile_db < 006_security_improvements.sql +-- ============================================================ + +-- ── 1. Indici performance su incidents ──────────────────────────────────── +-- Per query filtrate per org + scadenze NIS2 +ALTER TABLE incidents + ADD INDEX IF NOT EXISTS idx_inc_org_status (organization_id, status), + ADD INDEX IF NOT EXISTS idx_inc_org_significant (organization_id, is_significant), + ADD INDEX IF NOT EXISTS idx_inc_early_warning_due (organization_id, early_warning_due), + ADD INDEX IF NOT EXISTS idx_inc_notification_due (organization_id, notification_due), + ADD INDEX IF NOT EXISTS idx_inc_final_report_due (organization_id, final_report_due); + +-- ── 2. Indici performance su risks ──────────────────────────────────────── +ALTER TABLE risks + ADD INDEX IF NOT EXISTS idx_risks_org_status (organization_id, status), + ADD INDEX IF NOT EXISTS idx_risks_score (organization_id, inherent_risk_score DESC); + +-- ── 3. Indici performance su audit_logs (immutabilità) ──────────────────── +-- L'audit log deve essere append-only e ricercabile rapidamente +ALTER TABLE audit_logs + ADD INDEX IF NOT EXISTS idx_audit_org_created (organization_id, created_at DESC), + ADD INDEX IF NOT EXISTS idx_audit_entity (entity_type, entity_id); + +-- ── 4. Trigger per rendere audit_log immutabile ─────────────────────────── +-- Blocca UPDATE e DELETE sull'audit log (solo INSERT consentito) +DROP TRIGGER IF EXISTS prevent_audit_log_update; +CREATE TRIGGER prevent_audit_log_update + BEFORE UPDATE ON audit_logs + FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'audit_logs is append-only: UPDATE not permitted'; +END; + +DROP TRIGGER IF EXISTS prevent_audit_log_delete; +CREATE TRIGGER prevent_audit_log_delete + BEFORE DELETE ON audit_logs + FOR EACH ROW +BEGIN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = 'audit_logs is append-only: DELETE not permitted'; +END; + +-- ── 5. Colonna deleted_at per soft delete su tabelle critiche ───────────── +-- Permette di "eliminare" record senza perdere traccia storica + +-- Risks: soft delete +ALTER TABLE risks + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at; + +ALTER TABLE risks + ADD INDEX IF NOT EXISTS idx_risks_deleted (organization_id, deleted_at); + +-- Policies: soft delete +ALTER TABLE policies + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at; + +ALTER TABLE policies + ADD INDEX IF NOT EXISTS idx_policies_deleted (organization_id, deleted_at); + +-- Suppliers: soft delete +ALTER TABLE suppliers + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP NULL DEFAULT NULL AFTER updated_at; + +-- ── 6. Indice su refresh_tokens per performance ─────────────────────────── +ALTER TABLE refresh_tokens + ADD INDEX IF NOT EXISTS idx_refresh_user_expires (user_id, expires_at); + +-- ── 7. Pulizia automatica refresh token scaduti ─────────────────────────── +-- Evento schedulato (richiede event scheduler abilitato) +DROP EVENT IF EXISTS cleanup_expired_refresh_tokens; +CREATE EVENT IF NOT EXISTS cleanup_expired_refresh_tokens + ON SCHEDULE EVERY 6 HOUR + STARTS CURRENT_TIMESTAMP + DO + DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL 1 DAY; + +-- ── 8. Verifica ─────────────────────────────────────────────────────────── +SELECT 'Migration 006 completed successfully' AS status; + +-- Abilita event scheduler (da eseguire manualmente se necessario): +-- SET GLOBAL event_scheduler = ON; diff --git a/public/assessment.html b/public/assessment.html index 4cf30b6..5f3c60f 100644 --- a/public/assessment.html +++ b/public/assessment.html @@ -44,6 +44,20 @@
+ +
+
+ Avanzamento risposta domande + 0% +
+
+
+
+
+ 0 di 0 domande con risposta +
+
+
@@ -295,6 +309,22 @@ } } + function updateProgressBar() { + const total = questions.length; + const answered = Object.values(responses).filter(r => r && r.answer).length; + const pct = total > 0 ? Math.round((answered / total) * 100) : 0; + + const pctEl = document.getElementById('progress-pct'); + const fillEl = document.getElementById('progress-bar-fill'); + const answeredEl = document.getElementById('progress-answered'); + const totalEl = document.getElementById('progress-total'); + + if (pctEl) pctEl.textContent = pct + '%'; + if (fillEl) fillEl.style.width = pct + '%'; + if (answeredEl) answeredEl.textContent = answered; + if (totalEl) totalEl.textContent = total; + } + function renderCurrentQuestion() { if (categories.length === 0) return; @@ -365,6 +395,7 @@ `; document.getElementById('question-area').innerHTML = html; + updateProgressBar(); // Navigation buttons document.getElementById('btn-prev').disabled = (currentCategoryIdx === 0 && currentQuestionIdx === 0); diff --git a/public/dashboard.html b/public/dashboard.html index 127cbee..6223db6 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -29,7 +29,8 @@
-

Punteggio complessivo di conformita' NIS2

+

Avanzamento implementazione misure Art.21 NIS2

+

Misura l'implementazione tecnica, non la conformita' legale

diff --git a/public/incidents.html b/public/incidents.html index 81beefb..0f64fa2 100644 --- a/public/incidents.html +++ b/public/incidents.html @@ -519,6 +519,37 @@ + +
+
+ + Criteri Art. 23 NIS2 — Incidente Significativo +
+

Un incidente e' significativo se soddisfa almeno uno dei criteri seguenti (Considerando 101 NIS2 / D.Lgs. 138/2024 art. 25):

+
+ + + + + +
+ +
+ ${policy.ai_generated && (policy.status === 'draft' || policy.status === 'review') ? ` +
+ +
+ Bozza AI — Revisione obbligatoria prima dell'approvazione +

Questo documento e' stato generato automaticamente da un sistema AI. Prima di approvarlo, verificare che sia conforme alle procedure interne, al quadro normativo applicabile e alla realta' specifica dell'organizzazione. L'AI puo' produrre contenuti imprecisi o incompleti.

+
+
` : ''}
${policy.status === 'draft' || policy.status === 'review' ? ` diff --git a/public/risks.html b/public/risks.html index c89d382..c5bb035 100644 --- a/public/risks.html +++ b/public/risks.html @@ -442,7 +442,25 @@ const TREATMENT_STATUS_LABELS = { planned: 'Pianificato', in_progress: 'In Corso', completed: 'Completato', cancelled: 'Annullato' }; + // Scala Likelihood (probabilità annua) const LIKELIHOOD_LABELS = ['', 'Molto Basso', 'Basso', 'Medio', 'Alto', 'Molto Alto']; + const LIKELIHOOD_DETAILS = [ + '', + '1 - Remota: <1% probabilità annua (evento eccezionale)', + '2 - Improbabile: 1-10% (evento raro ma documentato nel settore)', + '3 - Possibile: 10-30% (può verificarsi nel corso dell'attività)', + '4 - Probabile: 30-70% (ci si aspetta che avvenga almeno una volta l'anno)', + '5 - Quasi Certa: >70% (si verificherà con elevata probabilità)', + ]; + // Scala Impact (danno economico / utenti impattati) + const IMPACT_DETAILS = [ + '', + '1 - Minimo: <€10k danno, <100 utenti, servizio ripristinato in <1h', + '2 - Basso: €10k-50k, 100-500 utenti, ripristino <8h', + '3 - Significativo: €50k-500k, 500-5000 utenti, ripristino <24h', + '4 - Grave: €500k-10M, 5000-50000 utenti, ripristino <7 giorni', + '5 - Catastrofico: >€10M, >50000 utenti, servizi critici nazionali impattati', + ]; // ── State ──────────────────────────────────────────────────── let currentView = 'table'; @@ -878,22 +896,16 @@
- +
${likelihood}
-
- 1 - Molto Basso - 5 - Molto Alto -
+
${LIKELIHOOD_DETAILS[likelihood]}
- +
${impact}
-
- 1 - Molto Basso - 5 - Molto Alto -
+
${IMPACT_DETAILS[impact]}
@@ -934,8 +946,10 @@ document.getElementById('likelihood-value').textContent = l; document.getElementById('impact-value').textContent = i; - document.getElementById('likelihood-label').textContent = LIKELIHOOD_LABELS[l]; - document.getElementById('impact-label').textContent = LIKELIHOOD_LABELS[i]; + const lDetail = document.getElementById('likelihood-detail'); + const iDetail = document.getElementById('impact-detail'); + if (lDetail) lDetail.textContent = LIKELIHOOD_DETAILS[l]; + if (iDetail) iDetail.textContent = IMPACT_DETAILS[i]; document.getElementById('score-preview-value').textContent = score; document.getElementById('score-preview-value').style.color = getScoreColorForRisk(score); }