From 21909994c2d2c927bd372ec3e472ec6d3fb03b74 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 30 May 2026 08:41:57 +0200 Subject: [PATCH] [FEAT] Ingestion incidenti SIEM/SOC/EDR (P1) -> endpoint /services/incidents-ingest - ServicesController::ingestIncident: crea incidente Art.23 da alert esterno (scope ingest:incidents) - Dedup su external_ref (org+ref), mapSeverity (CVSS/P1-P5/stringhe -> enum) - Classificazione AI best-effort (classifyIncident: IS-1..4, severity, significativita) - Deadline Art.23 (24h/72h/30g) su incidenti significativi + webhook dispatch - Migrazione 023: incidents += source/source_system/external_ref + indice univoco dedup - Route POST:incidentsIngest in index.php Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/ServicesController.php | 216 ++++++++++++++++++ docs/sql/023_incident_ingestion.sql | 27 +++ public/index.php | 1 + 3 files changed, 244 insertions(+) create mode 100644 docs/sql/023_incident_ingestion.sql diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index a8ae600..0fddd94 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -2185,4 +2185,220 @@ class ServicesController extends BaseController 'deadlines' => $deadlines, ]); } + + // ══════════════════════════════════════════════════════════════════════ + // INGESTION INCIDENTI (P1) — apertura automatica incidenti Art.23 da + // alert SIEM/SOC/EDR. Scope: ingest:incidents. Dedup su external_ref. + // ══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/services/incidents-ingest + * Auth: X-API-Key con scope ingest:incidents + * Body JSON (alert esterno): + * { + * "external_id": "splunk-8842", // per dedup (consigliato) + * "source": "siem|soc|edr|api|email", + * "source_system": "Splunk Enterprise", + * "title": "Brute force su VPN gateway", + * "description": "...", + * "severity": "high" | 8 | "P1", // mappata su low/medium/high/critical + * "detected_at": "2026-05-30T05:00:00Z", + * "classification": "cyber_attack", // opzionale (altrimenti AI) + * "ai_classify": true, // default true + * "affected_services": "...", "affected_users_count": 0, + * "cross_border_impact": false, "malicious_action": true + * } + */ + public function ingestIncident(): void + { + $this->requireApiKey('ingest:incidents'); + $orgId = $this->currentOrgId; + $body = $this->getJsonBody(); + + $title = trim((string) ($body['title'] ?? '')); + if ($title === '') { + $this->jsonError('Campo "title" obbligatorio', 422, 'VALIDATION'); + } + + // Sorgente + $source = strtolower((string) ($body['source'] ?? 'api')); + if (!in_array($source, ['siem', 'soc', 'edr', 'api', 'email'], true)) { + $source = 'api'; + } + $sourceSystem = isset($body['source_system']) + ? substr(trim((string) $body['source_system']), 0, 120) : null; + + // Dedup su external_id (org + ref) + $externalId = isset($body['external_id']) ? substr(trim((string) $body['external_id']), 0, 190) : ''; + if ($externalId !== '') { + $existing = Database::fetchOne( + 'SELECT id, incident_code FROM incidents WHERE organization_id = ? AND external_ref = ?', + [$orgId, $externalId] + ); + if ($existing) { + $this->jsonSuccess([ + 'id' => (int) $existing['id'], + 'incident_code' => $existing['incident_code'], + 'deduplicated' => true, + ], 'Alert già ingerito (dedup su external_id)', 200); + return; + } + } + $externalRef = $externalId !== '' ? $externalId : null; + + $description = trim((string) ($body['description'] ?? '')); + $ts = strtotime((string) ($body['detected_at'] ?? 'now')); + $detectedAt = date('Y-m-d H:i:s', $ts ?: time()); + + $validClass = ['cyber_attack', 'data_breach', 'system_failure', 'human_error', 'natural_disaster', 'supply_chain', 'other']; + $validSev = ['low', 'medium', 'high', 'critical']; + + $classification = in_array(($body['classification'] ?? ''), $validClass, true) ? $body['classification'] : null; + $severity = $this->mapSeverity($body['severity'] ?? null); + $isSignificant = array_key_exists('is_significant', $body) ? (bool) $body['is_significant'] : null; + $isType = null; + $aiUsed = false; + $aiReason = null; + $org = null; + + // Classificazione AI (best-effort) se mancano dati chiave + $aiClassify = $body['ai_classify'] ?? true; + if ($aiClassify && ($classification === null || $severity === null || $isSignificant === null)) { + try { + require_once __DIR__ . '/../services/AIService.php'; + $org = Database::fetchOne('SELECT sector, entity_type FROM organizations WHERE id = ?', [$orgId]); + $ai = (new AIService())->classifyIncident($title, $description ?: $title, $org ?: []); + if (is_array($ai)) { + $aiUsed = true; + if ($classification === null && in_array(($ai['classification'] ?? ''), $validClass, true)) { + $classification = $ai['classification']; + } + if ($severity === null && in_array(($ai['severity'] ?? ''), $validSev, true)) { + $severity = $ai['severity']; + } + if ($isSignificant === null && isset($ai['is_significant'])) { + $isSignificant = (bool) $ai['is_significant']; + } + if (!empty($ai['nis2_incident_type']) && $ai['nis2_incident_type'] !== 'none') { + $isType = $ai['nis2_incident_type']; + } + $aiReason = $ai['significance_reason'] ?? null; + } + } catch (Throwable $e) { + error_log('[INGEST] AI classify error: ' . $e->getMessage()); + } + } + + // Default sensati per alert da sistemi di sicurezza + if ($classification === null) { + $classification = in_array($source, ['siem', 'soc', 'edr'], true) ? 'cyber_attack' : 'other'; + } + if ($severity === null) $severity = 'medium'; + if ($isSignificant === null) $isSignificant = false; + + // Regime obblighi NIS2 (Determina ACN 164179/2025) — coerente con IncidentController::create + if ($org === null) { + $org = Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$orgId]); + } + $entityObligation = ($org && ($org['entity_type'] ?? '') === 'essential') ? 'essential' : 'important'; + $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' => $orgId, + 'incident_code' => $this->generateCode('INC'), + 'title' => $title, + 'description' => $description ?: null, + 'classification' => $classification, + 'source' => $source, + 'source_system' => $sourceSystem, + 'external_ref' => $externalRef, + 'severity' => $severity, + 'is_significant' => $isSignificant ? 1 : 0, + 'nis2_incident_type' => $isType, + 'entity_obligation' => $entityObligation, + 'detected_at' => $detectedAt, + 'affected_services' => $body['affected_services'] ?? null, + 'affected_users_count' => isset($body['affected_users_count']) ? (int) $body['affected_users_count'] : null, + 'cross_border_impact' => !empty($body['cross_border_impact']) ? 1 : 0, + 'malicious_action' => !empty($body['malicious_action']) ? 1 : (in_array($source, ['siem', 'soc', 'edr'], true) ? 1 : 0), + 'reported_by' => null, + 'assigned_to' => null, + ]; + + if ($isSignificant) { + $t = strtotime($detectedAt); + $data['early_warning_due'] = date('Y-m-d H:i:s', $t + 24 * 3600); + $data['notification_due'] = date('Y-m-d H:i:s', $t + 72 * 3600); + $data['final_report_due'] = date('Y-m-d H:i:s', $t + 30 * 86400); + } + + $incidentId = Database::insert('incidents', $data); + + Database::insert('incident_timeline', [ + 'incident_id' => $incidentId, + 'event_type' => 'detection', + 'description' => 'Incidente ingerito automaticamente da ' . strtoupper($source) + . ($sourceSystem ? " ({$sourceSystem})" : '') + . ($aiReason ? '. Classificazione AI: ' . $aiReason : ''), + 'created_by' => null, + ]); + + // Dispatch webhook (best-effort) + try { + require_once __DIR__ . '/../services/WebhookService.php'; + $incident = array_merge($data, ['id' => $incidentId]); + $wh = new WebhookService(); + $wh->dispatch($orgId, 'incident.created', WebhookService::incidentPayload($incident, 'created')); + if ($isSignificant) { + $wh->dispatch($orgId, 'incident.significant', WebhookService::incidentPayload($incident, 'significant')); + } + } catch (Throwable $e) { + error_log('[INGEST][WEBHOOK] ' . $e->getMessage()); + } + + $this->jsonSuccess([ + 'id' => $incidentId, + 'incident_code' => $data['incident_code'], + 'deduplicated' => false, + 'source' => $source, + 'classification' => $classification, + 'severity' => $severity, + 'is_significant' => $isSignificant, + 'ai_classified' => $aiUsed, + 'deadlines' => $isSignificant ? [ + 'early_warning' => $data['early_warning_due'], + 'notification' => $data['notification_due'], + 'final_report' => $data['final_report_due'], + ] : null, + ], 'Incidente ingerito', 201); + } + + /** + * Normalizza la severity di un alert esterno → enum incidents. + * Accetta stringhe (high/critical/P1/sev2/warning…) o numeri (CVSS 0-10, scala 1-5). + */ + private function mapSeverity($val): ?string + { + if ($val === null || $val === '') { + return null; + } + if (is_numeric($val)) { + $n = (float) $val; + if ($n <= 5) { // scala 1-5 + return $n >= 4 ? 'critical' : ($n >= 3 ? 'high' : ($n >= 2 ? 'medium' : 'low')); + } + return $n >= 9 ? 'critical' : ($n >= 7 ? 'high' : ($n >= 4 ? 'medium' : 'low')); // CVSS 0-10 + } + $s = strtolower(trim((string) $val)); + $map = [ + 'critical' => 'critical', 'crit' => 'critical', 'sev1' => 'critical', 'p1' => 'critical', 'emergency' => 'critical', 'fatal' => 'critical', + 'high' => 'high', 'severe' => 'high', 'sev2' => 'high', 'p2' => 'high', 'error' => 'high', + 'medium' => 'medium', 'moderate' => 'medium', 'warning' => 'medium', 'warn' => 'medium', 'sev3' => 'medium', 'p3' => 'medium', + 'low' => 'low', 'info' => 'low', 'informational' => 'low', 'notice' => 'low', 'sev4' => 'low', 'p4' => 'low', 'p5' => 'low', + ]; + return $map[$s] ?? null; + } } diff --git a/docs/sql/023_incident_ingestion.sql b/docs/sql/023_incident_ingestion.sql new file mode 100644 index 0000000..6d10494 --- /dev/null +++ b/docs/sql/023_incident_ingestion.sql @@ -0,0 +1,27 @@ +-- ============================================================ +-- Migration 023: Ingestion incidenti SIEM/SOC/EDR (P1 gap competitivo) +-- Aggiunge tracciamento sorgente + riferimento esterno (dedup) su incidents +-- Idempotente: usa procedura per ADD COLUMN IF NOT EXISTS +-- ============================================================ + +DELIMITER $$ +DROP PROCEDURE IF EXISTS add_col_023 $$ +CREATE PROCEDURE add_col_023() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='incidents' AND COLUMN_NAME='source') THEN + ALTER TABLE incidents ADD COLUMN source ENUM('manual','siem','soc','edr','api','email') NOT NULL DEFAULT 'manual' COMMENT 'Origine incidente (ingestion automatica vs manuale)' AFTER classification; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='incidents' AND COLUMN_NAME='source_system') THEN + ALTER TABLE incidents ADD COLUMN source_system VARCHAR(120) NULL COMMENT 'Nome sistema sorgente (es. Splunk, Sentinel, CrowdStrike)' AFTER source; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='incidents' AND COLUMN_NAME='external_ref') THEN + ALTER TABLE incidents ADD COLUMN external_ref VARCHAR(190) NULL COMMENT 'ID alert esterno (dedup ingestion)' AFTER source_system; + END IF; + -- Indice univoco per dedup ingestion (org + ref esterno). NULL ammessi multipli (incidenti manuali). + IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME='incidents' AND INDEX_NAME='uq_incident_external_ref') THEN + ALTER TABLE incidents ADD UNIQUE KEY uq_incident_external_ref (organization_id, external_ref); + END IF; +END $$ +DELIMITER ; +CALL add_col_023(); +DROP PROCEDURE IF EXISTS add_col_023; diff --git a/public/index.php b/public/index.php index 908d9a3..f0a3ae0 100644 --- a/public/index.php +++ b/public/index.php @@ -348,6 +348,7 @@ $actionMap = [ 'GET:complianceSummary' => 'complianceSummary', 'GET:risksFeed' => 'risksFeed', 'GET:incidentsFeed' => 'incidentsFeed', + 'POST:incidentsIngest' => 'ingestIncident', // P1: ingestion alert SIEM/SOC/EDR 'GET:controlsStatus' => 'controlsStatus', 'GET:assetsCritical' => 'assetsCritical', 'GET:suppliersRisk' => 'suppliersRisk',