[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
+75
View File
@@ -0,0 +1,75 @@
<?php
/**
* NIS2 Agile - Registro Fonti Normative Certe
* ----------------------------------------------------------------------------
* SINGLE SOURCE OF TRUTH per le fonti normative autoritative citabili.
*
* Principio (richiesta utente 2026-05-29): ogni scelta/risposta della
* piattaforma — sia nell'AI sia nell'help — DEVE riferirsi a fonti certe e
* citarle esplicitamente. Vietato inventare riferimenti normativi.
*
* I PDF originali risiedono in docs/nis2/ (repo) e sono ingeriti nella
* Knowledge Base (collection Qdrant nis2_kb, scope SYSTEM) per il grounding RAG.
*
* Usato da:
* - AIService (iniezione nei system prompt: "cita queste fonti")
* - public/js/help.js (riferimenti normativi nell'help contestuale, via /api/... o statico)
* - scripts/ingest-nis2-sources.php (ingest PDF -> KB)
*/
return [
'nis2_directive' => [
'key' => 'nis2_directive',
'short' => 'Direttiva NIS2',
'citation' => 'Direttiva (UE) 2022/2555 (NIS2)',
'full' => 'Direttiva (UE) 2022/2555 del Parlamento europeo e del Consiglio del 14 dicembre 2022 relativa a misure per un livello comune elevato di cibersicurezza nell\'Unione (NIS2)',
'file' => 'docs/nis2/Dir2022_2555_UE_NIS2_ITA.pdf',
'authority' => 'Parlamento europeo e Consiglio UE',
'url' => 'https://eur-lex.europa.eu/legal-content/IT/TXT/?uri=CELEX:32022L2555',
],
'cer_directive' => [
'key' => 'cer_directive',
'short' => 'Direttiva CER',
'citation' => 'Direttiva (UE) 2022/2557 (CER)',
'full' => 'Direttiva (UE) 2022/2557 del Parlamento europeo e del Consiglio del 14 dicembre 2022 relativa alla resilienza dei soggetti critici (Critical Entities Resilience)',
'file' => 'docs/nis2/Dir2022_2557_UE_ITA.pdf',
'authority' => 'Parlamento europeo e Consiglio UE',
'url' => 'https://eur-lex.europa.eu/legal-content/IT/TXT/?uri=CELEX:32022L2557',
],
'dlgs_138_2024' => [
'key' => 'dlgs_138_2024',
'short' => 'D.Lgs. 138/2024',
'citation' => 'D.Lgs. 4 settembre 2024, n. 138',
'full' => 'Decreto legislativo 4 settembre 2024, n. 138 - Recepimento della direttiva (UE) 2022/2555 (decreto NIS)',
'file' => null,
'authority' => 'Repubblica Italiana',
'url' => 'https://www.gazzettaufficiale.it/eli/id/2024/09/01/24G00155/sg',
],
'determina_164179_2025' => [
'key' => 'determina_164179_2025',
'short' => 'Determina ACN 164179/2025',
'citation' => 'Determinazione ACN n. 164179 del 14 aprile 2025',
'full' => 'Determinazione del Direttore generale dell\'Agenzia per la Cybersicurezza Nazionale n. 164179 del 14 aprile 2025, in attuazione del D.Lgs. 138/2024, che stabilisce modalita e specifiche di base per gli obblighi di cui agli articoli 23, 24, 25, 29 e 32 (incluse classificazione e notifica degli incidenti significativi, Allegati 3 e 4).',
'file' => 'docs/nis2/Determina164179_apr2025.pdf',
'authority' => 'Agenzia per la Cybersicurezza Nazionale (ACN)',
'url' => 'https://www.acn.gov.it/',
],
'determina_333017_2025' => [
'key' => 'determina_333017_2025',
'short' => 'Determina ACN 333017/2025',
'citation' => 'Determinazione ACN n. 333017 del settembre 2025',
'full' => 'Determinazione del Direttore generale ACN n. 333017 (settembre 2025): termini, modalita e procedimenti di utilizzo e accesso alla piattaforma digitale ACN, ulteriori informazioni che i soggetti devono fornire e designazione dei rappresentanti NIS sul territorio nazionale.',
'file' => 'docs/nis2/Determina333017_sett2025.pdf',
'authority' => 'Agenzia per la Cybersicurezza Nazionale (ACN)',
'url' => 'https://www.acn.gov.it/',
],
'ambiti_nis2' => [
'key' => 'ambiti_nis2',
'short' => 'Ambiti NIS2 (Allegati I/II)',
'citation' => 'Allegati I e II - Settori NIS2',
'full' => 'Ambiti di applicazione NIS2 - Allegato I (Settori ad alta criticita: energia, trasporti, bancario, infrastrutture dei mercati finanziari, sanitario, acqua potabile, acque reflue, infrastrutture digitali, gestione servizi TIC B2B, PA, spazio) e Allegato II (Altri settori critici).',
'file' => 'docs/nis2/AmbitiNIS2_ITA.pdf',
'authority' => 'ACN / Allegati al D.Lgs. 138/2024',
'url' => 'https://www.acn.gov.it/',
],
];
+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');
}
}
+55 -5
View File
@@ -28,6 +28,32 @@ class AIService
}
}
/**
* Blocco "fonti certe" da iniettare nei system prompt.
* Elenca le fonti normative autoritative e impone di citarle, vietando
* riferimenti inventati. (Richiesta utente 2026-05-29 - grounding su fonti certe.)
*/
private function authoritativeSourcesBlock(): string
{
static $sources = null;
if ($sources === null) {
$sources = @include __DIR__ . '/../config/nis2_sources.php';
if (!is_array($sources)) $sources = [];
}
if (empty($sources)) return '';
$lines = [];
foreach ($sources as $s) {
$lines[] = '- ' . $s['citation'] . ' — ' . $s['authority'];
}
return "\n## FONTI NORMATIVE CERTE (cita SEMPRE quella pertinente)\n"
. implode("\n", $lines)
. "\n\nREGOLE SULLE FONTI (vincolanti):\n"
. "1. Ogni affermazione normativa DEVE essere ancorata a una di queste fonti, citata esplicitamente (es. \"ai sensi dell'art. 23 della Direttiva (UE) 2022/2555\" o \"Determinazione ACN n. 164179/2025, Allegato 3\").\n"
. "2. NON inventare numeri di articolo, determine, allegati o date: se non sei certo, dichiaralo e invita a verificare la fonte ufficiale.\n"
. "3. Preferisci sempre il riferimento normativo italiano (D.Lgs. 138/2024 + Determine ACN) per gli obblighi operativi, e la Direttiva UE per i principi.\n";
}
/**
* Analizza risultati gap analysis e genera raccomandazioni
*/
@@ -162,8 +188,23 @@ PROMPT;
*/
public function classifyIncident(string $title, string $description, array $organization): array
{
$entityType = $organization['entity_type'] ?? 'important';
// Allegato 3 (soggetti essenziali) vs Allegato 4 (soggetti importanti):
// gli importanti NON hanno l'obbligo sugli incidenti ricorrenti (IS-4).
$isEssential = ($entityType === 'essential');
$allowedIs = $isEssential ? 'IS-1|IS-2|IS-3|IS-4' : 'IS-1|IS-2|IS-3';
$allegato = $isEssential ? 'Allegato 3 (soggetti essenziali)' : 'Allegato 4 (soggetti importanti)';
$sourcesBlock = $this->authoritativeSourcesBlock();
$prompt = <<<PROMPT
Sei un analista di incident response. Classifica il seguente incidente di sicurezza secondo i criteri NIS2.
Sei un analista di incident response NIS2. Classifica il seguente incidente secondo il quadro normativo italiano.
## Quadro normativo di riferimento (cita la fonte pertinente in ogni campo motivazionale)
- Obbligo di notifica: art. 23 D.Lgs. 138/2024 e Direttiva (UE) 2022/2555.
- Classificazione incidenti significativi e tempistiche: Determinazione ACN n. 164179/2025, {$allegato}.
- Tempistiche: preallarme entro 24h, notifica completa entro 72h, relazione finale entro 1 mese dalla conoscenza dell'incidente significativo.
- Tipologie significative applicabili a questo soggetto ({$entityType}): {$allowedIs}.
{$sourcesBlock}
## Incidente
- Titolo: {$title}
@@ -171,21 +212,23 @@ Sei un analista di incident response. Classifica il seguente incidente di sicure
## Organizzazione
- Settore: {$organization['sector']}
- Tipo entità: {$organization['entity_type']}
- Tipo entità: {$entityType}
Rispondi in formato JSON:
{
"classification": "cyber_attack|data_breach|system_failure|human_error|natural_disaster|supply_chain|other",
"nis2_incident_type": "{$allowedIs}|none",
"severity": "low|medium|high|critical",
"is_significant": true/false,
"significance_reason": "Motivo se significativo secondo NIS2",
"significance_reason": "Motivo con citazione esplicita della fonte (es. Determina ACN 164179/2025, {$allegato})",
"requires_csirt_notification": true/false,
"notification_basis": "Riferimento normativo dell'obbligo (es. art. 23 D.Lgs. 138/2024)",
"suggested_actions": ["Azione immediata 1", "Azione immediata 2"],
"potential_impact": "Descrizione impatto potenziale",
"iocs_to_check": ["Indicatore 1", "Indicatore 2"]
}
Rispondi SOLO con il JSON.
Non inventare riferimenti: usa solo le fonti elencate sopra. Rispondi SOLO con il JSON.
PROMPT;
$response = $this->callAPI($prompt);
@@ -219,6 +262,12 @@ Rispondi sempre in italiano, in modo professionale, preciso e conciso.
Non includere dati identificativi dell'organizzazione nelle risposte.
SYSTEM;
// Grounding su fonti certe: applicato solo al prompt di default
// (i system prompt espliciti gestiscono le fonti per conto proprio).
if ($systemPrompt === null) {
$system .= "\n" . $this->authoritativeSourcesBlock();
}
$body = [
'model' => $this->model,
'max_tokens' => $this->maxTokens,
@@ -602,7 +651,8 @@ PROMPT;
}
$systemPrompt = "Sei un esperto consulente di cybersecurity NIS2 (EU 2022/2555) e D.Lgs. 138/2024.\n"
. "Rispondi in modo preciso e cita le fonti del contesto quando rilevanti.\n";
. "Rispondi in modo preciso e cita le fonti del contesto quando rilevanti.\n"
. $this->authoritativeSourcesBlock();
if (!empty($contextBlock)) {
$systemPrompt .= "\n## Contesto documentale (knowledge base)\n" . $contextBlock
. "\n\nQuando rispondi, cita esplicitamente i numeri tra parentesi quadre [1], [2], ... che corrispondono ai documenti del contesto.";
@@ -0,0 +1,202 @@
<?php
/**
* NIS2 Agile - Asset Scoring Service
*
* Metodologia di scoring rilevanza NIS2 (requisito GV.OC-04).
* Adattata dai mockup docs/nis2/assets.html + doc-relevant-systems.html.
*
* 6 criteri pesati, punteggio 0-100:
* C1 Criticita Operativa 0-25
* C2 Impatto Interruzione 0-25
* C3 Dati Trattati 0-20
* C4 Dipendenze 0-15
* C5 Esposizione 0-10
* C6 Obblighi Normativi 0-5
*
* Soglia rilevanza NIS2: score >= 40.
* Classi: >=80 critico | 60-79 alto | 40-59 medio | 20-39 basso | <20 trascurabile.
*
* La logica e' PURA (nessun side effect / DB): si presta a unit test e riuso.
*/
class AssetScoringService
{
public const RELEVANCE_THRESHOLD = 40;
/**
* Griglia ufficiale: per ogni criterio, la lista di opzioni selezionabili
* (value => punti) con label per la UI. value e' una chiave stabile usata
* dal frontend e salvata in relevance_criteria JSON.
*/
public const GRID = [
'c1_operational_criticality' => [
'label' => 'Criticita Operativa',
'max' => 25,
'help' => 'Quanto il sistema e essenziale per l\'erogazione dei servizi core business.',
'options' => [
'critical' => ['label' => 'Critico', 'points' => 25],
'very_high' => ['label' => 'Molto Alto', 'points' => 20],
'high' => ['label' => 'Alto', 'points' => 15],
'medium' => ['label' => 'Medio', 'points' => 10],
'low' => ['label' => 'Basso', 'points' => 5],
'negligible' => ['label' => 'Trascurabile', 'points' => 0],
],
],
'c2_disruption_impact' => [
'label' => 'Impatto Interruzione',
'max' => 25,
'help' => 'Conseguenze di un\'interruzione in termini di durata e utenti impattati.',
'options' => [
'gt24h_gt70' => ['label' => '>24h + >70% utenti', 'points' => 25],
'h8_24_50_70' => ['label' => '8-24h + 50-70% utenti', 'points' => 20],
'h4_8_30_50' => ['label' => '4-8h + 30-50% utenti', 'points' => 15],
'h1_4_10_30' => ['label' => '1-4h + 10-30% utenti', 'points' => 10],
'lt1h_lt10' => ['label' => '<1h + <10% utenti', 'points' => 5],
'none' => ['label' => 'Nessun impatto', 'points' => 0],
],
],
'c3_data_processed' => [
'label' => 'Dati Trattati',
'max' => 20,
'help' => 'Sensibilita e criticita dei dati gestiti dal sistema.',
'options' => [
'gdpr_art9' => ['label' => 'Dati Sensibili Art.9 GDPR', 'points' => 20],
'personal_large' => ['label' => 'Dati Personali larga scala', 'points' => 15],
'personal_fin' => ['label' => 'Dati Personali + Finanziari', 'points' => 10],
'confidential' => ['label' => 'Dati Aziendali Riservati', 'points' => 5],
'public' => ['label' => 'Dati Pubblici', 'points' => 0],
],
],
'c4_dependencies' => [
'label' => 'Dipendenze',
'max' => 15,
'help' => 'Quanti altri sistemi critici dipendono da questo sistema.',
'options' => [
'ge5_critical' => ['label' => '>=5 sistemi critici', 'points' => 15],
'n3_4_critical' => ['label' => '3-4 sistemi critici', 'points' => 12],
'n2_critical' => ['label' => '2 sistemi critici', 'points' => 9],
'n1_critical' => ['label' => '1 sistema critico', 'points' => 6],
'noncritical' => ['label' => '1-2 sistemi non critici', 'points' => 3],
'none' => ['label' => 'Nessuna dipendenza', 'points' => 0],
],
],
'c5_exposure' => [
'label' => 'Esposizione',
'max' => 10,
'help' => 'Superficie di attacco ed esposizione del sistema.',
'options' => [
'internet_no_mfa' => ['label' => 'Internet pubblico senza MFA', 'points' => 10],
'internet_mfa' => ['label' => 'Internet con MFA', 'points' => 8],
'partner_net' => ['label' => 'Reti partner/fornitori', 'points' => 6],
'intranet' => ['label' => 'Rete aziendale intranet', 'points' => 4],
'mgmt_isolated' => ['label' => 'Rete gestione isolata', 'points' => 2],
'air_gapped' => ['label' => 'Completamente isolato', 'points' => 0],
],
],
'c6_regulatory' => [
'label' => 'Obblighi Normativi',
'max' => 5,
'help' => 'Se il sistema e soggetto a obblighi normativi o contrattuali specifici.',
'options' => [
'nis2_required' => ['label' => 'Richiesto da NIS2', 'points' => 5],
'mandatory_cert' => ['label' => 'Certificazioni obbligatorie', 'points' => 4],
'strict_sla' => ['label' => 'Obblighi SLA stringenti', 'points' => 3],
'external_audit' => ['label' => 'Audit esterni regolari', 'points' => 2],
'none' => ['label' => 'Nessun obbligo', 'points' => 0],
],
],
];
/**
* Calcola lo score a partire dalle selezioni dell'utente.
*
* @param array $criteria mappa criterioKey => optionValue
* es. ['c1_operational_criticality' => 'critical', ...]
* @return array{score:int, class:string, is_relevant:bool, breakdown:array, criticality:string}
* @throws InvalidArgumentException se un criterio/opzione non e valido
*/
public static function calculate(array $criteria): array
{
$score = 0;
$breakdown = [];
foreach (self::GRID as $key => $def) {
if (!array_key_exists($key, $criteria)) {
throw new InvalidArgumentException("Criterio mancante: {$key}");
}
$optVal = $criteria[$key];
if (!isset($def['options'][$optVal])) {
throw new InvalidArgumentException("Opzione non valida '{$optVal}' per criterio {$key}");
}
$pts = $def['options'][$optVal]['points'];
$score += $pts;
$breakdown[$key] = [
'value' => $optVal,
'label' => $def['options'][$optVal]['label'],
'points' => $pts,
'max' => $def['max'],
];
}
$class = self::classify($score);
return [
'score' => $score,
'class' => $class,
'is_relevant' => $score >= self::RELEVANCE_THRESHOLD,
'breakdown' => $breakdown,
// mapping verso l'enum legacy assets.criticality per coerenza UI esistente
'criticality' => self::toLegacyCriticality($score),
];
}
/** Classe testuale secondo le soglie ufficiali. */
public static function classify(int $score): string
{
if ($score >= 80) return 'critico';
if ($score >= 60) return 'alto';
if ($score >= 40) return 'medio';
if ($score >= 20) return 'basso';
return 'trascurabile';
}
/** Allinea lo score all'enum assets.criticality preesistente (low/medium/high/critical). */
public static function toLegacyCriticality(int $score): string
{
if ($score >= 80) return 'critical';
if ($score >= 60) return 'high';
if ($score >= 40) return 'medium';
return 'low';
}
/** Misure obbligatorie associate alla classe (per report GV.OC-04 / UI). */
public static function requiredMeasures(string $class): array
{
return [
'critico' => [
'Monitoraggio continuo 24/7 (SIEM/SOC)',
'Backup immutabile con test ripristino periodico',
'MFA obbligatoria + accessi privilegiati controllati (PAM)',
'Inclusione obbligatoria in BIA e piano di continuita',
'Test di vulnerabilita/penetration test almeno annuali',
],
'alto' => [
'Monitoraggio in orario lavorativo + alerting',
'Backup regolari con verifica integrita',
'MFA per accessi remoti',
'Inclusione in risk assessment ciclico',
],
'medio' => [
'Logging centralizzato',
'Backup periodici',
'Patch management documentato',
],
'basso' => [
'Inventario aggiornato',
'Patch management standard',
],
'trascurabile' => [
'Censimento in inventario asset',
],
][$class] ?? [];
}
}
+112
View File
@@ -255,6 +255,118 @@ class ReportService
* @param int $orgId ID dell'organizzazione
* @return string Documento HTML completo
*/
/**
* Registro formale "Elenco Sistemi Rilevanti NIS2" (requisito GV.OC-04).
* HTML stampabile (PDF via stampa browser). Cita le fonti normative certe.
* Adattato dal mockup docs/nis2/doc-relevant-systems.html.
*/
public function generateRelevantSystemsRegister(int $orgId): string
{
$org = Database::fetchOne('SELECT name, sector, entity_type FROM organizations WHERE id = ?', [$orgId]);
if (!$org) {
return $this->buildErrorHtml('Organizzazione non trovata');
}
$systems = Database::fetchAll(
"SELECT a.name, a.asset_type, a.category, a.ip_address, a.location,
a.relevance_score, a.relevance_class, 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",
[$orgId]
);
$orgName = htmlspecialchars($org['name'] ?? '', ENT_QUOTES, 'UTF-8');
$entity = htmlspecialchars($org['entity_type'] ?? '', ENT_QUOTES, 'UTF-8');
$sector = htmlspecialchars($org['sector'] ?? '', ENT_QUOTES, 'UTF-8');
$date = date('d/m/Y');
$appName = defined('APP_NAME') ? APP_NAME : 'NIS2 Agile';
$appVer = defined('APP_VERSION') ? APP_VERSION : '1.0.0';
$classColors = ['critico' => '#dc2626', 'alto' => '#ea580c', 'medio' => '#ca8a04'];
$counts = ['critico' => 0, 'alto' => 0, 'medio' => 0];
$rowsHtml = '';
if (empty($systems)) {
$rowsHtml = '<tr><td colspan="7" style="text-align:center;color:#888;padding:24px;">Nessun sistema classificato come rilevante (score &ge; 40). Eseguire la valutazione di rilevanza dal modulo Inventario Asset.</td></tr>';
} else {
$n = 0;
foreach ($systems as $s) {
$n++;
$cls = $s['relevance_class'] ?? 'medio';
if (isset($counts[$cls])) $counts[$cls]++;
$color = $classColors[$cls] ?? '#6b7280';
$rowsHtml .= '<tr>'
. '<td>' . $n . '</td>'
. '<td><strong>' . htmlspecialchars($s['name'], ENT_QUOTES, 'UTF-8') . '</strong></td>'
. '<td>' . htmlspecialchars(($s['asset_type'] ?? '') . ($s['category'] ? ' / ' . $s['category'] : ''), ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars($s['ip_address'] ?: '-', ENT_QUOTES, 'UTF-8') . '</td>'
. '<td>' . htmlspecialchars($s['owner_name'] ?: '-', ENT_QUOTES, 'UTF-8') . '</td>'
. '<td style="text-align:center;font-weight:700;">' . (int) $s['relevance_score'] . '/100</td>'
. '<td style="color:' . $color . ';font-weight:700;text-transform:uppercase;">' . htmlspecialchars($cls, ENT_QUOTES, 'UTF-8') . '</td>'
. '</tr>';
}
}
$total = count($systems);
return <<<HTML
<!DOCTYPE html>
<html lang="it"><head><meta charset="utf-8">
<title>Elenco Sistemi Rilevanti NIS2 - {$orgName}</title>
<style>
body{font-family:'Segoe UI',Arial,sans-serif;color:#1f2937;margin:40px;font-size:13px;}
.conf{background:#7f1d1d;color:#fff;text-align:center;padding:8px;font-weight:700;letter-spacing:1px;border-radius:6px;margin-bottom:18px;}
h1{font-size:22px;margin:0 0 4px;} h2{font-size:15px;border-bottom:2px solid #06b6d4;padding-bottom:4px;margin-top:28px;}
.meta{color:#6b7280;font-size:12px;margin-bottom:18px;}
table{width:100%;border-collapse:collapse;margin-top:10px;} th,td{border:1px solid #e5e7eb;padding:7px 9px;text-align:left;}
th{background:#f0f9ff;font-size:12px;} .stats{display:flex;gap:14px;margin:14px 0;}
.stat{flex:1;border:1px solid #e5e7eb;border-radius:8px;padding:10px;text-align:center;}
.stat .v{font-size:22px;font-weight:800;} .src{background:#f9fafb;border-left:4px solid #06b6d4;padding:10px 14px;font-size:11.5px;color:#374151;margin-top:24px;border-radius:0 6px 6px 0;}
.sign{margin-top:36px;display:flex;justify-content:space-between;} .sign div{width:45%;border-top:1px solid #9ca3af;padding-top:6px;font-size:12px;color:#4b5563;}
@media print{body{margin:12mm;} .noprint{display:none;}}
</style></head><body>
<div class="conf">&#9888; DOCUMENTO RISERVATO - DISTRIBUZIONE LIMITATA &#9888;</div>
<h1>{$orgName}</h1>
<h1 style="font-size:18px;color:#0e7490;">Elenco Formale dei Sistemi Rilevanti NIS2</h1>
<div class="meta">Documento ai sensi della Direttiva (UE) 2022/2555 e del D.Lgs. 138/2024 &mdash; Requisito GV.OC-04 (NIST CSF 2.0)<br>
Settore: {$sector} &nbsp;|&nbsp; Tipo soggetto: {$entity} &nbsp;|&nbsp; Data emissione: {$date} &nbsp;|&nbsp; Generato da {$appName} v{$appVer}</div>
<h2>1. Premessa e metodologia</h2>
<p>Il presente documento costituisce l'elenco formale dei sistemi informativi e di rete classificati come <strong>rilevanti</strong> ai fini della conformita alla Direttiva NIS2. La classificazione adotta una metodologia di scoring 0-100 su sei criteri (Criticita Operativa, Impatto Interruzione, Dati Trattati, Dipendenze, Esposizione, Obblighi Normativi). <strong>Soglia di rilevanza: punteggio &ge; 40.</strong> I sistemi con punteggio &ge; 80 sono considerati critici e richiedono misure di sicurezza massime e monitoraggio continuo.</p>
<div class="stats">
<div class="stat"><div class="v">{$total}</div>Sistemi rilevanti</div>
<div class="stat"><div class="v" style="color:#dc2626;">{$counts['critico']}</div>Critici (&ge;80)</div>
<div class="stat"><div class="v" style="color:#ea580c;">{$counts['alto']}</div>Alti (60-79)</div>
<div class="stat"><div class="v" style="color:#ca8a04;">{$counts['medio']}</div>Medi (40-59)</div>
</div>
<h2>2. Elenco sistemi rilevanti</h2>
<table>
<thead><tr><th>#</th><th>Sistema</th><th>Tipo/Categoria</th><th>IP</th><th>Responsabile</th><th>Punteggio</th><th>Classe</th></tr></thead>
<tbody>{$rowsHtml}</tbody>
</table>
<div class="src">
<strong>Fonti normative certe:</strong><br>
&bull; Direttiva (UE) 2022/2555 (NIS2) &mdash; Parlamento europeo e Consiglio UE<br>
&bull; D.Lgs. 4 settembre 2024, n. 138 &mdash; recepimento NIS2 (artt. 23, 24)<br>
&bull; NIST Cybersecurity Framework 2.0 &mdash; controllo GV.OC-04 (elenco sistemi rilevanti)
</div>
<div class="sign">
<div>Redatto da (CISO / Responsabile Compliance)</div>
<div>Approvato da (Organo di gestione / Direzione)</div>
</div>
<p class="noprint" style="margin-top:24px;"><button onclick="window.print()">Stampa / Salva PDF</button></p>
</body></html>
HTML;
}
public function generateExecutiveReport(int $orgId): string
{
$data = $this->generateComplianceReport($orgId);
+1 -1
View File
@@ -19,7 +19,7 @@ class VectorService
$url = getenv('QDRANT_URL')
?: ($_SERVER['QDRANT_URL'] ?? null)
?: ($_ENV['QDRANT_URL'] ?? null)
?: 'http://172.21.0.5:6333'; // IP nis2-qdrant nella docker_nis2-network
?: 'http://172.21.0.3:6333'; // IP nis2-qdrant (agg. 2026-05-29: era .5, container con IP dinamico driftato). TODO: assegnare ipv4_address statico in docker-compose per evitare ricorrenze.
$this->qdrantUrl = rtrim($url, '/');
$this->collection = $collection;
}