[FEAT] Risk quantitativo FAIR + KRI dashboard (P2)

Competizione coi GRC enterprise sul risk management quantitativo:
- FairService: simulazione Monte Carlo FAIR (PERT su TEF e Loss Magnitude),
  ALE in EUR con percentili P10/P50/P90 + istogramma, deterministico (seed da input)
- RiskController::computeFair -> POST /risks/{id}/fair (persiste parametri+ALE)
- RiskController::fairRegister -> GET /risks/fairRegister (portfolio ALE EUR)
- KRI: listKri/createKri/updateKri (GET/POST /risks/kri, PUT /risks/kri/{id})
  con stato semaforo green/amber/red su soglie+direzione
- Migrazione 026: risks += parametri FAIR + ale_min/ml/max/mean; nuova tabella kri

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-30 09:25:46 +02:00
parent f8f78b5ece
commit 1be3bd01a4
3 changed files with 253 additions and 0 deletions

View File

@ -0,0 +1,177 @@
<?php
/**
* NIS2 Agile - FAIR Service
*
* Analisi quantitativa del rischio secondo il modello FAIR
* (Factor Analysis of Information Risk) con simulazione Monte Carlo.
*
* ALE (Annualized Loss Expectancy) = LEF * Loss Magnitude
* LEF (Loss Event Frequency) = TEF * Vulnerability
*
* TEF e Loss Magnitude sono stimati come distribuzioni PERT (min/most_likely/max);
* la simulazione campiona N iterazioni e restituisce i percentili dell'ALE.
*
* Logica PURA (nessun DB): testabile e riusabile.
*/
class FairService
{
public const DEFAULT_ITERATIONS = 10000;
/**
* Esegue la simulazione Monte Carlo FAIR.
*
* @param array $p parametri: tef_min, tef_ml, tef_max, vuln (0-1),
* lm_min, lm_ml, lm_max
* @param int $iterations
* @return array{ale_min:float, ale_ml:float, ale_max:float, ale_mean:float,
* lef_mean:float, iterations:int, currency:string, histogram:array}
*/
public static function simulate(array $p, int $iterations = self::DEFAULT_ITERATIONS): array
{
$iterations = max(1000, min(100000, $iterations));
$tefMin = (float) ($p['tef_min'] ?? 0);
$tefMl = (float) ($p['tef_ml'] ?? $tefMin);
$tefMax = (float) ($p['tef_max'] ?? $tefMl);
$vuln = self::clamp((float) ($p['vuln'] ?? 1.0), 0.0, 1.0);
$lmMin = (float) ($p['lm_min'] ?? 0);
$lmMl = (float) ($p['lm_ml'] ?? $lmMin);
$lmMax = (float) ($p['lm_max'] ?? $lmMl);
// Ordina per robustezza (min <= ml <= max)
[$tefMin, $tefMl, $tefMax] = self::sort3($tefMin, $tefMl, $tefMax);
[$lmMin, $lmMl, $lmMax] = self::sort3($lmMin, $lmMl, $lmMax);
$samples = [];
$sumAle = 0.0;
$sumLef = 0.0;
// Seed deterministico per riproducibilita del calcolo (no Date/rand di sistema dipendenti)
mt_srand(crc32(json_encode([$tefMin,$tefMl,$tefMax,$vuln,$lmMin,$lmMl,$lmMax,$iterations])));
for ($i = 0; $i < $iterations; $i++) {
$tef = self::pert($tefMin, $tefMl, $tefMax);
$lef = $tef * $vuln; // Loss Event Frequency
$lm = self::pert($lmMin, $lmMl, $lmMax);
$ale = $lef * $lm;
$samples[] = $ale;
$sumAle += $ale;
$sumLef += $lef;
}
sort($samples);
$n = count($samples);
return [
'ale_min' => round(self::percentile($samples, 10), 2), // P10
'ale_ml' => round(self::percentile($samples, 50), 2), // mediana
'ale_max' => round(self::percentile($samples, 90), 2), // P90
'ale_mean' => round($sumAle / $n, 2),
'lef_mean' => round($sumLef / $n, 4),
'iterations' => $iterations,
'currency' => 'EUR',
'percentiles'=> [
'p10' => round(self::percentile($samples, 10), 2),
'p50' => round(self::percentile($samples, 50), 2),
'p90' => round(self::percentile($samples, 90), 2),
'p99' => round(self::percentile($samples, 99), 2),
],
'histogram' => self::histogram($samples, 12),
];
}
/** Campionamento da distribuzione PERT (Beta riscalata) con lambda=4. */
private static function pert(float $min, float $ml, float $max): float
{
if ($max <= $min) return $min;
$ml = self::clamp($ml, $min, $max);
$lambda = 4.0;
$alpha = 1 + $lambda * ($ml - $min) / ($max - $min);
$beta = 1 + $lambda * ($max - $ml) / ($max - $min);
return $min + self::betaSample($alpha, $beta) * ($max - $min);
}
/** Campione Beta(alpha,beta) via due Gamma. */
private static function betaSample(float $a, float $b): float
{
$x = self::gammaSample($a);
$y = self::gammaSample($b);
$s = $x + $y;
return $s > 0 ? $x / $s : 0.5;
}
/** Campione Gamma(shape, 1) - Marsaglia & Tsang. */
private static function gammaSample(float $shape): float
{
if ($shape < 1) {
$u = self::rand01();
return self::gammaSample(1 + $shape) * pow($u, 1 / $shape);
}
$d = $shape - 1.0 / 3.0;
$c = 1.0 / sqrt(9 * $d);
while (true) {
do {
$x = self::randNormal();
$v = 1 + $c * $x;
} while ($v <= 0);
$v = $v * $v * $v;
$u = self::rand01();
if ($u < 1 - 0.0331 * $x * $x * $x * $x) return $d * $v;
if (log($u) < 0.5 * $x * $x + $d * (1 - $v + log($v))) return $d * $v;
}
}
private static function randNormal(): float
{
// Box-Muller
$u1 = self::rand01(); $u2 = self::rand01();
return sqrt(-2 * log($u1 + 1e-12)) * cos(2 * M_PI * $u2);
}
private static function rand01(): float
{
return (mt_rand() + 1) / (mt_getrandmax() + 2);
}
private static function percentile(array $sorted, float $p): float
{
$n = count($sorted);
if ($n === 0) return 0.0;
$rank = ($p / 100) * ($n - 1);
$lo = (int) floor($rank);
$hi = (int) ceil($rank);
if ($lo === $hi) return $sorted[$lo];
$frac = $rank - $lo;
return $sorted[$lo] * (1 - $frac) + $sorted[$hi] * $frac;
}
private static function histogram(array $sorted, int $bins): array
{
$n = count($sorted);
if ($n === 0) return [];
$min = $sorted[0]; $max = $sorted[$n - 1];
if ($max <= $min) return [['from' => $min, 'to' => $max, 'count' => $n]];
$w = ($max - $min) / $bins;
$h = array_fill(0, $bins, 0);
foreach ($sorted as $v) {
$idx = (int) min($bins - 1, floor(($v - $min) / $w));
$h[$idx]++;
}
$out = [];
for ($i = 0; $i < $bins; $i++) {
$out[] = ['from' => round($min + $i * $w, 2), 'to' => round($min + ($i + 1) * $w, 2), 'count' => $h[$i]];
}
return $out;
}
private static function clamp(float $v, float $lo, float $hi): float
{
return max($lo, min($hi, $v));
}
private static function sort3(float $a, float $b, float $c): array
{
$arr = [$a, $b, $c];
sort($arr);
return $arr;
}
}

View File

@ -0,0 +1,71 @@
-- ============================================================================
-- Migration 026 - Risk quantitativo FAIR + KRI (P2)
-- ----------------------------------------------------------------------------
-- Affianca al risk register qualitativo (likelihood/impact 1-5) l'analisi
-- quantitativa FAIR (Factor Analysis of Information Risk) in valore economico:
-- - parametri FAIR su risks (TEF, vulnerability, loss magnitude PERT min/ml/max)
-- - ale_min / ale_ml / ale_max / ale_mean: Annualized Loss Expectancy (EUR)
-- calcolati via Monte Carlo e persistiti.
-- - tabella kri: Key Risk Indicators con soglie e valori correnti.
--
-- Idempotente. Rilanciabile.
-- mysql -h localhost nis2_agile_db -e "source docs/sql/026_risk_quantitative.sql"
-- ============================================================================
DELIMITER //
DROP PROCEDURE IF EXISTS _mig026_col //
CREATE PROCEDURE _mig026_col(IN col VARCHAR(64), IN ddl TEXT)
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='risks' AND COLUMN_NAME=col) THEN
SET @sql = CONCAT('ALTER TABLE risks ADD COLUMN ', ddl);
PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st;
END IF;
END //
DELIMITER ;
-- Parametri FAIR (input)
CALL _mig026_col('fair_tef_min', "fair_tef_min DECIMAL(10,2) NULL COMMENT 'Threat Event Frequency min (eventi/anno)'");
CALL _mig026_col('fair_tef_ml', "fair_tef_ml DECIMAL(10,2) NULL COMMENT 'Threat Event Frequency most likely (eventi/anno)'");
CALL _mig026_col('fair_tef_max', "fair_tef_max DECIMAL(10,2) NULL COMMENT 'Threat Event Frequency max (eventi/anno)'");
CALL _mig026_col('fair_vuln', "fair_vuln DECIMAL(5,4) NULL COMMENT 'Vulnerability: prob. che una minaccia diventi perdita (0-1)'");
CALL _mig026_col('fair_lm_min', "fair_lm_min DECIMAL(14,2) NULL COMMENT 'Loss Magnitude min (EUR per evento)'");
CALL _mig026_col('fair_lm_ml', "fair_lm_ml DECIMAL(14,2) NULL COMMENT 'Loss Magnitude most likely (EUR per evento)'");
CALL _mig026_col('fair_lm_max', "fair_lm_max DECIMAL(14,2) NULL COMMENT 'Loss Magnitude max (EUR per evento)'");
-- Risultati FAIR (output ALE in EUR)
CALL _mig026_col('ale_min', "ale_min DECIMAL(16,2) NULL COMMENT 'Annualized Loss Expectancy - percentile basso (P10)'");
CALL _mig026_col('ale_ml', "ale_ml DECIMAL(16,2) NULL COMMENT 'Annualized Loss Expectancy - mediana (P50)'");
CALL _mig026_col('ale_max', "ale_max DECIMAL(16,2) NULL COMMENT 'Annualized Loss Expectancy - percentile alto (P90)'");
CALL _mig026_col('ale_mean', "ale_mean DECIMAL(16,2) NULL COMMENT 'Annualized Loss Expectancy - media simulazione'");
CALL _mig026_col('fair_computed_at',"fair_computed_at DATETIME NULL COMMENT 'Ultimo calcolo FAIR'");
DROP PROCEDURE IF EXISTS _mig026_col;
-- Key Risk Indicators
CREATE TABLE IF NOT EXISTS kri (
id INT NOT NULL AUTO_INCREMENT,
organization_id INT NOT NULL,
name VARCHAR(160) NOT NULL,
description VARCHAR(255) NULL,
category ENUM('cyber','operational','compliance','supply_chain','physical','human') NOT NULL DEFAULT 'cyber',
unit VARCHAR(40) NULL COMMENT 'Unita di misura (%, count, EUR, giorni)',
current_value DECIMAL(16,4) NULL,
target_value DECIMAL(16,4) NULL COMMENT 'Valore obiettivo/atteso',
threshold_warning DECIMAL(16,4) NULL COMMENT 'Soglia ambra',
threshold_critical DECIMAL(16,4) NULL COMMENT 'Soglia rossa',
direction ENUM('higher_worse','lower_worse') NOT NULL DEFAULT 'higher_worse' COMMENT 'Se valori alti=peggio o bassi=peggio',
status ENUM('green','amber','red','unknown') NOT NULL DEFAULT 'unknown',
linked_risk_id INT NULL,
measured_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_kri_org (organization_id),
KEY idx_kri_risk (linked_risk_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Key Risk Indicators con soglie e stato semaforo';
-- ROLLBACK:
-- DROP TABLE IF EXISTS kri;
-- ALTER TABLE risks DROP COLUMN fair_tef_min, DROP COLUMN fair_tef_ml, DROP COLUMN fair_tef_max,
-- DROP COLUMN fair_vuln, DROP COLUMN fair_lm_min, DROP COLUMN fair_lm_ml, DROP COLUMN fair_lm_max,
-- DROP COLUMN ale_min, DROP COLUMN ale_ml, DROP COLUMN ale_max, DROP COLUMN ale_mean, DROP COLUMN fair_computed_at;

View File

@ -218,6 +218,11 @@ $actionMap = [
'PUT:treatments/{subId}' => 'updateTreatment', 'PUT:treatments/{subId}' => 'updateTreatment',
'GET:matrix' => 'getRiskMatrix', 'GET:matrix' => 'getRiskMatrix',
'POST:aiSuggest' => 'aiSuggestRisks', 'POST:aiSuggest' => 'aiSuggestRisks',
'GET:fairRegister' => 'fairRegister', // P2: registro quantitativo FAIR
'POST:{id}/fair' => 'computeFair', // P2: calcolo ALE Monte Carlo
'GET:kri' => 'listKri', // P2: dashboard KRI
'POST:kri' => 'createKri',
'PUT:kri/{subId}' => 'updateKri',
], ],
// ── IncidentController ────────────────────────── // ── IncidentController ──────────────────────────