- dashboard: complianceScore ora ritorna 'score' (overall_score ultimo assessment); la gauge usa avg_implementation se >0, altrimenti il punteggio assessment. Prima mostrava 0% per org con gap analysis ma senza modulo controlli (H2). - risks.html backToList(): ripristina la vista corrente tra le 4 (table/matrix/fair/kri), prima cadeva sempre su table/matrix (H1); renderDetail nasconde tutte e 4. - risks.html loadFair(): legge risksRes.data.items (endpoint paginato), prima risksRes.data.risks era undefined e il dropdown FAIR restava vuoto (M1). php -l + node --check OK. version 1.10.3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
431 lines
16 KiB
PHP
431 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Dashboard Controller
|
|
*
|
|
* Overview di compliance, score, scadenze, attività recenti.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class DashboardController extends BaseController
|
|
{
|
|
/**
|
|
* GET /api/dashboard/overview
|
|
*/
|
|
public function overview(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
// Ultimo assessment
|
|
$lastAssessment = Database::fetchOne(
|
|
'SELECT id, title, overall_score, status, completed_at
|
|
FROM assessments
|
|
WHERE organization_id = ? AND status = "completed"
|
|
ORDER BY completed_at DESC LIMIT 1',
|
|
[$orgId]
|
|
);
|
|
|
|
// Compliance controls status
|
|
$controlStats = Database::fetchAll(
|
|
'SELECT status, COUNT(*) as count
|
|
FROM compliance_controls
|
|
WHERE organization_id = ?
|
|
GROUP BY status',
|
|
[$orgId]
|
|
);
|
|
|
|
// Rischi aperti
|
|
$riskCounts = Database::fetchOne(
|
|
'SELECT
|
|
SUM(CASE WHEN inherent_risk_score >= 20 THEN 1 ELSE 0 END) as critical_high,
|
|
SUM(CASE WHEN inherent_risk_score BETWEEN 10 AND 19 THEN 1 ELSE 0 END) as medium,
|
|
SUM(CASE WHEN inherent_risk_score < 10 THEN 1 ELSE 0 END) as low,
|
|
COUNT(*) as total
|
|
FROM risks
|
|
WHERE organization_id = ? AND status != "closed"',
|
|
[$orgId]
|
|
);
|
|
|
|
// Incidenti attivi
|
|
$activeIncidents = Database::count(
|
|
'incidents',
|
|
'organization_id = ? AND status NOT IN ("closed", "post_mortem")',
|
|
[$orgId]
|
|
);
|
|
|
|
// Policy per stato
|
|
$policyStats = Database::fetchAll(
|
|
'SELECT status, COUNT(*) as count
|
|
FROM policies
|
|
WHERE organization_id = ?
|
|
GROUP BY status',
|
|
[$orgId]
|
|
);
|
|
|
|
// Fornitori critici
|
|
$criticalSuppliers = Database::count(
|
|
'suppliers',
|
|
'organization_id = ? AND criticality IN ("high", "critical") AND security_requirements_met = 0',
|
|
[$orgId]
|
|
);
|
|
|
|
// Training completamento
|
|
$trainingStats = Database::fetchOne(
|
|
'SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN status = "completed" THEN 1 ELSE 0 END) as completed,
|
|
SUM(CASE WHEN status = "overdue" THEN 1 ELSE 0 END) as overdue
|
|
FROM training_assignments
|
|
WHERE organization_id = ?',
|
|
[$orgId]
|
|
);
|
|
|
|
// Conteggi asset
|
|
$assetCount = Database::count('assets', 'organization_id = ? AND status = "active"', [$orgId]);
|
|
|
|
// Organizzazione info
|
|
$org = Database::fetchOne('SELECT name, sector, entity_type, subscription_plan FROM organizations WHERE id = ?', [$orgId]);
|
|
|
|
$this->jsonSuccess([
|
|
'organization' => $org,
|
|
'last_assessment' => $lastAssessment,
|
|
'compliance_score' => $lastAssessment ? (float) $lastAssessment['overall_score'] : null,
|
|
'controls' => $controlStats,
|
|
'risks' => $riskCounts,
|
|
'active_incidents' => $activeIncidents,
|
|
'policies' => $policyStats,
|
|
'critical_suppliers' => $criticalSuppliers,
|
|
'training' => $trainingStats,
|
|
'asset_count' => $assetCount,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /api/dashboard/compliance-score
|
|
*/
|
|
public function complianceScore(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
// Score dall'ultimo assessment
|
|
$assessments = Database::fetchAll(
|
|
'SELECT id, title, overall_score, category_scores, completed_at
|
|
FROM assessments
|
|
WHERE organization_id = ? AND status = "completed"
|
|
ORDER BY completed_at DESC
|
|
LIMIT 5',
|
|
[$orgId]
|
|
);
|
|
|
|
// Score dei controlli
|
|
$controls = Database::fetchAll(
|
|
'SELECT control_code, title, status, implementation_percentage
|
|
FROM compliance_controls
|
|
WHERE organization_id = ?
|
|
ORDER BY control_code',
|
|
[$orgId]
|
|
);
|
|
|
|
$totalControls = count($controls);
|
|
$implementedControls = 0;
|
|
$avgImplementation = 0;
|
|
|
|
foreach ($controls as $c) {
|
|
if ($c['status'] === 'implemented' || $c['status'] === 'verified') {
|
|
$implementedControls++;
|
|
}
|
|
$avgImplementation += (int) $c['implementation_percentage'];
|
|
}
|
|
|
|
$avgImplementation = $totalControls > 0 ? round($avgImplementation / $totalControls) : 0;
|
|
|
|
$this->jsonSuccess([
|
|
'assessments' => $assessments,
|
|
'controls' => $controls,
|
|
'total_controls' => $totalControls,
|
|
'implemented_controls' => $implementedControls,
|
|
'avg_implementation' => $avgImplementation,
|
|
// Fallback per la gauge: punteggio gap analysis quando non ci sono controlli.
|
|
'score' => isset($assessments[0]['overall_score']) ? (float) $assessments[0]['overall_score'] : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /api/dashboard/sectorBenchmark
|
|
* Benchmark settoriale ANONIMIZZATO (P2): confronta lo score di compliance
|
|
* dell'organizzazione con la media/distribuzione del proprio settore sulla
|
|
* rete multi-tenant. k-anonymity: aggregati solo se >= MIN_PEERS organizzazioni.
|
|
*/
|
|
public function sectorBenchmark(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
$MIN_PEERS = 3;
|
|
|
|
$org = Database::fetchOne('SELECT sector FROM organizations WHERE id = ?', [$orgId]);
|
|
if (!$org) {
|
|
$this->jsonError('Organizzazione non trovata', 404, 'NOT_FOUND');
|
|
}
|
|
$sector = $org['sector'];
|
|
|
|
$mine = Database::fetchOne(
|
|
"SELECT overall_score FROM assessments WHERE organization_id = ? AND status='completed'
|
|
ORDER BY completed_at DESC LIMIT 1",
|
|
[$orgId]
|
|
);
|
|
$myScore = $mine ? (float) $mine['overall_score'] : null;
|
|
|
|
// Esattamente UN punteggio per org (ultimo completato, tie-break su id): la subquery
|
|
// correlata evita il duplicato che il JOIN su MAX(completed_at) produceva quando due
|
|
// assessment della stessa org condividono identico timestamp.
|
|
$rows = Database::fetchAll(
|
|
"SELECT a.overall_score
|
|
FROM assessments a
|
|
JOIN organizations o ON o.id = a.organization_id
|
|
WHERE o.is_active = 1 AND o.sector = ? AND a.status='completed' AND a.overall_score IS NOT NULL
|
|
AND a.id = (
|
|
SELECT a2.id FROM assessments a2
|
|
WHERE a2.organization_id = a.organization_id
|
|
AND a2.status='completed' AND a2.overall_score IS NOT NULL
|
|
ORDER BY a2.completed_at DESC, a2.id DESC LIMIT 1
|
|
)",
|
|
[$sector]
|
|
);
|
|
$scores = array_map(fn($r) => (float) $r['overall_score'], $rows);
|
|
$n = count($scores);
|
|
|
|
if ($n < $MIN_PEERS) {
|
|
$this->jsonSuccess([
|
|
'sector' => $sector,
|
|
'sufficient_data' => false,
|
|
'min_peers_required' => $MIN_PEERS,
|
|
'peers_available' => $n,
|
|
'my_score' => $myScore,
|
|
'message' => 'Dati insufficienti per un benchmark anonimo (servono almeno ' . $MIN_PEERS . ' organizzazioni nel settore).',
|
|
]);
|
|
return;
|
|
}
|
|
|
|
sort($scores);
|
|
$avg = array_sum($scores) / $n;
|
|
$median = $n % 2 ? $scores[intdiv($n, 2)] : ($scores[$n / 2 - 1] + $scores[$n / 2]) / 2;
|
|
$p25 = $scores[(int) floor(0.25 * ($n - 1))];
|
|
$p75 = $scores[(int) floor(0.75 * ($n - 1))];
|
|
|
|
$percentile = null; $position = null;
|
|
if ($myScore !== null) {
|
|
$below = count(array_filter($scores, fn($s) => $s < $myScore));
|
|
$percentile = (int) round($below / $n * 100);
|
|
$position = $myScore >= $p75 ? 'top_quartile'
|
|
: ($myScore >= $median ? 'above_median'
|
|
: ($myScore >= $p25 ? 'below_median' : 'bottom_quartile'));
|
|
}
|
|
|
|
$this->jsonSuccess([
|
|
'sector' => $sector,
|
|
'sufficient_data' => true,
|
|
'peers' => $n,
|
|
'my_score' => $myScore !== null ? round($myScore, 1) : null,
|
|
'sector_average' => round($avg, 1),
|
|
'sector_median' => round($median, 1),
|
|
'sector_min' => round($scores[0], 1),
|
|
'sector_max' => round($scores[$n - 1], 1),
|
|
'sector_p25' => round($p25, 1),
|
|
'sector_p75' => round($p75, 1),
|
|
'my_percentile' => $percentile,
|
|
'my_position' => $position,
|
|
'delta_vs_average' => $myScore !== null ? round($myScore - $avg, 1) : null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* GET /api/dashboard/upcoming-deadlines
|
|
*/
|
|
public function deadlines(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$orgId = $this->getCurrentOrgId();
|
|
|
|
$deadlines = [];
|
|
|
|
// Incidenti con scadenze notifica
|
|
$incidentDeadlines = Database::fetchAll(
|
|
'SELECT id, incident_code, title, severity,
|
|
early_warning_due, early_warning_sent_at,
|
|
notification_due, notification_sent_at,
|
|
final_report_due, final_report_sent_at
|
|
FROM incidents
|
|
WHERE organization_id = ? AND is_significant = 1 AND status NOT IN ("closed", "post_mortem")
|
|
ORDER BY detected_at DESC',
|
|
[$orgId]
|
|
);
|
|
|
|
foreach ($incidentDeadlines as $inc) {
|
|
if ($inc['early_warning_due'] && !$inc['early_warning_sent_at']) {
|
|
$deadlines[] = [
|
|
'type' => 'incident_early_warning',
|
|
'title' => "Early Warning: {$inc['title']}",
|
|
'due_date' => $inc['early_warning_due'],
|
|
'severity' => 'critical',
|
|
'entity_type' => 'incident',
|
|
'entity_id' => $inc['id'],
|
|
];
|
|
}
|
|
if ($inc['notification_due'] && !$inc['notification_sent_at']) {
|
|
$deadlines[] = [
|
|
'type' => 'incident_notification',
|
|
'title' => "Notifica CSIRT: {$inc['title']}",
|
|
'due_date' => $inc['notification_due'],
|
|
'severity' => 'high',
|
|
'entity_type' => 'incident',
|
|
'entity_id' => $inc['id'],
|
|
];
|
|
}
|
|
if ($inc['final_report_due'] && !$inc['final_report_sent_at']) {
|
|
$deadlines[] = [
|
|
'type' => 'incident_final_report',
|
|
'title' => "Report finale: {$inc['title']}",
|
|
'due_date' => $inc['final_report_due'],
|
|
'severity' => 'medium',
|
|
'entity_type' => 'incident',
|
|
'entity_id' => $inc['id'],
|
|
];
|
|
}
|
|
}
|
|
|
|
// Policy in scadenza revisione
|
|
$policyDeadlines = Database::fetchAll(
|
|
'SELECT id, title, next_review_date
|
|
FROM policies
|
|
WHERE organization_id = ? AND next_review_date IS NOT NULL
|
|
AND next_review_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)
|
|
AND status NOT IN ("archived")
|
|
ORDER BY next_review_date',
|
|
[$orgId]
|
|
);
|
|
|
|
foreach ($policyDeadlines as $p) {
|
|
$deadlines[] = [
|
|
'type' => 'policy_review',
|
|
'title' => "Revisione policy: {$p['title']}",
|
|
'due_date' => $p['next_review_date'],
|
|
'severity' => 'medium',
|
|
'entity_type' => 'policy',
|
|
'entity_id' => $p['id'],
|
|
];
|
|
}
|
|
|
|
// Risk treatments in scadenza
|
|
$treatmentDeadlines = Database::fetchAll(
|
|
'SELECT rt.id, rt.action_description, rt.due_date, r.title as risk_title
|
|
FROM risk_treatments rt
|
|
JOIN risks r ON r.id = rt.risk_id
|
|
WHERE r.organization_id = ? AND rt.status IN ("planned", "in_progress")
|
|
AND rt.due_date IS NOT NULL AND rt.due_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)
|
|
ORDER BY rt.due_date',
|
|
[$orgId]
|
|
);
|
|
|
|
foreach ($treatmentDeadlines as $t) {
|
|
$deadlines[] = [
|
|
'type' => 'risk_treatment',
|
|
'title' => "Trattamento rischio: {$t['risk_title']}",
|
|
'due_date' => $t['due_date'],
|
|
'severity' => 'medium',
|
|
'entity_type' => 'risk_treatment',
|
|
'entity_id' => $t['id'],
|
|
];
|
|
}
|
|
|
|
// Training in scadenza
|
|
$trainingDeadlines = Database::fetchAll(
|
|
'SELECT ta.id, tc.title, ta.due_date, u.full_name
|
|
FROM training_assignments ta
|
|
JOIN training_courses tc ON tc.id = ta.course_id
|
|
JOIN users u ON u.id = ta.user_id
|
|
WHERE ta.organization_id = ? AND ta.status IN ("assigned", "in_progress")
|
|
AND ta.due_date IS NOT NULL AND ta.due_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)
|
|
ORDER BY ta.due_date',
|
|
[$orgId]
|
|
);
|
|
|
|
foreach ($trainingDeadlines as $t) {
|
|
$deadlines[] = [
|
|
'type' => 'training_due',
|
|
'title' => "Formazione: {$t['title']} - {$t['full_name']}",
|
|
'due_date' => $t['due_date'],
|
|
'severity' => 'low',
|
|
'entity_type' => 'training',
|
|
'entity_id' => $t['id'],
|
|
];
|
|
}
|
|
|
|
// Ordina per data
|
|
usort($deadlines, fn($a, $b) => strcmp($a['due_date'], $b['due_date']));
|
|
|
|
$this->jsonSuccess($deadlines);
|
|
}
|
|
|
|
/**
|
|
* GET /api/dashboard/recent-activity
|
|
*/
|
|
public function recentActivity(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
|
|
$activities = Database::fetchAll(
|
|
'SELECT al.*, u.full_name
|
|
FROM audit_logs al
|
|
LEFT JOIN users u ON u.id = al.user_id
|
|
WHERE al.organization_id = ?
|
|
ORDER BY al.created_at DESC
|
|
LIMIT 20',
|
|
[$this->getCurrentOrgId()]
|
|
);
|
|
|
|
$this->jsonSuccess($activities);
|
|
}
|
|
|
|
/**
|
|
* GET /api/dashboard/risk-heatmap
|
|
*/
|
|
public function riskHeatmap(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
|
|
$risks = Database::fetchAll(
|
|
'SELECT id, title, category, likelihood, impact, inherent_risk_score, status
|
|
FROM risks
|
|
WHERE organization_id = ? AND status != "closed"
|
|
ORDER BY inherent_risk_score DESC',
|
|
[$this->getCurrentOrgId()]
|
|
);
|
|
|
|
// Costruisci matrice 5x5
|
|
$matrix = [];
|
|
for ($l = 1; $l <= 5; $l++) {
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$matrix["{$l}_{$i}"] = [];
|
|
}
|
|
}
|
|
|
|
foreach ($risks as $risk) {
|
|
if ($risk['likelihood'] && $risk['impact']) {
|
|
$key = "{$risk['likelihood']}_{$risk['impact']}";
|
|
$matrix[$key][] = [
|
|
'id' => $risk['id'],
|
|
'title' => $risk['title'],
|
|
'score' => $risk['inherent_risk_score'],
|
|
];
|
|
}
|
|
}
|
|
|
|
$this->jsonSuccess([
|
|
'risks' => $risks,
|
|
'matrix' => $matrix,
|
|
]);
|
|
}
|
|
}
|