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à:
+
+
+ - Effettuare la Gap Analysis rispetto ai requisiti NIS2
+ - Gestire il Risk Assessment con matrice di rischio
+ - Gestire gli incidenti di sicurezza con i flussi di notifica Art. 23
+ - Generare policy di sicurezza personalizzate
+ - Monitorare la supply chain e i fornitori critici
+ - Gestire la formazione del personale (Art. 20)
+ - Preparare audit e documentazione per le autorità
+
+
+
+ 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}
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+ | Indicatore |
+ Valore |
+ Stato |
+
+
+
+
+ | 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)
+
+
+
+ | Categoria |
+ Score |
+ Progresso |
+
+
+
+ {$categoryRows}
+
+
+
+
+
+
+
+
+
Riepilogo Rischi Cyber
+
+
+
+
{$riskCritical}
+
Critici
+
+
+
+
+
+
+
+
+
+
+
+ | Categoria rischio |
+ N. rischi |
+
+
+
+ {$riskCategoryRows}
+
+
+
+
+
+
+
+ | Metrica |
+ Valore |
+
+
+
+
+ | 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