From 4924075142febdd5bdf58e79dbb203c1521a4b27 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 30 May 2026 09:14:12 +0200 Subject: [PATCH] [FEAT] Asset import CMDB/cloud + scoring automatico GV.OC-04 (P2) - AssetScoringService::inferCriteria: euristica 6 criteri da campi CMDB (criticality, data_classification, internet_facing, dependencies, regulated) - AssetController::import (JWT org_admin/compliance_manager) + bulkUpsert condiviso: upsert dedup su external_ref, scoring auto GV.OC-04, max 1000 asset/batch - ServicesController::ingestAssets -> POST /services/assets-ingest (scope ingest:assets) per connettori CMDB/cloud - Migrazione 025: assets += external_ref + discovery_source + indice univoco dedup - Route POST:assetsIngest (services) + POST:import (assets) Co-Authored-By: Claude Opus 4.8 (1M context) --- application/controllers/AssetController.php | 103 ++++++++++++++++++ .../controllers/ServicesController.php | 20 ++++ application/services/AssetScoringService.php | 49 +++++++++ docs/sql/025_asset_import.sql | 32 ++++++ public/index.php | 2 + 5 files changed, 206 insertions(+) create mode 100644 docs/sql/025_asset_import.sql diff --git a/application/controllers/AssetController.php b/application/controllers/AssetController.php index 95f2c38..3fc09aa 100644 --- a/application/controllers/AssetController.php +++ b/application/controllers/AssetController.php @@ -76,6 +76,109 @@ class AssetController extends BaseController $this->jsonSuccess(['id' => $assetId], 'Asset registrato', 201); } + /** + * POST /api/assets/import (JWT, org_admin/compliance_manager) + * Import bulk asset da CMDB/cloud/CSV con scoring automatico GV.OC-04. + * Body: { "source":"cmdb|aws|azure|csv|manual", "assets":[ {...}, ... ] } + */ + public function import(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $body = $this->getJsonBody(); + $source = strtolower((string) ($body['source'] ?? 'csv')); + $items = $body['assets'] ?? null; + if (!is_array($items) || !$items) { + $this->jsonError('Campo "assets" (array) obbligatorio', 422, 'VALIDATION'); + } + $result = self::bulkUpsert($this->getCurrentOrgId(), $items, $source, $this->getCurrentUserId()); + $this->logAudit('assets_imported', 'asset', null, [ + 'source' => $source, 'imported' => $result['imported'], 'updated' => $result['updated'], + ]); + $this->jsonSuccess($result, 'Import completato', 201); + } + + /** + * Upsert bulk + scoring GV.OC-04. Condiviso fra import UI (JWT) e + * ingestion connettori (API key, ServicesController). Riceve orgId esplicito. + * + * @return array{imported:int,updated:int,skipped:int,relevant:int,total:int,results:array} + */ + public static function bulkUpsert(int $orgId, array $items, string $source, ?int $userId): array + { + $validType = ['hardware', 'software', 'network', 'data', 'service', 'personnel', 'facility']; + $imported = 0; $updated = 0; $skipped = 0; $relevant = 0; $results = []; + + if (count($items) > 1000) { + $items = array_slice($items, 0, 1000); + } + + foreach ($items as $i => $a) { + if (!is_array($a)) { $skipped++; $results[] = ['index' => $i, 'ok' => false, 'error' => 'not_an_object']; continue; } + $name = trim((string) ($a['name'] ?? '')); + if ($name === '') { $skipped++; $results[] = ['index' => $i, 'ok' => false, 'error' => 'name mancante']; continue; } + + $type = strtolower((string) ($a['asset_type'] ?? 'service')); + if (!in_array($type, $validType, true)) $type = 'service'; + + // Scoring automatico GV.OC-04 da euristica sui campi CMDB + $criteria = AssetScoringService::inferCriteria($a); + $sc = AssetScoringService::calculate($criteria); + + $extRef = isset($a['external_ref']) ? substr(trim((string) $a['external_ref']), 0, 190) : null; + + $row = [ + 'organization_id' => $orgId, + 'name' => $name, + 'asset_type' => $type, + 'category' => $a['category'] ?? null, + 'description' => $a['description'] ?? null, + 'criticality' => $sc['criticality'], + 'location' => $a['location'] ?? null, + 'ip_address' => $a['ip_address'] ?? null, + 'vendor' => $a['vendor'] ?? null, + 'discovery_source' => substr($source, 0, 40), + 'external_ref' => $extRef, + 'relevance_score' => $sc['score'], + 'relevance_criteria' => json_encode($criteria, JSON_UNESCAPED_UNICODE), + 'relevance_class' => $sc['class'], + 'is_nis2_relevant' => $sc['is_relevant'] ? 1 : 0, + 'relevance_assessed_at' => date('Y-m-d H:i:s'), + 'relevance_assessed_by' => $userId, + ]; + + try { + $existing = $extRef !== null + ? Database::fetchOne('SELECT id FROM assets WHERE organization_id = ? AND external_ref = ?', [$orgId, $extRef]) + : null; + if ($existing) { + $sets = []; $vals = []; + foreach ($row as $k => $v) { if ($k === 'organization_id') continue; $sets[] = "$k = ?"; $vals[] = $v; } + $vals[] = $existing['id']; + Database::query('UPDATE assets SET ' . implode(', ', $sets) . ' WHERE id = ?', $vals); + $assetId = (int) $existing['id']; $updated++; + } else { + $assetId = Database::insert('assets', $row); $imported++; + } + } catch (Throwable $e) { + $skipped++; $results[] = ['index' => $i, 'ok' => false, 'error' => 'db_error']; + error_log('[ASSET_IMPORT] ' . $e->getMessage()); + continue; + } + + if ($sc['is_relevant']) $relevant++; + $results[] = [ + 'index' => $i, 'ok' => true, 'id' => $assetId, 'name' => $name, + 'relevance_score' => $sc['score'], 'relevance_class' => $sc['class'], + 'nis2_relevant' => $sc['is_relevant'], + ]; + } + + return [ + 'imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, + 'relevant' => $relevant, 'total' => count($items), 'results' => $results, + ]; + } + public function get(int $id): void { $this->requireOrgAccess(); diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index c56e0b5..63ff2db 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -2561,6 +2561,26 @@ class ServicesController extends BaseController return $monStatus; } + /** + * POST /api/services/assets-ingest + * Auth: X-API-Key con scope ingest:assets + * Import asset da CMDB/cloud con scoring automatico GV.OC-04. + * Body: { "source":"cmdb|aws|azure", "assets":[ {...}, ... ] } + */ + public function ingestAssets(): void + { + $this->requireApiKey('ingest:assets'); + $body = $this->getJsonBody(); + $source = strtolower((string) ($body['source'] ?? 'cmdb')); + $items = $body['assets'] ?? null; + if (!is_array($items) || !$items) { + $this->jsonError('Campo "assets" (array) obbligatorio', 422, 'VALIDATION'); + } + require_once __DIR__ . '/AssetController.php'; + $result = AssetController::bulkUpsert($this->currentOrgId, $items, $source, null); + $this->jsonSuccess($result, 'Asset importati', 201); + } + /** * GET /api/services/controls-monitoring * Auth: X-API-Key con scope read:compliance diff --git a/application/services/AssetScoringService.php b/application/services/AssetScoringService.php index 31a36b7..9797771 100644 --- a/application/services/AssetScoringService.php +++ b/application/services/AssetScoringService.php @@ -106,6 +106,55 @@ class AssetScoringService ], ]; + /** + * Inferenza euristica dei 6 criteri GV.OC-04 dai campi di un asset + * importato da CMDB/cloud. Permette lo scoring automatico in bulk senza + * compilazione manuale della griglia. I valori restituiti sono chiavi + * VALIDE della GRID, quindi calculate() li accetta senza eccezioni. + * + * Campi letti: criticality, asset_type, data_classification, + * internet_facing, dependencies_count, regulated. + */ + public static function inferCriteria(array $a): array + { + $crit = strtolower((string) ($a['criticality'] ?? 'medium')); + + // C1 Criticita Operativa <- criticality + $c1 = ['critical' => 'critical', 'high' => 'high', 'medium' => 'medium', 'low' => 'low'][$crit] ?? 'medium'; + + // C2 Impatto Interruzione <- criticality (proxy) + $c2 = ['critical' => 'gt24h_gt70', 'high' => 'h4_8_30_50', 'medium' => 'h1_4_10_30', 'low' => 'lt1h_lt10'][$crit] ?? 'h1_4_10_30'; + + // C3 Dati Trattati <- data_classification (con proxy dal tipo asset) + $dc = strtolower((string) ($a['data_classification'] ?? '')); + $c3map = [ + 'special' => 'gdpr_art9', 'art9' => 'gdpr_art9', 'sensitive' => 'gdpr_art9', + 'personal' => 'personal_large', 'pii' => 'personal_large', + 'financial' => 'personal_fin', 'confidential' => 'confidential', + 'internal' => 'confidential', 'public' => 'public', + ]; + $c3 = $c3map[$dc] ?? ((($a['asset_type'] ?? '') === 'data') ? 'personal_large' : 'confidential'); + + // C4 Dipendenze <- numero dipendenze critiche + $deps = (int) ($a['dependencies_count'] ?? 0); + $c4 = $deps >= 5 ? 'ge5_critical' : ($deps >= 3 ? 'n3_4_critical' : ($deps === 2 ? 'n2_critical' : ($deps === 1 ? 'n1_critical' : 'none'))); + + // C5 Esposizione <- internet_facing + $c5 = !empty($a['internet_facing']) ? 'internet_mfa' : 'intranet'; + + // C6 Obblighi Normativi <- regulated + $c6 = !empty($a['regulated']) ? 'nis2_required' : 'none'; + + return [ + 'c1_operational_criticality' => $c1, + 'c2_disruption_impact' => $c2, + 'c3_data_processed' => $c3, + 'c4_dependencies' => $c4, + 'c5_exposure' => $c5, + 'c6_regulatory' => $c6, + ]; + } + /** * Calcola lo score a partire dalle selezioni dell'utente. * diff --git a/docs/sql/025_asset_import.sql b/docs/sql/025_asset_import.sql new file mode 100644 index 0000000..c71d181 --- /dev/null +++ b/docs/sql/025_asset_import.sql @@ -0,0 +1,32 @@ +-- ============================================================================ +-- Migration 025 - Asset import CMDB/cloud (P2) +-- ---------------------------------------------------------------------------- +-- Abilita l'import bulk asset da CMDB/export cloud con dedup idempotente: +-- - assets.external_ref : ID asset nel sistema sorgente (CMDB/cloud) +-- - assets.discovery_source : provenienza (cmdb/aws/azure/csv/manual/...) +-- + indice univoco (organization_id, external_ref) per upsert. +-- +-- Idempotente via information_schema. Rilanciabile. +-- mysql -h localhost nis2_agile_db -e "source docs/sql/025_asset_import.sql" +-- ============================================================================ + +DELIMITER // +DROP PROCEDURE IF EXISTS _mig025 // +CREATE PROCEDURE _mig025() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets' AND COLUMN_NAME='external_ref') THEN + ALTER TABLE assets ADD COLUMN external_ref VARCHAR(190) NULL COMMENT 'ID asset nel sistema sorgente (dedup import)' AFTER serial_number; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets' AND COLUMN_NAME='discovery_source') THEN + ALTER TABLE assets ADD COLUMN discovery_source VARCHAR(40) NOT NULL DEFAULT 'manual' COMMENT 'Provenienza asset (cmdb/aws/azure/csv/manual)' AFTER external_ref; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets' AND INDEX_NAME='uq_asset_external_ref') THEN + ALTER TABLE assets ADD UNIQUE KEY uq_asset_external_ref (organization_id, external_ref); + END IF; +END // +DELIMITER ; +CALL _mig025(); +DROP PROCEDURE IF EXISTS _mig025; + +-- ROLLBACK: +-- ALTER TABLE assets DROP INDEX uq_asset_external_ref, DROP COLUMN external_ref, DROP COLUMN discovery_source; diff --git a/public/index.php b/public/index.php index b9d6699..f611bf4 100644 --- a/public/index.php +++ b/public/index.php @@ -273,6 +273,7 @@ $actionMap = [ 'assets' => [ 'GET:list' => 'list', 'POST:create' => 'create', + 'POST:import' => 'import', 'GET:scoringGrid' => 'scoringGrid', 'GET:relevantSystems' => 'relevantSystems', 'GET:dependencyMap' => 'dependencyMap', @@ -351,6 +352,7 @@ $actionMap = [ 'POST:incidentsIngest' => 'ingestIncident', // P1: ingestion alert SIEM/SOC/EDR 'POST:evidenceIngest' => 'ingestEvidence', // P1: evidence automation (collector connettori) 'GET:controlsMonitoring' => 'controlsMonitoring', // P1: continuous control monitoring + 'POST:assetsIngest' => 'ingestAssets', // P2: import asset CMDB/cloud + scoring GV.OC-04 'GET:controlsStatus' => 'controlsStatus', 'GET:assetsCritical' => 'assetsCritical', 'GET:suppliersRisk' => 'suppliersRisk',