requireOrgAccess(); $pagination = $this->getPagination(); $where = 'organization_id = ?'; $params = [$this->getCurrentOrgId()]; // Filtri opzionali if ($this->hasParam('status')) { $where .= ' AND status = ?'; $params[] = $this->getParam('status'); } if ($this->hasParam('category')) { $where .= ' AND category = ?'; $params[] = $this->getParam('category'); } $total = Database::count('risks', $where, $params); $risks = Database::fetchAll( "SELECT r.*, u.full_name as owner_name FROM risks r LEFT JOIN users u ON u.id = r.owner_user_id WHERE r.{$where} ORDER BY r.inherent_risk_score DESC LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}", $params ); $this->jsonPaginated($risks, $total, $pagination['page'], $pagination['per_page']); } /** * POST /api/risks/create */ public function create(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['title', 'category']); $likelihood = (int) $this->getParam('likelihood', 0); $impact = (int) $this->getParam('impact', 0); $riskId = Database::insert('risks', [ 'organization_id' => $this->getCurrentOrgId(), 'risk_code' => $this->generateCode('RSK'), 'title' => trim($this->getParam('title')), 'description' => $this->getParam('description'), 'category' => $this->getParam('category'), 'threat_source' => $this->getParam('threat_source'), 'vulnerability' => $this->getParam('vulnerability'), 'affected_assets' => $this->getParam('affected_assets') ? json_encode($this->getParam('affected_assets')) : null, 'likelihood' => $likelihood, 'impact' => $impact, 'inherent_risk_score' => $likelihood * $impact, 'treatment' => $this->getParam('treatment', 'mitigate'), 'owner_user_id' => $this->getParam('owner_user_id'), 'review_date' => $this->getParam('review_date'), 'nis2_article' => $this->getParam('nis2_article'), ]); $this->logAudit('risk_created', 'risk', $riskId); // Dispatch webhook per rischi HIGH/CRITICAL $riskScore = $likelihood * $impact; if ($riskScore >= 12) { // HIGH: 12-16, CRITICAL: >16 (su scala 5x5) try { $riskData = Database::fetchOne('SELECT * FROM risks WHERE id = ?', [$riskId]); $riskLevel = $riskScore >= 20 ? 'critical' : 'high'; $riskData['risk_level'] = $riskLevel; (new WebhookService())->dispatch( $this->getCurrentOrgId(), 'risk.high_created', WebhookService::riskPayload($riskData, 'created') ); } catch (Throwable $e) { error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); } } $this->jsonSuccess(['id' => $riskId], 'Rischio registrato', 201); } /** * GET /api/risks/{id} */ public function get(int $id): void { $this->requireOrgAccess(); $risk = Database::fetchOne( 'SELECT r.*, u.full_name as owner_name FROM risks r LEFT JOIN users u ON u.id = r.owner_user_id WHERE r.id = ? AND r.organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$risk) { $this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND'); } // Carica trattamenti $risk['treatments'] = Database::fetchAll( 'SELECT rt.*, u.full_name as responsible_name FROM risk_treatments rt LEFT JOIN users u ON u.id = rt.responsible_user_id WHERE rt.risk_id = ? ORDER BY rt.due_date', [$id] ); $this->jsonSuccess($risk); } /** * PUT /api/risks/{id} */ public function update(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $risk = Database::fetchOne( 'SELECT * FROM risks WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$risk) { $this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND'); } $updates = []; $allowedFields = [ 'title', 'description', 'category', 'threat_source', 'vulnerability', 'likelihood', 'impact', 'treatment', 'residual_likelihood', 'residual_impact', 'status', 'owner_user_id', 'review_date', 'nis2_article', ]; foreach ($allowedFields as $field) { if ($this->hasParam($field)) { $updates[$field] = $this->getParam($field); } } // Ricalcola score se likelihood o impact cambiano $likelihood = (int) ($updates['likelihood'] ?? $risk['likelihood']); $impact = (int) ($updates['impact'] ?? $risk['impact']); $updates['inherent_risk_score'] = $likelihood * $impact; if (isset($updates['residual_likelihood']) || isset($updates['residual_impact'])) { $resLikelihood = (int) ($updates['residual_likelihood'] ?? $risk['residual_likelihood']); $resImpact = (int) ($updates['residual_impact'] ?? $risk['residual_impact']); $updates['residual_risk_score'] = $resLikelihood * $resImpact; } if (!empty($updates)) { Database::update('risks', $updates, 'id = ?', [$id]); $this->logAudit('risk_updated', 'risk', $id, $updates); } $this->jsonSuccess($updates, 'Rischio aggiornato'); } /** * DELETE /api/risks/{id} */ public function delete(int $id): void { $this->requireOrgRole(['org_admin']); $deleted = Database::delete('risks', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if ($deleted === 0) { $this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND'); } $this->logAudit('risk_deleted', 'risk', $id); $this->jsonSuccess(null, 'Rischio eliminato'); } /** * POST /api/risks/{id}/treatments */ public function addTreatment(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['action_description']); // Verifica che il rischio esista per l'organizzazione $risk = Database::fetchOne( 'SELECT id FROM risks WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$risk) { $this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND'); } $treatmentId = Database::insert('risk_treatments', [ 'risk_id' => $id, 'action_description' => $this->getParam('action_description'), 'responsible_user_id' => $this->getParam('responsible_user_id'), 'due_date' => $this->getParam('due_date'), 'status' => 'planned', 'notes' => $this->getParam('notes'), ]); $this->logAudit('treatment_added', 'risk', $id, ['treatment_id' => $treatmentId]); $this->jsonSuccess(['id' => $treatmentId], 'Trattamento aggiunto', 201); } /** * PUT /api/risks/treatments/{id} */ public function updateTreatment(int $treatmentId): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $treatment = Database::fetchOne( 'SELECT rt.* FROM risk_treatments rt JOIN risks r ON r.id = rt.risk_id WHERE rt.id = ? AND r.organization_id = ?', [$treatmentId, $this->getCurrentOrgId()] ); if (!$treatment) { $this->jsonError('Trattamento non trovato', 404, 'TREATMENT_NOT_FOUND'); } $updates = []; foreach (['action_description', 'responsible_user_id', 'due_date', 'status', 'completion_date', 'notes'] as $field) { if ($this->hasParam($field)) { $updates[$field] = $this->getParam($field); } } if (!empty($updates)) { Database::update('risk_treatments', $updates, 'id = ?', [$treatmentId]); $this->logAudit('treatment_updated', 'risk_treatment', $treatmentId, $updates); } $this->jsonSuccess($updates, 'Trattamento aggiornato'); } /** * GET /api/risks/matrix */ public function getRiskMatrix(): void { $this->requireOrgAccess(); $risks = Database::fetchAll( 'SELECT id, title, category, likelihood, impact, inherent_risk_score, residual_likelihood, residual_impact, residual_risk_score, status FROM risks WHERE organization_id = ? AND status != "closed"', [$this->getCurrentOrgId()] ); $this->jsonSuccess([ 'risks' => $risks, 'summary' => [ 'total' => count($risks), 'critical' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 20)), 'high' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 12 && ($r['inherent_risk_score'] ?? 0) < 20)), 'medium' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 6 && ($r['inherent_risk_score'] ?? 0) < 12)), 'low' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) < 6)), ], ]); } /** * POST /api/risks/ai-suggest */ public function aiSuggestRisks(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); $assets = Database::fetchAll( 'SELECT name, asset_type, criticality FROM assets WHERE organization_id = ? AND status = "active"', [$this->getCurrentOrgId()] ); try { $aiService = new AIService(); $suggestions = $aiService->suggestRisks($org, $assets); $aiService->logInteraction( $this->getCurrentOrgId(), $this->getCurrentUserId(), 'risk_suggestion', 'Risk suggestions for ' . $org['sector'], substr(json_encode($suggestions), 0, 500) ); $this->jsonSuccess($suggestions, 'Suggerimenti rischi generati'); } catch (Throwable $e) { $this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR'); } } // ══════════════════════════════════════════════════════════════════════ // RISK QUANTITATIVO FAIR (P2) — analisi economica del rischio // ══════════════════════════════════════════════════════════════════════ /** * POST /api/risks/{id}/fair * Calcola l'ALE (Annualized Loss Expectancy) via Monte Carlo FAIR e * persiste parametri + risultati sul rischio. * Body: { tef_min, tef_ml, tef_max, vuln (0-1), lm_min, lm_ml, lm_max, iterations? } */ public function computeFair(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); $risk = Database::fetchOne( 'SELECT id FROM risks WHERE id = ? AND organization_id = ? AND deleted_at IS NULL', [$id, $this->getCurrentOrgId()] ); if (!$risk) { $this->jsonError('Rischio non trovato', 404, 'NOT_FOUND'); } // Limiti coerenti con le colonne DB per evitare overflow: // fair_tef_* DECIMAL(10,2) -> max ~99.999.999 | fair_lm_* DECIMAL(14,2) -> max ~999 mld // ale_* DECIMAL(16,2). Cappiamo TEF e Loss Magnitude a valori realistici così che // ALE = TEF * vuln * LM resti entro DECIMAL(16,2). $TEF_MAX = 1000000.0; // 1M eventi/anno (limite prudenziale) $LM_MAX = 1000000000000.0; // 1.000 mld EUR per evento $params = [ 'tef_min' => (float) $this->getParam('tef_min', 0), 'tef_ml' => (float) $this->getParam('tef_ml', 0), 'tef_max' => (float) $this->getParam('tef_max', 0), 'vuln' => (float) $this->getParam('vuln', 1), 'lm_min' => (float) $this->getParam('lm_min', 0), 'lm_ml' => (float) $this->getParam('lm_ml', 0), 'lm_max' => (float) $this->getParam('lm_max', 0), ]; // Validazione: niente valori negativi o NaN/inf foreach ($params as $k => $v) { if (!is_finite($v) || $v < 0) { $this->jsonError("Parametro FAIR non valido: {$k}", 422, 'INVALID_FAIR_PARAM'); } } foreach (['tef_min', 'tef_ml', 'tef_max'] as $k) { if ($params[$k] > $TEF_MAX) { $this->jsonError("Frequenza eventi ({$k}) oltre il limite ammesso ({$TEF_MAX})", 422, 'FAIR_OUT_OF_RANGE'); } } foreach (['lm_min', 'lm_ml', 'lm_max'] as $k) { if ($params[$k] > $LM_MAX) { $this->jsonError("Magnitudo di perdita ({$k}) oltre il limite ammesso ({$LM_MAX})", 422, 'FAIR_OUT_OF_RANGE'); } } if ($params['vuln'] > 1) { $this->jsonError('La vulnerabilità deve essere compresa tra 0 e 1', 422, 'FAIR_OUT_OF_RANGE'); } $iterations = (int) $this->getParam('iterations', FairService::DEFAULT_ITERATIONS); $sim = FairService::simulate($params, $iterations); Database::query( 'UPDATE risks SET fair_tef_min=?, fair_tef_ml=?, fair_tef_max=?, fair_vuln=?, fair_lm_min=?, fair_lm_ml=?, fair_lm_max=?, ale_min=?, ale_ml=?, ale_max=?, ale_mean=?, fair_computed_at=NOW() WHERE id=? AND organization_id=?', [ $params['tef_min'], $params['tef_ml'], $params['tef_max'], $params['vuln'], $params['lm_min'], $params['lm_ml'], $params['lm_max'], $sim['ale_min'], $sim['ale_ml'], $sim['ale_max'], $sim['ale_mean'], $id, $this->getCurrentOrgId(), ] ); $this->logAudit('risk_fair_computed', 'risk', $id, ['ale_mean' => $sim['ale_mean']]); $this->jsonSuccess(['risk_id' => $id, 'parameters' => $params, 'result' => $sim], 'Analisi FAIR calcolata'); } /** * GET /api/risks/fairRegister * Registro quantitativo: rischi con ALE calcolato, ordinati per esposizione * economica, + ALE totale di portafoglio. */ public function fairRegister(): void { $this->requireOrgAccess(); $rows = Database::fetchAll( 'SELECT id, risk_code, title, category, status, inherent_risk_score, ale_min, ale_ml, ale_max, ale_mean, fair_computed_at FROM risks WHERE organization_id = ? AND deleted_at IS NULL AND ale_mean IS NOT NULL ORDER BY ale_mean DESC', [$this->getCurrentOrgId()] ); $totalAle = 0.0; foreach ($rows as $r) { $totalAle += (float) $r['ale_mean']; } $this->jsonSuccess([ 'total_risks_quantified' => count($rows), 'portfolio_ale_mean' => round($totalAle, 2), 'currency' => 'EUR', 'risks' => $rows, ]); } // ══════════════════════════════════════════════════════════════════════ // KEY RISK INDICATORS (KRI) — dashboard // ══════════════════════════════════════════════════════════════════════ /** GET /api/risks/kri — lista KRI con stato semaforo ricalcolato. */ public function listKri(): void { $this->requireOrgAccess(); $rows = Database::fetchAll( 'SELECT * FROM kri WHERE organization_id = ? ORDER BY FIELD(status,"red","amber","green","unknown"), name', [$this->getCurrentOrgId()] ); foreach ($rows as &$r) { $r['status'] = self::evalKriStatus($r); } unset($r); $summary = ['green' => 0, 'amber' => 0, 'red' => 0, 'unknown' => 0]; foreach ($rows as $r) { $summary[$r['status']]++; } $this->jsonSuccess(['summary' => $summary, 'total' => count($rows), 'kris' => $rows]); } /** POST /api/risks/kri — crea un KRI. */ public function createKri(): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); $this->validateRequired(['name']); $dir = $this->getParam('direction', 'higher_worse'); if (!in_array($dir, ['higher_worse', 'lower_worse'], true)) $dir = 'higher_worse'; $cat = $this->getParam('category', 'cyber'); if (!in_array($cat, ['cyber','operational','compliance','supply_chain','physical','human'], true)) $cat = 'cyber'; $data = [ 'organization_id' => $this->getCurrentOrgId(), 'name' => trim($this->getParam('name')), 'description' => $this->getParam('description'), 'category' => $cat, 'unit' => $this->getParam('unit'), 'current_value' => $this->numOrNull($this->getParam('current_value')), 'target_value' => $this->numOrNull($this->getParam('target_value')), 'threshold_warning' => $this->numOrNull($this->getParam('threshold_warning')), 'threshold_critical' => $this->numOrNull($this->getParam('threshold_critical')), 'direction' => $dir, 'linked_risk_id' => $this->numOrNull($this->getParam('linked_risk_id')), 'measured_at' => $this->getParam('current_value') !== null ? date('Y-m-d H:i:s') : null, ]; $data['status'] = self::evalKriStatus($data); $kriId = Database::insert('kri', $data); $this->logAudit('kri_created', 'kri', $kriId); $this->jsonSuccess(['id' => $kriId, 'status' => $data['status']], 'KRI creato', 201); } /** PUT /api/risks/kri/{id} — aggiorna valore/soglie KRI (ricalcola stato). */ public function updateKri(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); $kri = Database::fetchOne('SELECT * FROM kri WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if (!$kri) { $this->jsonError('KRI non trovato', 404, 'NOT_FOUND'); } $fields = ['name', 'description', 'category', 'unit', 'current_value', 'target_value', 'threshold_warning', 'threshold_critical', 'direction', 'linked_risk_id']; $merged = $kri; foreach ($fields as $f) { $v = $this->getParam($f); if ($v !== null) { $merged[$f] = in_array($f, ['current_value','target_value','threshold_warning','threshold_critical','linked_risk_id'], true) ? $this->numOrNull($v) : $v; } } $merged['status'] = self::evalKriStatus($merged); $measuredAt = $this->getParam('current_value') !== null ? date('Y-m-d H:i:s') : $kri['measured_at']; Database::query( 'UPDATE kri SET name=?, description=?, category=?, unit=?, current_value=?, target_value=?, threshold_warning=?, threshold_critical=?, direction=?, linked_risk_id=?, status=?, measured_at=? WHERE id=? AND organization_id=?', [ $merged['name'], $merged['description'], $merged['category'], $merged['unit'], $merged['current_value'], $merged['target_value'], $merged['threshold_warning'], $merged['threshold_critical'], $merged['direction'], $merged['linked_risk_id'], $merged['status'], $measuredAt, $id, $this->getCurrentOrgId(), ] ); $this->logAudit('kri_updated', 'kri', $id); $this->jsonSuccess(['id' => $id, 'status' => $merged['status']], 'KRI aggiornato'); } /** Stato semaforo KRI in base a valore corrente, soglie e direzione. */ private static function evalKriStatus(array $k): string { $cur = $k['current_value'] ?? null; if ($cur === null || $cur === '') return 'unknown'; $cur = (float) $cur; $warn = isset($k['threshold_warning']) && $k['threshold_warning'] !== null ? (float) $k['threshold_warning'] : null; $crit = isset($k['threshold_critical']) && $k['threshold_critical'] !== null ? (float) $k['threshold_critical'] : null; $dir = $k['direction'] ?? 'higher_worse'; if ($dir === 'higher_worse') { if ($crit !== null && $cur >= $crit) return 'red'; if ($warn !== null && $cur >= $warn) return 'amber'; return 'green'; } if ($crit !== null && $cur <= $crit) return 'red'; if ($warn !== null && $cur <= $warn) return 'amber'; return 'green'; } private function numOrNull($v) { return ($v === null || $v === '') ? null : (float) $v; } }