requireOrgAccess(); $controls = Database::fetchAll( 'SELECT cc.*, u.full_name as responsible_name FROM compliance_controls cc LEFT JOIN users u ON u.id = cc.responsible_user_id WHERE cc.organization_id = ? ORDER BY cc.control_code', [$this->getCurrentOrgId()] ); $this->jsonSuccess($controls); } public function updateControl(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); $updates = []; foreach (['status', 'implementation_percentage', 'evidence_description', 'responsible_user_id', 'next_review_date'] as $field) { if ($this->hasParam($field)) { $updates[$field] = $this->getParam($field); } } if (isset($updates['status']) && $updates['status'] === 'verified') { $updates['last_verified_at'] = date('Y-m-d H:i:s'); } if (!empty($updates)) { Database::update('compliance_controls', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('control_updated', 'compliance_control', $id, $updates); } $this->jsonSuccess($updates, 'Controllo aggiornato'); } public function uploadEvidence(): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); if (!isset($_FILES['file'])) { $this->jsonError('File non fornito', 400, 'NO_FILE'); } $file = $_FILES['file']; $maxSize = 10 * 1024 * 1024; // 10MB if ($file['size'] > $maxSize) { $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); } $orgId = $this->getCurrentOrgId(); $uploadDir = UPLOAD_PATH . "/evidence/{$orgId}"; if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } $ext = pathinfo($file['name'], PATHINFO_EXTENSION); $filename = uniqid('ev_') . '.' . $ext; $filePath = $uploadDir . '/' . $filename; if (!move_uploaded_file($file['tmp_name'], $filePath)) { $this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR'); } $evidenceId = Database::insert('evidence_files', [ 'organization_id' => $orgId, 'control_id' => $this->getParam('control_id'), 'entity_type' => $this->getParam('entity_type'), 'entity_id' => $this->getParam('entity_id'), 'file_name' => $file['name'], 'file_path' => "evidence/{$orgId}/{$filename}", 'file_size' => $file['size'], 'mime_type' => $file['type'], 'uploaded_by' => $this->getCurrentUserId(), ]); $this->logAudit('evidence_uploaded', 'evidence', $evidenceId); $this->jsonSuccess(['id' => $evidenceId], 'Evidenza caricata', 201); } public function listEvidence(): void { $this->requireOrgAccess(); $where = 'organization_id = ?'; $params = [$this->getCurrentOrgId()]; if ($this->hasParam('control_id')) { $where .= ' AND control_id = ?'; $params[] = $this->getParam('control_id'); } if ($this->hasParam('entity_type') && $this->hasParam('entity_id')) { $where .= ' AND entity_type = ? AND entity_id = ?'; $params[] = $this->getParam('entity_type'); $params[] = $this->getParam('entity_id'); } $evidence = Database::fetchAll( "SELECT ef.*, u.full_name as uploaded_by_name FROM evidence_files ef LEFT JOIN users u ON u.id = ef.uploaded_by WHERE ef.{$where} ORDER BY ef.created_at DESC", $params ); $this->jsonSuccess($evidence); } public function generateReport(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$orgId]); $controls = Database::fetchAll('SELECT * FROM compliance_controls WHERE organization_id = ? ORDER BY control_code', [$orgId]); $lastAssessment = Database::fetchOne('SELECT * FROM assessments WHERE organization_id = ? AND status = "completed" ORDER BY completed_at DESC LIMIT 1', [$orgId]); $riskCount = Database::count('risks', 'organization_id = ? AND status != "closed"', [$orgId]); $incidentCount = Database::count('incidents', 'organization_id = ?', [$orgId]); $policyCount = Database::count('policies', 'organization_id = ? AND status IN ("approved","published")', [$orgId]); $totalControls = count($controls); $implemented = count(array_filter($controls, fn($c) => in_array($c['status'], ['implemented', 'verified']))); $this->jsonSuccess([ 'organization' => $org, 'report_date' => date('Y-m-d H:i:s'), 'compliance_summary' => [ 'total_controls' => $totalControls, 'implemented_controls' => $implemented, 'compliance_percentage' => $totalControls > 0 ? round($implemented / $totalControls * 100) : 0, ], 'controls' => $controls, 'last_assessment' => $lastAssessment, 'risk_count' => $riskCount, 'incident_count' => $incidentCount, 'policy_count' => $policyCount, ]); } public function getAuditLogs(): void { $this->requireOrgRole(['org_admin', 'auditor']); $pagination = $this->getPagination(50); $total = Database::count('audit_logs', 'organization_id = ?', [$this->getCurrentOrgId()]); $logs = 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 {$pagination['per_page']} OFFSET {$pagination['offset']}", [$this->getCurrentOrgId()] ); $this->jsonPaginated($logs, $total, $pagination['page'], $pagination['per_page']); } public function getIsoMapping(): void { $this->requireOrgAccess(); $mapping = [ ['nis2' => '21.2.a', 'iso27001' => 'A.5.1, A.5.2, A.8.1, A.8.2', 'title' => 'Risk analysis and security policies'], ['nis2' => '21.2.b', 'iso27001' => 'A.5.24, A.5.25, A.5.26, A.6.8', 'title' => 'Incident handling'], ['nis2' => '21.2.c', 'iso27001' => 'A.5.29, A.5.30', 'title' => 'Business continuity'], ['nis2' => '21.2.d', 'iso27001' => 'A.5.19, A.5.20, A.5.21, A.5.22', 'title' => 'Supply chain security'], ['nis2' => '21.2.e', 'iso27001' => 'A.8.25, A.8.26, A.8.27, A.8.28', 'title' => 'System acquisition and development'], ['nis2' => '21.2.f', 'iso27001' => 'A.5.35, A.5.36', 'title' => 'Effectiveness assessment'], ['nis2' => '21.2.g', 'iso27001' => 'A.6.3, A.6.6', 'title' => 'Cyber hygiene and training'], ['nis2' => '21.2.h', 'iso27001' => 'A.8.24', 'title' => 'Cryptography and encryption'], ['nis2' => '21.2.i', 'iso27001' => 'A.5.15, A.5.16, A.5.17, A.5.18, A.6.1, A.6.2', 'title' => 'HR security, access control, asset management'], ['nis2' => '21.2.j', 'iso27001' => 'A.8.5', 'title' => 'Multi-factor authentication'], ]; $this->jsonSuccess($mapping); } /** * GET /api/audit/nistCsfMapping * Layer di mapping NIST CSF 2.0 (43 controlli) -> NIS2 Art.21 / D.Lgs.138/2024 -> modulo piattaforma. * Reference-only (nessuna persistenza): arricchisce l'assessment Art.21 con i codici controllo * NIST CSF 2.0 usati come standard de-facto. Fonte mapping: NIST CSF 2.0 + Direttiva (UE) 2022/2555. */ public function getNistCsfMapping(): void { $this->requireOrgAccess(); // [code, function, nis2, module] $rows = [ // GOVERN ['GV.OC-04', 'Govern', '21.1', 'Asset - Sistemi rilevanti (GV.OC-04)'], ['GV.RM-03', 'Govern', '21.2.a', 'Risk Management'], ['GV.RR-02', 'Govern', '20', 'Organizzazione - Ruoli e responsabilita'], ['GV.RR-04', 'Govern', '20', 'Organizzazione - Risorse cybersecurity'], ['GV.PO-01', 'Govern', '21.2.a', 'Policy - Politica di sicurezza'], ['GV.PO-02', 'Govern', '21.2.a', 'Policy - Revisione politiche'], ['GV.SC-01', 'Govern', '21.2.d', 'Supply Chain - Strategia'], ['GV.SC-02', 'Govern', '21.2.d', 'Supply Chain - Ruoli fornitori'], ['GV.SC-04', 'Govern', '21.2.d', 'Supply Chain - Valutazione fornitori'], ['GV.SC-05', 'Govern', '21.2.d', 'Supply Chain - Requisiti contrattuali'], ['GV.SC-07', 'Govern', '21.2.d', 'Supply Chain - Monitoraggio rischio fornitori'], // IDENTIFY ['ID.AM-01', 'Identify', '21.2.i', 'Asset - Inventario hardware'], ['ID.AM-02', 'Identify', '21.2.i', 'Asset - Inventario software'], ['ID.AM-03', 'Identify', '21.2.i', 'Asset - Diagrammi flussi/rete (essenziali)'], ['ID.AM-04', 'Identify', '21.2.i', 'Asset - Catalogo servizi'], ['ID.RA-01', 'Identify', '21.2.a', 'Risk Management - Vulnerabilita'], ['ID.RA-05', 'Identify', '21.2.a', 'Risk Management - Valutazione rischio'], ['ID.RA-06', 'Identify', '21.2.a', 'Risk Management - Trattamento rischio'], ['ID.RA-08', 'Identify', '21.2.e', 'Risk Management - Gestione vulnerabilita/disclosure'], ['ID.IM-01', 'Identify', '21.2.f', 'Audit - Miglioramento da valutazioni'], ['ID.IM-04', 'Identify', '21.2.c', 'Incidenti - Piani BC/DR e test'], // PROTECT ['PR.AA-01', 'Protect', '21.2.i', 'Asset/Access - Gestione identita'], ['PR.AA-03', 'Protect', '21.2.i', 'Access - Autenticazione'], ['PR.AA-05', 'Protect', '21.2.i', 'Access - Privilegi e accessi'], ['PR.AA-06', 'Protect', '21.2.i', 'Access - Accesso fisico'], ['PR.AT-01', 'Protect', '21.2.g', 'Training - Awareness'], ['PR.AT-02', 'Protect', '21.2.g', 'Training - Ruoli privilegiati'], ['PR.DS-01', 'Protect', '21.2.h', 'Policy - Protezione dati a riposo'], ['PR.DS-02', 'Protect', '21.2.h', 'Policy - Protezione dati in transito'], ['PR.DS-11', 'Protect', '21.2.c', 'Incidenti - Backup'], ['PR.PS-01', 'Protect', '21.2.e', 'Policy - Configurazione sicura'], ['PR.PS-02', 'Protect', '21.2.e', 'Asset - Gestione software'], ['PR.PS-03', 'Protect', '21.2.e', 'Asset - Gestione hardware'], ['PR.PS-04', 'Protect', '21.2.b', 'Audit - Log generation'], ['PR.PS-06', 'Protect', '21.2.e', 'Policy - Secure development lifecycle'], ['PR.IR-01', 'Protect', '21.2.i', 'Asset - Protezione reti'], ['PR.IR-03', 'Protect', '21.2.c', 'Incidenti - Resilienza/ridondanza'], // DETECT ['DE.CM-01', 'Detect', '21.2.b', 'Incidenti - Monitoraggio reti'], ['DE.CM-09', 'Detect', '21.2.b', 'Incidenti - Monitoraggio asset/sistemi'], // RESPOND / RECOVER ['RS.MA-01', 'Respond', '21.2.b / 23', 'Incidenti - Gestione incidenti'], ['RS.CO-02', 'Respond', '23', 'Incidenti - Notifica CSIRT'], ['RC.RP-01', 'Recover', '21.2.c', 'Incidenti - Piano di ripristino'], ['RC.CO-03', 'Recover', '21.2.c', 'Incidenti - Post-Incident Review'], ]; $mapping = array_map(fn($r) => [ 'csf_code' => $r[0], 'function' => $r[1], 'nis2_art' => $r[2], 'module' => $r[3], ], $rows); $this->jsonSuccess([ 'mapping' => $mapping, 'count' => count($mapping), 'functions' => ['Govern', 'Identify', 'Protect', 'Detect', 'Respond', 'Recover'], 'source' => 'NIST Cybersecurity Framework 2.0 + Direttiva (UE) 2022/2555 (NIS2) Art.20-21-23 / D.Lgs. 138/2024', ]); } /** * GET /api/audit/executive-report * Genera report esecutivo HTML (stampabile come PDF) */ public function executiveReport(): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); $reportService = new ReportService(); $html = $reportService->generateExecutiveReport($this->getCurrentOrgId()); header('Content-Type: text/html; charset=utf-8'); echo $html; exit; } /** * GET /api/audit/relevantSystemsRegister * Registro formale "Sistemi Rilevanti NIS2" (GV.OC-04), HTML stampabile. */ public function relevantSystemsRegister(): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']); $reportService = new ReportService(); $html = $reportService->generateRelevantSystemsRegister($this->getCurrentOrgId()); header('Content-Type: text/html; charset=utf-8'); echo $html; exit; } /** * GET /api/audit/export/{type} * Esporta dati in CSV */ public function export(int $type = 0): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); $exportType = $_GET['type'] ?? 'controls'; $orgId = $this->getCurrentOrgId(); $reportService = new ReportService(); $csv = match ($exportType) { 'risks' => $reportService->exportRisksCSV($orgId), 'incidents' => $reportService->exportIncidentsCSV($orgId), 'controls' => $reportService->exportControlsCSV($orgId), 'assets' => $reportService->exportAssetsCSV($orgId), default => $reportService->exportControlsCSV($orgId), }; $filename = "nis2_{$exportType}_" . date('Y-m-d') . '.csv'; header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="' . $filename . '"'); echo $csv; exit; } /** * GET /api/audit/chain-verify * Verifica l'integrità dell'hash chain per l'organizzazione corrente. * Risponde con: valid, total, hashed, coverage_pct, broken_at, last_hash */ public function chainVerify(): void { $this->requireOrgRole(['org_admin', 'auditor']); $orgId = $this->getCurrentOrgId(); $db = Database::getInstance(); $result = AuditService::verifyChain($db, $orgId); // Se la catena è rotta, registra la violazione if (!$result['valid'] && $result['broken_at'] !== null) { $performer = $this->currentUser['email'] ?? 'system'; try { $ins = $db->prepare( "INSERT INTO audit_violations (organization_id, detected_by, broken_at_id, chain_length, notes) VALUES (:org, :by, :bid, :len, :notes)" ); $ins->execute([ 'org' => $orgId, 'by' => $performer, 'bid' => $result['broken_at'], 'len' => $result['total'], 'notes' => 'Violazione rilevata tramite API chain-verify', ]); } catch (Throwable $e) { error_log('[AuditController] chain violation log error: ' . $e->getMessage()); } $this->logAudit('audit.chain_broken', 'audit_logs', $result['broken_at'], [ 'total' => $result['total'], 'coverage_pct' => $result['coverage_pct'], ]); } $this->jsonSuccess($result); } /** * GET /api/audit/export-certified * Genera un export JSON certificato con hash SHA-256 dell'intero contenuto. * Adatto per ispezioni ACN, audit NIS2 Art.32, certificazione ISO 27001. */ public function exportCertified(): void { $this->requireOrgRole(['org_admin', 'auditor']); $orgId = $this->getCurrentOrgId(); $userId = $this->getCurrentUserId(); $email = $this->currentUser['email'] ?? 'system'; $db = Database::getInstance(); $result = AuditService::exportCertified( $db, $orgId, $userId, $email, $_GET['purpose'] ?? 'export_certificato', $_GET['from'] ?? null, $_GET['to'] ?? null ); $this->logAudit('audit.export_certified', 'audit_logs', null, [ 'records_count' => $result['records_count'], 'chain_valid' => $result['chain_valid'], 'export_hash' => $result['export_hash'], ]); header('Content-Disposition: attachment; filename="nis2_audit_certified_' . date('Y-m-d') . '.json"'); $this->jsonSuccess($result); } /** * GET /api/audit/controlsMonitoring * Continuous Control Monitoring (P1): stato/freschezza dei controlli * alimentato dalle evidenze automatiche. Versione JWT per la UI. */ public function controlsMonitoring(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); try { Database::query( "UPDATE compliance_controls cc LEFT JOIN ( SELECT t.control_code, t.valid_until FROM control_evidence_auto t JOIN (SELECT control_code, MAX(collected_at) mx FROM control_evidence_auto WHERE organization_id = ? GROUP BY control_code) m ON m.control_code = t.control_code AND m.mx = t.collected_at WHERE t.organization_id = ? ) last ON last.control_code = cc.control_code SET cc.monitoring_status = 'stale' WHERE cc.organization_id = ? AND cc.monitoring_status NOT IN ('not_monitored') AND last.valid_until IS NOT NULL AND last.valid_until < NOW()", [$orgId, $orgId, $orgId] ); } catch (Throwable $e) { error_log('[CCM] ' . $e->getMessage()); } $rows = Database::fetchAll( 'SELECT control_code, title, status, monitoring_status, last_checked_at, freshness_days, implementation_percentage FROM compliance_controls WHERE organization_id = ? ORDER BY control_code', [$orgId] ); $summary = ['healthy' => 0, 'warning' => 0, 'stale' => 0, 'failing' => 0, 'not_monitored' => 0]; foreach ($rows as $r) { $ms = $r['monitoring_status'] ?? 'not_monitored'; if (isset($summary[$ms])) $summary[$ms]++; } $monitored = count($rows) - $summary['not_monitored']; $recent = []; try { $recent = Database::fetchAll( 'SELECT control_code, source, source_system, status, summary, collected_at, valid_until FROM control_evidence_auto WHERE organization_id = ? ORDER BY collected_at DESC LIMIT 20', [$orgId] ); } catch (Throwable $e) { /* ignore */ } $this->jsonSuccess([ 'total_controls' => count($rows), 'monitored' => $monitored, 'coverage_percent' => count($rows) ? (int) round($monitored * 100 / count($rows)) : 0, 'summary' => $summary, 'controls' => $rows, 'recent_evidence' => $recent, ]); } }