[FEAT] Add EmailService, RateLimitService, ReportService + integrations
Services: - EmailService: CSIRT notifications (24h/72h/30d), training alerts, welcome email - RateLimitService: File-based rate limiting for auth and AI endpoints - ReportService: Executive HTML report, CSV exports (risks/incidents/controls/assets) Integrations: - AuthController: Rate limiting on login (5/min, 20/h) and register (3/10min) - IncidentController: Email notifications on CSIRT milestones - AuditController: Executive report and CSV export endpoints - Router: 429 rate limit error handling, new audit export routes Database: - Migration 002: email_log table for notification tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9aa2788c68
commit
6f4b457ce0
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/BaseController.php';
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
require_once APP_PATH . '/services/ReportService.php';
|
||||||
|
|
||||||
class AuditController extends BaseController
|
class AuditController extends BaseController
|
||||||
{
|
{
|
||||||
@ -193,4 +194,48 @@ class AuditController extends BaseController
|
|||||||
|
|
||||||
$this->jsonSuccess($mapping);
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/BaseController.php';
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
require_once APP_PATH . '/services/RateLimitService.php';
|
||||||
|
|
||||||
class AuthController extends BaseController
|
class AuthController extends BaseController
|
||||||
{
|
{
|
||||||
@ -14,6 +15,11 @@ class AuthController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
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']);
|
$this->validateRequired(['email', 'password', 'full_name']);
|
||||||
|
|
||||||
$email = strtolower(trim($this->getParam('email')));
|
$email = strtolower(trim($this->getParam('email')));
|
||||||
@ -78,6 +84,11 @@ class AuthController extends BaseController
|
|||||||
*/
|
*/
|
||||||
public function login(): void
|
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']);
|
$this->validateRequired(['email', 'password']);
|
||||||
|
|
||||||
$email = strtolower(trim($this->getParam('email')));
|
$email = strtolower(trim($this->getParam('email')));
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
require_once __DIR__ . '/BaseController.php';
|
require_once __DIR__ . '/BaseController.php';
|
||||||
require_once APP_PATH . '/services/AIService.php';
|
require_once APP_PATH . '/services/AIService.php';
|
||||||
|
require_once APP_PATH . '/services/EmailService.php';
|
||||||
|
|
||||||
class IncidentController extends BaseController
|
class IncidentController extends BaseController
|
||||||
{
|
{
|
||||||
@ -237,6 +238,9 @@ class IncidentController extends BaseController
|
|||||||
'created_by' => $this->getCurrentUserId(),
|
'created_by' => $this->getCurrentUserId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Invia notifica email ai responsabili
|
||||||
|
$this->notifyIncidentStakeholders($id, 'early_warning');
|
||||||
|
|
||||||
$this->logAudit('early_warning_sent', 'incident', $id);
|
$this->logAudit('early_warning_sent', 'incident', $id);
|
||||||
$this->jsonSuccess(null, 'Early warning registrato');
|
$this->jsonSuccess(null, 'Early warning registrato');
|
||||||
}
|
}
|
||||||
@ -260,6 +264,9 @@ class IncidentController extends BaseController
|
|||||||
'created_by' => $this->getCurrentUserId(),
|
'created_by' => $this->getCurrentUserId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Invia notifica email ai responsabili
|
||||||
|
$this->notifyIncidentStakeholders($id, 'notification');
|
||||||
|
|
||||||
$this->logAudit('notification_sent', 'incident', $id);
|
$this->logAudit('notification_sent', 'incident', $id);
|
||||||
$this->jsonSuccess(null, 'Notifica CSIRT registrata');
|
$this->jsonSuccess(null, 'Notifica CSIRT registrata');
|
||||||
}
|
}
|
||||||
@ -283,10 +290,48 @@ class IncidentController extends BaseController
|
|||||||
'created_by' => $this->getCurrentUserId(),
|
'created_by' => $this->getCurrentUserId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Invia notifica email ai responsabili
|
||||||
|
$this->notifyIncidentStakeholders($id, 'final_report');
|
||||||
|
|
||||||
$this->logAudit('final_report_sent', 'incident', $id);
|
$this->logAudit('final_report_sent', 'incident', $id);
|
||||||
$this->jsonSuccess(null, 'Report finale registrato');
|
$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
|
* POST /api/incidents/{id}/ai-classify
|
||||||
*/
|
*/
|
||||||
|
|||||||
728
application/services/EmailService.php
Normal file
728
application/services/EmailService.php
Normal file
@ -0,0 +1,728 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Email Service
|
||||||
|
*
|
||||||
|
* Gestione notifiche email per compliance NIS2.
|
||||||
|
* Utilizza mail() nativa con Postfix/sendmail su server Hetzner.
|
||||||
|
*
|
||||||
|
* Notifiche supportate:
|
||||||
|
* - Early warning incidenti (24h, Art. 23 NIS2)
|
||||||
|
* - Notifica incidenti (72h)
|
||||||
|
* - Report finale incidenti (30 giorni)
|
||||||
|
* - Assegnazione e promemoria formazione
|
||||||
|
* - Avvisi scadenze generiche
|
||||||
|
* - Benvenuto registrazione
|
||||||
|
* - Invito organizzazione
|
||||||
|
*/
|
||||||
|
|
||||||
|
class EmailService
|
||||||
|
{
|
||||||
|
private string $defaultFrom;
|
||||||
|
private string $appName;
|
||||||
|
private string $appUrl;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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 = <<<HTML
|
||||||
|
<div style="background-color: #fef2f2; border-left: 4px solid #dc2626; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
|
||||||
|
<strong style="color: #dc2626; font-size: 16px;">EARLY WARNING - Preallarme 24 ore</strong><br>
|
||||||
|
<span style="color: #991b1b;">Ai sensi dell'Art. 23, par. 4, lett. a) della Direttiva NIS2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Si comunica che l'organizzazione <strong>{$this->esc($organization['name'])}</strong> ha rilevato un incidente significativo che richiede la notifica obbligatoria al CSIRT nazionale (ACN).</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Codice Incidente</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['incident_code'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Titolo</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['title'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Classificazione</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['classification'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Gravità</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($severityLabel)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Rilevato il</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($detectedAt)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza Early Warning</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; color: #dc2626; font-weight: 600;">{$this->esc($deadline)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 14px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<strong>Azione richiesta:</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}/dashboard/incidents" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Gestisci Incidente</a>
|
||||||
|
</p>
|
||||||
|
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 = <<<HTML
|
||||||
|
<div style="background-color: #fff7ed; border-left: 4px solid #ea580c; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
|
||||||
|
<strong style="color: #ea580c; font-size: 16px;">NOTIFICA INCIDENTE - Termine 72 ore</strong><br>
|
||||||
|
<span style="color: #9a3412;">Ai sensi dell'Art. 23, par. 4, lett. b) della Direttiva NIS2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Si comunica la necessità di inviare la notifica formale dell'incidente significativo al CSIRT nazionale (ACN) per l'organizzazione <strong>{$this->esc($organization['name'])}</strong>.</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Codice Incidente</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['incident_code'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Titolo</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['title'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Gravità</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($severityLabel)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Rilevato il</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($detectedAt)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Servizi Interessati</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['affected_services'] ?? 'Non specificati')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza Notifica</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; color: #ea580c; font-weight: 600;">{$this->esc($deadline)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 14px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<strong>Azione richiesta:</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}/dashboard/incidents" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Gestisci Incidente</a>
|
||||||
|
</p>
|
||||||
|
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 = <<<HTML
|
||||||
|
<div style="background-color: #eff6ff; border-left: 4px solid #1e40af; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
|
||||||
|
<strong style="color: #1e40af; font-size: 16px;">REPORT FINALE - Termine 30 giorni</strong><br>
|
||||||
|
<span style="color: #1e3a8a;">Ai sensi dell'Art. 23, par. 4, lett. d) della Direttiva NIS2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>È necessario predisporre e trasmettere il report finale dell'incidente al CSIRT nazionale (ACN) per l'organizzazione <strong>{$this->esc($organization['name'])}</strong>.</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Codice Incidente</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['incident_code'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Titolo</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['title'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Rilevato il</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($detectedAt)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Causa Radice</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['root_cause'] ?? 'Da determinare')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Azioni di Rimedio</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($incident['remediation_actions'] ?? 'Da documentare')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza Report Finale</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; color: #1e40af; font-weight: 600;">{$this->esc($deadline)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 14px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<strong>Azione richiesta:</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}/dashboard/incidents" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Prepara Report Finale</a>
|
||||||
|
</p>
|
||||||
|
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 = <<<HTML
|
||||||
|
<div style="background-color: #f0fdf4; border-left: 4px solid #16a34a; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
|
||||||
|
<strong style="color: #16a34a; font-size: 16px;">Nuovo corso di formazione assegnato</strong><br>
|
||||||
|
<span style="color: #166534;">Formazione cybersecurity ai sensi dell'Art. 20 della Direttiva NIS2</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Gentile <strong>{$this->esc($user['full_name'])}</strong>,</p>
|
||||||
|
|
||||||
|
<p>Le è stato assegnato un nuovo corso di formazione sulla sicurezza informatica nell'ambito del programma di conformità NIS2 della Sua organizzazione.</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Corso</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($course['title'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Descrizione</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($course['description'] ?? '-')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Durata Stimata</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$duration} minuti</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Tipo</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($mandatoryText)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">{$this->esc($dueDate)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}/dashboard/training" style="display: inline-block; background-color: #16a34a; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Inizia il Corso</a>
|
||||||
|
</p>
|
||||||
|
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 = <<<HTML
|
||||||
|
<div style="background-color: #fffbeb; border-left: 4px solid {$urgencyColor}; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
|
||||||
|
<strong style="color: {$urgencyColor}; font-size: 16px;">Promemoria Formazione</strong><br>
|
||||||
|
<span style="color: #92400e;">{$daysText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Gentile <strong>{$this->esc($user['full_name'])}</strong>,</p>
|
||||||
|
|
||||||
|
<p>Le ricordiamo che il corso di formazione <strong>{$this->esc($course['title'])}</strong> deve essere completato entro il <strong>{$this->esc($dueDate)}</strong>.</p>
|
||||||
|
|
||||||
|
<p>Il completamento della formazione sulla sicurezza informatica è un requisito previsto dall'Art. 20 della Direttiva NIS2 e dal programma di compliance della Sua organizzazione.</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}/dashboard/training" style="display: inline-block; background-color: {$urgencyColor}; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Completa il Corso</a>
|
||||||
|
</p>
|
||||||
|
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 = <<<HTML
|
||||||
|
<div style="background-color: #f8fafc; border-left: 4px solid {$urgencyColor}; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
|
||||||
|
<strong style="color: {$urgencyColor}; font-size: 16px;">Avviso Scadenza - {$this->esc($typeLabel)}</strong><br>
|
||||||
|
<span style="color: #64748b;">{$daysText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Si segnala la seguente scadenza relativa al programma di conformità NIS2:</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Tipo</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($typeLabel)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Descrizione</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($title)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Scadenza</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; color: {$urgencyColor}; font-weight: 600;">{$this->esc($formattedDate)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}/dashboard" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Vai alla Dashboard</a>
|
||||||
|
</p>
|
||||||
|
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 = <<<HTML
|
||||||
|
<div style="text-align: center; margin-bottom: 30px;">
|
||||||
|
<div style="font-size: 48px; margin-bottom: 10px;">🔒</div>
|
||||||
|
<h2 style="color: #1e40af; margin: 0;">Benvenuto in {$this->esc($this->appName)}!</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Gentile <strong>{$this->esc($user['full_name'])}</strong>,</p>
|
||||||
|
|
||||||
|
<p>La Sua registrazione su <strong>{$this->esc($this->appName)}</strong> è stata completata con successo.</p>
|
||||||
|
|
||||||
|
<p>{$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à:</p>
|
||||||
|
|
||||||
|
<ul style="line-height: 1.8; color: #374151;">
|
||||||
|
<li>Effettuare la <strong>Gap Analysis</strong> rispetto ai requisiti NIS2</li>
|
||||||
|
<li>Gestire il <strong>Risk Assessment</strong> con matrice di rischio</li>
|
||||||
|
<li>Gestire gli <strong>incidenti di sicurezza</strong> con i flussi di notifica Art. 23</li>
|
||||||
|
<li>Generare <strong>policy di sicurezza</strong> personalizzate</li>
|
||||||
|
<li>Monitorare la <strong>supply chain</strong> e i fornitori critici</li>
|
||||||
|
<li>Gestire la <strong>formazione</strong> del personale (Art. 20)</li>
|
||||||
|
<li>Preparare <strong>audit e documentazione</strong> per le autorità</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 24px;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}/dashboard" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;">Accedi alla Piattaforma</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 24px; color: #6b7280; font-size: 14px;">Se non ha effettuato questa registrazione, può ignorare questa email.</p>
|
||||||
|
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 = <<<HTML
|
||||||
|
<div style="background-color: #eff6ff; border-left: 4px solid #1e40af; padding: 16px; margin-bottom: 24px; border-radius: 4px;">
|
||||||
|
<strong style="color: #1e40af; font-size: 16px;">Invito a collaborare</strong><br>
|
||||||
|
<span style="color: #1e3a8a;">{$this->esc($organization['name'])}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Gentile <strong>{$this->esc($user['full_name'])}</strong>,</p>
|
||||||
|
|
||||||
|
<p>È stata invitata/o a unirsi all'organizzazione <strong>{$this->esc($organization['name'])}</strong> sulla piattaforma <strong>{$this->esc($this->appName)}</strong> per la gestione della conformità NIS2.</p>
|
||||||
|
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
|
<tr style="background-color: #f8fafc;">
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600; width: 200px;">Organizzazione</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($organization['name'])}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0; font-weight: 600;">Ruolo Assegnato</td>
|
||||||
|
<td style="padding: 10px 14px; border: 1px solid #e2e8f0;">{$this->esc($roleLabel)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}/dashboard" style="display: inline-block; background-color: #1e40af; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 600;">Accetta Invito</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 20px; color: #6b7280; font-size: 14px;">Se non riconosce questa richiesta, può ignorare questa email.</p>
|
||||||
|
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 <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{$this->esc($subject)}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 30px 15px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width: 600px; width: 100%;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #1e40af; padding: 28px 32px; border-radius: 8px 8px 0 0; text-align: center;">
|
||||||
|
<h1 style="margin: 0; color: #ffffff; font-size: 22px; font-weight: 700; letter-spacing: 0.5px;">{$this->esc($this->appName)}</h1>
|
||||||
|
<p style="margin: 6px 0 0; color: #bfdbfe; font-size: 13px; font-weight: 400;">Piattaforma di Conformità NIS2</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #ffffff; padding: 32px; border-left: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0; color: #1e293b; font-size: 15px; line-height: 1.6;">
|
||||||
|
{$bodyHtml}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #f8fafc; padding: 24px 32px; border-radius: 0 0 8px 8px; border: 1px solid #e2e8f0; border-top: none;">
|
||||||
|
<p style="margin: 0 0 12px; color: #64748b; font-size: 13px; line-height: 1.5; text-align: center;">
|
||||||
|
Questa è una notifica automatica inviata da <strong>{$this->esc($this->appName)}</strong>.<br>
|
||||||
|
Piattaforma di gestione della conformità alla Direttiva NIS2 (EU 2022/2555).
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 12px; text-align: center;">
|
||||||
|
<a href="{$this->esc($this->appUrl)}" style="color: #1e40af; text-decoration: none; font-size: 13px;">{$this->esc($this->appUrl)}</a>
|
||||||
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 16px 0;">
|
||||||
|
<p style="margin: 0; color: #94a3b8; font-size: 11px; line-height: 1.5; text-align: center;">
|
||||||
|
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).
|
||||||
|
</p>
|
||||||
|
<p style="margin: 12px 0 0; color: #94a3b8; font-size: 11px; text-align: center;">
|
||||||
|
© {$year} {$this->esc($this->appName)} - Tutti i diritti riservati
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
214
application/services/RateLimitService.php
Normal file
214
application/services/RateLimitService.php
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Rate Limit Service
|
||||||
|
*
|
||||||
|
* Rate limiting basato su file per proteggere gli endpoint API da abusi.
|
||||||
|
* Supporta finestre multiple per endpoint (es. 5/min + 20/ora).
|
||||||
|
* Storage: /tmp/nis2_ratelimit/{md5(key)}.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
class RateLimitService
|
||||||
|
{
|
||||||
|
private const STORAGE_DIR = '/tmp/nis2_ratelimit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica il rate limit e lancia eccezione se superato
|
||||||
|
*
|
||||||
|
* @param string $key Identificatore univoco (es. "login:192.168.1.1", "ai:user_42")
|
||||||
|
* @param array $limits Array di finestre [['max' => 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
1306
application/services/ReportService.php
Normal file
1306
application/services/ReportService.php
Normal file
File diff suppressed because it is too large
Load Diff
17
docs/sql/002_email_log.sql
Normal file
17
docs/sql/002_email_log.sql
Normal file
@ -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;
|
||||||
@ -258,6 +258,8 @@ $actionMap = [
|
|||||||
'GET:report' => 'generateReport',
|
'GET:report' => 'generateReport',
|
||||||
'GET:logs' => 'getAuditLogs',
|
'GET:logs' => 'getAuditLogs',
|
||||||
'GET:iso27001Mapping' => 'getIsoMapping',
|
'GET:iso27001Mapping' => 'getIsoMapping',
|
||||||
|
'GET:executiveReport' => 'executiveReport',
|
||||||
|
'GET:export' => 'export',
|
||||||
],
|
],
|
||||||
|
|
||||||
// ── AdminController ─────────────────────────────
|
// ── AdminController ─────────────────────────────
|
||||||
@ -373,6 +375,19 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
$controller->$resolvedAction();
|
$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) {
|
} catch (PDOException $e) {
|
||||||
error_log('[DB_ERROR] ' . $e->getMessage());
|
error_log('[DB_ERROR] ' . $e->getMessage());
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
|
|||||||
@ -238,6 +238,8 @@ class NIS2API {
|
|||||||
generateComplianceReport() { return this.get('/audit/report'); }
|
generateComplianceReport() { return this.get('/audit/report'); }
|
||||||
getAuditLogs(params = {}) { return this.get('/audit/logs?' + new URLSearchParams(params)); }
|
getAuditLogs(params = {}) { return this.get('/audit/logs?' + new URLSearchParams(params)); }
|
||||||
getIsoMapping() { return this.get('/audit/iso27001-mapping'); }
|
getIsoMapping() { return this.get('/audit/iso27001-mapping'); }
|
||||||
|
getExecutiveReportUrl() { return this.baseUrl + '/audit/executive-report'; }
|
||||||
|
getExportUrl(type) { return this.baseUrl + '/audit/export?type=' + type; }
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// Onboarding
|
// Onboarding
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user