Compare commits

..

3 Commits

Author SHA1 Message Date
DevEnv nis2-agile
21909994c2 [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>
2026-05-30 08:41:57 +02:00
DevEnv nis2-agile
094d453e8e [FEAT] Catalogo connettori Evidence Automation in area provider (gap competitivo Evix)
8 card 'In roadmap' (M365, Google Workspace, AWS, Azure, IdP, EDR/XDR, SIEM ingestion, Ticketing)
+ badge .badge-roadmap. Predispone l'area provider ai connettori inbound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:41:57 +02:00
DevEnv nis2-agile
d5d83bb3b9 [FEAT] AiController /api/ai/ask (ARIA) -> askWithRag + fix DNS Qdrant php-fpm
Il FAB ARIA (common.js) chiamava POST /api/ai/ask ma il controller non esisteva
(assistente AI rotto). Creato AiController::ask -> AIService::askWithRag con RAG su KB
+ grounding fonti certe. Verificato in produzione: rag_used=True, cita Ambiti NIS2 / Determina ACN.

Fix DNS Qdrant: nei worker php-fpm (musl) getenv e gethostbyname NON funzionano per
hostname Docker single-label; funziona solo un IP letterale. VectorService fallback ->
172.21.0.3 (fpm-safe); QDRANT_URL compose resta hostname per CLI. Vedi nota drift in VectorService.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:55:44 +02:00
7 changed files with 435 additions and 2 deletions

View File

@ -0,0 +1,75 @@
<?php
/**
* NIS2 Agile - AI Assistant Controller (ARIA)
*
* Endpoint dell'assistente conversazionale AI usato dal FAB "ARIA" (common.js).
* Usa AIService::askWithRag() -> RAG su Knowledge Base (incl. fonti normative
* certe ingerite, scope SYSTEM) + grounding con citazioni.
*/
require_once __DIR__ . '/BaseController.php';
require_once __DIR__ . '/../services/AIService.php';
require_once __DIR__ . '/../services/RateLimitService.php';
class AiController extends BaseController
{
/**
* POST /api/ai/ask
* Body: { question: string, history?: array }
* Risposta: { answer, sources, rag_used }
*/
public function ask(): void
{
$this->requireAuth();
$question = trim((string) $this->getParam('question', ''));
if ($question === '') {
$this->jsonError('Domanda mancante', 422, 'QUESTION_REQUIRED');
}
if (mb_strlen($question) > 2000) {
$this->jsonError('Domanda troppo lunga (max 2000 caratteri)', 422, 'QUESTION_TOO_LONG');
}
// Rate limit per utente (riusa la soglia AI configurata)
$userId = $this->getCurrentUserId();
$rlKey = "ai_ask:{$userId}";
if (defined('RATE_LIMIT_AI')) {
RateLimitService::check($rlKey, RATE_LIMIT_AI);
RateLimitService::increment($rlKey);
}
$user = $this->getCurrentUser() ?? [];
$userContext = [
'user_id' => $userId,
'organization_id' => $this->getCurrentOrgId(), // può essere null
'consulting_firm_id' => $user['consulting_firm_id'] ?? null,
];
try {
$aiService = new AIService();
$result = $aiService->askWithRag($question, $userContext);
// Audit best-effort (non blocca la risposta)
try {
$aiService->logInteraction(
(int) ($userContext['organization_id'] ?? 0),
(int) ($userId ?? 0),
'qa',
mb_substr($question, 0, 200),
mb_substr((string) ($result['answer'] ?? ''), 0, 500),
);
} catch (Throwable $e) {
error_log('[AiController::ask] logInteraction failed: ' . $e->getMessage());
}
$this->jsonSuccess([
'answer' => $result['answer'] ?? '',
'sources' => $result['sources'] ?? [],
'rag_used' => $result['rag_used'] ?? false,
]);
} catch (Throwable $e) {
error_log('[AiController::ask] ' . $e->getMessage());
$this->jsonError('Assistente AI non disponibile in questo momento', 503, 'AI_UNAVAILABLE');
}
}
}

View File

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

View File

@ -16,11 +16,34 @@ class VectorService
// PHP-FPM Alpine non popola correttamente env via getenv() (clear_env non
// applicato + bug DNS musl per hostname senza dots). Workaround: leggi da
// multiple sources e ricadi su IP statico del container Qdrant.
// Risoluzione Qdrant URL. Bug noto (verificato 2026-05-29): nei worker PHP-FPM
// (Alpine/musl) durante una request HTTP NON funziona né getenv('QDRANT_URL')
// (ritorna false) né la risoluzione DNS dell'hostname (gethostbyname e curl
// falliscono per i nomi single-label Docker). Funziona SOLO un IP letterale.
// In CLI invece getenv + gethostbyname funzionano. Strategia:
// - env (CLI): usa QDRANT_URL (hostname) e, sotto, gethostbyname -> IP (drift-proof in CLI)
// - fpm: env=false -> fallback IP LETTERALE qui sotto (unico modo che funziona in fpm)
// NOTA DRIFT: se il container nis2-qdrant viene ricreato e cambia IP, aggiornare
// questo fallback. Fix definitivo: assegnare ipv4_address statico a qdrant in
// docker-compose.yml (richiede recreate della rete) e allineare qui l'IP.
$url = getenv('QDRANT_URL')
?: ($_SERVER['QDRANT_URL'] ?? null)
?: ($_ENV['QDRANT_URL'] ?? null)
?: 'http://nis2-qdrant:6333'; // hostname Qdrant (agg. 2026-05-29): drift-proof. L'IP hardcoded .5 era driftato a .3; con clear_env=no php-fpm eredita QDRANT_URL e risolve l'hostname via Docker DNS (CLI verificato 200). Evita ricorrenze del drift IP.
$this->qdrantUrl = rtrim($url, '/');
?: 'http://172.21.0.3:6333'; // IP letterale nis2-qdrant (fpm-safe). Vedi nota drift sopra.
// Se l'URL contiene un hostname (caso CLI/env), prova a risolverlo a IP per
// evitare il problema di risoluzione dentro curl. In fpm gethostbyname fallisce
// e l'URL resta invariato: per questo il fallback sopra è già un IP.
$url = rtrim($url, '/');
if (preg_match('#^(https?://)([^/:]+)(:\d+)?(.*)$#', $url, $m)) {
$host = $m[2];
if (!filter_var($host, FILTER_VALIDATE_IP)) {
$ip = gethostbyname($host);
if ($ip && $ip !== $host && filter_var($ip, FILTER_VALIDATE_IP)) {
$url = $m[1] . $ip . ($m[3] ?? '') . ($m[4] ?? '');
}
}
}
$this->qdrantUrl = $url;
$this->collection = $collection;
}

View File

@ -34,6 +34,31 @@ Scoperto che **"Agile" = `consulting_firms` id 1 → "Agile Technology SRL"** (u
- ❌ **La chiave SSH documentata `docs/credentials/hetzner_key` è ASSENTE.**
- ✅ Usata la **chiave SSH effimera `.ssh-temp/id_ed25519_nis2-agile_8h_20260529-120834`** (validità 8h da 12:08 → scade ~20:08 del 29/05) per `ssh root@135.181.149.254``mysql -h localhost -u nis2_user`. Creds DB da `/var/www/nis2-agile/.env` sul server.
### 3. Integrazione analisi `docs/nis2/` → prodotto (v1.7.0) — DEPLOYATA in produzione
L'utente ha caricato in `docs/nis2/` mockup HTML di un sistema NIS2 + 5 PDF normativi ufficiali. Integrati nel prodotto e **deployati live**. Doc completo: **`docs/nis2/INTEGRAZIONE_COMPLETATA.md`**.
- **Fase 1** Asset Relevance Scoring NIS2 0-100 a 6 criteri (GV.OC-04): `AssetScoringService.php`, endpoint `assets/scoringGrid|{id}/score|relevantSystems`, UI `assets.html`, registro stampabile (`audit/relevantSystemsRegister`). SQL `020`.
- **Fase 2** Tassonomia incidenti Determina ACN 164179/2025: IS-1..4 + `entity_obligation` essential/important (Allegati 3/4). SQL `021`, `IncidentController::create`, `AIService::classifyIncident`.
- **Fase 3** PIR 5-Whys + metriche TTD/TTC/TTR + timestamp di fase auto. SQL `022` (tab `incident_pir`), endpoint `incidents/{id}/metrics|pir`.
- **Fase 4** Mapping NIST CSF 2.0 (43 controlli) reference-only: `audit/nistCsfMapping` (no migrazione).
- **FONTI CERTE** (richiesta utente: AI+help citano fonti certe): `application/config/nis2_sources.php` (registry), `AIService::authoritativeSourcesBlock()` iniettato nei prompt (vieta riferimenti inventati), citazioni in `help.js`, **ingest PDF normativi nella KB**: `scripts/ingest-nis2-sources.php`**287 chunk in Qdrant `nis2_kb` scope SYSTEM** (retrieval verificato).
- **Analisi concorrenza Evix**: `docs/EVIX_ANALISI_CONCORRENZA.html` (gap-driven, suite=prodotti Agile, vs GRC intl + NIS2 IT).
**Eseguito su Hetzner**: backup `/root/backup_pre_v170_20260529_165447.sql`; migrazioni 020/021/022 applicate; ingest 287 chunk; smoke test endpoint (401 ok).
### ⚙️ Scoperte infrastrutturali NON OVVIE (per prossime sessioni)
- 🔑 **`/projects/nis2-agile` (devenv) e `/var/www/nis2-agile` (host) sono LO STESSO filesystem via bind mount** → le modifiche ai file sono **già live in produzione**, scp/git-pull NON servono. Confermato: stesso `git status`.
- 🐛 **Qdrant IP drift**: `nis2-qdrant` non ha IP statico in compose, era driftato `172.21.0.5`→`172.21.0.3`. L'IP era hardcoded in `VectorService.php` E in `docker-compose.yml` (`QDRANT_URL`). **Fix applicato**: entrambi → hostname `http://nis2-qdrant:6333` (drift-proof). `app` ricreato (`docker compose up -d app`), vault/chiavi preservate, RAG web verificata.
- ⚠️ **`docker.conf` ha `clear_env = no`** → php-fpm **EREDITA** le env (contraddice la vecchia nota CLAUDE.md su clear_env): per questo l'IP morto in `QDRANT_URL` rompeva la RAG web. CLI risolve l'hostname Qdrant via Docker DNS.
- `pdftotext` presente sull'host ma NON nel container `nis2-app` (alpine); Qdrant raggiungibile solo dal container (network), non dall'host. Strategia ingest: estrarre testo su host in cache `.txt`, far girare l'ingest nel container (legge `.txt`).
- ✅ **ARIA cablato**: creato `AiController::ask` (`POST /api/ai/ask`) → `AIService::askWithRag`. Il FAB ARIA (`common.js`) chiamava `/api/ai/ask` ma il controller NON esisteva → assistente AI **era rotto**, ora funziona e cita le fonti certe (verificato in prod: `rag_used=True`, sources = Ambiti/Determina). `kb_uploaded_documents` non esiste su questo DB (mig. KB 012-014 non applicate qui) → tracking MySQL KB saltato (best-effort), ma la ricerca legge da Qdrant.
- 🐛🔑 **DNS php-fpm (verificato con diagnostico fpm-fcgi reale, poi rimosso)**: nei worker php-fpm Alpine/musl, durante una request HTTP, **`getenv('QDRANT_URL')` ritorna false** E **`gethostbyname`/curl NON risolvono gli hostname Docker single-label** (`nis2-qdrant` → "Could not resolve host"). **Funziona SOLO un IP letterale** (172.21.0.3 → 200). In CLI invece getenv+gethostbyname funzionano. Perciò: `VectorService` fallback = **IP letterale `172.21.0.3`** (fpm-safe, live via bind mount); `QDRANT_URL` compose = hostname (per CLI/cron, drift-proof via gethostbyname). ⚠️ **DRIFT**: se nis2-qdrant viene ricreato e cambia IP, aggiornare il fallback in `VectorService.php`. **Fix definitivo** (non fatto, richiede recreate rete): `ipv4_address` statico per qdrant in `docker-compose.yml` (subnet 172.21.0.0/16; IP liberi es. .10). NB: `clear_env=no` in docker.conf NON basta a passare l'env ai worker (verificato getenv=false).
- Esiste un `public/_dbg_voyage.php` (debug Voyage, non mio) ancora presente in prod — valutare rimozione.
### ⚠️ TODO aperti (sessione 3)
- **PUSH Gitea**: 2 commit miei (`5c545ea` feat + `94d7867` fix Qdrant) + i 5 dell'agente sono SOLO locali. Serve `git-login` (token) + `git push origin main`. Tentato, fallito per token assente.
- **Lavoro "prossimo" da AgileHub**: l'utente l'ha menzionato ma **senza allegare contenuto/ID ticket** → da chiarire alla ripresa.
- Opzionale: cablare `askWithRag` a endpoint web; valutare IP statico Qdrant in compose; applicare migrazioni KB `kb_uploaded_documents` se serve la lista doc in UI.
---
## SESSIONE MATTINA — 2026-05-29 (TRPG alignment)

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

@ -108,6 +108,7 @@ $controllerMap = [
'mktg-lead' => 'MktgLeadController', // standard condiviso TRPG/NIS2
'feedback' => 'FeedbackController', // segnalazioni & risoluzione AI
'knowledgebase' => 'KnowledgeBaseController', // KB multi-livello (Migration 012-014)
'ai' => 'AiController', // Assistente AI ARIA (askWithRag + fonti certe)
'branding' => 'BrandingController', // White-label firm (Fase 5 / G16)
];
@ -347,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',
@ -433,6 +435,10 @@ $actionMap = [
'DELETE:{id}' => 'delete',
],
'ai' => [
'POST:ask' => 'ask',
],
// ── BrandingController — White-label firm (Fase 5 / G16) ──
'branding' => [
'GET:current' => 'getCurrent',

View File

@ -39,6 +39,7 @@
}
.badge-available { background: rgba(34,197,94,.15); color: #22c55e; }
.badge-planned { background: rgba(234,179,8,.15); color: #eab308; }
.badge-roadmap { background: rgba(6,182,212,.15); color: #06b6d4; }
.provider-card h3 { font-size: .95rem; margin-bottom: .4rem; }
.provider-card p { font-size: .82rem; color: var(--text-secondary); line-height: 1.5; }
@ -251,6 +252,66 @@
</div>
</div>
<!-- Connettori inbound: Evidence Automation — colma il gap connettori (vs Vanta/Drata) emerso dall'analisi concorrenza Evix -->
<div class="section-title">Catalogo Connettori — Evidence Automation
<span style="font-size:.7rem;color:var(--text-secondary);font-weight:400">(Continuous Control Monitoring)</span>
</div>
<div class="integ-hero" style="margin-bottom:1rem">
<p style="margin:0">Connettori <strong>inbound</strong> per la raccolta <strong>automatica</strong> delle evidenze di conformità
(NIS2 Art.21) da cloud, identità ed endpoint — costruiti sulla base esistente <strong>Services API + Webhook HMAC</strong>.
Riducono la raccolta manuale e abilitano il monitoraggio continuo dei controlli.</p>
</div>
<div class="provider-grid">
<div class="provider-card">
<span class="badge badge-roadmap">In roadmap</span>
<h3>🟦 Microsoft 365 / Entra ID</h3>
<p>Evidenze automatiche: MFA &amp; conditional access, offboarding utenti, sicurezza mailbox, log di audit.
Mappate su Art.21 (controllo accessi, gestione identità).</p>
</div>
<div class="provider-card">
<span class="badge badge-roadmap">In roadmap</span>
<h3>🟩 Google Workspace</h3>
<p>Verifica 2SV, policy amministrative, gestione dispositivi e accessi.
Evidenze per controllo accessi e sicurezza degli endpoint.</p>
</div>
<div class="provider-card">
<span class="badge badge-roadmap">In roadmap</span>
<h3>🟧 Amazon Web Services</h3>
<p>IAM, CloudTrail, Security Hub, cifratura S3/EBS.
Raccolta evidenze su crittografia, logging e gestione accessi cloud.</p>
</div>
<div class="provider-card">
<span class="badge badge-roadmap">In roadmap</span>
<h3>🟦 Microsoft Azure</h3>
<p>Defender for Cloud, Azure Policy, Network Security Group.
Posture di sicurezza cloud → controlli Art.21 e asset critici.</p>
</div>
<div class="provider-card">
<span class="badge badge-roadmap">In roadmap</span>
<h3>🔐 Identity Provider (Okta / Entra / Google)</h3>
<p>Enforcement SSO/MFA, lifecycle utenti, revoche accessi.
Evidenza continua su gestione identità e accessi privilegiati.</p>
</div>
<div class="provider-card">
<span class="badge badge-roadmap">In roadmap</span>
<h3>🛡️ EDR / XDR (CrowdStrike, SentinelOne, Defender)</h3>
<p>Copertura endpoint, rilevamento minacce e stato agenti →
popolano gli asset critici e aprono incidenti Art.23 in automatico.</p>
</div>
<div class="provider-card">
<span class="badge badge-roadmap">In roadmap</span>
<h3>📥 SIEM / SOC — Ingestion (inbound)</h3>
<p>Alert da Splunk, Microsoft Sentinel, Elastic, Wazuh → apertura automatica di incidenti
NIS2 (Art.23) con triage e timeline. Complementare al webhook outbound già disponibile.</p>
</div>
<div class="provider-card">
<span class="badge badge-roadmap">In roadmap</span>
<h3>🎫 Ticketing (Jira / ServiceNow)</h3>
<p>Sincronizzazione bidirezionale di non-conformità/CAPA e task di raccolta evidenze.
Le azioni correttive NIS2 diventano ticket tracciati.</p>
</div>
</div>
<!-- Tabs -->
<div class="tab-nav">
<button class="tab-btn active" onclick="showTab('api')">Services API</button>