[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:
parent
094d453e8e
commit
21909994c2
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
docs/sql/023_incident_ingestion.sql
Normal file
27
docs/sql/023_incident_ingestion.sql
Normal 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;
|
||||||
@ -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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user