[FEAT] Modello Organizzativo SGSI (ISO 27001/27017/27018) + SoA pre-popolato da NIS2

Nuovo modulo guidato in 6 step (cl. 4-10 + Statement of Applicability):
- migration 037 (isms_models/roles/soa/documents) + 038 (dataset 111 controlli:
  93 Annex A:2022 + 7 CLD/27017 + 11 PII/27018) + runner scripts/migrate-isms.php
- IsmsModelController (16 endpoint) registrato in index.php
- SoA pre-popolato dalle risposte Gap Analysis NIS2 (mapping iso27001_control)
- estensioni cloud condizionali 27017/27018 via flag uses_public_cloud/
  is_cloud_provider/processes_pii_in_cloud
- AIService::generateIsmsDocument + fonti ISO in nis2_sources.php
- frontend isms.html/isms.js + api client + sidebar + help + i18n IT/EN
- ingest KB ISO (scope SYSTEM, solo titoli/sintesi: no testo coperto da copyright)
- version.json 1.14.0; doc studio + deploy handoff

Strumento di supporto/pre-audit (non certificazione). Migration DA APPLICARE su host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-11 11:32:42 +02:00
parent 66d50cd2cf
commit 3f74165531
17 changed files with 2166 additions and 1 deletions
+173
View File
@@ -0,0 +1,173 @@
<?php
/**
* NIS2 Agile - Ingest fonti ISO 27000 nella Knowledge Base (RAG)
* ----------------------------------------------------------------------------
* Indicizza nella collection Qdrant `nis2_kb` (scope SYSTEM) il CATALOGO CURATO
* dei controlli ISO (dalla tabella iso27001_annex_controls) + le descrizioni
* delle fonti ISO registrate in application/config/nis2_sources.php, cosi che
* AIService::askWithRag() e il modulo SGSI abbiano grounding sui controlli
* 27001/27017/27018.
*
* IMPORTANTE (copyright): NON viene indicizzato il testo letterale delle norme
* ISO (coperto da copyright), ma solo titoli/sintesi proprietari e riferimenti.
*
* PREREQUISITI: migration 038 applicata (tabella iso27001_annex_controls
* popolata) + accesso a Qdrant + Voyage. ESEGUIRE SU HETZNER:
* docker exec -i nis2-app php /var/www/nis2-agile/scripts/ingest-iso-sources.php
*
* Opzioni:
* --dry-run costruisce i chunk e stampa le statistiche senza upsert
* ============================================================================
*/
if (PHP_SAPI !== 'cli') { fwrite(STDERR, "Solo CLI\n"); exit(1); }
if (!defined('BASE_PATH')) define('BASE_PATH', dirname(__DIR__));
if (!defined('APP_PATH')) define('APP_PATH', BASE_PATH . '/application');
require_once APP_PATH . '/config/env.php';
require_once APP_PATH . '/config/config.php';
require_once APP_PATH . '/config/database.php';
require_once APP_PATH . '/services/EmbedService.php';
require_once APP_PATH . '/services/VectorService.php';
$opts = getopt('', ['dry-run']);
$dryRun = isset($opts['dry-run']);
function logln(string $m): void { echo '[' . date('Y-m-d H:i:s') . "] $m\n"; }
function uuid(): string
{
$b = random_bytes(16);
$b[6] = chr((ord($b[6]) & 0x0f) | 0x40);
$b[8] = chr((ord($b[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4));
}
function chunkText(string $text, int $size = 2000, int $overlap = 200): array
{
$text = mb_convert_encoding($text, 'UTF-8', 'UTF-8');
$chunks = []; $len = mb_strlen($text, 'UTF-8'); $start = 0;
while ($start < $len) {
$take = min($size, $len - $start);
$piece = mb_substr($text, $start, $take, 'UTF-8');
if (trim($piece) !== '') $chunks[] = $piece;
if ($start + $take >= $len) break;
$start += ($size - $overlap);
}
return $chunks;
}
$sources = require APP_PATH . '/config/nis2_sources.php';
$isoKeys = ['iso_27001_2022','iso_27002_2022','iso_27017_2015','iso_27018_2019'];
// Costruisce un documento di testo per ciascuno standard ISO: descrizione fonte
// + elenco dei controlli (codice, titolo IT/EN, tema) presi dal DB curato.
$standardMap = [
'iso_27001_2022' => 'iso27001',
'iso_27017_2015' => 'iso27017',
'iso_27018_2019' => 'iso27018',
];
logln('=== Ingest fonti ISO 27000 nella KB (scope SYSTEM) ===');
if ($dryRun) logln('MODALITA DRY-RUN: nessun upsert.');
$embed = null; $vector = null;
if (!$dryRun) {
$embed = new EmbedService();
$vector = new VectorService();
$vector->ensureCollection($embed->dims);
}
$totalChunks = 0; $done = 0;
foreach ($isoKeys as $key) {
if (!isset($sources[$key])) { logln("SKIP {$key}: non nel registry"); continue; }
$src = $sources[$key];
$body = "FONTE: {$src['citation']}\nAUTORITA: {$src['authority']}\n\n{$src['full']}\n\n";
// Catalogo controlli (se questo standard ha una mappatura DB)
if (isset($standardMap[$key])) {
$std = $standardMap[$key];
try {
$rows = Database::fetchAll(
'SELECT control_code, theme, title_it, title_en, iso27002_ref, condition_tag
FROM iso27001_annex_controls WHERE standard = ? ORDER BY sort_order',
[$std]
);
} catch (Throwable $e) {
logln(" ERRORE lettura iso27001_annex_controls (migration 038 applicata?): " . $e->getMessage());
$rows = [];
}
if ($rows) {
$body .= "CATALOGO CONTROLLI ({$src['short']}):\n";
foreach ($rows as $r) {
$cond = $r['condition_tag'] ? " [condizionale: {$r['condition_tag']}]" : '';
$ref = $r['iso27002_ref'] ? " ({$r['iso27002_ref']})" : '';
$body .= "- {$r['control_code']} - {$r['title_it']} / {$r['title_en']} [tema: {$r['theme']}]{$ref}{$cond}\n";
}
}
}
$body = preg_replace('/[ \t]+/', ' ', $body);
$chunks = chunkText($body, 2000, 200);
logln("Fonte: {$src['short']} -> " . count($chunks) . ' chunk');
$totalChunks += count($chunks);
if ($dryRun) { $done++; continue; }
// Idempotenza: rimuovi i chunk SYSTEM esistenti per questa fonte
try {
$vector->deleteByFilter(['must' => [
['key' => 'scope', 'match' => ['value' => 'SYSTEM']],
['key' => 'source', 'match' => ['value' => $src['citation']]],
]]);
} catch (Exception $e) { logln(' (warning) delete precedente: ' . $e->getMessage()); }
$docUuid = uuid();
$points = [];
foreach ($chunks as $i => $chunk) {
$vec = null;
for ($try = 1; $try <= 5; $try++) {
try { $vec = $embed->embed($chunk); break; }
catch (Throwable $e) {
if ($try === 5) { logln(" ERRORE embed chunk {$i}: " . $e->getMessage()); throw $e; }
sleep($try);
}
}
$points[] = [
'id' => uuid(),
'vector' => $vec,
'payload' => [
'doc_uuid' => $docUuid,
'title' => $src['short'] . ($i > 0 ? ' (parte ' . ($i + 1) . ')' : ''),
'chunk' => $chunk,
'entity_type' => 'standard_iso',
'source' => $src['citation'],
'lang' => 'it',
'scope' => 'SYSTEM',
'consulting_firm_id' => null,
'organization_id' => null,
'shared_with_orgs' => [],
'uploaded_by' => 0,
],
];
}
foreach (array_chunk($points, 64) as $batch) {
$vector->upsertBatch($batch);
}
try {
$stmt = Database::getInstance()->prepare(
"INSERT INTO kb_uploaded_documents
(qdrant_doc_uuid, scope, consulting_firm_id, organization_id, uploaded_by, title, entity_type, source, lang, chunk_count, shared_with_orgs, status)
VALUES (?, 'SYSTEM', NULL, NULL, 0, ?, 'standard_iso', ?, 'it', ?, '[]', 'ready')"
);
$stmt->execute([$docUuid, $src['short'], $src['citation'], count($chunks)]);
} catch (Exception $e) { logln(' (warning) tracking insert: ' . $e->getMessage()); }
logln(" OK indicizzato (doc_uuid={$docUuid})");
$done++;
}
logln("=== Completato: {$done} fonti ISO, {$totalChunks} chunk totali ===");