nis2-agile/application/services/ReportService.php
Cristiano Benassati 6f4b457ce0 [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>
2026-02-17 19:12:46 +01:00

1307 lines
51 KiB
PHP

<?php
/**
* NIS2 Agile - Report Service
*
* Genera report di compliance NIS2:
* - Report completo compliance (dati strutturati)
* - Report esecutivo HTML per CdA (Art. 20 NIS2)
* - Export CSV (rischi, incidenti, controlli, asset)
*
* Tutti i CSV usano separatore punto e virgola e BOM UTF-8
* per massima compatibilita con Excel in ambiente italiano.
*/
class ReportService
{
// ═══════════════════════════════════════════════════════════════════════════
// REPORT COMPLIANCE COMPLETO
// ═══════════════════════════════════════════════════════════════════════════
/**
* Genera il report di compliance NIS2 completo per un'organizzazione.
*
* Restituisce un array strutturato con tutti i dati necessari
* per il rendering HTML lato frontend.
*
* @param int $orgId ID dell'organizzazione
* @return array Dati strutturati del report
*/
public function generateComplianceReport(int $orgId): array
{
// --- Informazioni organizzazione ---
$organization = Database::fetchOne(
'SELECT id, name, sector, entity_type, subscription_plan,
employee_count, annual_turnover_eur, created_at
FROM organizations
WHERE id = ?',
[$orgId]
);
if (!$organization) {
return ['error' => '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 .= <<<ROW
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">{$catLabel}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">
<span style="color: {$catColor}; font-weight: 600;">{$catScoreFmt}%</span>
</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">
<div style="background: #e5e7eb; border-radius: 4px; height: 12px; width: 100%;">
<div style="background: {$catColor}; border-radius: 4px; height: 12px; width: {$catScore}%;"></div>
</div>
</td>
</tr>
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 .= <<<ROW
<tr>
<td style="padding: 6px 12px; border-bottom: 1px solid #e5e7eb;">{$catLabel}</td>
<td style="padding: 6px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; font-weight: 600;">{$count}</td>
</tr>
ROW;
}
// Raccomandazioni
$recommendations = $this->buildRecommendations($data);
$recommendationItems = '';
foreach ($recommendations as $rec) {
$recText = htmlspecialchars($rec, ENT_QUOTES, 'UTF-8');
$recommendationItems .= "<li style=\"margin-bottom: 8px; line-height: 1.5;\">{$recText}</li>\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 = <<<HTML
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Report Esecutivo NIS2 - {$orgName}</title>
<style>
@media print {
body { margin: 0; padding: 15mm; }
.page-break { page-break-before: always; }
.no-print { display: none !important; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 13px;
line-height: 1.6;
color: #1f2937;
background: #ffffff;
padding: 40px;
max-width: 900px;
margin: 0 auto;
}
.header {
background: linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%);
color: #ffffff;
padding: 30px 35px;
border-radius: 8px;
margin-bottom: 30px;
}
.header h1 {
font-size: 22px;
font-weight: 700;
margin-bottom: 4px;
}
.header h2 {
font-size: 15px;
font-weight: 400;
opacity: 0.9;
margin-bottom: 12px;
}
.header-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
opacity: 0.85;
border-top: 1px solid rgba(255,255,255,0.25);
padding-top: 12px;
margin-top: 8px;
}
.section {
margin-bottom: 28px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #1e40af;
border-bottom: 2px solid #1e40af;
padding-bottom: 6px;
margin-bottom: 16px;
}
.score-gauge {
text-align: center;
padding: 25px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e5e7eb;
margin-bottom: 20px;
}
.score-value {
font-size: 52px;
font-weight: 800;
line-height: 1.1;
}
.score-label {
font-size: 14px;
font-weight: 600;
margin-top: 4px;
}
.score-sublabel {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.metric-card {
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.metric-value {
font-size: 26px;
font-weight: 800;
color: #1e40af;
line-height: 1.2;
}
.metric-label {
font-size: 11px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
table thead th {
background: #1e40af;
color: #ffffff;
padding: 10px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
table thead th:first-child {
border-top-left-radius: 6px;
}
table thead th:last-child {
border-top-right-radius: 6px;
}
table tbody td {
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
font-size: 13px;
}
table tbody tr:last-child td {
border-bottom: none;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 16px;
}
.heatmap-cell {
border-radius: 8px;
padding: 14px 10px;
text-align: center;
color: #ffffff;
font-weight: 700;
}
.heatmap-cell .count {
font-size: 28px;
line-height: 1.1;
}
.heatmap-cell .label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
opacity: 0.9;
}
.recommendations {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-left: 4px solid #1e40af;
border-radius: 0 8px 8px 0;
padding: 20px 24px;
}
.recommendations ol {
padding-left: 20px;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 2px solid #e5e7eb;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: #9ca3af;
}
.footer-brand {
font-weight: 700;
color: #1e40af;
font-size: 13px;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media print {
.metrics-grid { grid-template-columns: repeat(4, 1fr); }
.heatmap-grid { grid-template-columns: repeat(4, 1fr); }
.header { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.heatmap-cell { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
table thead th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head>
<body>
<!-- HEADER -->
<div class="header">
<h1>Report Esecutivo Compliance NIS2</h1>
<h2>{$orgName}</h2>
<div class="header-meta">
<span>Settore: {$orgSector} | Tipo entit&agrave;: {$orgEntityType}</span>
<span>Data: {$reportDate} ore {$reportTime}</span>
</div>
</div>
<!-- COMPLIANCE SCORE GAUGE -->
<div class="section">
<div class="section-title">Indice di Compliance NIS2</div>
<div class="score-gauge">
<div class="score-value" style="color: {$scoreColor};">{$scoreFormatted}%</div>
<div class="score-label" style="color: {$scoreColor};">{$scoreLabel}</div>
<div class="score-sublabel">Ultimo assessment: {$assessmentTitle} ({$assessmentDate})</div>
</div>
</div>
<!-- KEY METRICS -->
<div class="section">
<div class="section-title">Metriche Chiave</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-value">{$controls['total']}</div>
<div class="metric-label">Controlli totali</div>
</div>
<div class="metric-card">
<div class="metric-value">{$controls['avg_implementation']}%</div>
<div class="metric-label">Implementazione media</div>
</div>
<div class="metric-card">
<div class="metric-value">{$riskSummary['open_risks']}</div>
<div class="metric-label">Rischi aperti</div>
</div>
<div class="metric-card">
<div class="metric-value">{$incidents['open']}</div>
<div class="metric-label">Incidenti attivi</div>
</div>
</div>
<table>
<thead>
<tr>
<th style="width: 50%;">Indicatore</th>
<th style="width: 25%; text-align: center;">Valore</th>
<th style="width: 25%; text-align: center;">Stato</th>
</tr>
</thead>
<tbody>
<tr>
<td>Policy approvate</td>
<td style="text-align: center; font-weight: 600;">{$policies['approved']} / {$policies['total']}</td>
<td style="text-align: center;">{$this->getStatusBadge($policies['total'] > 0 ? $policies['approved'] / $policies['total'] * 100 : 0)}</td>
</tr>
<tr>
<td>Formazione completata</td>
<td style="text-align: center; font-weight: 600;">{$trainingRate}%</td>
<td style="text-align: center;">{$this->getStatusBadge($training['completion_rate'] ?? 0)}</td>
</tr>
<tr>
<td>Fornitori conformi (supply chain)</td>
<td style="text-align: center; font-weight: 600;">{$supplyChain['compliant']} / {$supplyChain['total_suppliers']}</td>
<td style="text-align: center;">{$this->getStatusBadge($scCompliance)}</td>
</tr>
<tr>
<td>Incidenti significativi (NIS2 Art. 23)</td>
<td style="text-align: center; font-weight: 600;">{$incidents['significant']}</td>
<td style="text-align: center;">{$this->getCountBadge($incidents['significant'])}</td>
</tr>
<tr>
<td>Fornitori critici non conformi</td>
<td style="text-align: center; font-weight: 600;">{$supplyChain['non_compliant']}</td>
<td style="text-align: center;">{$this->getCountBadge($supplyChain['non_compliant'])}</td>
</tr>
</tbody>
</table>
</div>
<!-- CATEGORY BREAKDOWN -->
<div class="section">
<div class="section-title">Dettaglio per Categoria (Art. 21.2 NIS2)</div>
<table>
<thead>
<tr>
<th style="width: 40%;">Categoria</th>
<th style="width: 15%; text-align: center;">Score</th>
<th style="width: 45%;">Progresso</th>
</tr>
</thead>
<tbody>
{$categoryRows}
</tbody>
</table>
</div>
<div class="page-break"></div>
<!-- RISK HEATMAP -->
<div class="section">
<div class="section-title">Riepilogo Rischi Cyber</div>
<div class="heatmap-grid">
<div class="heatmap-cell" style="background: #dc2626;">
<div class="count">{$riskCritical}</div>
<div class="label">Critici</div>
</div>
<div class="heatmap-cell" style="background: #ea580c;">
<div class="count">{$riskHigh}</div>
<div class="label">Alti</div>
</div>
<div class="heatmap-cell" style="background: #ca8a04;">
<div class="count">{$riskMedium}</div>
<div class="label">Medi</div>
</div>
<div class="heatmap-cell" style="background: #16a34a;">
<div class="count">{$riskLow}</div>
<div class="label">Bassi</div>
</div>
</div>
<div class="two-col">
<div>
<table>
<thead>
<tr>
<th>Categoria rischio</th>
<th style="text-align: center;">N. rischi</th>
</tr>
</thead>
<tbody>
{$riskCategoryRows}
</tbody>
</table>
</div>
<div>
<table>
<thead>
<tr>
<th>Metrica</th>
<th style="text-align: center;">Valore</th>
</tr>
</thead>
<tbody>
<tr>
<td style="padding: 6px 12px; border-bottom: 1px solid #e5e7eb;">Rischi totali registrati</td>
<td style="padding: 6px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; font-weight: 600;">{$riskSummary['total_risks']}</td>
</tr>
<tr>
<td style="padding: 6px 12px; border-bottom: 1px solid #e5e7eb;">Rischi aperti</td>
<td style="padding: 6px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; font-weight: 600;">{$riskSummary['open_risks']}</td>
</tr>
<tr>
<td style="padding: 6px 12px; border-bottom: 1px solid #e5e7eb;">Critici + Alti</td>
<td style="padding: 6px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; font-weight: 600; color: #dc2626;">{$this->sumValues($riskCritical, $riskHigh)}</td>
</tr>
<tr>
<td style="padding: 6px 12px;">Score medio fornitori</td>
<td style="padding: 6px 12px; text-align: center; font-weight: 600;">{$supplyChain['avg_risk_score']}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- RACCOMANDAZIONI -->
<div class="section">
<div class="section-title">Raccomandazioni Prioritarie</div>
<div class="recommendations">
<ol>
{$recommendationItems}
</ol>
</div>
</div>
<!-- DISCLAIMER -->
<div class="section" style="margin-top: 20px;">
<p style="font-size: 11px; color: #9ca3af; line-height: 1.5; font-style: italic;">
Il presente report &egrave; generato automaticamente dalla piattaforma {$appName} ed &egrave;
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.
</p>
</div>
<!-- FOOTER -->
<div class="footer">
<div>
<span class="footer-brand">{$appName}</span>
<span style="margin-left: 8px;">v{$appVersion}</span>
</div>
<div>
Generato il {$reportDate} alle {$reportTime} | Documento riservato
</div>
</div>
</body>
</html>
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<int, string[]> $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 "<span style=\"background: {$color}; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;\">{$label}</span>";
}
/**
* Genera un badge HTML basato su un conteggio (0 = buono, >0 = attenzione).
*/
private function getCountBadge(int $count): string
{
if ($count === 0) {
return '<span style="background: #16a34a; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;">OK</span>';
}
$color = $count >= 3 ? '#dc2626' : '#ea580c';
return "<span style=\"background: {$color}; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;\">Attenzione</span>";
}
/**
* 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 <<<HTML
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Errore Report - {$appName}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; margin: 0; background: #f8fafc;
}
.error-box {
background: #fff; border: 1px solid #fecaca; border-left: 4px solid #dc2626;
border-radius: 0 8px 8px 0; padding: 24px 32px; max-width: 500px;
}
.error-box h2 { color: #dc2626; margin-bottom: 8px; font-size: 18px; }
.error-box p { color: #374151; }
</style>
</head>
<body>
<div class="error-box">
<h2>Errore generazione report</h2>
<p>{$escapedMessage}</p>
</div>
</body>
</html>
HTML;
}
}