[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:
co-authored by
Claude Opus 4.8
parent
a7a21faa82
commit
5c545ea3d0
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user