[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) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-30 08:41:57 +02:00
parent 094d453e8e
commit 21909994c2
3 changed files with 244 additions and 0 deletions

View File

@ -2185,4 +2185,220 @@ class ServicesController extends BaseController
'deadlines' => $deadlines, '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;
}
} }

View File

@ -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;

View File

@ -348,6 +348,7 @@ $actionMap = [
'GET:complianceSummary' => 'complianceSummary', 'GET:complianceSummary' => 'complianceSummary',
'GET:risksFeed' => 'risksFeed', 'GET:risksFeed' => 'risksFeed',
'GET:incidentsFeed' => 'incidentsFeed', 'GET:incidentsFeed' => 'incidentsFeed',
'POST:incidentsIngest' => 'ingestIncident', // P1: ingestion alert SIEM/SOC/EDR
'GET:controlsStatus' => 'controlsStatus', 'GET:controlsStatus' => 'controlsStatus',
'GET:assetsCritical' => 'assetsCritical', 'GET:assetsCritical' => 'assetsCritical',
'GET:suppliersRisk' => 'suppliersRisk', 'GET:suppliersRisk' => 'suppliersRisk',