[FEAT] Integrazione analisi docs/nis2 v1.7.0 — scoring asset, tassonomia incidenti, PIR, NIST CSF, fonti certe

Fase 1 - Asset Relevance Scoring NIS2 (GV.OC-04): metodologia 0-100 a 6 criteri,
  AssetScoringService + endpoint scoringGrid/score/relevantSystems + UI assets.html + registro stampabile.
Fase 2 - Tassonomia incidenti Determina ACN 164179/2025: IS-1..4 + regime essenziale/importante (Allegati 3/4).
Fase 3 - Post-Incident Review (5-Whys) + metriche TTD/TTC/TTR + timestamp di fase.
Fase 4 - Mapping NIST CSF 2.0 (43 controlli) reference-only.
Fonti certe: registry config/nis2_sources.php + grounding AI (vieta riferimenti inventati) +
  citazioni help.js + ingest PDF normativi nella KB RAG (scripts/ingest-nis2-sources.php).
Migrazioni 020/021/022 (additive idempotenti). Fix VectorService IP Qdrant (drift .5->.3).
Analisi concorrenza Evix (docs/EVIX_ANALISI_CONCORRENZA.html, gap-driven).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-05-29 17:15:13 +02:00
co-authored by Claude Opus 4.8
parent a7a21faa82
commit 5c545ea3d0
41 changed files with 21078 additions and 24 deletions
+117
View File
@@ -6,6 +6,7 @@
*/
require_once __DIR__ . '/BaseController.php';
require_once __DIR__ . '/../services/AssetScoringService.php';
class AssetController extends BaseController
{
@@ -29,6 +30,10 @@ class AssetController extends BaseController
$where .= ' AND status = ?';
$params[] = $this->getParam('status');
}
if ($this->hasParam('nis2_relevant')) {
$where .= ' AND is_nis2_relevant = ?';
$params[] = $this->getParam('nis2_relevant') ? 1 : 0;
}
$total = Database::count('assets', $where, $params);
$assets = Database::fetchAll(
@@ -157,4 +162,116 @@ class AssetController extends BaseController
$this->jsonSuccess(['nodes' => $nodes, 'edges' => $edges]);
}
/**
* GET /api/assets/scoringGrid
* Ritorna la griglia ufficiale di valutazione rilevanza NIS2 (GV.OC-04)
* per costruire la UI di scoring lato client.
*/
public function scoringGrid(): void
{
$this->requireOrgAccess();
$this->jsonSuccess([
'grid' => AssetScoringService::GRID,
'threshold' => AssetScoringService::RELEVANCE_THRESHOLD,
'classes' => [
['key' => 'critico', 'min' => 80, 'max' => 100, 'label' => 'Critico - Priorita Massima'],
['key' => 'alto', 'min' => 60, 'max' => 79, 'label' => 'Alto - Priorita Alta'],
['key' => 'medio', 'min' => 40, 'max' => 59, 'label' => 'Medio - Rilevante'],
['key' => 'basso', 'min' => 20, 'max' => 39, 'label' => 'Basso - Monitoraggio'],
['key' => 'trascurabile', 'min' => 0, 'max' => 19, 'label' => 'Trascurabile'],
],
]);
}
/**
* POST /api/assets/{id}/score
* Calcola e salva la rilevanza NIS2 dell'asset a partire dalle selezioni
* sui 6 criteri. Body: { criteria: { c1_operational_criticality: 'critical', ... } }
*/
public function score(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$asset = Database::fetchOne(
'SELECT id FROM assets WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$asset) {
$this->jsonError('Asset non trovato', 404, 'ASSET_NOT_FOUND');
}
$criteria = $this->getParam('criteria');
if (!is_array($criteria)) {
$this->jsonError('Campo "criteria" mancante o non valido', 422, 'INVALID_CRITERIA');
}
try {
$result = AssetScoringService::calculate($criteria);
} catch (InvalidArgumentException $e) {
$this->jsonError($e->getMessage(), 422, 'INVALID_CRITERIA');
return;
}
Database::update('assets', [
'relevance_score' => $result['score'],
'relevance_criteria' => json_encode($result['breakdown'], JSON_UNESCAPED_UNICODE),
'relevance_class' => $result['class'],
'is_nis2_relevant' => $result['is_relevant'] ? 1 : 0,
'criticality' => $result['criticality'],
'relevance_assessed_at' => date('Y-m-d H:i:s'),
'relevance_assessed_by' => $this->getCurrentUserId(),
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('asset_scored', 'asset', $id, [
'score' => $result['score'],
'class' => $result['class'],
]);
$this->jsonSuccess([
'score' => $result['score'],
'class' => $result['class'],
'is_nis2_relevant' => $result['is_relevant'],
'breakdown' => $result['breakdown'],
'required_measures'=> AssetScoringService::requiredMeasures($result['class']),
], 'Rilevanza NIS2 calcolata');
}
/**
* GET /api/assets/relevantSystems
* Elenco dei sistemi classificati rilevanti NIS2 (score >= 40), ordinati
* per punteggio. Alimenta il registro formale "Sistemi Rilevanti" (GV.OC-04).
*/
public function relevantSystems(): void
{
$this->requireOrgAccess();
$rows = Database::fetchAll(
"SELECT a.id, a.name, a.asset_type, a.category, a.ip_address, a.location,
a.relevance_score, a.relevance_class, a.relevance_criteria,
a.relevance_assessed_at, u.full_name AS owner_name
FROM assets a
LEFT JOIN users u ON u.id = a.owner_user_id
WHERE a.organization_id = ? AND a.is_nis2_relevant = 1
ORDER BY a.relevance_score DESC, a.name",
[$this->getCurrentOrgId()]
);
$stats = ['critico' => 0, 'alto' => 0, 'medio' => 0];
foreach ($rows as &$r) {
$r['relevance_criteria'] = json_decode($r['relevance_criteria'] ?? 'null', true);
$r['required_measures'] = AssetScoringService::requiredMeasures($r['relevance_class'] ?? '');
if (isset($stats[$r['relevance_class']])) {
$stats[$r['relevance_class']]++;
}
}
unset($r);
$this->jsonSuccess([
'systems' => $rows,
'count' => count($rows),
'by_class' => $stats,
'threshold' => AssetScoringService::RELEVANCE_THRESHOLD,
]);
}
}
@@ -195,6 +195,83 @@ class AuditController extends BaseController
$this->jsonSuccess($mapping);
}
/**
* GET /api/audit/nistCsfMapping
* Layer di mapping NIST CSF 2.0 (43 controlli) -> NIS2 Art.21 / D.Lgs.138/2024 -> modulo piattaforma.
* Reference-only (nessuna persistenza): arricchisce l'assessment Art.21 con i codici controllo
* NIST CSF 2.0 usati come standard de-facto. Fonte mapping: NIST CSF 2.0 + Direttiva (UE) 2022/2555.
*/
public function getNistCsfMapping(): void
{
$this->requireOrgAccess();
// [code, function, nis2, module]
$rows = [
// GOVERN
['GV.OC-04', 'Govern', '21.1', 'Asset - Sistemi rilevanti (GV.OC-04)'],
['GV.RM-03', 'Govern', '21.2.a', 'Risk Management'],
['GV.RR-02', 'Govern', '20', 'Organizzazione - Ruoli e responsabilita'],
['GV.RR-04', 'Govern', '20', 'Organizzazione - Risorse cybersecurity'],
['GV.PO-01', 'Govern', '21.2.a', 'Policy - Politica di sicurezza'],
['GV.PO-02', 'Govern', '21.2.a', 'Policy - Revisione politiche'],
['GV.SC-01', 'Govern', '21.2.d', 'Supply Chain - Strategia'],
['GV.SC-02', 'Govern', '21.2.d', 'Supply Chain - Ruoli fornitori'],
['GV.SC-04', 'Govern', '21.2.d', 'Supply Chain - Valutazione fornitori'],
['GV.SC-05', 'Govern', '21.2.d', 'Supply Chain - Requisiti contrattuali'],
['GV.SC-07', 'Govern', '21.2.d', 'Supply Chain - Monitoraggio rischio fornitori'],
// IDENTIFY
['ID.AM-01', 'Identify', '21.2.i', 'Asset - Inventario hardware'],
['ID.AM-02', 'Identify', '21.2.i', 'Asset - Inventario software'],
['ID.AM-03', 'Identify', '21.2.i', 'Asset - Diagrammi flussi/rete (essenziali)'],
['ID.AM-04', 'Identify', '21.2.i', 'Asset - Catalogo servizi'],
['ID.RA-01', 'Identify', '21.2.a', 'Risk Management - Vulnerabilita'],
['ID.RA-05', 'Identify', '21.2.a', 'Risk Management - Valutazione rischio'],
['ID.RA-06', 'Identify', '21.2.a', 'Risk Management - Trattamento rischio'],
['ID.RA-08', 'Identify', '21.2.e', 'Risk Management - Gestione vulnerabilita/disclosure'],
['ID.IM-01', 'Identify', '21.2.f', 'Audit - Miglioramento da valutazioni'],
['ID.IM-04', 'Identify', '21.2.c', 'Incidenti - Piani BC/DR e test'],
// PROTECT
['PR.AA-01', 'Protect', '21.2.i', 'Asset/Access - Gestione identita'],
['PR.AA-03', 'Protect', '21.2.i', 'Access - Autenticazione'],
['PR.AA-05', 'Protect', '21.2.i', 'Access - Privilegi e accessi'],
['PR.AA-06', 'Protect', '21.2.i', 'Access - Accesso fisico'],
['PR.AT-01', 'Protect', '21.2.g', 'Training - Awareness'],
['PR.AT-02', 'Protect', '21.2.g', 'Training - Ruoli privilegiati'],
['PR.DS-01', 'Protect', '21.2.h', 'Policy - Protezione dati a riposo'],
['PR.DS-02', 'Protect', '21.2.h', 'Policy - Protezione dati in transito'],
['PR.DS-11', 'Protect', '21.2.c', 'Incidenti - Backup'],
['PR.PS-01', 'Protect', '21.2.e', 'Policy - Configurazione sicura'],
['PR.PS-02', 'Protect', '21.2.e', 'Asset - Gestione software'],
['PR.PS-03', 'Protect', '21.2.e', 'Asset - Gestione hardware'],
['PR.PS-04', 'Protect', '21.2.b', 'Audit - Log generation'],
['PR.PS-06', 'Protect', '21.2.e', 'Policy - Secure development lifecycle'],
['PR.IR-01', 'Protect', '21.2.i', 'Asset - Protezione reti'],
['PR.IR-03', 'Protect', '21.2.c', 'Incidenti - Resilienza/ridondanza'],
// DETECT
['DE.CM-01', 'Detect', '21.2.b', 'Incidenti - Monitoraggio reti'],
['DE.CM-09', 'Detect', '21.2.b', 'Incidenti - Monitoraggio asset/sistemi'],
// RESPOND / RECOVER
['RS.MA-01', 'Respond', '21.2.b / 23', 'Incidenti - Gestione incidenti'],
['RS.CO-02', 'Respond', '23', 'Incidenti - Notifica CSIRT'],
['RC.RP-01', 'Recover', '21.2.c', 'Incidenti - Piano di ripristino'],
['RC.CO-03', 'Recover', '21.2.c', 'Incidenti - Post-Incident Review'],
];
$mapping = array_map(fn($r) => [
'csf_code' => $r[0],
'function' => $r[1],
'nis2_art' => $r[2],
'module' => $r[3],
], $rows);
$this->jsonSuccess([
'mapping' => $mapping,
'count' => count($mapping),
'functions' => ['Govern', 'Identify', 'Protect', 'Detect', 'Respond', 'Recover'],
'source' => 'NIST Cybersecurity Framework 2.0 + Direttiva (UE) 2022/2555 (NIS2) Art.20-21-23 / D.Lgs. 138/2024',
]);
}
/**
* GET /api/audit/executive-report
* Genera report esecutivo HTML (stampabile come PDF)
@@ -211,6 +288,22 @@ class AuditController extends BaseController
exit;
}
/**
* GET /api/audit/relevantSystemsRegister
* Registro formale "Sistemi Rilevanti NIS2" (GV.OC-04), HTML stampabile.
*/
public function relevantSystemsRegister(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']);
$reportService = new ReportService();
$html = $reportService->generateRelevantSystemsRegister($this->getCurrentOrgId());
header('Content-Type: text/html; charset=utf-8');
echo $html;
exit;
}
/**
* GET /api/audit/export/{type}
* Esporta dati in CSV
+157 -6
View File
@@ -59,6 +59,17 @@ class IncidentController extends BaseController
$detectedAt = $this->getParam('detected_at');
$isSignificant = (bool) $this->getParam('is_significant', false);
// Regime obblighi NIS2 (Determina ACN 164179/2025): Allegato 3 essenziali / Allegato 4 importanti.
$org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
$entityObligation = ($org && ($org['entity_type'] ?? '') === 'essential') ? 'essential' : 'important';
// IS-4 (incidenti ricorrenti) non si applica ai soggetti importanti.
$isType = $this->getParam('nis2_incident_type');
$validIs = $entityObligation === 'essential' ? ['IS-1','IS-2','IS-3','IS-4'] : ['IS-1','IS-2','IS-3'];
if ($isType !== null && !in_array($isType, $validIs, true)) {
$isType = null;
}
$data = [
'organization_id' => $this->getCurrentOrgId(),
'incident_code' => $this->generateCode('INC'),
@@ -67,6 +78,8 @@ class IncidentController extends BaseController
'classification' => $this->getParam('classification'),
'severity' => $this->getParam('severity'),
'is_significant' => $isSignificant ? 1 : 0,
'nis2_incident_type' => $isType,
'entity_obligation' => $entityObligation,
'detected_at' => $detectedAt,
'affected_services' => $this->getParam('affected_services'),
'affected_users_count' => $this->getParam('affected_users_count'),
@@ -173,9 +186,9 @@ class IncidentController extends BaseController
$updates = [];
$allowedFields = [
'title', 'description', 'classification', 'severity', 'is_significant',
'status', 'affected_services', 'affected_users_count', 'cross_border_impact',
'malicious_action', 'root_cause', 'remediation_actions', 'lessons_learned',
'assigned_to',
'nis2_incident_type', 'status', 'affected_services', 'affected_users_count',
'cross_border_impact', 'malicious_action', 'root_cause', 'remediation_actions',
'lessons_learned', 'assigned_to',
];
foreach ($allowedFields as $field) {
@@ -184,9 +197,24 @@ class IncidentController extends BaseController
}
}
// Se chiuso, registra data
if (isset($updates['status']) && $updates['status'] === 'closed') {
$updates['closed_at'] = date('Y-m-d H:i:s');
// Timbra automaticamente i timestamp di fase al primo ingresso nello stato
// (per il calcolo metriche TTD/TTC/TTR). Non sovrascrive valori gia' presenti.
if (isset($updates['status'])) {
$now = date('Y-m-d H:i:s');
$stamp = [
'analyzing' => 'triaged_at',
'containing' => 'contained_at',
'eradicating'=> 'eradicated_at',
'recovering' => 'recovered_at',
];
$col = $stamp[$updates['status']] ?? null;
if ($col !== null && empty($incident[$col])) {
$updates[$col] = $now;
}
if ($updates['status'] === 'closed') {
$updates['closed_at'] = $now;
if (empty($incident['recovered_at'])) $updates['recovered_at'] = $now;
}
}
// Se diventa significativo, calcola scadenze
@@ -392,4 +420,127 @@ class IncidentController extends BaseController
$this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR');
}
}
/**
* GET /api/incidents/{id}/metrics
* Calcola TTD/TTC/TTR e downtime dai timestamp di fase (in minuti).
*/
public function metrics(int $id): void
{
$this->requireOrgAccess();
$inc = Database::fetchOne(
'SELECT detected_at, triaged_at, contained_at, eradicated_at, recovered_at, closed_at, affected_users_count
FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$inc) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$this->jsonSuccess($this->computeMetrics($inc));
}
/** Differenza in minuti tra due datetime, null se mancante. */
private function minutesBetween(?string $from, ?string $to): ?int
{
if (empty($from) || empty($to)) return null;
$a = strtotime($from); $b = strtotime($to);
if ($a === false || $b === false) return null;
return (int) round(($b - $a) / 60);
}
private function computeMetrics(array $inc): array
{
$det = $inc['detected_at'] ?? null;
return [
'ttd_minutes' => $this->minutesBetween($det, $inc['triaged_at'] ?? null),
'ttc_minutes' => $this->minutesBetween($det, $inc['contained_at'] ?? null),
'ttr_minutes' => $this->minutesBetween($det, $inc['recovered_at'] ?? null),
'downtime_minutes' => $this->minutesBetween($det, $inc['recovered_at'] ?? $inc['closed_at'] ?? null),
'affected_users' => isset($inc['affected_users_count']) ? (int) $inc['affected_users_count'] : null,
];
}
/**
* GET /api/incidents/{id}/pir
* Ritorna la Post-Incident Review (RC.CO-03) con le metriche calcolate.
*/
public function getPir(int $id): void
{
$this->requireOrgAccess();
$inc = Database::fetchOne(
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$inc) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$pir = Database::fetchOne('SELECT * FROM incident_pir WHERE incident_id = ?', [$id]);
if ($pir && !empty($pir['improvement_actions'])) {
$pir['improvement_actions'] = json_decode($pir['improvement_actions'], true);
}
$this->jsonSuccess([
'pir' => $pir,
'metrics' => $this->computeMetrics($inc),
'reference' => 'RC.CO-03 (NIST CSF) - PIR da completare entro 2 settimane dalla chiusura per incidenti critici',
]);
}
/**
* POST /api/incidents/{id}/pir
* Crea o aggiorna la Post-Incident Review (upsert).
*/
public function savePir(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$inc = Database::fetchOne(
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$inc) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$m = $this->computeMetrics($inc);
$actions = $this->getParam('improvement_actions');
$fields = [
'organization_id' => $this->getCurrentOrgId(),
'problem_statement' => $this->getParam('problem_statement'),
'why_1' => $this->getParam('why_1'), 'why_2' => $this->getParam('why_2'),
'why_3' => $this->getParam('why_3'), 'why_4' => $this->getParam('why_4'),
'why_5' => $this->getParam('why_5'),
'root_cause' => $this->getParam('root_cause'),
'ttd_minutes' => $m['ttd_minutes'],
'ttc_minutes' => $m['ttc_minutes'],
'ttr_minutes' => $m['ttr_minutes'],
'downtime_minutes' => $m['downtime_minutes'],
'affected_users' => $m['affected_users'],
'estimated_cost_eur' => $this->getParam('estimated_cost_eur'),
'notification_compliance' => $this->getParam('notification_compliance') !== null ? (int)(bool)$this->getParam('notification_compliance') : null,
'what_went_well' => $this->getParam('what_went_well'),
'what_to_improve' => $this->getParam('what_to_improve'),
'improvement_actions' => is_array($actions) ? json_encode($actions, JSON_UNESCAPED_UNICODE) : null,
'participants' => $this->getParam('participants'),
'reviewed_by' => $this->getCurrentUserId(),
'reviewed_at' => date('Y-m-d H:i:s'),
'status' => $this->getParam('status', 'draft'),
];
$existing = Database::fetchOne('SELECT id FROM incident_pir WHERE incident_id = ?', [$id]);
if ($existing) {
Database::update('incident_pir', $fields, 'incident_id = ?', [$id]);
$pirId = (int) $existing['id'];
} else {
$fields['incident_id'] = $id;
$pirId = Database::insert('incident_pir', $fields);
}
// Se la root cause e' definita, allineala anche all'incidente (campo legacy)
if (!empty($fields['root_cause'])) {
Database::update('incidents', ['root_cause' => $fields['root_cause']], 'id = ?', [$id]);
}
$this->logAudit('incident_pir_saved', 'incident', $id, ['pir_id' => $pirId, 'status' => $fields['status']]);
$this->jsonSuccess(['pir_id' => $pirId, 'metrics' => $m], 'Post-Incident Review salvata');
}
}