From b8ac793c8f4e710abf797b76baab7dea839041cd Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Fri, 26 Jun 2026 11:38:55 +0200 Subject: [PATCH] =?UTF-8?q?[FEAT]=20Connettori=20discovery=20rete/cloud=20?= =?UTF-8?q?per-org=20=E2=86=92=20auto-popola=20Inventario=20(backend,=20mi?= =?UTF-8?q?g=20064)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Più connettori per azienda; ogni connettore ha una api_key dedicata (scope ingest:assets) che l'agente esterno usa per mappare e auto-popolare NIS2. - mig 064: discovery_connectors (per-org, multi) + discovery_runs (storico) + network_flows (ID.AM-03). - DiscoveryConnectorController (org_admin): CRUD connettori + emissione/rotazione api_key (mostrata 1 volta) + dettaglio con ultimi run. - ServicesController::ingestAssets esteso: accetta anche "flows" (upsert network_flows, dedup external_ref) e, se la chiave appartiene a un connettore, registra discovery_run + aggiorna last_run del connettore. Auto-scoring rilevanza NIS2 già in bulkUpsert. - AssetController::bulkUpsert: ora salva anche "dependencies" → popola la Mappa Dipendenze. - Router: /api/discovery-connectors (list/create/{id}/update/delete/rotateKey). Smoke E2E (HTTP 201): 2 asset scorati + 1 flusso + run tracciato + dipendenze persistite. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/migrate_064_discovery_connectors.php | 65 +++++++ application/controllers/AssetController.php | 4 + .../DiscoveryConnectorController.php | 180 ++++++++++++++++++ .../controllers/ServicesController.php | 70 +++++++ docs/sql/064_discovery_connectors.sql | 57 ++++++ public/index.php | 11 ++ 6 files changed, 387 insertions(+) create mode 100644 application/cli/migrate_064_discovery_connectors.php create mode 100644 application/controllers/DiscoveryConnectorController.php create mode 100644 docs/sql/064_discovery_connectors.sql diff --git a/application/cli/migrate_064_discovery_connectors.php b/application/cli/migrate_064_discovery_connectors.php new file mode 100644 index 0000000..4680f2f --- /dev/null +++ b/application/cli/migrate_064_discovery_connectors.php @@ -0,0 +1,65 @@ +exec("CREATE TABLE IF NOT EXISTS discovery_connectors ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + name VARCHAR(120) NOT NULL, + connector_type ENUM('network','aws','azure','gcp','cmdb','agent','custom') NOT NULL DEFAULT 'network', + config JSON NULL, + api_key_id INT UNSIGNED NULL, + status ENUM('active','paused','error') NOT NULL DEFAULT 'active', + schedule ENUM('manual','hourly','daily','weekly') NOT NULL DEFAULT 'manual', + last_run_at TIMESTAMP NULL, + last_status VARCHAR(20) NULL, + last_discovered INT NULL, + last_message VARCHAR(255) NULL, + created_by INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_dc_org_name (organization_id, name), + INDEX idx_dc_org (organization_id), + INDEX idx_dc_key (api_key_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); +echo " + discovery_connectors OK\n"; + +$pdo->exec("CREATE TABLE IF NOT EXISTS discovery_runs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + connector_id INT UNSIGNED NOT NULL, + organization_id INT NOT NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP NULL, + status ENUM('running','ok','error') NOT NULL DEFAULT 'ok', + discovered_assets INT NOT NULL DEFAULT 0, + discovered_flows INT NOT NULL DEFAULT 0, + message VARCHAR(255) NULL, + INDEX idx_dr_conn (connector_id), + INDEX idx_dr_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); +echo " + discovery_runs OK\n"; + +$pdo->exec("CREATE TABLE IF NOT EXISTS network_flows ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + src VARCHAR(190) NOT NULL, + dst VARCHAR(190) NOT NULL, + port INT NULL, + protocol VARCHAR(12) NULL, + direction ENUM('internal','inbound','outbound') NOT NULL DEFAULT 'internal', + discovery_source VARCHAR(40) NOT NULL DEFAULT 'manual', + external_ref VARCHAR(190) NULL, + last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_flow (organization_id, external_ref), + INDEX idx_flow_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); +echo " + network_flows OK\n"; + +echo "Migrazione 064 — connettori discovery OK. Prossima mig=065.\n"; diff --git a/application/controllers/AssetController.php b/application/controllers/AssetController.php index 764eda9..58a7ac2 100644 --- a/application/controllers/AssetController.php +++ b/application/controllers/AssetController.php @@ -167,6 +167,10 @@ class AssetController extends BaseController 'relevance_assessed_at' => date('Y-m-d H:i:s'), 'relevance_assessed_by' => $userId, ]; + // Dipendenze scoperte dal connettore → popolano la Mappa Dipendenze. + if (isset($a['dependencies']) && is_array($a['dependencies'])) { + $row['dependencies'] = json_encode(array_values($a['dependencies']), JSON_UNESCAPED_UNICODE); + } try { $existing = $extRef !== null diff --git a/application/controllers/DiscoveryConnectorController.php b/application/controllers/DiscoveryConnectorController.php new file mode 100644 index 0000000..cdb3aec --- /dev/null +++ b/application/controllers/DiscoveryConnectorController.php @@ -0,0 +1,180 @@ +requireOrgRole(['org_admin']); + $orgId = $this->getCurrentOrgId(); + $rows = Database::fetchAll( + "SELECT dc.id, dc.name, dc.connector_type, dc.config, dc.status, dc.schedule, + dc.last_run_at, dc.last_status, dc.last_discovered, dc.last_message, dc.created_at, + ak.key_prefix + FROM discovery_connectors dc + LEFT JOIN api_keys ak ON ak.id = dc.api_key_id + WHERE dc.organization_id = ? + ORDER BY dc.created_at DESC", + [$orgId] + ); + foreach ($rows as &$r) { $r['config'] = json_decode($r['config'] ?? 'null', true); } + $this->jsonSuccess(['connectors' => $rows, 'total' => count($rows), 'types' => self::TYPES]); + } + + public function create(): void + { + $this->requireOrgRole(['org_admin']); + $this->validateRequired(['name']); + $orgId = $this->getCurrentOrgId(); + $userId = $this->getCurrentUserId(); + + $name = trim((string) $this->getParam('name')); + $type = $this->getParam('connector_type', 'network'); + if (!in_array($type, self::TYPES, true)) $type = 'network'; + $schedule = $this->getParam('schedule', 'manual'); + if (!in_array($schedule, self::SCHEDULES, true)) $schedule = 'manual'; + $config = $this->getParam('config'); + $configJson = $config !== null ? json_encode($config, JSON_UNESCAPED_UNICODE) : null; + + if (Database::fetchOne('SELECT id FROM discovery_connectors WHERE organization_id=? AND name=?', [$orgId, $name])) { + $this->jsonError('Esiste già un connettore con questo nome', 409, 'DUPLICATE'); + } + + [$keyId, $rawKey, $prefix] = $this->issueIngestKey($orgId, $userId, "discovery: {$name}"); + + $id = Database::insert('discovery_connectors', [ + 'organization_id' => $orgId, + 'name' => $name, + 'connector_type' => $type, + 'config' => $configJson, + 'api_key_id' => $keyId, + 'status' => 'active', + 'schedule' => $schedule, + 'created_by' => $userId, + ]); + $this->logAudit('discovery_connector_created', 'discovery_connector', $id, ['name' => $name, 'type' => $type]); + + $this->jsonSuccess([ + 'id' => $id, + 'name' => $name, + 'connector_type' => $type, + 'api_key' => $rawKey, // mostrata UNA sola volta + 'api_key_prefix' => $prefix, + 'ingest_url' => 'https://nis2.agile.software/api/services/assets-ingest', + 'note' => "Copia ORA la chiave: non sarà più visibile. L'agente la usa nell'header X-API-Key.", + ], 'Connettore creato', 201); + } + + public function get(int $id): void + { + $this->requireOrgRole(['org_admin']); + $orgId = $this->getCurrentOrgId(); + $c = Database::fetchOne( + "SELECT dc.*, ak.key_prefix FROM discovery_connectors dc + LEFT JOIN api_keys ak ON ak.id = dc.api_key_id + WHERE dc.id = ? AND dc.organization_id = ?", + [$id, $orgId] + ); + if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND'); + $c['config'] = json_decode($c['config'] ?? 'null', true); + unset($c['api_key_id']); + $c['runs'] = Database::fetchAll( + 'SELECT id, started_at, finished_at, status, discovered_assets, discovered_flows, message + FROM discovery_runs WHERE connector_id = ? ORDER BY started_at DESC LIMIT 20', + [$id] + ); + $this->jsonSuccess($c); + } + + public function update(int $id): void + { + $this->requireOrgRole(['org_admin']); + $orgId = $this->getCurrentOrgId(); + $c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]); + if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND'); + + $updates = []; + if ($this->hasParam('name')) $updates['name'] = trim((string) $this->getParam('name')); + if ($this->hasParam('connector_type')) { $t = $this->getParam('connector_type'); if (in_array($t, self::TYPES, true)) $updates['connector_type'] = $t; } + if ($this->hasParam('schedule')) { $s = $this->getParam('schedule'); if (in_array($s, self::SCHEDULES, true)) $updates['schedule'] = $s; } + if ($this->hasParam('status')) { $st = $this->getParam('status'); if (in_array($st, self::STATUSES, true)) $updates['status'] = $st; } + if ($this->hasParam('config')) $updates['config'] = json_encode($this->getParam('config'), JSON_UNESCAPED_UNICODE); + + if (!$updates) { $this->jsonSuccess(null, 'Nessuna modifica'); return; } + Database::query( + 'UPDATE discovery_connectors SET ' . implode(', ', array_map(fn($k) => "{$k} = ?", array_keys($updates))) . ', updated_at = NOW() WHERE id = ?', + array_merge(array_values($updates), [$id]) + ); + $this->logAudit('discovery_connector_updated', 'discovery_connector', $id, $updates); + $this->jsonSuccess(null, 'Connettore aggiornato'); + } + + public function delete(int $id): void + { + $this->requireOrgRole(['org_admin']); + $orgId = $this->getCurrentOrgId(); + $c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]); + if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND'); + if (!empty($c['api_key_id'])) { + Database::query('UPDATE api_keys SET is_active=0 WHERE id=? AND organization_id=?', [$c['api_key_id'], $orgId]); + } + Database::query('DELETE FROM discovery_runs WHERE connector_id=?', [$id]); + Database::query('DELETE FROM discovery_connectors WHERE id=?', [$id]); + $this->logAudit('discovery_connector_deleted', 'discovery_connector', $id, ['name' => $c['name']]); + $this->jsonSuccess(null, 'Connettore eliminato'); + } + + public function rotateKey(int $id): void + { + $this->requireOrgRole(['org_admin']); + $orgId = $this->getCurrentOrgId(); + $userId = $this->getCurrentUserId(); + $c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]); + if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND'); + if (!empty($c['api_key_id'])) { + Database::query('UPDATE api_keys SET is_active=0 WHERE id=?', [$c['api_key_id']]); + } + [$keyId, $rawKey, $prefix] = $this->issueIngestKey($orgId, $userId, "discovery: {$c['name']}"); + Database::query('UPDATE discovery_connectors SET api_key_id=?, updated_at=NOW() WHERE id=?', [$keyId, $id]); + $this->logAudit('discovery_connector_key_rotated', 'discovery_connector', $id, []); + $this->jsonSuccess(['api_key' => $rawKey, 'api_key_prefix' => $prefix], 'Chiave rigenerata (copia ora)'); + } + + /** Emette una api_key con scope ingest:assets. @return [id, rawKey, prefix] */ + private function issueIngestKey(int $orgId, int $userId, string $name): array + { + $rawKey = 'nis2_' . bin2hex(random_bytes(16)); + $prefix = substr($rawKey, 0, 12); + $keyId = Database::insert('api_keys', [ + 'organization_id' => $orgId, + 'created_by' => $userId, + 'name' => substr($name, 0, 100), + 'key_prefix' => $prefix, + 'key_hash' => hash('sha256', $rawKey), + 'scopes' => json_encode(['ingest:assets']), + 'is_active' => 1, + ]); + return [$keyId, $rawKey, $prefix]; + } +} diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index 84e9ec3..948c2af 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -2645,9 +2645,79 @@ class ServicesController extends BaseController } require_once __DIR__ . '/AssetController.php'; $result = AssetController::bulkUpsert($this->currentOrgId, $items, $source, null); + + // Flussi di rete (ID.AM-03) opzionali nello stesso payload + $flowsCount = 0; + if (!empty($body['flows']) && is_array($body['flows'])) { + $flowsCount = $this->upsertFlows($this->currentOrgId, $body['flows'], $source); + } + $result['flows_ingested'] = $flowsCount; + + // Se la chiave appartiene a un connettore discovery → registra il run + last_run + $this->recordDiscoveryRun((int) ($this->apiKeyRecord['id'] ?? 0), $result, $flowsCount); + $this->jsonSuccess($result, 'Asset importati', 201); } + /** Upsert idempotente dei flussi di rete (dedup su organization_id+external_ref). */ + private function upsertFlows(int $orgId, array $flows, string $source): int + { + $n = 0; + foreach ($flows as $f) { + if (!is_array($f)) continue; + $src = trim((string) ($f['src'] ?? '')); + $dst = trim((string) ($f['dst'] ?? '')); + if ($src === '' || $dst === '') continue; + $port = isset($f['port']) ? (int) $f['port'] : null; + $proto = isset($f['protocol']) ? substr((string) $f['protocol'], 0, 12) : null; + $dir = in_array(($f['direction'] ?? ''), ['internal', 'inbound', 'outbound'], true) ? $f['direction'] : 'internal'; + $ref = isset($f['external_ref']) + ? substr((string) $f['external_ref'], 0, 190) + : substr($src . '>' . $dst . ':' . $port . '/' . $proto, 0, 190); + try { + Database::query( + "INSERT INTO network_flows (organization_id, src, dst, port, protocol, direction, discovery_source, external_ref, last_seen_at) + VALUES (?,?,?,?,?,?,?,?,NOW()) + ON DUPLICATE KEY UPDATE port=VALUES(port), protocol=VALUES(protocol), direction=VALUES(direction), last_seen_at=NOW()", + [$orgId, substr($src, 0, 190), substr($dst, 0, 190), $port, $proto, $dir, substr($source, 0, 40), $ref] + ); + $n++; + } catch (Throwable $e) { + error_log('[flows-ingest] ' . $e->getMessage()); + } + } + return $n; + } + + /** Registra il run del connettore discovery legato all'api_key, se esiste. */ + private function recordDiscoveryRun(int $apiKeyId, array $result, int $flowsCount): void + { + if ($apiKeyId <= 0) return; + try { + $conn = Database::fetchOne( + 'SELECT id FROM discovery_connectors WHERE api_key_id = ? AND organization_id = ?', + [$apiKeyId, $this->currentOrgId] + ); + if (!$conn) return; + $assets = (int) ($result['imported'] ?? 0) + (int) ($result['updated'] ?? 0); + Database::insert('discovery_runs', [ + 'connector_id' => $conn['id'], + 'organization_id' => $this->currentOrgId, + 'finished_at' => date('Y-m-d H:i:s'), + 'status' => 'ok', + 'discovered_assets' => $assets, + 'discovered_flows' => $flowsCount, + 'message' => 'ingest via api', + ]); + Database::query( + 'UPDATE discovery_connectors SET last_run_at=NOW(), last_status=?, last_discovered=?, last_message=? WHERE id=?', + ['ok', $assets, "asset {$assets}, flussi {$flowsCount}", $conn['id']] + ); + } catch (Throwable $e) { + error_log('[discovery-run] ' . $e->getMessage()); + } + } + /** * GET /api/services/controls-monitoring * Auth: X-API-Key con scope read:compliance diff --git a/docs/sql/064_discovery_connectors.sql b/docs/sql/064_discovery_connectors.sql new file mode 100644 index 0000000..41929b2 --- /dev/null +++ b/docs/sql/064_discovery_connectors.sql @@ -0,0 +1,57 @@ +-- 064_discovery_connectors.sql — Connettori di discovery (mappatura rete/cloud) per-org. +-- Più connettori per organizzazione; l'agente esterno autentica con una api_key dedicata +-- (scope ingest:assets) e auto-popola Inventario + dipendenze + flussi di rete (ID.AM-03). +-- Apply idempotente: application/cli/migrate_064_discovery_connectors.php +-- Additiva, non distruttiva. + +CREATE TABLE IF NOT EXISTS discovery_connectors ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + name VARCHAR(120) NOT NULL, -- es. "Rete sede Modena", "AWS prod" + connector_type ENUM('network','aws','azure','gcp','cmdb','agent','custom') NOT NULL DEFAULT 'network', + config JSON NULL, -- CIDR target, account/region cloud, opzioni + api_key_id INT UNSIGNED NULL, -- chiave usata dall'agente (api_keys.id) + status ENUM('active','paused','error') NOT NULL DEFAULT 'active', + schedule ENUM('manual','hourly','daily','weekly') NOT NULL DEFAULT 'manual', + last_run_at TIMESTAMP NULL, + last_status VARCHAR(20) NULL, -- ok|error|running + last_discovered INT NULL, -- n. asset scoperti all'ultimo run + last_message VARCHAR(255) NULL, + created_by INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_dc_org_name (organization_id, name), + INDEX idx_dc_org (organization_id), + INDEX idx_dc_key (api_key_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS discovery_runs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + connector_id INT UNSIGNED NOT NULL, + organization_id INT NOT NULL, + started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP NULL, + status ENUM('running','ok','error') NOT NULL DEFAULT 'ok', + discovered_assets INT NOT NULL DEFAULT 0, + discovered_flows INT NOT NULL DEFAULT 0, + message VARCHAR(255) NULL, + INDEX idx_dr_conn (connector_id), + INDEX idx_dr_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Flussi di rete (ID.AM-03): inventario dei flussi tra sistemi e verso l'esterno. +CREATE TABLE IF NOT EXISTS network_flows ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + src VARCHAR(190) NOT NULL, -- host/asset sorgente (ip o nome) + dst VARCHAR(190) NOT NULL, -- host/asset destinazione (ip o nome) + port INT NULL, + protocol VARCHAR(12) NULL, -- tcp|udp|icmp + direction ENUM('internal','inbound','outbound') NOT NULL DEFAULT 'internal', + discovery_source VARCHAR(40) NOT NULL DEFAULT 'manual', -- network/aws/...; manual + external_ref VARCHAR(190) NULL, -- chiave dedup dal sorgente + last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_flow (organization_id, external_ref), + INDEX idx_flow_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/public/index.php b/public/index.php index f4eb7d1..14f8237 100644 --- a/public/index.php +++ b/public/index.php @@ -114,6 +114,7 @@ $controllerMap = [ 'services' => 'ServicesController', 'invites' => 'InviteController', 'webhooks' => 'WebhookController', + 'discovery-connectors' => 'DiscoveryConnectorController', // Connettori discovery rete/cloud → auto-popola Inventario 'whistleblowing'=> 'WhistleblowingController', 'normative' => 'NormativeController', 'cross-analysis' => 'CrossAnalysisController', @@ -701,6 +702,16 @@ $actionMap = [ 'POST:tts' => 'tts', // voce naturale ARIA (proxy nexus-voice-ms) ], + // ── DiscoveryConnectorController — connettori mappatura rete/cloud (org_admin) ── + 'discovery-connectors' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'POST:{id}/rotateKey' => 'rotateKey', + ], + // ── BrandingController — White-label firm (Fase 5 / G16) ── 'branding' => [ 'GET:current' => 'getCurrent',