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>
1307 lines
51 KiB
PHP
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à: {$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 è generato automaticamente dalla piattaforma {$appName} ed è
|
|
destinato agli organi di gestione ai sensi dell'Art. 20 della Direttiva NIS2 (UE 2022/2555) e
|
|
del D.Lgs. 138/2024. I dati si riferiscono alla data di generazione e potrebbero non riflettere
|
|
variazioni successive. Si raccomanda una revisione periodica da parte del responsabile compliance.
|
|
</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;
|
|
}
|
|
}
|