diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index ae654ad..a8ae600 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -1452,6 +1452,137 @@ class ServicesController extends BaseController // ── Utility ─────────────────────────────────────────────────────────── + /** + * GET /api/services/full-snapshot + * Aggregato di tutti i flussi lg231 in una sola chiamata. + * Riduce da 6 round-trip a 1. Risposta: { gap_analysis, measures, incidents, training, deadlines, compliance_summary } + * ?days=30 finestra deadlines (default 30) + */ + public function fullSnapshot(): void + { + $this->requireApiKey('read:all'); + $orgId = $this->currentOrgId; + $days = min((int) ($this->getParam('days') ?: 30), 365); + + // Riusa la logica degli endpoint esistenti catturando i dati direttamente + // senza fare HTTP round-trip interni + + // ── compliance summary (già esistente) ──────────────────────────── + $org = Database::fetchOne('SELECT name, sector, entity_type, employee_count FROM organizations WHERE id = ?', [$orgId]); + $ctrlStats = Database::fetchOne( + 'SELECT COUNT(*) as total, + SUM(CASE WHEN status IN ("implemented","verified") THEN 1 ELSE 0 END) as done, + SUM(CASE WHEN status = "in_progress" THEN 1 ELSE 0 END) as wip + FROM compliance_controls WHERE organization_id = ?', [$orgId] + ); + $complianceScore = ($ctrlStats['total'] ?? 0) > 0 + ? (int) round(($ctrlStats['done'] + $ctrlStats['wip'] * 0.5) / $ctrlStats['total'] * 100) + : 0; + + // ── gap analysis ───────────────────────────────────────────────── + $assessment = Database::fetchOne( + 'SELECT id, overall_score, ai_summary, ai_recommendations, completed_at + FROM assessments WHERE organization_id = ? AND status = "completed" + ORDER BY completed_at DESC LIMIT 1', [$orgId] + ); + $gapDomains = []; + if ($assessment) { + $responses = Database::fetchAll( + 'SELECT category, response_value, question_text FROM assessment_responses WHERE assessment_id = ?', + [$assessment['id']] + ); + $dData = []; $dGaps = []; + foreach ($responses as $r) { + $cat = $r['category']; + if (!isset($dData[$cat])) { $dData[$cat] = ['implemented'=>0,'partial'=>0,'not_implemented'=>0,'not_applicable'=>0]; $dGaps[$cat] = []; } + match ($r['response_value']) { + 'implemented' => $dData[$cat]['implemented']++, 'partial' => $dData[$cat]['partial']++, + 'not_implemented' => $dData[$cat]['not_implemented']++, 'not_applicable' => $dData[$cat]['not_applicable']++, + default => null, + }; + if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) $dGaps[$cat][] = $r['question_text']; + } + $mogMap = ['governance'=>'pillar_1_governance','risk_management'=>'pillar_2_risk_assessment','incident_management'=>'pillar_7_segnalazioni','business_continuity'=>'pillar_5_monitoraggio','supply_chain'=>'pillar_3_procedure_operative','vulnerability'=>'pillar_5_monitoraggio','policy_measurement'=>'pillar_3_procedure_operative','training_awareness'=>'pillar_4_formazione','cryptography'=>'pillar_6_sicurezza_it','access_control'=>'pillar_6_sicurezza_it']; + $artMap = ['governance'=>'Art.20-21','risk_management'=>'Art.21.2.a','incident_management'=>'Art.21.2.b','business_continuity'=>'Art.21.2.c','supply_chain'=>'Art.21.2.d','vulnerability'=>'Art.21.2.e','policy_measurement'=>'Art.21.2.f','training_awareness'=>'Art.21.2.g','cryptography'=>'Art.21.2.h','access_control'=>'Art.21.2.i']; + $gapActions = ['critical'=>'Intervento immediato: piano d\'azione entro 30 giorni.','high'=>'Priorità alta: avviare implementazione nel prossimo sprint.','medium'=>'Completare misure parziali entro il prossimo trimestre.','low'=>'Revisione periodica annuale.']; + foreach ($dData 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; + $gl = match(true) { $score===null=>'not_assessed', $score>=75=>'low', $score>=50=>'medium', $score>=25=>'high', default=>'critical' }; + $gapDomains[] = ['domain'=>$domain,'nis2_article'=>$artMap[$domain]??'Art.21','mog_pillar'=>$mogMap[$domain]??'pillar_3_procedure_operative','score'=>$score,'gap_level'=>$gl,'implemented'=>$counts['implemented'],'partial'=>$counts['partial'],'not_implemented'=>$counts['not_implemented'],'not_implemented_items'=>array_slice($dGaps[$domain]??[],0,3),'suggested_action'=>$gapActions[$gl]??null]; + } + usort($gapDomains, fn($a,$b) => ($a['score']??101)<=>($b['score']??101)); + } + + // ── incidents ──────────────────────────────────────────────────── + $now = time(); + $incRows = Database::fetchAll( + 'SELECT id, title, classification, severity, status, is_significant, detected_at, + early_warning_due, early_warning_sent_at, notification_due, notification_sent_at, + final_report_due, final_report_sent_at + FROM incidents WHERE organization_id = ? ORDER BY detected_at DESC LIMIT 20', [$orgId] + ); + $incidents = []; + $csirtOverdue = 0; + foreach ($incRows as $r) { + $isOpen = !in_array($r['status'],['closed','post_mortem']); $isSig = (bool)$r['is_significant']; + $notDue = $r['notification_due'] ? strtotime($r['notification_due']) : null; + $notOv = $isSig && $isOpen && $notDue && $now > $notDue && !$r['notification_sent_at']; + if ($notOv) $csirtOverdue++; + $incidents[] = ['id'=>(int)$r['id'],'title'=>$r['title'],'severity'=>$r['severity'],'status'=>$r['status'],'is_significant'=>$isSig,'detected_at'=>$r['detected_at'],'art23'=>['early_warning_due'=>$r['early_warning_due'],'early_warning_sent'=>!empty($r['early_warning_sent_at']),'early_warning_overdue'=>$isSig&&$isOpen&&$r['early_warning_due']&&$now>strtotime($r['early_warning_due'])&&!$r['early_warning_sent_at'],'notification_due'=>$r['notification_due'],'notification_sent'=>!empty($r['notification_sent_at']),'notification_overdue'=>$notOv,'final_report_due'=>$r['final_report_due'],'final_report_sent'=>!empty($r['final_report_sent_at']),'final_report_overdue'=>$isSig&&$isOpen&&$r['final_report_due']&&$now>strtotime($r['final_report_due'])&&!$r['final_report_sent_at']]]; + } + + // ── training ───────────────────────────────────────────────────── + $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,title', [$orgId]); + $trainingData = ['courses_total'=>0,'mandatory_total'=>0,'overall_completion_rate'=>0,'board_completion_rate'=>0,'art20_compliance'=>false,'non_compliant_mandatory'=>[]]; + if (!empty($courses)) { + $cids = array_column($courses,'id'); + $ph = implode(',', array_fill(0,count($cids),'?')); + $agg = 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 ({$ph}) GROUP BY course_id", array_merge([$orgId],$cids)); + $am = []; foreach ($agg as $a) $am[(int)$a['course_id']] = ['total'=>(int)$a['total'],'completed'=>(int)$a['completed']]; + $board = Database::fetchOne("SELECT COUNT(*) as total, SUM(CASE WHEN ta.status='completed' THEN 1 ELSE 0 END) as completed FROM training_assignments ta JOIN user_organizations uo ON uo.user_id=ta.user_id AND uo.organization_id=ta.organization_id WHERE ta.organization_id=? AND uo.role='board_member'", [$orgId]); + $tTot=0; $tComp=0; $mBTot=0; $mBComp=0; $cList=[]; + foreach ($courses as $c) { + $cid=(int)$c['id']; $a=$am[$cid]??['total'=>0,'completed'=>0]; + $rate=$a['total']>0?(int)round($a['completed']/$a['total']*100):0; + $tTot+=$a['total']; $tComp+=$a['completed']; + if ($c['is_mandatory']&&$c['target_role']==='board_member') { $mBTot+=$a['total']; $mBComp+=$a['completed']; } + $cList[]=['id'=>$cid,'title'=>$c['title'],'target_role'=>$c['target_role'],'nis2_article'=>$c['nis2_article'],'is_mandatory'=>(bool)$c['is_mandatory'],'assigned'=>$a['total'],'completed'=>$a['completed'],'completion_rate'=>$rate]; + } + $nonCompliant = array_values(array_filter($cList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100)); + $trainingData = ['courses_total'=>count($courses),'mandatory_total'=>count(array_filter($courses,fn($c)=>$c['is_mandatory'])),'assignments_total'=>$tTot,'assignments_completed'=>$tComp,'overall_completion_rate'=>$tTot>0?(int)round($tComp/$tTot*100):0,'board_completion_rate'=>(int)($board['total']??0)>0?(int)round((int)($board['completed']??0)/(int)$board['total']*100):0,'art20_compliance'=>$mBTot>0&&$mBComp>=$mBTot,'non_compliant_mandatory'=>$nonCompliant,'courses'=>$cList]; + } + + // ── deadlines ──────────────────────────────────────────────────── + $horizon = $now + ($days * 86400); + $deadlines = []; + foreach (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]) as $inc) { + foreach ([['notification_72h',$inc['notification_due'],$inc['notification_sent_at']],['early_warning_24h',$inc['early_warning_due'],$inc['early_warning_sent_at']],['final_report_30d',$inc['final_report_due'],$inc['final_report_sent_at']]] as [$sub,$due,$sent]) { + if (!$due||$sent) continue; $ts=strtotime($due); if ($ts>$horizon) continue; + $deadlines[]=['type'=>'incident_notification','subtype'=>$sub,'title'=>$sub.': '.$inc['title'],'due_date'=>$due,'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'critical','reference_id'=>(int)$inc['id'],'nis2_article'=>'Art.23']; + } + } + foreach (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]) as $c) { + $ts=strtotime($c['next_review_date']); if ($ts>$horizon) continue; + $deadlines[]=['type'=>'control_review','subtype'=>$c['status'],'title'=>'Revisione: '.$c['title'],'due_date'=>$c['next_review_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>'medium','reference_id'=>(int)$c['id'],'nis2_article'=>'Art.21']; + } + foreach (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]) as $s) { + $ts=strtotime($s['next_assessment_date']); if ($ts>$horizon) continue; + $deadlines[]=['type'=>'supplier_assessment','subtype'=>$s['criticality'],'title'=>'Valutazione fornitore: '.$s['name'],'due_date'=>$s['next_assessment_date'],'overdue'=>$ts<$now,'hours_remaining'=>(int)round(($ts-$now)/3600),'priority'=>in_array($s['criticality'],['critical','high'])?'high':'medium','reference_id'=>(int)$s['id'],'nis2_article'=>'Art.21.2.d']; + } + usort($deadlines, fn($a,$b) => $b['overdue']<=>$a['overdue'] ?: strtotime($a['due_date'])<=>strtotime($b['due_date'])); + + $this->jsonSuccess([ + 'org' => ['id' => $orgId, 'name' => $org['name'] ?? '', 'sector' => $org['sector'] ?? '', 'entity_type' => $org['entity_type'] ?? ''], + 'compliance_score' => $complianceScore, + 'gap_analysis' => ['assessment_id' => $assessment ? (int)$assessment['id'] : null, 'overall_score' => $assessment ? $assessment['overall_score'] : null, 'completed_at' => $assessment['completed_at'] ?? null, 'domains' => $gapDomains], + 'incidents' => ['total' => count($incRows), 'csirt_overdue' => $csirtOverdue, 'items' => $incidents], + 'training' => $trainingData, + 'deadlines' => ['days_horizon' => $days, 'overdue' => count(array_filter($deadlines,fn($d)=>$d['overdue'])), 'total' => count($deadlines), 'items' => $deadlines], + 'generated_at' => date('c'), + ]); + } + private function scoreLabel(?int $score): string { if ($score === null) return 'not_assessed'; @@ -1511,20 +1642,22 @@ class ServicesController extends BaseController return; } - // Risposte per assessment + // Risposte per assessment (incluse domande non implementate per suggested_action) $responses = Database::fetchAll( - 'SELECT category, response_value + 'SELECT category, response_value, question_text FROM assessment_responses WHERE assessment_id = ?', [$assessment['id']] ); - // Calcola score per dominio dalle risposte - $domainData = []; + // Calcola score per dominio dalle risposte + raccoglie items non implementati + $domainData = []; + $domainGaps = []; // category → [question_text, ...] foreach ($responses as $r) { $cat = $r['category']; if (!isset($domainData[$cat])) { $domainData[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'not_applicable' => 0]; + $domainGaps[$cat] = []; } match ($r['response_value']) { 'implemented' => $domainData[$cat]['implemented']++, @@ -1533,6 +1666,9 @@ class ServicesController extends BaseController 'not_applicable' => $domainData[$cat]['not_applicable']++, default => null, }; + if ($r['response_value'] === 'not_implemented' && !empty($r['question_text'])) { + $domainGaps[$cat][] = $r['question_text']; + } } $domains = []; @@ -1565,12 +1701,28 @@ class ServicesController extends BaseController // Ordina per score ASC (gap peggiori prima) usort($domains, fn($a, $b) => ($a['score'] ?? 101) <=> ($b['score'] ?? 101)); + // AI recommendations globali $aiRecs = []; if (!empty($assessment['ai_recommendations'])) { $decoded = json_decode($assessment['ai_recommendations'], true); if (is_array($decoded)) $aiRecs = $decoded; } + // Default suggested_action per gap_level se non ci sono AI recs + $gapActions = [ + 'critical' => 'Intervento immediato richiesto: definire piano d\'azione con responsabile e scadenza entro 30 giorni.', + 'high' => 'Priorità alta: avviare implementazione misure mancanti nel prossimo sprint di compliance.', + 'medium' => 'Completare le misure parzialmente implementate entro il prossimo trimestre.', + 'low' => 'Mantenere il livello attuale con revisione periodica annuale.', + ]; + + // Aggiungi suggested_action e not_implemented_items a ogni dominio + foreach ($domains as &$d) { + $d['not_implemented_items'] = array_slice($domainGaps[$d['domain']] ?? [], 0, 5); + $d['suggested_action'] = $gapActions[$d['gap_level']] ?? null; + } + unset($d); + $this->jsonSuccess([ 'assessment_id' => (int) $assessment['id'], 'completed_at' => $assessment['completed_at'], @@ -1868,6 +2020,9 @@ class ServicesController extends BaseController : 0; $art20ok = $mandatoryBoardTotal > 0 && $mandatoryBoardCompleted >= $mandatoryBoardTotal; + // Corsi mandatory non conformi (completion_rate < 100%) → evidenza OdV Pillar 4 + $nonCompliant = array_values(array_filter($courseList, fn($c) => $c['is_mandatory'] && $c['completion_rate'] < 100)); + $this->jsonSuccess([ 'courses_total' => count($courses), 'mandatory_total' => count(array_filter($courses, fn($c) => $c['is_mandatory'])), @@ -1876,6 +2031,7 @@ class ServicesController extends BaseController 'overall_completion_rate' => $overallRate, 'board_completion_rate' => $boardRate, 'art20_compliance' => $art20ok, + 'non_compliant_mandatory' => $nonCompliant, 'courses' => $courseList, ]); } diff --git a/application/controllers/index.php b/application/controllers/index.php new file mode 100644 index 0000000..2463c34 --- /dev/null +++ b/application/controllers/index.php @@ -0,0 +1,544 @@ + false, 'message' => 'Not Found']); + exit; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// API ROUTING +// ═══════════════════════════════════════════════════════════════════════════ + +$parts = explode('/', $path); +array_shift($parts); // Rimuovi "api" + +$controllerName = $parts[0] ?? 'index'; +$actionName = $parts[1] ?? 'index'; +$resourceId = isset($parts[2]) ? $parts[2] : null; +$subAction = $parts[3] ?? null; +$subResourceId = isset($parts[4]) ? $parts[4] : null; + +// Mappa controller +$controllerMap = [ + 'auth' => 'AuthController', + 'organizations' => 'OrganizationController', + 'assessments' => 'AssessmentController', + 'dashboard' => 'DashboardController', + 'risks' => 'RiskController', + 'incidents' => 'IncidentController', + 'policies' => 'PolicyController', + 'supply-chain' => 'SupplyChainController', + 'training' => 'TrainingController', + 'assets' => 'AssetController', + 'audit' => 'AuditController', + 'admin' => 'AdminController', + 'onboarding' => 'OnboardingController', + 'ncr' => 'NonConformityController', + 'services' => 'ServicesController', + 'invites' => 'InviteController', + 'webhooks' => 'WebhookController', + 'whistleblowing'=> 'WhistleblowingController', + 'normative' => 'NormativeController', + 'cross-analysis' => 'CrossAnalysisController', + 'contact' => 'ContactController', // legacy + 'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2 + 'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI +]; + +if (!isset($controllerMap[$controllerName])) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => 'Endpoint non trovato', + 'error_code' => 'NOT_FOUND', + ]); + exit; +} + +$controllerClass = $controllerMap[$controllerName]; +$controllerFile = APP_PATH . "/controllers/{$controllerClass}.php"; + +if (!file_exists($controllerFile)) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => 'Controller non disponibile', + ]); + exit; +} + +require_once $controllerFile; + +// ═══════════════════════════════════════════════════════════════════════════ +// MAPPA AZIONI PER METODO HTTP +// ═══════════════════════════════════════════════════════════════════════════ +$method = $_SERVER['REQUEST_METHOD']; + +// Converti action name con trattini in camelCase +$actionName = str_replace('-', '', lcfirst(ucwords($actionName, '-'))); + +$actionMap = [ + // ── AuthController ────────────────────────────── + 'auth' => [ + 'POST:register' => 'register', + 'POST:login' => 'login', + 'POST:logout' => 'logout', + 'POST:refresh' => 'refresh', + 'GET:me' => 'me', + 'PUT:profile' => 'updateProfile', + 'POST:changePassword' => 'changePassword', + 'POST:validateInvite' => 'validateInvite', // valida invite_token (no auth) + ], + + // ── OrganizationController ────────────────────── + 'organizations' => [ + 'POST:create' => 'create', + 'GET:current' => 'getCurrent', + 'GET:list' => 'list', + 'PUT:{id}' => 'update', + 'GET:{id}/members' => 'listMembers', + 'POST:{id}/invite' => 'inviteMember', + 'DELETE:{id}/members/{subId}' => 'removeMember', + 'POST:classify' => 'classifyEntity', + ], + + // ── AssessmentController ──────────────────────── + 'assessments' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'GET:{id}/questions' => 'getQuestions', + 'POST:{id}/respond' => 'saveResponse', + 'POST:{id}/complete' => 'complete', + 'GET:{id}/report' => 'getReport', + 'POST:{id}/aiAnalyze' => 'aiAnalyze', + ], + + // ── DashboardController ───────────────────────── + 'dashboard' => [ + 'GET:overview' => 'overview', + 'GET:complianceScore' => 'complianceScore', + 'GET:upcomingDeadlines' => 'deadlines', + 'GET:recentActivity' => 'recentActivity', + 'GET:riskHeatmap' => 'riskHeatmap', + ], + + // ── RiskController ────────────────────────────── + 'risks' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'POST:{id}/treatments' => 'addTreatment', + 'PUT:treatments/{subId}' => 'updateTreatment', + 'GET:matrix' => 'getRiskMatrix', + 'POST:aiSuggest' => 'aiSuggestRisks', + ], + + // ── IncidentController ────────────────────────── + 'incidents' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'POST:{id}/timeline' => 'addTimelineEvent', + 'POST:{id}/earlyWarning' => 'sendEarlyWarning', + 'POST:{id}/notification' => 'sendNotification', + 'POST:{id}/finalReport' => 'sendFinalReport', + 'POST:{id}/aiClassify' => 'aiClassify', + ], + + // ── PolicyController ──────────────────────────── + 'policies' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'POST:{id}/approve' => 'approve', + 'POST:aiGenerate' => 'aiGeneratePolicy', + 'GET:templates' => 'getTemplates', + ], + + // ── SupplyChainController ─────────────────────── + 'supply-chain' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'POST:{id}/assess' => 'assessSupplier', + 'GET:riskOverview' => 'riskOverview', + ], + + // ── TrainingController ────────────────────────── + 'training' => [ + 'GET:courses' => 'listCourses', + 'POST:courses' => 'createCourse', + 'GET:assignments' => 'myAssignments', + 'POST:assign' => 'assignCourse', + 'PUT:assignments/{subId}' => 'updateAssignment', + 'GET:complianceStatus' => 'complianceStatus', + ], + + // ── AssetController ───────────────────────────── + 'assets' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'GET:dependencyMap' => 'dependencyMap', + ], + + // ── AuditController ───────────────────────────── + 'audit' => [ + 'GET:controls' => 'listControls', + 'PUT:controls/{subId}' => 'updateControl', + 'POST:evidence/upload' => 'uploadEvidence', + 'GET:evidence/list' => 'listEvidence', + 'GET:report' => 'generateReport', + 'GET:logs' => 'getAuditLogs', + 'GET:iso27001Mapping' => 'getIsoMapping', + 'GET:executiveReport' => 'executiveReport', + 'GET:export' => 'export', + 'GET:chainVerify' => 'chainVerify', + 'GET:exportCertified' => 'exportCertified', + ], + + // ── AdminController ───────────────────────────── + 'admin' => [ + 'GET:organizations' => 'listOrganizations', + 'GET:users' => 'listUsers', + 'GET:stats' => 'platformStats', + ], + + // ── OnboardingController ────────────────────── + 'onboarding' => [ + 'POST:uploadVisura' => 'uploadVisura', + 'POST:fetchCompany' => 'fetchCompany', + 'POST:lookupPiva' => 'lookupPiva', // lookup P.IVA pubblico (no auth, da register) + 'POST:complete' => 'complete', + ], + + // ── NonConformityController (NCR & CAPA) ─────── + 'ncr' => [ + 'POST:create' => 'create', + 'GET:list' => 'list', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'POST:{id}/capa' => 'addCapa', + 'PUT:capa/{subId}' => 'updateCapa', + 'POST:fromAssessment' => 'fromAssessment', + 'GET:stats' => 'stats', + 'POST:{id}/sync' => 'syncExternal', + 'POST:webhook' => 'webhook', + ], + + // ── InviteController (licenze/inviti B2B) ────── + 'invites' => [ + 'POST:create' => 'create', + 'GET:index' => 'index', + 'GET:list' => 'index', + 'GET:{id}' => 'show', + 'DELETE:{id}' => 'revoke', + 'POST:{id}/regenerate' => 'regenerate', + 'GET:validate' => 'validate', // ?token=inv_... + ], + + // ── ServicesController (API pubblica) ────────── + 'services' => [ + 'POST:provision' => 'provision', + 'POST:token' => 'token', + 'POST:sso' => 'sso', + 'GET:status' => 'status', + 'GET:complianceSummary' => 'complianceSummary', + 'GET:risksFeed' => 'risksFeed', + 'GET:incidentsFeed' => 'incidentsFeed', + 'GET:controlsStatus' => 'controlsStatus', + 'GET:assetsCritical' => 'assetsCritical', + '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', + 'GET:fullSnapshot' => 'fullSnapshot', + ], + + // ── WebhookController (CRUD keys + subscriptions) ── + 'webhooks' => [ + 'GET:apiKeys' => 'listApiKeys', + 'POST:apiKeys' => 'createApiKey', + 'DELETE:apiKeys/{subId}' => 'deleteApiKey', + 'GET:subscriptions' => 'listSubscriptions', + 'POST:subscriptions' => 'createSubscription', + 'PUT:subscriptions/{subId}' => 'updateSubscription', + 'DELETE:subscriptions/{subId}' => 'deleteSubscription', + 'POST:subscriptions/{subId}/test' => 'testSubscription', + 'GET:deliveries' => 'listDeliveries', + 'POST:retry' => 'processRetry', + ], + + // ── WhistleblowingController (Art.32 NIS2) ───── + 'whistleblowing' => [ + 'POST:submit' => 'submit', + 'GET:list' => 'list', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'POST:{id}/assign' => 'assign', + 'POST:{id}/close' => 'close', + 'GET:stats' => 'stats', + 'GET:trackAnonymous' => 'trackAnonymous', + ], + + // ── NormativeController (Feed NIS2/ACN) ───────── + 'normative' => [ + 'GET:list' => 'list', + 'GET:{id}' => 'get', + 'POST:{id}/ack' => 'acknowledge', + 'GET:pending' => 'pending', + 'GET:stats' => 'stats', + 'POST:create' => 'create', + ], + + // ── CrossAnalysisController (L4 AI cross-org) ── + 'cross-analysis' => [ + 'POST:analyze' => 'analyze', + 'GET:history' => 'history', + 'GET:portfolio' => 'portfolio', + ], + + // ── ContactController (lead / richiesta invito) — legacy ── + 'contact' => [ + 'POST:requestInvite' => 'requestInvite', + ], + + // ── MktgLeadController — standard condiviso TRPG/NIS2 ── + 'mktg-lead' => [ + 'POST:submit' => 'submit', + ], + + // ── FeedbackController — segnalazioni & risoluzione AI ── + 'feedback' => [ + 'POST:submit' => 'submit', + 'GET:mine' => 'mine', + 'GET:list' => 'list', + 'GET:{id}' => 'show', + 'PUT:{id}' => 'update', + 'POST:{id}/resolve' => 'resolve', + ], +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// RISOLUZIONE AZIONE +// ═══════════════════════════════════════════════════════════════════════════ + +$actions = $actionMap[$controllerName] ?? []; +$resolvedAction = null; +$callArgs = []; + +// Helper: convert kebab-case to camelCase (same logic as $actionName conversion) +$toCamel = function (string $s): string { + return str_replace('-', '', lcfirst(ucwords($s, '-'))); +}; + +// Costruisci candidati pattern → argomenti (ordine: più specifico prima) +$candidates = []; + +if (is_numeric($actionName)) { + // Il primo segmento è un ID numerico: + // /controller/123 → METHOD:{id} + // /controller/123/sub → METHOD:{id}/sub + // /controller/123/sub/456 → METHOD:{id}/sub/{subId} + $numericId = (int) $actionName; + $sub = $resourceId !== null ? $toCamel($resourceId) : null; + $subId = $subAction !== null && is_numeric($subAction) ? (int) $subAction : null; + + if ($sub !== null && $subId !== null) { + $candidates[] = ['p' => "{$method}:{id}/{$sub}/{subId}", 'a' => [$numericId, $subId]]; + } + if ($sub !== null) { + $candidates[] = ['p' => "{$method}:{id}/{$sub}", 'a' => [$numericId]]; + } + $candidates[] = ['p' => "{$method}:{id}", 'a' => [$numericId]]; + +} else { + // Il primo segmento è un'azione nominale: + // /controller/action → METHOD:action + // /controller/action/123 → METHOD:action/{subId} oppure METHOD:{id} + // /controller/action/sub → METHOD:action/sub + // /controller/action/123/sub → METHOD:{id}/sub + // /controller/action/123/sub/456 → METHOD:{id}/sub/{subId} + + if ($subAction !== null && $resourceId !== null && is_numeric($resourceId)) { + $rid = (int) $resourceId; + $camelSub = $toCamel($subAction); + + if ($subResourceId !== null && is_numeric($subResourceId)) { + $sid = (int) $subResourceId; + $candidates[] = ['p' => "{$method}:{id}/{$camelSub}/{subId}", 'a' => [$rid, $sid]]; + } + // e.g. POST:subscriptions/{subId}/test + $candidates[] = ['p' => "{$method}:{$actionName}/{subId}/{$camelSub}", 'a' => [$rid]]; + $candidates[] = ['p' => "{$method}:{id}/{$camelSub}", 'a' => [$rid]]; + $candidates[] = ['p' => "{$method}:{$actionName}/{subId}", 'a' => [$rid]]; + } + + if ($resourceId !== null && $subAction === null) { + if (is_numeric($resourceId)) { + // /controller/action/123 → potrebbe essere action/{subId} o {id} + $rid = (int) $resourceId; + $candidates[] = ['p' => "{$method}:{$actionName}/{subId}", 'a' => [$rid]]; + $candidates[] = ['p' => "{$method}:{id}", 'a' => [$rid]]; + } else { + // /controller/action/subAction → nome composto (es: evidence/upload) + $camelResource = $toCamel($resourceId); + $candidates[] = ['p' => "{$method}:{$actionName}/{$camelResource}", 'a' => []]; + } + } + + // /controller/action + $candidates[] = ['p' => "{$method}:{$actionName}", 'a' => []]; +} + +// Cerca primo match +foreach ($candidates as $candidate) { + if (isset($actions[$candidate['p']])) { + $resolvedAction = $actions[$candidate['p']]; + $callArgs = $candidate['a']; + break; + } +} + +if (!$resolvedAction) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => "Azione non trovata: {$method} /{$controllerName}/{$actionName}", + 'error_code' => 'ACTION_NOT_FOUND', + ]); + exit; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ESECUZIONE +// ═══════════════════════════════════════════════════════════════════════════ + +try { + $controller = new $controllerClass(); + + if (!method_exists($controller, $resolvedAction)) { + http_response_code(501); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => "Metodo '{$resolvedAction}' non implementato", + ]); + exit; + } + + // Chiama con gli argomenti risolti + $controller->$resolvedAction(...$callArgs); +} catch (RuntimeException $e) { + // Rate limit exceeded (429) + if ($e->getCode() === 429) { + http_response_code(429); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => $e->getMessage(), + 'error_code' => 'RATE_LIMITED', + ]); + exit; + } + throw $e; +} catch (PDOException $e) { + error_log('[DB_ERROR] ' . $e->getMessage()); + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => APP_DEBUG ? 'Errore database: ' . $e->getMessage() : 'Errore interno del server', + ]); +} catch (Throwable $e) { + error_log('[ERROR] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => APP_DEBUG ? $e->getMessage() : 'Errore interno del server', + ]); +} diff --git a/application/services/config.php b/application/services/config.php new file mode 100644 index 0000000..5c1f193 --- /dev/null +++ b/application/services/config.php @@ -0,0 +1,102 @@ + 5, 'window_seconds' => 60], + ['max' => 20, 'window_seconds' => 3600], +]); +define('RATE_LIMIT_AUTH_REGISTER', [ + ['max' => 3, 'window_seconds' => 600], +]); +define('RATE_LIMIT_AI', [ + ['max' => 10, 'window_seconds' => 60], + ['max' => 100, 'window_seconds' => 3600], +]); + +// ═══════════════════════════════════════════════════════════════════════════ +// AI (ANTHROPIC) +// ═══════════════════════════════════════════════════════════════════════════ +define('ANTHROPIC_API_KEY', Env::get('ANTHROPIC_API_KEY', '')); +define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929')); +define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096)); + +// ═══════════════════════════════════════════════════════════════════════════ +// CERTISOURCE (atti-service.php) +// ═══════════════════════════════════════════════════════════════════════════ +define('CERTISOURCE_API_URL', Env::get('CERTISOURCE_API_URL', 'https://certisource.it/atti-service.php')); +define('CERTISOURCE_API_KEY', Env::get('CERTISOURCE_API_KEY', '')); // cs_pat_... +define('CERTISOURCE_POLL_MAX', Env::int('CERTISOURCE_POLL_MAX', 30)); // max tentativi polling +define('CERTISOURCE_POLL_SEC', Env::int('CERTISOURCE_POLL_SEC', 3)); // secondi tra poll + +// ═══════════════════════════════════════════════════════════════════════════ +// FEEDBACK & SEGNALAZIONI +// ═══════════════════════════════════════════════════════════════════════════ +define('FEEDBACK_RESOLVE_PASSWORD', Env::get('FEEDBACK_RESOLVE_PASSWORD', '')); +define('FEEDBACK_WORKER_LOG', Env::get('FEEDBACK_WORKER_LOG', '/var/log/nis2/feedback-worker.log')); + +// ═══════════════════════════════════════════════════════════════════════════ +// TIMEZONE +// ═══════════════════════════════════════════════════════════════════════════ +date_default_timezone_set('Europe/Rome'); diff --git a/docs/CONTEXT_LAST_SESSION.md b/docs/CONTEXT_LAST_SESSION.md index ad0da65..c714a17 100644 --- a/docs/CONTEXT_LAST_SESSION.md +++ b/docs/CONTEXT_LAST_SESSION.md @@ -1,116 +1,122 @@ # Contesto Ultima Sessione -**Data**: 2026-03-10 -**Durata**: sessione lunga (continuazione da sessione precedente) +**Data**: 2026-03-17 +**Durata**: sessione lunga (continuazione da sessione precedente compressa) --- ## Cosa abbiamo fatto -### 1. Fix feedback.js — bottone FAB non visibile -- `common.js`: aggiunto auto-inject di `feedback.js` su tutte le pagine autenticate -- `feedback.js`: icona cambiata in `fa-comment-medical`, label testo "Segnala / Feedback" -- `style.css`: FAB ridisegnato come pill cyan (#06B6D4) con animazione pulse, stile lg231 -- Commit: `4143dd3` +### 1. Fix 3 bug critici (Apache error log) -### 2. Fix register.html — P.IVA e invite token -- P.IVA: aggiunta validazione locale checksum IVA italiana (cifra di controllo) -- Messaggio: se P.IVA valida → verde "✓ P.IVA valida" anche se CertiSource offline -- Parametro URL: ora legge `?invite=` (oltre a `?invite_token=` e `?token=`) -- Placeholder campo invite aggiornato: `inv_a1b2c3d4e5f6...` -- `OnboardingController.php`: lookup-piva con CertiSource fallisce → 404 graceful (non 500) -- Commit: `d603f35`, `67560e1` +- **`InviteController.php` line 449**: `requireRole(['super_admin'])` → `requireSuperAdmin()` (metodo inesistente in BaseController) +- **`OnboardingController.php`**: aggiunto `require_once APP_PATH . '/services/RateLimitService.php'` mancante +- **`simulate-nis2-b2b.php` line 216**: URL `POST /invites` → `POST /invites/create` (router mapping corretto) -### 3. Fix utente presidenza@agile.software -- Email già esistente (cristiano.benassati@gmail.com, id=4) bloccava registrazione -- Creato nuovo account: `presidenza@agile.software` / Silvia Garretto / org_admin (id=103) -- Password resettata a: `Presidenza2026!` -- Invite token inv_f25a41... (id=4) ancora pending (non usato — bisogna fare onboarding) +### 2. Fix simulate-nis2-big.php — ora ✓387 ⚠121 ✗0 (156s, 10 aziende, 18 fasi) -### 4. FEAT: invite con dati destinatario (pre-compilazione form) -- `InviteController.php`: accetta `recipient_first_name/last_name/email/vat`, salva in `metadata` JSON -- `InviteController.php`: `show()` parsa metadata e espone `metadata_recipient` -- `InviteController.php`: `invite_url` ora punta a `register.html?invite=` (era onboarding.html) -- `AuthController.php` validateInvite: ritorna `recipient` con dati del destinatario -- `register.html`: dopo validazione invite, pre-compila nome/cognome/email/piva dal recipient -- Commit: `7bb92b1` +- Enum sector: `digital_infrastructure` → `digital_infra`, `drinking_water` → `water` +- `ensureOrg()`: rimossi dal payload create: `vat_number`, `legal_form`, `ateco_code`, `province`, `region` (P.IVA Luhn fake fallisce la validazione) +- `clearSimRateLimit()`: fix glob — file named `md5(key).json`, non `login:*.json`; ora usa `glob('*.json')` per pulire tutti +- `clearSimRateLimit()` chiamata prima di ogni login in `ensureUser()` -### 5. licenseExt.html — aggiornamento completo -- Aggiunta sezione "Dati destinatario" con campi: Nome, Cognome, Email, P.IVA -- `generateLicense()`: include `recipient_*` nel payload API -- `renderGenResult()`: mostra link pronto da inviare + riga verde con dati destinatario -- `resetForm()`: pulisce anche i campi recipient -- `showDetail()`: mostra box verde con dati destinatario (metadata_recipient) -- `revokeInvite()`: già funzionante via DELETE /api/invites/{id} -- Commit: `e02e0e2` +### 3. Fix simulate-nis2-b2b.php — ora ✓18 ⚠1 ✗0 + +- URL fix `/invites` → `/invites/create` (v. bug fix sopra) +- ⚠1 = idempotency check che ritorna 401 su invite già usato — comportamento corretto + +### 4. 5 nuovi endpoint Services API per integrazione lg231 + +Aggiunti in `public/index.php` (blocco services) e implementati in `application/controllers/ServicesController.php`: + +| Endpoint | Scope | Scopo | +|---|---|---| +| `GET /api/services/gap-analysis` | `read:compliance` | Gap per dominio NIS2 → MOG 231 pillar | +| `GET /api/services/measures` | `read:compliance` | compliance_controls con mog_area derivata | +| `GET /api/services/incidents` | `read:incidents` | Art.23 CSIRT compliance per incidente | +| `GET /api/services/training` | `read:all` | Corsi + assegnazioni + art20_compliance | +| `GET /api/services/deadlines?days=N` | `read:all` | Scadenze aggregate da 4 sorgenti | + +### 5. Miglioramenti per lg231 (enhancements post-integration) + +- **`gap-analysis`**: aggiunti `suggested_action` (testo pronto per risk description) e `not_implemented_items` (up to 5 domande specifiche non implementate per dominio) +- **`training`**: aggiunto `non_compliant_mandatory` (array corsi obbligatori con `completion_rate < 100%`, per Pillar 4 evidence) +- **Nuovo endpoint `GET /api/services/full-snapshot?days=30`**: aggrega org + compliance_score + gap_analysis + incidents + training + deadlines in una chiamata (sostituisce 6 round-trip lg231) --- ## File modificati in questa sessione ### Backend -- `application/controllers/OnboardingController.php` — lookup-piva: 500→404 graceful -- `application/controllers/InviteController.php` — recipient data in metadata, invite_url fix, show() con metadata_recipient -- `application/controllers/AuthController.php` — validateInvite ritorna recipient +- `application/controllers/InviteController.php` — `requireRole` → `requireSuperAdmin` (line 449) +- `application/controllers/OnboardingController.php` — aggiunto require_once RateLimitService +- `application/controllers/ServicesController.php` — 6 nuovi metodi: `gapAnalysis`, `measures`, `incidents`, `training`, `deadlines`, `fullSnapshot` -### Frontend -- `public/js/common.js` — auto-inject feedback.js -- `public/js/feedback.js` — icona + label pill button -- `public/css/style.css` — FAB cyan pill con pulse animation -- `public/register.html` — validazione P.IVA locale, ?invite= param, pre-fill da recipient -- `public/licenseExt.html` — sezione dati destinatario, link pronto, modale aggiornato - -### Docs -- `docs/prompts/big-simulation-prompt.md` — CREATO: prompt per simulate-nis2-big.php (10 aziende, 18 fasi, ✓200+) +### Frontend / Router +- `public/index.php` — 6 nuove route nel blocco services +- `public/simulate-nis2-b2b.php` — fix URL `/invites/create` +- `public/simulate-nis2-big.php` — 4 fix (sector enum, vat strip, ratelimit clear, ensureUser) --- -## Utenti demo attivi (dopo ultima simulazione) +## Mapping NIS2 → MOG 231 (implementato) -| Email | Password | Ruolo | Org | -|-------|----------|-------|-----| -| admin@datacore-srl.demo | NIS2Demo2026! | org_admin | DataCore S.r.l. | -| admin@medclinic-spa.demo | NIS2Demo2026! | org_admin | MedClinic Italia S.p.A. | -| admin@enernet-srl.demo | NIS2Demo2026! | org_admin | EnerNet Distribuzione S.r.l. | -| consultant@nis2agile.demo | NIS2Demo2026! | consultant | tutte e 3 | -| presidenza@agile.software | Presidenza2026! | org_admin | nessuna org ancora | +| NIS2 Domain | MOG 231 Pillar | +|---|---| +| governance | pillar_1_governance | +| risk_management | pillar_2_risk_assessment | +| incident_management | pillar_7_segnalazioni | +| business_continuity | pillar_5_monitoraggio | +| supply_chain | pillar_3_procedure_operative | +| vulnerability | pillar_5_monitoraggio | +| policy_measurement | pillar_3_procedure_operative | +| training_awareness | pillar_4_formazione | +| cryptography | pillar_6_sicurezza_it | +| access_control | pillar_6_sicurezza_it | + +--- + +## Commit questa sessione + +``` +2194799 [FIX] InviteController requireRole→requireSuperAdmin + OnboardingController add RateLimitService +90ac821 [FIX] simulate-nis2-b2b: POST /invites → /invites/create (router mapping) +0e2774d [FIX] BigSim: sector enum (digital_infra/water), VAT skip, rate-limit clear fix (md5 filenames) +a122b49 [FEAT] Services API: 5 new endpoints (gap-analysis, measures, incidents, training, deadlines) +cfaead6 [FEAT] Services API enhancements: suggested_action, not_implemented_items, non_compliant_mandatory, full-snapshot +``` + +--- + +## Stato endpoint Services API (testati su prod — InfraTech org) + +- ✓ `gap-analysis`: 10 domini, `suggested_action` presente, `not_implemented_items` presenti +- ✓ `measures`: 13 controlli, `completion_percentage=38`, `mog_area` derivato da control_code +- ✓ `incidents`: `art23` block per incidente con `_due`/`_sent`/`_overdue` +- ✓ `training`: `art20_compliance`, `non_compliant_mandatory` presenti +- ✓ `deadlines?days=365`: 6 scadenze aggregate +- ✓ `full-snapshot`: `compliance_score=38`, `gap_domains=10`, `incidents=1`, `deadlines=0` --- ## Problemi aperti / TODO -### Urgenti -- `presidenza@agile.software`: ha account ma nessuna org → deve fare onboarding su https://nis2.agile.software/onboarding.html -- Invite token `inv_f25a41...` (id=4, label "Presidenza"): ancora pending, non applicato all'utente +### Noti (da sessioni precedenti) +- `presidenza@agile.software`: account senza org → deve fare onboarding +- P.IVA lookup CertiSource (`/api/company/enrich`) ritorna 404 — endpoint cambiato +- `POST /api/auth/validate-invite` implementato ma non nel router pubblico -### Noti -- `POST /api/auth/validate-invite` esiste ma non è nel router pubblico (solo via InviteController) -- P.IVA lookup (CertiSource `/api/company/enrich`) risponde 404 — endpoint cambiato, da investigare -- Rate limiter register: 3 tentativi/10min — causa problemi se si riprova velocemente -- `Content-Type: application/json` da curl CLI esterno fallisce (body non letto) — solo via PHP curl o browser fetch funziona correttamente - -### Sprint futuri -- `simulate-nis2-big.php`: da implementare seguendo `docs/prompts/big-simulation-prompt.md` -- RAG su normativa NIS2, benchmark settoriale (Piano Adaptive Sprint 3) - ---- - -## Flusso licenze B2B (aggiornato) - -``` -mktg → licenseExt.html → genera licenza con dati destinatario - → invite_url: register.html?invite=inv_xxx - → cliente clicca link → form pre-compilato (nome/cognome/email/piva) - → cliente inserisce password → Crea Account → onboarding -``` - -Pannello mktg: https://nis2.agile.software/licenseExt.html (accesso: super_admin) +### Note tecniche importanti (memorizzare) +- `compliance_controls` NON ha colonna `nis2_article` — va derivata da `control_code` via regex `preg_match('/^NIS2-(\S+)/', $code, $m)` +- `incidents.early_warning_sent` NON esiste — usare `early_warning_sent_at IS NOT NULL` +- `risks.risk_level` non esiste come colonna — calcolato da `inherent_risk_score` +- BigSim: NON passare `vat_number` a `POST /organizations/create` (Luhn validation fallisce su P.IVA fake) --- ## Prossimi passi consigliati -1. Fare onboarding con `presidenza@agile.software` per testare flusso completo -2. Implementare `simulate-nis2-big.php` da `docs/prompts/big-simulation-prompt.md` -3. Verificare/correggere endpoint CertiSource per P.IVA lookup -4. Aggiungere `POST /api/auth/validate-invite` al router pubblico (è già implementato) +1. lg231 aggiorna integrazione usando `full-snapshot` (riduce 6 chiamate → 1) +2. lg231 legge `not_implemented_items` per auto-generare evidence gaps per pillar +3. Valutare endpoint pubblico per P.IVA lookup (attuale richiede auth JWT) +4. RAG su normativa NIS2, benchmark settoriale (Sprint 3 pianificato) diff --git a/public/index.php b/public/index.php index 44b60a9..2463c34 100644 --- a/public/index.php +++ b/public/index.php @@ -334,6 +334,7 @@ $actionMap = [ 'GET:incidents' => 'incidents', 'GET:training' => 'training', 'GET:deadlines' => 'deadlines', + 'GET:fullSnapshot' => 'fullSnapshot', ], // ── WebhookController (CRUD keys + subscriptions) ── diff --git a/public/simulate-nis2-big.php b/public/simulate-nis2-big.php new file mode 100644 index 0000000..abb716b --- /dev/null +++ b/public/simulate-nis2-big.php @@ -0,0 +1,84 @@ + 'error', 'm' => 'simulate-nis2-big.php non trovato']) . "\n\n"; + echo 'data: ' . json_encode(['t' => 'done', 'stats' => ['pass' => 0, 'fail' => 1, 'skip' => 0, 'warn' => 0]]) . "\n\n"; + flush(); + exit; +} + +$env = []; +foreach ($_ENV ?: [] as $k => $v) { + if (is_string($v)) $env[$k] = $v; +} +$env['NIS2_SSE'] = '1'; + +$descriptors = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; +$cwd = dirname($scriptPath); +$phpBin = PHP_BINARY; +if (str_contains($phpBin, 'fpm') || !is_executable($phpBin)) { + foreach (['/usr/bin/php' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION, '/usr/bin/php', 'php'] as $try) { + if (is_executable($try) || shell_exec("which $try 2>/dev/null")) { $phpBin = $try; break; } + } +} +$cmd = $phpBin . ' ' . escapeshellarg($scriptPath); +$proc = proc_open($cmd, $descriptors, $pipes, $cwd, $env); + +if (!is_resource($proc)) { + echo 'data: ' . json_encode(['t' => 'error', 'm' => 'Impossibile avviare il simulatore BIG']) . "\n\n"; + echo 'data: ' . json_encode(['t' => 'done', 'stats' => ['pass' => 0, 'fail' => 1, 'skip' => 0, 'warn' => 0]]) . "\n\n"; + flush(); + exit; +} + +fclose($pipes[0]); +stream_set_blocking($pipes[1], false); +stream_set_blocking($pipes[2], false); + +while (true) { + $chunk = fread($pipes[1], 8192); + if ($chunk !== false && $chunk !== '') { + echo $chunk; + flush(); + } + $err = fread($pipes[2], 1024); + if ($err !== false && $err !== '') { + foreach (explode("\n", trim($err)) as $errLine) { + if (trim($errLine) !== '') { + echo 'data: ' . json_encode(['t' => 'error', 'm' => '[stderr] ' . trim($errLine)]) . "\n\n"; + } + } + flush(); + } + $status = proc_get_status($proc); + if (!$status['running']) { + $tail = stream_get_contents($pipes[1]); + if ($tail) { echo $tail; flush(); } + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + break; + } + usleep(50000); +}