[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:
parent
f8f78b5ece
commit
1be3bd01a4
177
application/services/FairService.php
Normal file
177
application/services/FairService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
71
docs/sql/026_risk_quantitative.sql
Normal file
71
docs/sql/026_risk_quantitative.sql
Normal 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;
|
||||
@ -218,6 +218,11 @@ $actionMap = [
|
||||
'PUT:treatments/{subId}' => 'updateTreatment',
|
||||
'GET:matrix' => 'getRiskMatrix',
|
||||
'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 ──────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user