diff --git a/application/controllers/AuditController.php b/application/controllers/AuditController.php index 2e4dcb5..54bf5b9 100644 --- a/application/controllers/AuditController.php +++ b/application/controllers/AuditController.php @@ -6,6 +6,7 @@ */ require_once __DIR__ . '/BaseController.php'; +require_once APP_PATH . '/services/ReportService.php'; class AuditController extends BaseController { @@ -193,4 +194,48 @@ class AuditController extends BaseController $this->jsonSuccess($mapping); } + + /** + * GET /api/audit/executive-report + * Genera report esecutivo HTML (stampabile come PDF) + */ + public function executiveReport(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + + $reportService = new ReportService(); + $html = $reportService->generateExecutiveReport($this->getCurrentOrgId()); + + header('Content-Type: text/html; charset=utf-8'); + echo $html; + exit; + } + + /** + * GET /api/audit/export/{type} + * Esporta dati in CSV + */ + public function export(int $type = 0): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); + + $exportType = $_GET['type'] ?? 'controls'; + $orgId = $this->getCurrentOrgId(); + $reportService = new ReportService(); + + $csv = match ($exportType) { + 'risks' => $reportService->exportRisksCSV($orgId), + 'incidents' => $reportService->exportIncidentsCSV($orgId), + 'controls' => $reportService->exportControlsCSV($orgId), + 'assets' => $reportService->exportAssetsCSV($orgId), + default => $reportService->exportControlsCSV($orgId), + }; + + $filename = "nis2_{$exportType}_" . date('Y-m-d') . '.csv'; + + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + echo $csv; + exit; + } } diff --git a/application/controllers/AuthController.php b/application/controllers/AuthController.php index 8371199..fa378ac 100644 --- a/application/controllers/AuthController.php +++ b/application/controllers/AuthController.php @@ -6,6 +6,7 @@ */ require_once __DIR__ . '/BaseController.php'; +require_once APP_PATH . '/services/RateLimitService.php'; class AuthController extends BaseController { @@ -14,6 +15,11 @@ class AuthController extends BaseController */ public function register(): void { + // Rate limiting + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + RateLimitService::check("register:{$ip}", RATE_LIMIT_AUTH_REGISTER); + RateLimitService::increment("register:{$ip}"); + $this->validateRequired(['email', 'password', 'full_name']); $email = strtolower(trim($this->getParam('email'))); @@ -78,6 +84,11 @@ class AuthController extends BaseController */ public function login(): void { + // Rate limiting + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + RateLimitService::check("login:{$ip}", RATE_LIMIT_AUTH_LOGIN); + RateLimitService::increment("login:{$ip}"); + $this->validateRequired(['email', 'password']); $email = strtolower(trim($this->getParam('email'))); diff --git a/application/controllers/IncidentController.php b/application/controllers/IncidentController.php index 7117817..2257424 100644 --- a/application/controllers/IncidentController.php +++ b/application/controllers/IncidentController.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/BaseController.php'; require_once APP_PATH . '/services/AIService.php'; +require_once APP_PATH . '/services/EmailService.php'; class IncidentController extends BaseController { @@ -237,6 +238,9 @@ class IncidentController extends BaseController 'created_by' => $this->getCurrentUserId(), ]); + // Invia notifica email ai responsabili + $this->notifyIncidentStakeholders($id, 'early_warning'); + $this->logAudit('early_warning_sent', 'incident', $id); $this->jsonSuccess(null, 'Early warning registrato'); } @@ -260,6 +264,9 @@ class IncidentController extends BaseController 'created_by' => $this->getCurrentUserId(), ]); + // Invia notifica email ai responsabili + $this->notifyIncidentStakeholders($id, 'notification'); + $this->logAudit('notification_sent', 'incident', $id); $this->jsonSuccess(null, 'Notifica CSIRT registrata'); } @@ -283,10 +290,48 @@ class IncidentController extends BaseController 'created_by' => $this->getCurrentUserId(), ]); + // Invia notifica email ai responsabili + $this->notifyIncidentStakeholders($id, 'final_report'); + $this->logAudit('final_report_sent', 'incident', $id); $this->jsonSuccess(null, 'Report finale registrato'); } + /** + * Notifica stakeholder via email per milestone incidente + */ + private function notifyIncidentStakeholders(int $incidentId, string $type): void + { + try { + $incident = Database::fetchOne('SELECT * FROM incidents WHERE id = ?', [$incidentId]); + $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); + + if (!$incident || !$org) return; + + // Trova org_admin e compliance_manager + $recipients = Database::fetchAll( + 'SELECT u.email, u.full_name FROM users u + JOIN user_organizations uo ON uo.user_id = u.id + WHERE uo.organization_id = ? AND uo.role IN ("org_admin", "compliance_manager") AND u.is_active = 1', + [$this->getCurrentOrgId()] + ); + + if (empty($recipients)) return; + + $emailService = new EmailService(); + $emails = array_column($recipients, 'email'); + + match ($type) { + 'early_warning' => $emailService->sendIncidentEarlyWarning($incident, $org, $emails), + 'notification' => $emailService->sendIncidentNotification($incident, $org, $emails), + 'final_report' => $emailService->sendIncidentFinalReport($incident, $org, $emails), + }; + } catch (Throwable $e) { + error_log('[EMAIL_ERROR] ' . $e->getMessage()); + // Non bloccare il flusso principale per errori email + } + } + /** * POST /api/incidents/{id}/ai-classify */ diff --git a/application/services/EmailService.php b/application/services/EmailService.php new file mode 100644 index 0000000..e114b30 --- /dev/null +++ b/application/services/EmailService.php @@ -0,0 +1,728 @@ +appName = APP_NAME; + $this->appUrl = APP_URL; + $this->defaultFrom = 'noreply@' . parse_url($this->appUrl, PHP_URL_HOST); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // INVIO EMAIL + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Invia una email HTML con template professionale + * + * @param string $to Indirizzo destinatario + * @param string $subject Oggetto email + * @param string $htmlBody Contenuto HTML (inserito nel template) + * @param string|null $from Indirizzo mittente (opzionale) + * @return bool true se mail() ha accettato il messaggio + */ + public function send(string $to, string $subject, string $htmlBody, ?string $from = null): bool + { + $from = $from ?? $this->defaultFrom; + + $headers = implode("\r\n", [ + 'From: ' . $this->appName . ' <' . $from . '>', + 'Reply-To: ' . $from, + 'MIME-Version: 1.0', + 'Content-Type: text/html; charset=UTF-8', + 'X-Mailer: NIS2 Agile', + ]); + + $fullHtml = $this->wrapInTemplate($subject, $htmlBody); + + $result = @mail($to, '=?UTF-8?B?' . base64_encode($subject) . '?=', $fullHtml, $headers); + + $this->logEmail($to, $subject, $result); + + return $result; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // NOTIFICHE INCIDENTI (Art. 23 NIS2) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Notifica early warning 24h (Art. 23 par. 4 lett. a) + * + * @param array $incident Dati incidente (title, incident_code, severity, detected_at, classification) + * @param array $organization Dati organizzazione (name, sector) + * @param array $recipients Lista di indirizzi email destinatari + */ + public function sendIncidentEarlyWarning(array $incident, array $organization, array $recipients): void + { + $severityLabel = $this->translateSeverity($incident['severity'] ?? 'medium'); + $detectedAt = $this->formatDateTime($incident['detected_at'] ?? ''); + $deadline = $this->formatDateTime($incident['early_warning_due'] ?? ''); + + $html = << + EARLY WARNING - Preallarme 24 ore
+ Ai sensi dell'Art. 23, par. 4, lett. a) della Direttiva NIS2 + + +

Si comunica che l'organizzazione {$this->esc($organization['name'])} ha rilevato un incidente significativo che richiede la notifica obbligatoria al CSIRT nazionale (ACN).

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Codice Incidente{$this->esc($incident['incident_code'] ?? '-')}
Titolo{$this->esc($incident['title'] ?? '-')}
Classificazione{$this->esc($incident['classification'] ?? '-')}
Gravità{$this->esc($severityLabel)}
Rilevato il{$this->esc($detectedAt)}
Scadenza Early Warning{$this->esc($deadline)}
+ +
+ Azione richiesta: Inviare il preallarme al CSIRT nazionale (ACN) entro 24 ore dalla rilevazione dell'incidente, indicando se l'incidente sia sospettato di essere causato da atti illegittimi o malevoli e se possa avere un impatto transfrontaliero. +
+ +

+ Gestisci Incidente +

+ HTML; + + $subject = "[URGENTE] Early Warning Incidente {$incident['incident_code'] ?? ''} - {$organization['name']}"; + + foreach ($recipients as $email) { + $this->send($email, $subject, $html); + } + } + + /** + * Notifica incidente 72h (Art. 23 par. 4 lett. b) + * + * @param array $incident Dati incidente + * @param array $organization Dati organizzazione + * @param array $recipients Lista indirizzi email + */ + public function sendIncidentNotification(array $incident, array $organization, array $recipients): void + { + $severityLabel = $this->translateSeverity($incident['severity'] ?? 'medium'); + $detectedAt = $this->formatDateTime($incident['detected_at'] ?? ''); + $deadline = $this->formatDateTime($incident['notification_due'] ?? ''); + + $html = << + NOTIFICA INCIDENTE - Termine 72 ore
+ Ai sensi dell'Art. 23, par. 4, lett. b) della Direttiva NIS2 + + +

Si comunica la necessità di inviare la notifica formale dell'incidente significativo al CSIRT nazionale (ACN) per l'organizzazione {$this->esc($organization['name'])}.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Codice Incidente{$this->esc($incident['incident_code'] ?? '-')}
Titolo{$this->esc($incident['title'] ?? '-')}
Gravità{$this->esc($severityLabel)}
Rilevato il{$this->esc($detectedAt)}
Servizi Interessati{$this->esc($incident['affected_services'] ?? 'Non specificati')}
Scadenza Notifica{$this->esc($deadline)}
+ +
+ Azione richiesta: Aggiornare la notifica al CSIRT nazionale (ACN) entro 72 ore dalla rilevazione, fornendo una valutazione iniziale dell'incidente, compresa la sua gravità e il suo impatto, nonché gli indicatori di compromissione, ove disponibili. +
+ +

+ Gestisci Incidente +

+ HTML; + + $subject = "[NIS2] Notifica Incidente 72h {$incident['incident_code'] ?? ''} - {$organization['name']}"; + + foreach ($recipients as $email) { + $this->send($email, $subject, $html); + } + } + + /** + * Notifica report finale 30 giorni (Art. 23 par. 4 lett. d) + * + * @param array $incident Dati incidente + * @param array $organization Dati organizzazione + * @param array $recipients Lista indirizzi email + */ + public function sendIncidentFinalReport(array $incident, array $organization, array $recipients): void + { + $detectedAt = $this->formatDateTime($incident['detected_at'] ?? ''); + $deadline = $this->formatDateTime($incident['final_report_due'] ?? ''); + + $html = << + REPORT FINALE - Termine 30 giorni
+ Ai sensi dell'Art. 23, par. 4, lett. d) della Direttiva NIS2 + + +

È necessario predisporre e trasmettere il report finale dell'incidente al CSIRT nazionale (ACN) per l'organizzazione {$this->esc($organization['name'])}.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Codice Incidente{$this->esc($incident['incident_code'] ?? '-')}
Titolo{$this->esc($incident['title'] ?? '-')}
Rilevato il{$this->esc($detectedAt)}
Causa Radice{$this->esc($incident['root_cause'] ?? 'Da determinare')}
Azioni di Rimedio{$this->esc($incident['remediation_actions'] ?? 'Da documentare')}
Scadenza Report Finale{$this->esc($deadline)}
+ +
+ Azione richiesta: Predisporre il report finale entro 30 giorni dalla notifica dell'incidente, contenente: descrizione dettagliata dell'incidente, tipo di minaccia o causa radice, misure di attenuazione applicate e in corso, eventuale impatto transfrontaliero. +
+ +

+ Prepara Report Finale +

+ HTML; + + $subject = "[NIS2] Report Finale Incidente {$incident['incident_code'] ?? ''} - {$organization['name']}"; + + foreach ($recipients as $email) { + $this->send($email, $subject, $html); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // NOTIFICHE FORMAZIONE (Art. 20 NIS2) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Notifica assegnazione corso di formazione + * + * @param array $assignment Dati assegnazione (due_date) + * @param array $user Dati utente (full_name, email) + * @param array $course Dati corso (title, description, duration_minutes, is_mandatory) + */ + public function sendTrainingAssignment(array $assignment, array $user, array $course): void + { + $dueDate = $this->formatDate($assignment['due_date'] ?? ''); + $duration = (int) ($course['duration_minutes'] ?? 0); + $mandatoryText = !empty($course['is_mandatory']) ? 'Obbligatorio' : 'Facoltativo'; + + $html = << + Nuovo corso di formazione assegnato
+ Formazione cybersecurity ai sensi dell'Art. 20 della Direttiva NIS2 + + +

Gentile {$this->esc($user['full_name'])},

+ +

Le è stato assegnato un nuovo corso di formazione sulla sicurezza informatica nell'ambito del programma di conformità NIS2 della Sua organizzazione.

+ + + + + + + + + + + + + + + + + + + + + + +
Corso{$this->esc($course['title'] ?? '-')}
Descrizione{$this->esc($course['description'] ?? '-')}
Durata Stimata{$duration} minuti
Tipo{$this->esc($mandatoryText)}
Scadenza{$this->esc($dueDate)}
+ +

+ Inizia il Corso +

+ HTML; + + $subject = "Formazione assegnata: {$course['title']}"; + + $this->send($user['email'], $subject, $html); + } + + /** + * Promemoria scadenza formazione + * + * @param array $assignment Dati assegnazione (due_date) + * @param array $user Dati utente (full_name, email) + * @param array $course Dati corso (title, is_mandatory) + */ + public function sendTrainingReminder(array $assignment, array $user, array $course): void + { + $dueDate = $this->formatDate($assignment['due_date'] ?? ''); + $daysLeft = $this->daysUntil($assignment['due_date'] ?? ''); + + $urgencyColor = match (true) { + $daysLeft <= 1 => '#dc2626', + $daysLeft <= 3 => '#ea580c', + $daysLeft <= 7 => '#f59e0b', + default => '#1e40af', + }; + + $daysText = match (true) { + $daysLeft < 0 => 'La scadenza è stata superata', + $daysLeft === 0 => 'La scadenza è oggi', + $daysLeft === 1 => 'Manca 1 giorno alla scadenza', + default => "Mancano {$daysLeft} giorni alla scadenza", + }; + + $html = << + Promemoria Formazione
+ {$daysText} + + +

Gentile {$this->esc($user['full_name'])},

+ +

Le ricordiamo che il corso di formazione {$this->esc($course['title'])} deve essere completato entro il {$this->esc($dueDate)}.

+ +

Il completamento della formazione sulla sicurezza informatica è un requisito previsto dall'Art. 20 della Direttiva NIS2 e dal programma di compliance della Sua organizzazione.

+ +

+ Completa il Corso +

+ HTML; + + $subject = "Promemoria: {$course['title']} - scadenza {$dueDate}"; + + $this->send($user['email'], $subject, $html); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // AVVISI SCADENZE + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Avviso generico di scadenza + * + * @param string $type Tipo scadenza (assessment, policy_review, training, incident_report, audit, ecc.) + * @param string $title Titolo descrittivo della scadenza + * @param string $dueDate Data di scadenza (Y-m-d o Y-m-d H:i:s) + * @param array $recipients Lista indirizzi email + */ + public function sendDeadlineAlert(string $type, string $title, string $dueDate, array $recipients): void + { + $formattedDate = $this->formatDate($dueDate); + $daysLeft = $this->daysUntil($dueDate); + $typeLabel = $this->translateDeadlineType($type); + + $urgencyColor = match (true) { + $daysLeft <= 1 => '#dc2626', + $daysLeft <= 3 => '#ea580c', + $daysLeft <= 7 => '#f59e0b', + default => '#1e40af', + }; + + $daysText = match (true) { + $daysLeft < 0 => 'Scadenza superata', + $daysLeft === 0 => 'Scade oggi', + $daysLeft === 1 => 'Scade domani', + default => "Scade tra {$daysLeft} giorni", + }; + + $html = << + Avviso Scadenza - {$this->esc($typeLabel)}
+ {$daysText} + + +

Si segnala la seguente scadenza relativa al programma di conformità NIS2:

+ + + + + + + + + + + + + + +
Tipo{$this->esc($typeLabel)}
Descrizione{$this->esc($title)}
Scadenza{$this->esc($formattedDate)}
+ +

+ Vai alla Dashboard +

+ HTML; + + $subject = "[Scadenza] {$typeLabel}: {$title} - {$formattedDate}"; + + foreach ($recipients as $email) { + $this->send($email, $subject, $html); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // BENVENUTO E INVITI + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Email di benvenuto dopo la registrazione + * + * @param array $user Dati utente (full_name, email) + */ + public function sendWelcome(array $user): void + { + $html = << +
🔒
+

Benvenuto in {$this->esc($this->appName)}!

+ + +

Gentile {$this->esc($user['full_name'])},

+ +

La Sua registrazione su {$this->esc($this->appName)} è stata completata con successo.

+ +

{$this->esc($this->appName)} è la piattaforma per la gestione della conformità alla Direttiva NIS2 (EU 2022/2555) e al D.Lgs. 138/2024. Con questa piattaforma potrà:

+ + + +

+ Accedi alla Piattaforma +

+ +

Se non ha effettuato questa registrazione, può ignorare questa email.

+ HTML; + + $subject = "Benvenuto in {$this->appName}"; + + $this->send($user['email'], $subject, $html); + } + + /** + * Invito a unirsi a un'organizzazione + * + * @param array $user Dati utente invitato (full_name, email) + * @param array $organization Dati organizzazione (name) + * @param string $role Ruolo assegnato + */ + public function sendMemberInvite(array $user, array $organization, string $role): void + { + $roleLabel = $this->translateRole($role); + + $html = << + Invito a collaborare
+ {$this->esc($organization['name'])} + + +

Gentile {$this->esc($user['full_name'])},

+ +

È stata invitata/o a unirsi all'organizzazione {$this->esc($organization['name'])} sulla piattaforma {$this->esc($this->appName)} per la gestione della conformità NIS2.

+ + + + + + + + + + +
Organizzazione{$this->esc($organization['name'])}
Ruolo Assegnato{$this->esc($roleLabel)}
+ +

+ Accetta Invito +

+ +

Se non riconosce questa richiesta, può ignorare questa email.

+ HTML; + + $subject = "Invito: unisciti a {$organization['name']} su {$this->appName}"; + + $this->send($user['email'], $subject, $html); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TEMPLATE HTML + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Avvolge il contenuto HTML nel template email professionale + */ + private function wrapInTemplate(string $subject, string $bodyHtml): string + { + $year = date('Y'); + + return << + + + + + {$this->esc($subject)} + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

{$this->esc($this->appName)}

+

Piattaforma di Conformità NIS2

+
+ {$bodyHtml} +
+

+ Questa è una notifica automatica inviata da {$this->esc($this->appName)}.
+ Piattaforma di gestione della conformità alla Direttiva NIS2 (EU 2022/2555). +

+

+ {$this->esc($this->appUrl)} +

+
+

+ Questa email e i suoi allegati sono riservati e destinati esclusivamente ai destinatari indicati. + Se ha ricevuto questa comunicazione per errore, La preghiamo di contattare il mittente e cancellare il messaggio. + La informiamo che il trattamento dei dati personali avviene in conformità al Regolamento (UE) 2016/679 (GDPR). +

+

+ © {$year} {$this->esc($this->appName)} - Tutti i diritti riservati +

+
+
+ + + HTML; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // LOGGING + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Registra l'invio email nel database per audit + */ + private function logEmail(string $to, string $subject, bool $success): void + { + try { + Database::insert('email_log', [ + 'recipient' => $to, + 'subject' => mb_substr($subject, 0, 255), + 'status' => $success ? 'sent' : 'failed', + 'sent_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + error_log('[EmailService] Errore log email: ' . $e->getMessage()); + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // UTILITA' + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Escape HTML per prevenire XSS nei contenuti dinamici + */ + private function esc(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + /** + * Formatta data e ora in formato italiano + */ + private function formatDateTime(string $datetime): string + { + if (empty($datetime)) { + return '-'; + } + + $ts = strtotime($datetime); + if ($ts === false) { + return $datetime; + } + + return date('d/m/Y H:i', $ts); + } + + /** + * Formatta solo la data in formato italiano + */ + private function formatDate(string $date): string + { + if (empty($date)) { + return '-'; + } + + $ts = strtotime($date); + if ($ts === false) { + return $date; + } + + return date('d/m/Y', $ts); + } + + /** + * Calcola i giorni rimanenti fino a una data + */ + private function daysUntil(string $date): int + { + if (empty($date)) { + return 0; + } + + $target = strtotime(date('Y-m-d', strtotime($date))); + $today = strtotime(date('Y-m-d')); + + if ($target === false) { + return 0; + } + + return (int) (($target - $today) / 86400); + } + + /** + * Traduce il livello di gravita' in italiano + */ + private function translateSeverity(string $severity): string + { + return match ($severity) { + 'low' => 'Bassa', + 'medium' => 'Media', + 'high' => 'Alta', + 'critical' => 'Critica', + default => ucfirst($severity), + }; + } + + /** + * Traduce il tipo di scadenza in italiano + */ + private function translateDeadlineType(string $type): string + { + return match ($type) { + 'assessment' => 'Gap Analysis', + 'policy_review' => 'Revisione Policy', + 'training' => 'Formazione', + 'incident_report' => 'Report Incidente', + 'audit' => 'Audit', + 'risk_review' => 'Revisione Rischi', + 'supply_chain' => 'Valutazione Supply Chain', + default => ucfirst(str_replace('_', ' ', $type)), + }; + } + + /** + * Traduce il ruolo organizzativo in italiano + */ + private function translateRole(string $role): string + { + return match ($role) { + 'org_admin' => 'Amministratore Organizzazione', + 'compliance_manager' => 'Compliance Manager', + 'risk_manager' => 'Risk Manager', + 'it_manager' => 'IT Manager', + 'employee' => 'Collaboratore', + 'auditor' => 'Auditor', + 'viewer' => 'Osservatore', + default => ucfirst(str_replace('_', ' ', $role)), + }; + } +} diff --git a/application/services/RateLimitService.php b/application/services/RateLimitService.php new file mode 100644 index 0000000..65a46fc --- /dev/null +++ b/application/services/RateLimitService.php @@ -0,0 +1,214 @@ + int, 'window_seconds' => int], ...] + * @throws RuntimeException Se il rate limit è superato + */ + public static function check(string $key, array $limits): void + { + $timestamps = self::loadTimestamps($key); + $now = time(); + + foreach ($limits as $limit) { + $windowStart = $now - $limit['window_seconds']; + $hitsInWindow = count(array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart)); + + if ($hitsInWindow >= $limit['max']) { + // Calcola secondi rimanenti fino alla scadenza del hit più vecchio nella finestra + $oldestInWindow = array_values(array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart)); + sort($oldestInWindow); + $retryAfter = ($oldestInWindow[0] ?? $now) + $limit['window_seconds'] - $now; + $retryAfter = max(1, $retryAfter); + + throw new RuntimeException( + "Troppi tentativi. Riprova tra {$retryAfter} secondi.", + 429 + ); + } + } + } + + /** + * Registra un hit per la chiave specificata + * + * @param string $key Identificatore univoco + */ + public static function increment(string $key): void + { + $timestamps = self::loadTimestamps($key); + $timestamps[] = time(); + + self::saveTimestamps($key, $timestamps); + } + + /** + * Verifica se il rate limit è superato senza lanciare eccezione + * + * @param string $key Identificatore univoco + * @param array $limits Array di finestre + * @return bool True se il limite è superato + */ + public static function isExceeded(string $key, array $limits): bool + { + try { + self::check($key, $limits); + return false; + } catch (RuntimeException) { + return true; + } + } + + /** + * Restituisce i tentativi rimanenti per ogni finestra + * + * @param string $key Identificatore univoco + * @param array $limits Array di finestre + * @return array Array di ['max' => int, 'window_seconds' => int, 'remaining' => int, 'reset_at' => int] + */ + public static function getRemaining(string $key, array $limits): array + { + $timestamps = self::loadTimestamps($key); + $now = time(); + $result = []; + + foreach ($limits as $limit) { + $windowStart = $now - $limit['window_seconds']; + $hitsInWindow = array_filter($timestamps, fn(int $ts): bool => $ts > $windowStart); + $hitCount = count($hitsInWindow); + + // Calcola quando si resetta la finestra (scadenza del hit più vecchio) + $resetAt = $now + $limit['window_seconds']; + if (!empty($hitsInWindow)) { + $oldest = min($hitsInWindow); + $resetAt = $oldest + $limit['window_seconds']; + } + + $result[] = [ + 'max' => $limit['max'], + 'window_seconds' => $limit['window_seconds'], + 'remaining' => max(0, $limit['max'] - $hitCount), + 'reset_at' => $resetAt, + ]; + } + + return $result; + } + + /** + * Pulisce i file di rate limit scaduti + * Da chiamare periodicamente (es. cron ogni 10 minuti) + */ + public static function cleanup(): void + { + $dir = self::STORAGE_DIR; + + if (!is_dir($dir)) { + return; + } + + $now = time(); + // Finestra massima ragionevole: 1 ora. Elimina file non modificati da più di 2 ore + $maxAge = 7200; + + $files = glob($dir . '/*.json'); + if ($files === false) { + return; + } + + foreach ($files as $file) { + $mtime = filemtime($file); + if ($mtime !== false && ($now - $mtime) > $maxAge) { + @unlink($file); + continue; + } + + // Pulisci anche i timestamp scaduti all'interno dei file ancora attivi + $content = @file_get_contents($file); + if ($content === false) { + continue; + } + + $timestamps = json_decode($content, true); + if (!is_array($timestamps)) { + @unlink($file); + continue; + } + + $cutoff = $now - $maxAge; + $filtered = array_values(array_filter($timestamps, fn(int $ts): bool => $ts > $cutoff)); + + if (empty($filtered)) { + @unlink($file); + } else { + @file_put_contents($file, json_encode($filtered), LOCK_EX); + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Metodi privati + // ───────────────────────────────────────────────────────────────────────── + + /** + * Carica i timestamp dal file di storage + */ + private static function loadTimestamps(string $key): array + { + $file = self::getFilePath($key); + + if (!file_exists($file)) { + return []; + } + + $content = @file_get_contents($file); + if ($content === false) { + return []; + } + + $timestamps = json_decode($content, true); + + return is_array($timestamps) ? $timestamps : []; + } + + /** + * Salva i timestamp nel file di storage + */ + private static function saveTimestamps(string $key, array $timestamps): void + { + $dir = self::STORAGE_DIR; + + if (!is_dir($dir)) { + if (!mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException("Impossibile creare directory rate limit: {$dir}"); + } + } + + $file = self::getFilePath($key); + + if (file_put_contents($file, json_encode($timestamps), LOCK_EX) === false) { + throw new RuntimeException("Impossibile scrivere file rate limit: {$file}"); + } + } + + /** + * Genera il percorso del file per una chiave + */ + private static function getFilePath(string $key): string + { + return self::STORAGE_DIR . '/' . md5($key) . '.json'; + } +} diff --git a/application/services/ReportService.php b/application/services/ReportService.php new file mode 100644 index 0000000..6e6ce29 --- /dev/null +++ b/application/services/ReportService.php @@ -0,0 +1,1306 @@ + 'Organizzazione non trovata']; + } + + // --- Ultimo assessment completato --- + $lastAssessment = Database::fetchOne( + 'SELECT id, title, overall_score, category_scores, status, + completed_at, ai_summary, ai_recommendations + FROM assessments + WHERE organization_id = ? AND status = "completed" + ORDER BY completed_at DESC + LIMIT 1', + [$orgId] + ); + + $categoryScores = []; + if ($lastAssessment && $lastAssessment['category_scores']) { + $categoryScores = json_decode($lastAssessment['category_scores'], true) ?? []; + } + + // --- Compliance controls --- + $controls = Database::fetchAll( + 'SELECT control_code, title, status, implementation_percentage + FROM compliance_controls + WHERE organization_id = ? + ORDER BY control_code', + [$orgId] + ); + + $controlStats = [ + 'total' => count($controls), + 'implemented' => 0, + 'in_progress' => 0, + 'not_started' => 0, + 'not_applicable' => 0, + 'verified' => 0, + ]; + + $totalImplementation = 0; + foreach ($controls as $c) { + $status = $c['status'] ?? 'not_started'; + if (isset($controlStats[$status])) { + $controlStats[$status]++; + } else { + $controlStats['not_started']++; + } + $totalImplementation += (int) ($c['implementation_percentage'] ?? 0); + } + + $controlStats['avg_implementation'] = $controlStats['total'] > 0 + ? round($totalImplementation / $controlStats['total'], 1) + : 0; + + // --- Rischi --- + $risks = Database::fetchAll( + 'SELECT id, risk_code, title, category, likelihood, impact, + inherent_risk_score, residual_risk_score, treatment, status + FROM risks + WHERE organization_id = ? + ORDER BY inherent_risk_score DESC', + [$orgId] + ); + + $openRisks = array_filter($risks, fn(array $r): bool => ($r['status'] ?? '') !== 'closed'); + + $riskByCategory = []; + foreach ($openRisks as $r) { + $cat = $r['category'] ?? 'other'; + if (!isset($riskByCategory[$cat])) { + $riskByCategory[$cat] = 0; + } + $riskByCategory[$cat]++; + } + + $severityDistribution = [ + 'critical' => count(array_filter($openRisks, fn(array $r): bool => ($r['inherent_risk_score'] ?? 0) >= 20)), + 'high' => count(array_filter($openRisks, fn(array $r): bool => ($r['inherent_risk_score'] ?? 0) >= 12 && ($r['inherent_risk_score'] ?? 0) < 20)), + 'medium' => count(array_filter($openRisks, fn(array $r): bool => ($r['inherent_risk_score'] ?? 0) >= 6 && ($r['inherent_risk_score'] ?? 0) < 12)), + 'low' => count(array_filter($openRisks, fn(array $r): bool => ($r['inherent_risk_score'] ?? 0) < 6)), + ]; + + $riskSummary = [ + 'total_risks' => count($risks), + 'open_risks' => count($openRisks), + 'by_category' => $riskByCategory, + 'severity_distribution' => $severityDistribution, + ]; + + // --- Incidenti aperti --- + $openIncidents = Database::count( + 'incidents', + 'organization_id = ? AND status NOT IN ("closed", "post_mortem")', + [$orgId] + ); + + $totalIncidents = Database::count('incidents', 'organization_id = ?', [$orgId]); + + $significantIncidents = Database::count( + 'incidents', + 'organization_id = ? AND is_significant = 1', + [$orgId] + ); + + $incidentSummary = [ + 'total' => $totalIncidents, + 'open' => $openIncidents, + 'significant' => $significantIncidents, + ]; + + // --- Policy --- + $policyStats = Database::fetchAll( + 'SELECT status, COUNT(*) as count + FROM policies + WHERE organization_id = ? + GROUP BY status', + [$orgId] + ); + + $policyMap = []; + $totalPolicies = 0; + foreach ($policyStats as $ps) { + $policyMap[$ps['status']] = (int) $ps['count']; + $totalPolicies += (int) $ps['count']; + } + + $policySummary = [ + 'total' => $totalPolicies, + 'approved' => $policyMap['approved'] ?? 0, + 'draft' => $policyMap['draft'] ?? 0, + 'review' => $policyMap['review'] ?? 0, + 'archived' => $policyMap['archived'] ?? 0, + ]; + + // --- Formazione --- + $trainingStats = Database::fetchOne( + 'SELECT + COUNT(*) as total, + SUM(CASE WHEN status = "completed" THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = "in_progress" THEN 1 ELSE 0 END) as in_progress, + SUM(CASE WHEN status = "assigned" THEN 1 ELSE 0 END) as assigned, + SUM(CASE WHEN status = "overdue" THEN 1 ELSE 0 END) as overdue + FROM training_assignments + WHERE organization_id = ?', + [$orgId] + ); + + $trainingTotal = (int) ($trainingStats['total'] ?? 0); + $trainingCompleted = (int) ($trainingStats['completed'] ?? 0); + + $trainingSummary = [ + 'total_assignments' => $trainingTotal, + 'completed' => $trainingCompleted, + 'in_progress' => (int) ($trainingStats['in_progress'] ?? 0), + 'assigned' => (int) ($trainingStats['assigned'] ?? 0), + 'overdue' => (int) ($trainingStats['overdue'] ?? 0), + 'completion_rate' => $trainingTotal > 0 + ? round($trainingCompleted / $trainingTotal * 100, 1) + : 0, + ]; + + // --- Supply chain --- + $supplierStats = Database::fetchOne( + 'SELECT + COUNT(*) as total, + SUM(CASE WHEN criticality IN ("high", "critical") THEN 1 ELSE 0 END) as critical_high, + SUM(CASE WHEN security_requirements_met = 1 THEN 1 ELSE 0 END) as compliant, + SUM(CASE WHEN security_requirements_met = 0 THEN 1 ELSE 0 END) as non_compliant, + AVG(risk_score) as avg_risk_score + FROM suppliers + WHERE organization_id = ? AND status = "active"', + [$orgId] + ); + + $supplyChainSummary = [ + 'total_suppliers' => (int) ($supplierStats['total'] ?? 0), + 'critical_high' => (int) ($supplierStats['critical_high'] ?? 0), + 'compliant' => (int) ($supplierStats['compliant'] ?? 0), + 'non_compliant' => (int) ($supplierStats['non_compliant'] ?? 0), + 'avg_risk_score' => round((float) ($supplierStats['avg_risk_score'] ?? 0), 1), + ]; + + // --- Composizione report --- + return [ + 'generated_at' => date('Y-m-d H:i:s'), + 'organization' => $organization, + 'assessment' => [ + 'last_assessment' => $lastAssessment, + 'overall_score' => $lastAssessment ? (float) $lastAssessment['overall_score'] : null, + 'category_scores' => $categoryScores, + ], + 'controls' => [ + 'items' => $controls, + 'stats' => $controlStats, + ], + 'risks' => $riskSummary, + 'incidents' => $incidentSummary, + 'policies' => $policySummary, + 'training' => $trainingSummary, + 'supply_chain' => $supplyChainSummary, + ]; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // REPORT ESECUTIVO HTML (Art. 20 NIS2) + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Genera un report esecutivo HTML stampabile per il CdA. + * + * Il documento e completamente autonomo (inline CSS) e pensato + * per essere stampato dal browser o salvato come PDF. + * + * Riferimento: Art. 20 Direttiva NIS2 - Governance e responsabilita + * degli organi di gestione. + * + * @param int $orgId ID dell'organizzazione + * @return string Documento HTML completo + */ + public function generateExecutiveReport(int $orgId): string + { + $data = $this->generateComplianceReport($orgId); + + if (isset($data['error'])) { + return $this->buildErrorHtml($data['error']); + } + + $org = $data['organization']; + $assessment = $data['assessment']; + $controls = $data['controls']['stats']; + $riskSummary = $data['risks']; + $incidents = $data['incidents']; + $policies = $data['policies']; + $training = $data['training']; + $supplyChain = $data['supply_chain']; + + $orgName = htmlspecialchars($org['name'] ?? '', ENT_QUOTES, 'UTF-8'); + $orgSector = htmlspecialchars($org['sector'] ?? '', ENT_QUOTES, 'UTF-8'); + $orgEntityType = htmlspecialchars($org['entity_type'] ?? '', ENT_QUOTES, 'UTF-8'); + $score = $assessment['overall_score'] ?? 0; + $scoreFormatted = number_format((float) $score, 1, ',', '.'); + $reportDate = date('d/m/Y'); + $reportTime = date('H:i'); + $appName = defined('APP_NAME') ? APP_NAME : 'NIS2 Agile'; + $appVersion = defined('APP_VERSION') ? APP_VERSION : '1.0.0'; + $appUrl = defined('APP_URL') ? APP_URL : ''; + + $scoreColor = $this->getScoreColor((float) $score); + $scoreLabel = $this->getScoreLabel((float) $score); + + // Categoria scores per la tabella + $categoryRows = ''; + if (!empty($assessment['category_scores'])) { + foreach ($assessment['category_scores'] as $catId => $catData) { + $catScore = is_array($catData) ? ($catData['score'] ?? 0) : (float) $catData; + $catColor = $this->getScoreColor($catScore); + $catLabel = htmlspecialchars(ucfirst(str_replace('_', ' ', $catId)), ENT_QUOTES, 'UTF-8'); + $catScoreFmt = number_format($catScore, 1, ',', '.'); + $categoryRows .= << + {$catLabel} + + {$catScoreFmt}% + + +
+
+
+ + + ROW; + } + } + + // Risk heatmap summary + $sevDist = $riskSummary['severity_distribution'] ?? []; + $riskCritical = (int) ($sevDist['critical'] ?? 0); + $riskHigh = (int) ($sevDist['high'] ?? 0); + $riskMedium = (int) ($sevDist['medium'] ?? 0); + $riskLow = (int) ($sevDist['low'] ?? 0); + + // Risk by category + $riskCategoryRows = ''; + $riskByCategory = $riskSummary['by_category'] ?? []; + arsort($riskByCategory); + foreach ($riskByCategory as $cat => $count) { + $catLabel = htmlspecialchars(ucfirst(str_replace('_', ' ', $cat)), ENT_QUOTES, 'UTF-8'); + $riskCategoryRows .= << + {$catLabel} + {$count} + + ROW; + } + + // Raccomandazioni + $recommendations = $this->buildRecommendations($data); + $recommendationItems = ''; + foreach ($recommendations as $rec) { + $recText = htmlspecialchars($rec, ENT_QUOTES, 'UTF-8'); + $recommendationItems .= "
  • {$recText}
  • \n"; + } + + // Assessment info + $assessmentDate = 'N/D'; + $assessmentTitle = 'Nessun assessment completato'; + if ($assessment['last_assessment']) { + $assessmentDate = date('d/m/Y', strtotime($assessment['last_assessment']['completed_at'])); + $assessmentTitle = htmlspecialchars($assessment['last_assessment']['title'] ?? '', ENT_QUOTES, 'UTF-8'); + } + + // Training completion + $trainingRate = number_format($training['completion_rate'] ?? 0, 1, ',', '.'); + $trainingColor = $this->getScoreColor($training['completion_rate'] ?? 0); + + // Supply chain + $scCompliance = $supplyChain['total_suppliers'] > 0 + ? round($supplyChain['compliant'] / $supplyChain['total_suppliers'] * 100, 1) + : 0; + $scComplianceFmt = number_format($scCompliance, 1, ',', '.'); + + // Costruzione HTML + $html = << + + + + + Report Esecutivo NIS2 - {$orgName} + + + + + +
    +

    Report Esecutivo Compliance NIS2

    +

    {$orgName}

    +
    + Settore: {$orgSector} | Tipo entità: {$orgEntityType} + Data: {$reportDate} ore {$reportTime} +
    +
    + + +
    +
    Indice di Compliance NIS2
    +
    +
    {$scoreFormatted}%
    +
    {$scoreLabel}
    +
    Ultimo assessment: {$assessmentTitle} ({$assessmentDate})
    +
    +
    + + +
    +
    Metriche Chiave
    +
    +
    +
    {$controls['total']}
    +
    Controlli totali
    +
    +
    +
    {$controls['avg_implementation']}%
    +
    Implementazione media
    +
    +
    +
    {$riskSummary['open_risks']}
    +
    Rischi aperti
    +
    +
    +
    {$incidents['open']}
    +
    Incidenti attivi
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    IndicatoreValoreStato
    Policy approvate{$policies['approved']} / {$policies['total']}{$this->getStatusBadge($policies['total'] > 0 ? $policies['approved'] / $policies['total'] * 100 : 0)}
    Formazione completata{$trainingRate}%{$this->getStatusBadge($training['completion_rate'] ?? 0)}
    Fornitori conformi (supply chain){$supplyChain['compliant']} / {$supplyChain['total_suppliers']}{$this->getStatusBadge($scCompliance)}
    Incidenti significativi (NIS2 Art. 23){$incidents['significant']}{$this->getCountBadge($incidents['significant'])}
    Fornitori critici non conformi{$supplyChain['non_compliant']}{$this->getCountBadge($supplyChain['non_compliant'])}
    +
    + + +
    +
    Dettaglio per Categoria (Art. 21.2 NIS2)
    + + + + + + + + + + {$categoryRows} + +
    CategoriaScoreProgresso
    +
    + +
    + + +
    +
    Riepilogo Rischi Cyber
    + +
    +
    +
    {$riskCritical}
    +
    Critici
    +
    +
    +
    {$riskHigh}
    +
    Alti
    +
    +
    +
    {$riskMedium}
    +
    Medi
    +
    +
    +
    {$riskLow}
    +
    Bassi
    +
    +
    + +
    +
    + + + + + + + + + {$riskCategoryRows} + +
    Categoria rischioN. rischi
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    MetricaValore
    Rischi totali registrati{$riskSummary['total_risks']}
    Rischi aperti{$riskSummary['open_risks']}
    Critici + Alti{$this->sumValues($riskCritical, $riskHigh)}
    Score medio fornitori{$supplyChain['avg_risk_score']}
    +
    +
    +
    + + +
    +
    Raccomandazioni Prioritarie
    +
    +
      + {$recommendationItems} +
    +
    +
    + + +
    +

    + Il presente report è generato automaticamente dalla piattaforma {$appName} ed è + destinato agli organi di gestione ai sensi dell'Art. 20 della Direttiva NIS2 (UE 2022/2555) e + del D.Lgs. 138/2024. I dati si riferiscono alla data di generazione e potrebbero non riflettere + variazioni successive. Si raccomanda una revisione periodica da parte del responsabile compliance. +

    +
    + + + + + + + HTML; + + return $html; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // EXPORT CSV + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Esporta tutti i rischi dell'organizzazione in formato CSV. + * + * @param int $orgId ID dell'organizzazione + * @return string Contenuto CSV con BOM UTF-8 + */ + public function exportRisksCSV(int $orgId): string + { + $risks = Database::fetchAll( + 'SELECT r.risk_code, r.title, r.description, r.category, + r.threat_source, r.vulnerability, + r.likelihood, r.impact, r.inherent_risk_score, + r.residual_likelihood, r.residual_impact, r.residual_risk_score, + r.treatment, r.status, r.nis2_article, r.review_date, + r.created_at, + u.full_name AS responsabile + FROM risks r + LEFT JOIN users u ON u.id = r.owner_user_id + WHERE r.organization_id = ? + ORDER BY r.inherent_risk_score DESC', + [$orgId] + ); + + $headers = [ + 'Codice', + 'Titolo', + 'Descrizione', + 'Categoria', + 'Fonte minaccia', + 'Vulnerabilita', + 'Probabilita', + 'Impatto', + 'Score rischio inerente', + 'Probabilita residua', + 'Impatto residuo', + 'Score rischio residuo', + 'Trattamento', + 'Stato', + 'Articolo NIS2', + 'Data revisione', + 'Data creazione', + 'Responsabile', + ]; + + $rows = []; + foreach ($risks as $r) { + $rows[] = [ + $r['risk_code'] ?? '', + $r['title'] ?? '', + $r['description'] ?? '', + $r['category'] ?? '', + $r['threat_source'] ?? '', + $r['vulnerability'] ?? '', + $r['likelihood'] ?? '', + $r['impact'] ?? '', + $r['inherent_risk_score'] ?? '', + $r['residual_likelihood'] ?? '', + $r['residual_impact'] ?? '', + $r['residual_risk_score'] ?? '', + $r['treatment'] ?? '', + $r['status'] ?? '', + $r['nis2_article'] ?? '', + $this->formatDateIT($r['review_date'] ?? ''), + $this->formatDateTimeIT($r['created_at'] ?? ''), + $r['responsabile'] ?? '', + ]; + } + + return $this->buildCSV($headers, $rows); + } + + /** + * Esporta tutti gli incidenti dell'organizzazione in formato CSV. + * + * @param int $orgId ID dell'organizzazione + * @return string Contenuto CSV con BOM UTF-8 + */ + public function exportIncidentsCSV(int $orgId): string + { + $incidents = Database::fetchAll( + 'SELECT i.incident_code, i.title, i.description, i.classification, + i.severity, i.is_significant, i.status, + i.detected_at, i.closed_at, + i.affected_services, i.affected_users_count, + i.cross_border_impact, i.malicious_action, + i.root_cause, i.remediation_actions, i.lessons_learned, + i.early_warning_due, i.early_warning_sent_at, + i.notification_due, i.notification_sent_at, + i.final_report_due, i.final_report_sent_at, + u1.full_name AS segnalato_da, + u2.full_name AS assegnato_a + FROM incidents i + LEFT JOIN users u1 ON u1.id = i.reported_by + LEFT JOIN users u2 ON u2.id = i.assigned_to + WHERE i.organization_id = ? + ORDER BY i.detected_at DESC', + [$orgId] + ); + + $headers = [ + 'Codice', + 'Titolo', + 'Descrizione', + 'Classificazione', + 'Severita', + 'Significativo NIS2', + 'Stato', + 'Data rilevamento', + 'Data chiusura', + 'Servizi impattati', + 'Utenti impattati', + 'Impatto transfrontaliero', + 'Azione malevola', + 'Causa radice', + 'Azioni di rimedio', + 'Lezioni apprese', + 'Early warning scadenza', + 'Early warning inviato', + 'Notifica CSIRT scadenza', + 'Notifica CSIRT inviata', + 'Report finale scadenza', + 'Report finale inviato', + 'Segnalato da', + 'Assegnato a', + ]; + + $rows = []; + foreach ($incidents as $i) { + $rows[] = [ + $i['incident_code'] ?? '', + $i['title'] ?? '', + $i['description'] ?? '', + $i['classification'] ?? '', + $i['severity'] ?? '', + $i['is_significant'] ? 'Si' : 'No', + $i['status'] ?? '', + $this->formatDateTimeIT($i['detected_at'] ?? ''), + $this->formatDateTimeIT($i['closed_at'] ?? ''), + $i['affected_services'] ?? '', + $i['affected_users_count'] ?? '', + $i['cross_border_impact'] ? 'Si' : 'No', + $i['malicious_action'] ? 'Si' : 'No', + $i['root_cause'] ?? '', + $i['remediation_actions'] ?? '', + $i['lessons_learned'] ?? '', + $this->formatDateTimeIT($i['early_warning_due'] ?? ''), + $this->formatDateTimeIT($i['early_warning_sent_at'] ?? ''), + $this->formatDateTimeIT($i['notification_due'] ?? ''), + $this->formatDateTimeIT($i['notification_sent_at'] ?? ''), + $this->formatDateTimeIT($i['final_report_due'] ?? ''), + $this->formatDateTimeIT($i['final_report_sent_at'] ?? ''), + $i['segnalato_da'] ?? '', + $i['assegnato_a'] ?? '', + ]; + } + + return $this->buildCSV($headers, $rows); + } + + /** + * Esporta i controlli di compliance dell'organizzazione in formato CSV. + * + * @param int $orgId ID dell'organizzazione + * @return string Contenuto CSV con BOM UTF-8 + */ + public function exportControlsCSV(int $orgId): string + { + $controls = Database::fetchAll( + 'SELECT control_code, title, status, implementation_percentage + FROM compliance_controls + WHERE organization_id = ? + ORDER BY control_code', + [$orgId] + ); + + $headers = [ + 'Codice controllo', + 'Titolo', + 'Stato', + 'Percentuale implementazione', + ]; + + $rows = []; + foreach ($controls as $c) { + $rows[] = [ + $c['control_code'] ?? '', + $c['title'] ?? '', + $c['status'] ?? '', + $c['implementation_percentage'] ?? '0', + ]; + } + + return $this->buildCSV($headers, $rows); + } + + /** + * Esporta tutti gli asset dell'organizzazione in formato CSV. + * + * @param int $orgId ID dell'organizzazione + * @return string Contenuto CSV con BOM UTF-8 + */ + public function exportAssetsCSV(int $orgId): string + { + $assets = Database::fetchAll( + 'SELECT a.name, a.asset_type, a.category, a.description, + a.criticality, a.location, a.ip_address, + a.vendor, a.version, a.serial_number, + a.purchase_date, a.warranty_expiry, a.status, + u.full_name AS responsabile + FROM assets a + LEFT JOIN users u ON u.id = a.owner_user_id + WHERE a.organization_id = ? + ORDER BY a.criticality DESC, a.name', + [$orgId] + ); + + $headers = [ + 'Nome', + 'Tipo asset', + 'Categoria', + 'Descrizione', + 'Criticita', + 'Ubicazione', + 'Indirizzo IP', + 'Vendor', + 'Versione', + 'Numero seriale', + 'Data acquisto', + 'Scadenza garanzia', + 'Stato', + 'Responsabile', + ]; + + $rows = []; + foreach ($assets as $a) { + $rows[] = [ + $a['name'] ?? '', + $a['asset_type'] ?? '', + $a['category'] ?? '', + $a['description'] ?? '', + $a['criticality'] ?? '', + $a['location'] ?? '', + $a['ip_address'] ?? '', + $a['vendor'] ?? '', + $a['version'] ?? '', + $a['serial_number'] ?? '', + $this->formatDateIT($a['purchase_date'] ?? ''), + $this->formatDateIT($a['warranty_expiry'] ?? ''), + $a['status'] ?? '', + $a['responsabile'] ?? '', + ]; + } + + return $this->buildCSV($headers, $rows); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // METODI PRIVATI - COSTRUZIONE CSV + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Costruisce il contenuto CSV con BOM UTF-8 e separatore punto e virgola. + * + * @param string[] $headers Intestazioni colonne + * @param array $rows Righe dati + * @return string Contenuto CSV completo + */ + private function buildCSV(array $headers, array $rows): string + { + // BOM UTF-8 per compatibilita Excel + $bom = "\xEF\xBB\xBF"; + + $output = fopen('php://temp', 'r+'); + fwrite($output, $bom); + + // Intestazioni + fputcsv($output, $headers, ';', '"', '\\'); + + // Righe dati + foreach ($rows as $row) { + fputcsv($output, $row, ';', '"', '\\'); + } + + rewind($output); + $csv = stream_get_contents($output); + fclose($output); + + return $csv; + } + + /** + * Formatta una data in formato italiano (gg/mm/aaaa). + */ + private function formatDateIT(string $date): string + { + if (empty($date) || $date === '0000-00-00') { + return ''; + } + + $ts = strtotime($date); + return $ts ? date('d/m/Y', $ts) : ''; + } + + /** + * Formatta una data/ora in formato italiano (gg/mm/aaaa HH:ii). + */ + private function formatDateTimeIT(string $datetime): string + { + if (empty($datetime) || str_starts_with($datetime, '0000')) { + return ''; + } + + $ts = strtotime($datetime); + return $ts ? date('d/m/Y H:i', $ts) : ''; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // METODI PRIVATI - HTML HELPER + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Restituisce il colore corrispondente allo score di compliance. + */ + private function getScoreColor(float $score): string + { + return match (true) { + $score >= 80 => '#16a34a', // verde + $score >= 60 => '#ca8a04', // giallo scuro + $score >= 40 => '#ea580c', // arancione + default => '#dc2626', // rosso + }; + } + + /** + * Restituisce l'etichetta testuale dello score di compliance. + */ + private function getScoreLabel(float $score): string + { + return match (true) { + $score >= 80 => 'Buon livello di compliance', + $score >= 60 => 'Compliance parziale - interventi necessari', + $score >= 40 => 'Compliance insufficiente - azioni urgenti', + $score > 0 => 'Compliance critica - rischio elevato', + default => 'Nessun assessment completato', + }; + } + + /** + * Genera un badge HTML per una percentuale di raggiungimento. + */ + private function getStatusBadge(float $percentage): string + { + $color = $this->getScoreColor($percentage); + $label = match (true) { + $percentage >= 80 => 'Buono', + $percentage >= 60 => 'Parziale', + $percentage >= 40 => 'Insufficiente', + default => 'Critico', + }; + + return "{$label}"; + } + + /** + * Genera un badge HTML basato su un conteggio (0 = buono, >0 = attenzione). + */ + private function getCountBadge(int $count): string + { + if ($count === 0) { + return 'OK'; + } + + $color = $count >= 3 ? '#dc2626' : '#ea580c'; + return "Attenzione"; + } + + /** + * Somma due valori interi (helper per il template HTML). + */ + private function sumValues(int $a, int $b): int + { + return $a + $b; + } + + /** + * Genera la lista di raccomandazioni prioritarie basate sui dati del report. + * + * @param array $data Dati completi del report di compliance + * @return string[] Lista di raccomandazioni + */ + private function buildRecommendations(array $data): array + { + $recs = []; + + $score = $data['assessment']['overall_score'] ?? 0; + $controls = $data['controls']['stats']; + $risks = $data['risks']; + $incidents = $data['incidents']; + $policies = $data['policies']; + $training = $data['training']; + $supplyChain = $data['supply_chain']; + + // Score complessivo basso + if ($score === null || $score === 0) { + $recs[] = 'Completare il primo assessment NIS2 per ottenere una baseline di compliance.'; + } elseif ($score < 40) { + $recs[] = 'Lo score di compliance e critico (' . number_format($score, 1, ',', '.') . '%). Avviare un programma di adeguamento urgente con priorita sui controlli a rischio piu elevato.'; + } elseif ($score < 60) { + $recs[] = 'Lo score di compliance e insufficiente (' . number_format($score, 1, ',', '.') . '%). Definire un piano di remediation con timeline e responsabili per ogni area carente.'; + } + + // Rischi critici/alti + $sevDist = $risks['severity_distribution'] ?? []; + $criticalHigh = ($sevDist['critical'] ?? 0) + ($sevDist['high'] ?? 0); + if ($criticalHigh > 0) { + $recs[] = "Sono presenti {$criticalHigh} rischi con severita critica o alta. Prioritizzare il trattamento immediato con piani di mitigazione specifici."; + } + + // Controlli non avviati + $notStarted = $controls['not_started'] ?? 0; + if ($notStarted > 3) { + $recs[] = "{$notStarted} controlli di sicurezza non sono ancora stati avviati. Assegnare responsabili e definire tempistiche per l'implementazione."; + } + + // Policy non approvate + if ($policies['total'] > 0 && $policies['approved'] < $policies['total']) { + $pending = $policies['total'] - $policies['approved']; + $recs[] = "{$pending} policy sono in attesa di approvazione. L'Art. 20 NIS2 richiede che gli organi di gestione approvino le misure di sicurezza."; + } elseif ($policies['total'] === 0) { + $recs[] = 'Nessuna policy di sicurezza presente. Creare e approvare le policy fondamentali (sicurezza informazioni, gestione incidenti, continuita operativa).'; + } + + // Formazione + $trainingRate = $training['completion_rate'] ?? 0; + if ($trainingRate < 50) { + $recs[] = 'Il tasso di completamento della formazione e basso (' . number_format($trainingRate, 1, ',', '.') . '%). L\'Art. 20 NIS2 richiede formazione obbligatoria per organi di gestione e personale.'; + } + + $overdueTraining = $training['overdue'] ?? 0; + if ($overdueTraining > 0) { + $recs[] = "{$overdueTraining} assegnazioni formative sono scadute. Sollecitare il completamento per garantire la conformita."; + } + + // Supply chain + if ($supplyChain['non_compliant'] > 0) { + $recs[] = "{$supplyChain['non_compliant']} fornitori non soddisfano i requisiti di sicurezza (Art. 21.2.d NIS2). Avviare assessment e definire requisiti contrattuali."; + } + + // Incidenti significativi + if ($incidents['significant'] > 0 && $incidents['open'] > 0) { + $recs[] = "Sono presenti {$incidents['open']} incidenti attivi di cui {$incidents['significant']} significativi. Verificare il rispetto delle scadenze di notifica CSIRT (Art. 23 NIS2: 24h/72h/30gg)."; + } + + // Se tutto sembra a posto + if (empty($recs)) { + $recs[] = 'Il livello di compliance e complessivamente buono. Continuare con il monitoraggio continuo e le revisioni periodiche.'; + $recs[] = 'Pianificare il prossimo assessment per verificare il mantenimento dello score.'; + } + + return $recs; + } + + /** + * Costruisce una pagina HTML di errore minimale. + */ + private function buildErrorHtml(string $message): string + { + $escapedMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); + $appName = defined('APP_NAME') ? APP_NAME : 'NIS2 Agile'; + + return << + + + + Errore Report - {$appName} + + + +
    +

    Errore generazione report

    +

    {$escapedMessage}

    +
    + + + HTML; + } +} diff --git a/docs/sql/002_email_log.sql b/docs/sql/002_email_log.sql new file mode 100644 index 0000000..b81d4c4 --- /dev/null +++ b/docs/sql/002_email_log.sql @@ -0,0 +1,17 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- NIS2 Agile - Migration 002: Email Log +-- ═══════════════════════════════════════════════════════════════════════════ + +USE nis2_agile_db; + +CREATE TABLE IF NOT EXISTS email_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + recipient VARCHAR(255) NOT NULL, + subject VARCHAR(500) NOT NULL, + type VARCHAR(100), + status ENUM('sent','failed') DEFAULT 'sent', + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_recipient (recipient), + INDEX idx_created (created_at) +) ENGINE=InnoDB; diff --git a/public/index.php b/public/index.php index fe560d3..22a591b 100644 --- a/public/index.php +++ b/public/index.php @@ -258,6 +258,8 @@ $actionMap = [ 'GET:report' => 'generateReport', 'GET:logs' => 'getAuditLogs', 'GET:iso27001Mapping' => 'getIsoMapping', + 'GET:executiveReport' => 'executiveReport', + 'GET:export' => 'export', ], // ── AdminController ───────────────────────────── @@ -373,6 +375,19 @@ try { } else { $controller->$resolvedAction(); } +} catch (RuntimeException $e) { + // Rate limit exceeded (429) + if ($e->getCode() === 429) { + http_response_code(429); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => $e->getMessage(), + 'error_code' => 'RATE_LIMITED', + ]); + exit; + } + throw $e; } catch (PDOException $e) { error_log('[DB_ERROR] ' . $e->getMessage()); http_response_code(500); diff --git a/public/js/api.js b/public/js/api.js index bb3e2b7..e2e4e04 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -238,6 +238,8 @@ class NIS2API { generateComplianceReport() { return this.get('/audit/report'); } getAuditLogs(params = {}) { return this.get('/audit/logs?' + new URLSearchParams(params)); } getIsoMapping() { return this.get('/audit/iso27001-mapping'); } + getExecutiveReportUrl() { return this.baseUrl + '/audit/executive-report'; } + getExportUrl(type) { return this.baseUrl + '/audit/export?type=' + type; } // ═══════════════════════════════════════════════════════════════════ // Onboarding