requireOrgAccess(); $pagination = $this->getPagination(); $where = 'organization_id = ?'; $params = [$this->getCurrentOrgId()]; if ($this->hasParam('status')) { $where .= ' AND status = ?'; $params[] = $this->getParam('status'); } if ($this->hasParam('severity')) { $where .= ' AND severity = ?'; $params[] = $this->getParam('severity'); } $total = Database::count('incidents', $where, $params); $incidents = Database::fetchAll( "SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name FROM incidents i LEFT JOIN users u1 ON u1.id = i.reported_by LEFT JOIN users u2 ON u2.id = i.assigned_to WHERE i.{$where} ORDER BY i.detected_at DESC LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}", $params ); $this->jsonPaginated($incidents, $total, $pagination['page'], $pagination['per_page']); } /** * POST /api/incidents/create */ public function create(): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']); $this->validateRequired(['title', 'classification', 'severity', 'detected_at']); $detectedAt = $this->getParam('detected_at'); $isSignificant = (bool) $this->getParam('is_significant', false); // Regime obblighi NIS2 (Determina ACN 164179/2025): Allegato 4 essenziali / Allegato 3 importanti. $org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); $entityObligation = ($org && ($org['entity_type'] ?? '') === 'essential') ? 'essential' : 'important'; // IS-4 (incidenti ricorrenti) non si applica ai soggetti importanti. $isType = $this->getParam('nis2_incident_type'); $validIs = $entityObligation === 'essential' ? ['IS-1','IS-2','IS-3','IS-4'] : ['IS-1','IS-2','IS-3']; if ($isType !== null && !in_array($isType, $validIs, true)) { $isType = null; } $data = [ 'organization_id' => $this->getCurrentOrgId(), 'incident_code' => $this->generateCode('INC'), 'title' => trim($this->getParam('title')), 'description' => $this->getParam('description'), 'classification' => $this->getParam('classification'), 'severity' => $this->getParam('severity'), 'is_significant' => $isSignificant ? 1 : 0, 'nis2_incident_type' => $isType, 'entity_obligation' => $entityObligation, 'detected_at' => $detectedAt, 'affected_services' => $this->getParam('affected_services'), 'affected_users_count' => $this->getParam('affected_users_count'), 'cross_border_impact' => $this->getParam('cross_border_impact', 0), 'malicious_action' => $this->getParam('malicious_action', 0), 'reported_by' => $this->getCurrentUserId(), 'assigned_to' => $this->getParam('assigned_to'), ]; // Calcola scadenze NIS2 Art. 23 se significativo if ($isSignificant) { $detectedTime = strtotime($detectedAt); $data['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600); // +24h $data['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600); // +72h $data['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400); // +30 giorni } $incidentId = Database::insert('incidents', $data); // Aggiungi evento timeline Database::insert('incident_timeline', [ 'incident_id' => $incidentId, 'event_type' => 'detection', 'description' => "Incidente rilevato: {$data['title']}", 'created_by' => $this->getCurrentUserId(), ]); $this->logAudit('incident_created', 'incident', $incidentId, [ 'severity' => $data['severity'], 'is_significant' => $isSignificant ]); // Dispatch webhook events try { $incident = array_merge($data, ['id' => $incidentId]); $webhookSvc = new WebhookService(); $webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.created', WebhookService::incidentPayload($incident, 'created')); if ($isSignificant) { $webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.significant', WebhookService::incidentPayload($incident, 'significant')); } } catch (Throwable $e) { error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); } $this->jsonSuccess([ 'id' => $incidentId, 'incident_code' => $data['incident_code'], 'is_significant' => $isSignificant, 'deadlines' => $isSignificant ? [ 'early_warning' => $data['early_warning_due'], 'notification' => $data['notification_due'], 'final_report' => $data['final_report_due'], ] : null, ], 'Incidente registrato', 201); } /** * GET /api/incidents/{id} */ public function get(int $id): void { $this->requireOrgAccess(); $incident = Database::fetchOne( 'SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name FROM incidents i LEFT JOIN users u1 ON u1.id = i.reported_by LEFT JOIN users u2 ON u2.id = i.assigned_to WHERE i.id = ? AND i.organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$incident) { $this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND'); } $incident['timeline'] = Database::fetchAll( 'SELECT it.*, u.full_name as created_by_name FROM incident_timeline it LEFT JOIN users u ON u.id = it.created_by WHERE it.incident_id = ? ORDER BY it.created_at', [$id] ); $this->jsonSuccess($incident); } /** * PUT /api/incidents/{id} */ public function update(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $incident = Database::fetchOne( 'SELECT * FROM incidents WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$incident) { $this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND'); } $updates = []; $allowedFields = [ 'title', 'description', 'classification', 'severity', 'is_significant', 'nis2_incident_type', 'status', 'affected_services', 'affected_users_count', 'cross_border_impact', 'malicious_action', 'root_cause', 'remediation_actions', 'lessons_learned', 'assigned_to', ]; foreach ($allowedFields as $field) { if ($this->hasParam($field)) { $updates[$field] = $this->getParam($field); } } // Timbra automaticamente i timestamp di fase al primo ingresso nello stato // (per il calcolo metriche TTD/TTC/TTR). Non sovrascrive valori gia' presenti. if (isset($updates['status'])) { $now = date('Y-m-d H:i:s'); $stamp = [ 'analyzing' => 'triaged_at', 'containing' => 'contained_at', 'eradicating'=> 'eradicated_at', 'recovering' => 'recovered_at', ]; $col = $stamp[$updates['status']] ?? null; if ($col !== null && empty($incident[$col])) { $updates[$col] = $now; } if ($updates['status'] === 'closed') { $updates['closed_at'] = $now; if (empty($incident['recovered_at'])) $updates['recovered_at'] = $now; } } // Se diventa significativo, calcola scadenze if (isset($updates['is_significant']) && $updates['is_significant'] && !$incident['is_significant']) { $detectedTime = strtotime($incident['detected_at']); $updates['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600); $updates['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600); $updates['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400); } if (!empty($updates)) { Database::update('incidents', $updates, 'id = ?', [$id]); $this->logAudit('incident_updated', 'incident', $id, $updates); // Dispatch webhook: incident.updated e incident.significant se appena flaggato try { $updatedIncident = Database::fetchOne('SELECT * FROM incidents WHERE id = ?', [$id]); $webhookSvc = new WebhookService(); $webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.updated', WebhookService::incidentPayload($updatedIncident, 'updated')); if (isset($updates['is_significant']) && $updates['is_significant'] && !$incident['is_significant']) { $webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.significant', WebhookService::incidentPayload($updatedIncident, 'significant')); } } catch (Throwable $e) { error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); } } $this->jsonSuccess($updates, 'Incidente aggiornato'); } /** * POST /api/incidents/{id}/timeline */ public function addTimelineEvent(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']); $this->validateRequired(['event_type', 'description']); $incident = Database::fetchOne( 'SELECT id FROM incidents WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$incident) { $this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND'); } $eventId = Database::insert('incident_timeline', [ 'incident_id' => $id, 'event_type' => $this->getParam('event_type'), 'description' => $this->getParam('description'), 'created_by' => $this->getCurrentUserId(), ]); $this->jsonSuccess(['id' => $eventId], 'Evento aggiunto alla timeline', 201); } /** * POST /api/incidents/{id}/early-warning * Registra invio early warning (24h) al CSIRT */ public function sendEarlyWarning(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); Database::update('incidents', [ 'early_warning_sent_at' => date('Y-m-d H:i:s'), ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); Database::insert('incident_timeline', [ 'incident_id' => $id, 'event_type' => 'notification', 'description' => 'Early warning (24h) inviato al CSIRT nazionale (ACN)', 'created_by' => $this->getCurrentUserId(), ]); // Invia notifica email ai responsabili $this->notifyIncidentStakeholders($id, 'early_warning'); $this->logAudit('early_warning_sent', 'incident', $id); $this->jsonSuccess(null, 'Early warning registrato'); } /** * POST /api/incidents/{id}/notification * Registra invio notifica (72h) al CSIRT */ public function sendNotification(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); Database::update('incidents', [ 'notification_sent_at' => date('Y-m-d H:i:s'), ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); Database::insert('incident_timeline', [ 'incident_id' => $id, 'event_type' => 'notification', 'description' => 'Notifica incidente (72h) inviata al CSIRT nazionale (ACN)', 'created_by' => $this->getCurrentUserId(), ]); // Invia notifica email ai responsabili $this->notifyIncidentStakeholders($id, 'notification'); $this->logAudit('notification_sent', 'incident', $id); $this->jsonSuccess(null, 'Notifica CSIRT registrata'); } /** * POST /api/incidents/{id}/final-report * Registra invio report finale (30 giorni) */ public function sendFinalReport(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); Database::update('incidents', [ 'final_report_sent_at' => date('Y-m-d H:i:s'), ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); Database::insert('incident_timeline', [ 'incident_id' => $id, 'event_type' => 'notification', 'description' => 'Report finale (30 giorni) inviato al CSIRT nazionale (ACN)', 'created_by' => $this->getCurrentUserId(), ]); // Invia notifica email ai responsabili $this->notifyIncidentStakeholders($id, 'final_report'); $this->logAudit('final_report_sent', 'incident', $id); $this->jsonSuccess(null, 'Report finale registrato'); } /** * Notifica stakeholder via email per milestone incidente */ private function notifyIncidentStakeholders(int $incidentId, string $type): void { try { $incident = Database::fetchOne('SELECT * FROM incidents WHERE id = ?', [$incidentId]); $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); if (!$incident || !$org) return; // Trova org_admin e compliance_manager $recipients = Database::fetchAll( 'SELECT u.email, u.full_name FROM users u JOIN user_organizations uo ON uo.user_id = u.id WHERE uo.organization_id = ? AND uo.role IN ("org_admin", "compliance_manager") AND u.is_active = 1', [$this->getCurrentOrgId()] ); if (empty($recipients)) return; $emailService = new EmailService(); $emails = array_column($recipients, 'email'); match ($type) { 'early_warning' => $emailService->sendIncidentEarlyWarning($incident, $org, $emails), 'notification' => $emailService->sendIncidentNotification($incident, $org, $emails), 'final_report' => $emailService->sendIncidentFinalReport($incident, $org, $emails), }; } catch (Throwable $e) { error_log('[EMAIL_ERROR] ' . $e->getMessage()); // Non bloccare il flusso principale per errori email } } /** * POST /api/incidents/{id}/ai-classify */ public function aiClassify(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $incident = Database::fetchOne( 'SELECT * FROM incidents WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$incident) { $this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND'); } $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); try { $aiService = new AIService(); $classification = $aiService->classifyIncident($incident['title'], $incident['description'] ?? '', $org); $aiService->logInteraction( $this->getCurrentOrgId(), $this->getCurrentUserId(), 'incident_classification', "Classify incident #{$id}: {$incident['title']}", substr(json_encode($classification), 0, 500) ); $this->jsonSuccess($classification, 'Classificazione AI completata'); } catch (Throwable $e) { $this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR'); } } /** * GET /api/incidents/{id}/metrics * Calcola TTD/TTC/TTR e downtime dai timestamp di fase (in minuti). */ public function metrics(int $id): void { $this->requireOrgAccess(); $inc = Database::fetchOne( 'SELECT detected_at, triaged_at, contained_at, eradicated_at, recovered_at, closed_at, affected_users_count FROM incidents WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$inc) { $this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND'); } $this->jsonSuccess($this->computeMetrics($inc)); } /** Differenza in minuti tra due datetime, null se mancante. */ private function minutesBetween(?string $from, ?string $to): ?int { if (empty($from) || empty($to)) return null; $a = strtotime($from); $b = strtotime($to); if ($a === false || $b === false) return null; return (int) round(($b - $a) / 60); } private function computeMetrics(array $inc): array { $det = $inc['detected_at'] ?? null; return [ 'ttd_minutes' => $this->minutesBetween($det, $inc['triaged_at'] ?? null), 'ttc_minutes' => $this->minutesBetween($det, $inc['contained_at'] ?? null), 'ttr_minutes' => $this->minutesBetween($det, $inc['recovered_at'] ?? null), 'downtime_minutes' => $this->minutesBetween($det, $inc['recovered_at'] ?? $inc['closed_at'] ?? null), 'affected_users' => isset($inc['affected_users_count']) ? (int) $inc['affected_users_count'] : null, ]; } /** * GET /api/incidents/{id}/pir * Ritorna la Post-Incident Review (RC.CO-03) con le metriche calcolate. */ public function getPir(int $id): void { $this->requireOrgAccess(); $inc = Database::fetchOne( 'SELECT * FROM incidents WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$inc) { $this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND'); } $pir = Database::fetchOne('SELECT * FROM incident_pir WHERE incident_id = ?', [$id]); if ($pir && !empty($pir['improvement_actions'])) { $pir['improvement_actions'] = json_decode($pir['improvement_actions'], true); } $this->jsonSuccess([ 'pir' => $pir, 'metrics' => $this->computeMetrics($inc), 'reference' => 'RC.CO-03 (NIST CSF) - PIR da completare entro 2 settimane dalla chiusura per incidenti critici', ]); } /** * POST /api/incidents/{id}/pir * Crea o aggiorna la Post-Incident Review (upsert). */ public function savePir(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $inc = Database::fetchOne( 'SELECT * FROM incidents WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$inc) { $this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND'); } $m = $this->computeMetrics($inc); $actions = $this->getParam('improvement_actions'); $fields = [ 'organization_id' => $this->getCurrentOrgId(), 'problem_statement' => $this->getParam('problem_statement'), 'why_1' => $this->getParam('why_1'), 'why_2' => $this->getParam('why_2'), 'why_3' => $this->getParam('why_3'), 'why_4' => $this->getParam('why_4'), 'why_5' => $this->getParam('why_5'), 'root_cause' => $this->getParam('root_cause'), 'ttd_minutes' => $m['ttd_minutes'], 'ttc_minutes' => $m['ttc_minutes'], 'ttr_minutes' => $m['ttr_minutes'], 'downtime_minutes' => $m['downtime_minutes'], 'affected_users' => $m['affected_users'], 'estimated_cost_eur' => $this->getParam('estimated_cost_eur'), 'notification_compliance' => $this->getParam('notification_compliance') !== null ? (int)(bool)$this->getParam('notification_compliance') : null, 'what_went_well' => $this->getParam('what_went_well'), 'what_to_improve' => $this->getParam('what_to_improve'), 'improvement_actions' => is_array($actions) ? json_encode($actions, JSON_UNESCAPED_UNICODE) : null, 'participants' => $this->getParam('participants'), 'reviewed_by' => $this->getCurrentUserId(), 'reviewed_at' => date('Y-m-d H:i:s'), 'status' => $this->getParam('status', 'draft'), ]; $existing = Database::fetchOne('SELECT id FROM incident_pir WHERE incident_id = ?', [$id]); if ($existing) { Database::update('incident_pir', $fields, 'incident_id = ?', [$id]); $pirId = (int) $existing['id']; } else { $fields['incident_id'] = $id; $pirId = Database::insert('incident_pir', $fields); } // Se la root cause e' definita, allineala anche all'incidente (campo legacy) if (!empty($fields['root_cause'])) { Database::update('incidents', ['root_cause' => $fields['root_cause']], 'id = ?', [$id]); } $this->logAudit('incident_pir_saved', 'incident', $id, ['pir_id' => $pirId, 'status' => $fields['status']]); $this->jsonSuccess(['pir_id' => $pirId, 'metrics' => $m], 'Post-Incident Review salvata'); } }