diff --git a/application/services/FairService.php b/application/services/FairService.php new file mode 100644 index 0000000..42f9d32 --- /dev/null +++ b/application/services/FairService.php @@ -0,0 +1,177 @@ + 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; + } +} diff --git a/docs/sql/026_risk_quantitative.sql b/docs/sql/026_risk_quantitative.sql new file mode 100644 index 0000000..9bf975b --- /dev/null +++ b/docs/sql/026_risk_quantitative.sql @@ -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; diff --git a/public/index.php b/public/index.php index f611bf4..7c19bf3 100644 --- a/public/index.php +++ b/public/index.php @@ -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 ──────────────────────────