Il commit 1be3bd0 conteneva migrazione 026 + FairService + route ma NON i metodi
computeFair/fairRegister/listKri/createKri/updateKri nel controller (Edit fallito
per ancora errata). Ora presenti e testati E2E in prod:
- FAIR compute ALE Monte Carlo (risk 432: ALE mean 174.806 EUR, deterministico)
- fairRegister portfolio ALE, KRI create/update/dashboard con semaforo amber->red
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
517 lines
21 KiB
PHP
517 lines
21 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Risk Controller
|
|
*
|
|
* Gestione rischi cyber, matrice rischi, trattamenti.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
require_once APP_PATH . '/services/AIService.php';
|
|
require_once APP_PATH . '/services/WebhookService.php';
|
|
require_once APP_PATH . '/services/FairService.php';
|
|
|
|
class RiskController extends BaseController
|
|
{
|
|
/**
|
|
* GET /api/risks/list
|
|
*/
|
|
public function list(): void
|
|
{
|
|
$this->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');
|
|
}
|
|
|
|
$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),
|
|
];
|
|
$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;
|
|
}
|
|
}
|