diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index 9782ea0..ee6fac8 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -1460,4 +1460,567 @@ class ServicesController extends BaseController if ($score >= 40) return 'partial'; return 'significant_gaps'; } + + // ══════════════════════════════════════════════════════════════════════ + // lg231 / GRC INTEGRATION ENDPOINTS + // ══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/services/gap-analysis + * Gap analysis per dominio NIS2 Art.21 con mapping ai 7 pilastri MOG 231. + * Basato sull'assessment più recente completato. + */ + public function gapAnalysis(): void + { + $this->requireApiKey('read:compliance'); + $orgId = $this->currentOrgId; + + // Mapping NIS2 domain → MOG 231 pillar e articolo + $domainMeta = [ + 'governance' => ['article' => 'Art.20-21', 'mog_pillar' => 'pillar_1_governance'], + 'risk_management' => ['article' => 'Art.21.2.a', 'mog_pillar' => 'pillar_2_risk_assessment'], + 'incident_management' => ['article' => 'Art.21.2.b', 'mog_pillar' => 'pillar_7_segnalazioni'], + 'business_continuity' => ['article' => 'Art.21.2.c', 'mog_pillar' => 'pillar_5_monitoraggio'], + 'supply_chain' => ['article' => 'Art.21.2.d', 'mog_pillar' => 'pillar_3_procedure_operative'], + 'vulnerability' => ['article' => 'Art.21.2.e', 'mog_pillar' => 'pillar_5_monitoraggio'], + 'policy_measurement' => ['article' => 'Art.21.2.f', 'mog_pillar' => 'pillar_3_procedure_operative'], + 'training_awareness' => ['article' => 'Art.21.2.g', 'mog_pillar' => 'pillar_4_formazione'], + 'cryptography' => ['article' => 'Art.21.2.h', 'mog_pillar' => 'pillar_6_sicurezza_it'], + 'access_control' => ['article' => 'Art.21.2.i', 'mog_pillar' => 'pillar_6_sicurezza_it'], + ]; + + // Assessment più recente completato + $assessment = Database::fetchOne( + 'SELECT id, overall_score, category_scores, ai_summary, ai_recommendations, completed_at + FROM assessments + WHERE organization_id = ? AND status = "completed" + ORDER BY completed_at DESC LIMIT 1', + [$orgId] + ); + + if (!$assessment) { + $this->jsonSuccess([ + 'assessment_id' => null, + 'completed_at' => null, + 'overall_score' => null, + 'domains' => [], + 'ai_summary' => null, + 'ai_recommendations' => [], + 'note' => 'Nessun assessment completato. Avvia un gap analysis dal modulo Assessment.', + ]); + return; + } + + // Risposte per assessment + $responses = Database::fetchAll( + 'SELECT category, response_value + FROM assessment_responses + WHERE assessment_id = ?', + [$assessment['id']] + ); + + // Calcola score per dominio dalle risposte + $domainData = []; + foreach ($responses as $r) { + $cat = $r['category']; + if (!isset($domainData[$cat])) { + $domainData[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0]; + } + match ($r['response_value']) { + 'implemented' => $domainData[$cat]['implemented']++, + 'partial' => $domainData[$cat]['partial']++, + 'not_implemented' => $domainData[$cat]['not_implemented']++, + 'not_applicable' => $domainData[$cat]['not_applicable']++, + default => null, + }; + } + + $domains = []; + foreach ($domainData as $domain => $counts) { + $scorable = $counts['implemented'] + $counts['partial'] + $counts['not_implemented']; + $score = $scorable > 0 + ? (int) round(($counts['implemented'] * 4 + $counts['partial'] * 2) / ($scorable * 4) * 100) + : null; + $gapLevel = match (true) { + $score === null => 'not_assessed', + $score >= 75 => 'low', + $score >= 50 => 'medium', + $score >= 25 => 'high', + default => 'critical', + }; + $meta = $domainMeta[$domain] ?? ['article' => 'Art.21', 'mog_pillar' => 'pillar_3_procedure_operative']; + $domains[] = [ + 'domain' => $domain, + 'nis2_article' => $meta['article'], + 'mog_pillar' => $meta['mog_pillar'], + 'score' => $score, + 'gap_level' => $gapLevel, + 'implemented' => $counts['implemented'], + 'partial' => $counts['partial'], + 'not_implemented' => $counts['not_implemented'], + 'not_applicable' => $counts['not_applicable'], + ]; + } + + // Ordina per score ASC (gap peggiori prima) + usort($domains, fn($a, $b) => ($a['score'] ?? 101) <=> ($b['score'] ?? 101)); + + $aiRecs = []; + if (!empty($assessment['ai_recommendations'])) { + $decoded = json_decode($assessment['ai_recommendations'], true); + if (is_array($decoded)) $aiRecs = $decoded; + } + + $this->jsonSuccess([ + 'assessment_id' => (int) $assessment['id'], + 'completed_at' => $assessment['completed_at'], + 'overall_score' => $assessment['overall_score'] !== null ? (int) $assessment['overall_score'] : null, + 'domains' => $domains, + 'ai_summary' => $assessment['ai_summary'], + 'ai_recommendations' => $aiRecs, + ]); + } + + /** + * GET /api/services/measures + * Misure di sicurezza Art.21 con stato implementazione e mapping MOG 231. + * ?framework=nis2|iso27001|both (default: tutti) + * ?status=not_started|in_progress|implemented|verified + */ + public function measures(): void + { + $this->requireApiKey('read:compliance'); + $orgId = $this->currentOrgId; + + // Mapping control_code prefix → mog_area + $mogAreaMap = [ + 'NIS2-20' => 'governance', + 'NIS2-21' => 'governance', + 'NIS2-21.2.a' => 'risk_assessment', + 'NIS2-21.2.b' => 'incident_management', + 'NIS2-21.2.c' => 'business_continuity', + 'NIS2-21.2.d' => 'supply_chain', + 'NIS2-21.2.e' => 'vulnerability_management', + 'NIS2-21.2.f' => 'policy_procedure', + 'NIS2-21.2.g' => 'training_awareness', + 'NIS2-21.2.h' => 'cryptography', + 'NIS2-21.2.i' => 'access_control', + 'NIS2-21.2.j' => 'access_control', + 'ISO' => 'iso27001_control', + ]; + + $where = 'organization_id = ?'; + $params = [$orgId]; + + $filterFramework = $this->getParam('framework'); + if ($filterFramework && in_array($filterFramework, ['nis2', 'iso27001', 'both'])) { + $where .= ' AND framework = ?'; + $params[] = $filterFramework; + } + + $filterStatus = $this->getParam('status'); + if ($filterStatus) { + $where .= ' AND status = ?'; + $params[] = $filterStatus; + } + + $controls = Database::fetchAll( + "SELECT id, control_code, framework, title, status, + implementation_percentage, nis2_article, next_review_date, updated_at + FROM compliance_controls + WHERE {$where} + ORDER BY framework, control_code", + $params + ); + + $stats = ['total' => 0, 'not_started' => 0, 'in_progress' => 0, 'implemented' => 0, 'verified' => 0]; + $measures = []; + foreach ($controls as $c) { + $stats['total']++; + $s = $c['status'] ?? 'not_started'; + if (isset($stats[$s])) $stats[$s]++; + + // Derive mog_area from control_code + $mogArea = 'other'; + $code = $c['control_code'] ?? ''; + foreach ($mogAreaMap as $prefix => $area) { + if (str_starts_with($code, $prefix)) { $mogArea = $area; break; } + } + if ($mogArea === 'other' && str_starts_with($code, 'ISO')) $mogArea = 'iso27001_control'; + + $measures[] = [ + 'id' => (int) $c['id'], + 'code' => $code, + 'framework' => $c['framework'], + 'title' => $c['title'], + 'status' => $s, + 'implementation_percentage' => (int) ($c['implementation_percentage'] ?? 0), + 'nis2_article' => $c['nis2_article'], + 'mog_area' => $mogArea, + 'next_review_date' => $c['next_review_date'], + ]; + } + + $completionPct = $stats['total'] > 0 + ? (int) round(($stats['implemented'] + $stats['verified'] + $stats['in_progress'] * 0.5) / $stats['total'] * 100) + : 0; + + $this->jsonSuccess(array_merge($stats, [ + 'completion_percentage' => $completionPct, + 'measures' => $measures, + ])); + } + + /** + * GET /api/services/incidents + * Incidenti con stato Art.23 CSIRT (diverso da incidentsFeed: focus compliance). + * ?significant=1 solo incidenti significativi + * ?status=open solo aperti (non closed/post_mortem) + * ?limit=50 + */ + public function incidents(): void + { + $this->requireApiKey('read:incidents'); + $orgId = $this->currentOrgId; + + $limit = min((int) ($this->getParam('limit') ?: 50), 200); + $where = 'organization_id = ?'; + $params = [$orgId]; + + if ($this->getParam('significant') === '1') { + $where .= ' AND is_significant = 1'; + } + if ($this->getParam('status') === 'open') { + $where .= ' AND status NOT IN ("closed","post_mortem")'; + } + + $rows = Database::fetchAll( + "SELECT id, title, classification, severity, status, is_significant, + detected_at, closed_at, + early_warning_due, early_warning_sent_at, + notification_due, notification_sent_at, + final_report_due, final_report_sent_at, + affected_services, root_cause + FROM incidents + WHERE {$where} + ORDER BY detected_at DESC + LIMIT {$limit}", + $params + ); + + $now = time(); + $csirtOverdue = 0; + $incidents = []; + + foreach ($rows as $r) { + $isOpen = !in_array($r['status'], ['closed', 'post_mortem']); + $isSig = (bool) $r['is_significant']; + + // Art.23 compliance per incidente significativo aperto + $ewDue = $r['early_warning_due'] ? strtotime($r['early_warning_due']) : null; + $notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null; + $frDue = $r['final_report_due'] ? strtotime($r['final_report_due']) : null; + + $notOverdue = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at']; + if ($notOverdue) $csirtOverdue++; + + $incidents[] = [ + 'id' => (int) $r['id'], + 'title' => $r['title'], + 'classification' => $r['classification'], + 'severity' => $r['severity'], + 'status' => $r['status'], + 'is_significant' => $isSig, + 'detected_at' => $r['detected_at'], + 'closed_at' => $r['closed_at'], + 'art23' => [ + 'early_warning_due' => $r['early_warning_due'], + 'early_warning_sent' => !empty($r['early_warning_sent_at']), + 'early_warning_overdue'=> $isSig && $isOpen && $ewDue && $now > $ewDue && !$r['early_warning_sent_at'], + 'notification_due' => $r['notification_due'], + 'notification_sent' => !empty($r['notification_sent_at']), + 'notification_overdue' => $notOverdue, + 'final_report_due' => $r['final_report_due'], + 'final_report_sent' => !empty($r['final_report_sent_at']), + 'final_report_overdue' => $isSig && $isOpen && $frDue && $now > $frDue && !$r['final_report_sent_at'], + ], + ]; + } + + // Statistiche totali (non solo quelle nella pagina) + $stats = Database::fetchOne( + 'SELECT COUNT(*) as total, + SUM(CASE WHEN status NOT IN ("closed","post_mortem") THEN 1 ELSE 0 END) as open_count, + SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant + FROM incidents WHERE organization_id = ?', + [$orgId] + ); + + $this->jsonSuccess([ + 'total' => (int) ($stats['total'] ?? 0), + 'open' => (int) ($stats['open_count'] ?? 0), + 'significant' => (int) ($stats['significant'] ?? 0), + 'csirt_overdue' => $csirtOverdue, + 'fetched' => count($incidents), + 'incidents' => $incidents, + ]); + } + + /** + * GET /api/services/training + * Formazione NIS2 Art.20 — corsi, completamento, compliance board. + * Utile per lg231 per evidenza OdV Pillar 4 (Formazione). + */ + public function training(): void + { + $this->requireApiKey('read:all'); + $orgId = $this->currentOrgId; + + // Corsi dell'org + corsi globali (org_id IS NULL) + $courses = Database::fetchAll( + 'SELECT id, title, target_role, nis2_article, is_mandatory, duration_minutes + FROM training_courses + WHERE (organization_id = ? OR organization_id IS NULL) AND is_active = 1 + ORDER BY is_mandatory DESC, target_role, title', + [$orgId] + ); + + if (empty($courses)) { + $this->jsonSuccess([ + 'courses_total' => 0, 'mandatory_total' => 0, + 'assignments_total' => 0, 'assignments_completed' => 0, + 'overall_completion_rate' => 0, 'board_completion_rate' => 0, + 'art20_compliance' => false, 'courses' => [], + ]); + return; + } + + $courseIds = array_column($courses, 'id'); + $placeholders = implode(',', array_fill(0, count($courseIds), '?')); + + // Aggregazione assignments per corso + $assignRows = Database::fetchAll( + "SELECT course_id, + COUNT(*) as total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed + FROM training_assignments + WHERE organization_id = ? AND course_id IN ({$placeholders}) + GROUP BY course_id", + array_merge([$orgId], $courseIds) + ); + $assignMap = []; + foreach ($assignRows as $row) { + $assignMap[(int)$row['course_id']] = ['total' => (int)$row['total'], 'completed' => (int)$row['completed']]; + } + + // Completion board members: corsi mandatory × utenti board + $boardAssign = Database::fetchOne( + "SELECT COUNT(*) as total, + SUM(CASE WHEN ta.status = 'completed' THEN 1 ELSE 0 END) as completed + FROM training_assignments ta + JOIN users u ON u.id = ta.user_id + JOIN user_organizations uo ON uo.user_id = u.id AND uo.organization_id = ta.organization_id + WHERE ta.organization_id = ? AND uo.role = 'board_member'", + [$orgId] + ); + + $totalAssign = 0; + $totalCompleted = 0; + $mandatoryBoardTotal = 0; + $mandatoryBoardCompleted = 0; + $courseList = []; + + foreach ($courses as $c) { + $cid = (int) $c['id']; + $a = $assignMap[$cid] ?? ['total' => 0, 'completed' => 0]; + $rate = $a['total'] > 0 ? (int) round($a['completed'] / $a['total'] * 100) : 0; + + $totalAssign += $a['total']; + $totalCompleted += $a['completed']; + + if ($c['is_mandatory'] && $c['target_role'] === 'board_member') { + $mandatoryBoardTotal += $a['total']; + $mandatoryBoardCompleted += $a['completed']; + } + + $courseList[] = [ + 'id' => $cid, + 'title' => $c['title'], + 'target_role' => $c['target_role'], + 'nis2_article' => $c['nis2_article'], + 'is_mandatory' => (bool) $c['is_mandatory'], + 'duration_minutes' => (int) ($c['duration_minutes'] ?? 0), + 'assigned' => $a['total'], + 'completed' => $a['completed'], + 'completion_rate' => $rate, + ]; + } + + $overallRate = $totalAssign > 0 ? (int) round($totalCompleted / $totalAssign * 100) : 0; + $boardRate = ((int)($boardAssign['total'] ?? 0)) > 0 + ? (int) round((int)($boardAssign['completed'] ?? 0) / (int)$boardAssign['total'] * 100) + : 0; + $art20ok = $mandatoryBoardTotal > 0 && $mandatoryBoardCompleted >= $mandatoryBoardTotal; + + $this->jsonSuccess([ + 'courses_total' => count($courses), + 'mandatory_total' => count(array_filter($courses, fn($c) => $c['is_mandatory'])), + 'assignments_total' => $totalAssign, + 'assignments_completed' => $totalCompleted, + 'overall_completion_rate' => $overallRate, + 'board_completion_rate' => $boardRate, + 'art20_compliance' => $art20ok, + 'courses' => $courseList, + ]); + } + + /** + * GET /api/services/deadlines + * Scadenze aggregate da 4 sorgenti: incidenti CSIRT, revisioni controlli, + * assessment fornitori, training assignments. + * ?days=30 finestra (default 30, max 365) + */ + public function deadlines(): void + { + $this->requireApiKey('read:all'); + $orgId = $this->currentOrgId; + + $days = min((int) ($this->getParam('days') ?: 30), 365); + $now = time(); + $horizon = $now + ($days * 86400); + $deadlines = []; + + // ── 1. Notifiche CSIRT incidenti significativi aperti ────────────── + $openInc = Database::fetchAll( + 'SELECT id, 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")', + [$orgId] + ); + + foreach ($openInc as $inc) { + $steps = [ + ['subtype' => 'early_warning_24h', 'due' => $inc['early_warning_due'], 'sent' => $inc['early_warning_sent_at']], + ['subtype' => 'notification_72h', 'due' => $inc['notification_due'], 'sent' => $inc['notification_sent_at']], + ['subtype' => 'final_report_30d', 'due' => $inc['final_report_due'], 'sent' => $inc['final_report_sent_at']], + ]; + foreach ($steps as $step) { + if (!$step['due'] || $step['sent']) continue; + $dueTs = strtotime($step['due']); + if ($dueTs > $horizon) continue; + $hoursLeft = (int) round(($dueTs - $now) / 3600); + $deadlines[] = [ + 'type' => 'incident_notification', + 'subtype' => $step['subtype'], + 'title' => $step['subtype'] . ': ' . $inc['title'], + 'due_date' => $step['due'], + 'overdue' => $dueTs < $now, + 'hours_remaining' => $hoursLeft, + 'priority' => 'critical', + 'reference_id' => (int) $inc['id'], + 'nis2_article' => 'Art.23', + ]; + } + } + + // ── 2. Revisioni controlli compliance ───────────────────────────── + $controls = Database::fetchAll( + 'SELECT id, control_code, title, next_review_date, status + FROM compliance_controls + WHERE organization_id = ? AND next_review_date IS NOT NULL + AND status != "verified"', + [$orgId] + ); + foreach ($controls as $c) { + $dueTs = strtotime($c['next_review_date']); + if ($dueTs > $horizon) continue; + $hoursLeft = (int) round(($dueTs - $now) / 3600); + $deadlines[] = [ + 'type' => 'control_review', + 'subtype' => $c['status'], + 'title' => 'Revisione: ' . $c['title'], + 'due_date' => $c['next_review_date'], + 'overdue' => $dueTs < $now, + 'hours_remaining' => $hoursLeft, + 'priority' => 'medium', + 'reference_id' => (int) $c['id'], + 'nis2_article' => 'Art.21', + ]; + } + + // ── 3. Assessment fornitori ──────────────────────────────────────── + $suppliers = Database::fetchAll( + 'SELECT id, name, criticality, next_assessment_date + FROM suppliers + WHERE organization_id = ? AND deleted_at IS NULL + AND next_assessment_date IS NOT NULL', + [$orgId] + ); + foreach ($suppliers as $s) { + $dueTs = strtotime($s['next_assessment_date']); + if ($dueTs > $horizon) continue; + $hoursLeft = (int) round(($dueTs - $now) / 3600); + $priority = in_array($s['criticality'], ['critical', 'high']) ? 'high' : 'medium'; + $deadlines[] = [ + 'type' => 'supplier_assessment', + 'subtype' => $s['criticality'], + 'title' => 'Valutazione fornitore: ' . $s['name'], + 'due_date' => $s['next_assessment_date'], + 'overdue' => $dueTs < $now, + 'hours_remaining' => $hoursLeft, + 'priority' => $priority, + 'reference_id' => (int) $s['id'], + 'nis2_article' => 'Art.21.2.d', + ]; + } + + // ── 4. Training assignments scaduti/in scadenza ──────────────────── + $trainings = Database::fetchAll( + 'SELECT ta.id, tc.title, ta.due_date, ta.status + FROM training_assignments ta + JOIN training_courses tc ON tc.id = ta.course_id + WHERE ta.organization_id = ? AND ta.status NOT IN ("completed") + AND ta.due_date IS NOT NULL', + [$orgId] + ); + foreach ($trainings as $t) { + $dueTs = strtotime($t['due_date']); + if ($dueTs > $horizon) continue; + $hoursLeft = (int) round(($dueTs - $now) / 3600); + $deadlines[] = [ + 'type' => 'training_assignment', + 'subtype' => $t['status'], + 'title' => 'Formazione: ' . $t['title'], + 'due_date' => $t['due_date'], + 'overdue' => $dueTs < $now, + 'hours_remaining' => $hoursLeft, + 'priority' => 'low', + 'reference_id' => (int) $t['id'], + 'nis2_article' => 'Art.20', + ]; + } + + // Ordina: overdue + urgenti prima + usort($deadlines, function ($a, $b) { + if ($a['overdue'] !== $b['overdue']) return $b['overdue'] <=> $a['overdue']; + return strtotime($a['due_date']) <=> strtotime($b['due_date']); + }); + + $overdue = count(array_filter($deadlines, fn($d) => $d['overdue'])); + $due7days = count(array_filter($deadlines, fn($d) => !$d['overdue'] && strtotime($d['due_date']) <= $now + 7 * 86400)); + $due30days = count(array_filter($deadlines, fn($d) => !$d['overdue'])); + + $this->jsonSuccess([ + 'days_horizon' => $days, + 'overdue' => $overdue, + 'due_7_days' => $due7days, + 'due_30_days' => $due30days, + 'total' => count($deadlines), + 'deadlines' => $deadlines, + ]); + } } diff --git a/public/index.php b/public/index.php index 4d1afbd..44b60a9 100644 --- a/public/index.php +++ b/public/index.php @@ -328,6 +328,12 @@ $actionMap = [ 'GET:suppliersRisk' => 'suppliersRisk', 'GET:policiesApproved' => 'policiesApproved', 'GET:openapi' => 'openapi', + // lg231 / GRC integration endpoints + 'GET:gapAnalysis' => 'gapAnalysis', + 'GET:measures' => 'measures', + 'GET:incidents' => 'incidents', + 'GET:training' => 'training', + 'GET:deadlines' => 'deadlines', ], // ── WebhookController (CRUD keys + subscriptions) ──