[], // ['email' => token] 'orgs' => [], // ['slug' => ['id', 'name', 'jwt']] 'users' => [], // ['email' => ['id', 'jwt']] 'stats' => ['pass' => 0, 'skip' => 0, 'fail' => 0, 'warn' => 0], 'sim_start'=> microtime(true), ]; // ── Output helpers ────────────────────────────────────────────────────────── function simLog(string $msg, string $type = 'info'): void { static $lastHeartbeat = 0; $ts = date('H:i:s'); if (IS_CLI) { $prefix = [ 'phase' => "\033[34m══\033[0m", 'ok' => "\033[32m ✓\033[0m", 'skip' => "\033[90m →\033[0m", 'warn' => "\033[33m ⚠\033[0m", 'error' => "\033[31m ✗\033[0m", 'email' => "\033[36m ✉\033[0m", 'info' => ' ', ][$type] ?? ' '; echo "[$ts] {$prefix} {$msg}\n"; flush(); } else { // Heartbeat SSE ogni 25s — mantiene viva la connessione attraverso proxy (pattern lg231) $now = time(); if ($now - $lastHeartbeat >= 25) { echo ": heartbeat " . $ts . "\n\n"; $lastHeartbeat = $now; } echo 'data: ' . json_encode(['t' => $type, 'm' => "[$ts] $msg"]) . "\n\n"; flush(); } } function simDone(array $stats): void { $elapsed = round(microtime(true) - $GLOBALS['S']['sim_start'], 1); $msg = "Simulazione completata in {$elapsed}s — ✓{$stats['pass']} →{$stats['skip']} ⚠{$stats['warn']} ✗{$stats['fail']}"; simLog($msg, IS_CLI ? 'phase' : 'info'); if (IS_WEB) { echo 'data: ' . json_encode(['t' => 'done', 'stats' => $stats]) . "\n\n"; flush(); } } function simPhase(int $n, string $title): void { if (IS_CLI) { $line = str_pad("FASE {$n}: {$title}", 60); echo "\n\033[34m╔════════════════════════════════════════════════════════════╗\033[0m\n"; echo "\033[34m║ {$line} ║\033[0m\n"; echo "\033[34m╚════════════════════════════════════════════════════════════╝\033[0m\n"; } else { simLog("━━ FASE {$n}: {$title}", 'phase'); } } function ok(string $msg): void { global $S; $S['stats']['pass']++; simLog($msg, 'ok'); } function skip(string $msg): void { global $S; $S['stats']['skip']++; simLog($msg, 'skip'); } function fail(string $msg): void { global $S; $S['stats']['fail']++; simLog($msg, 'error'); } function warn(string $msg): void { global $S; $S['stats']['warn']++; simLog($msg, 'warn'); } function info(string $msg): void { simLog($msg, 'info'); } // ── API client ────────────────────────────────────────────────────────────── function api(string $method, string $path, ?array $body = null, ?string $jwt = null, ?int $orgId = null): array { $url = API_BASE . $path; $ch = curl_init($url); $headers = ['Content-Type: application/json', 'Accept: application/json']; if ($jwt) $headers[] = 'Authorization: Bearer ' . $jwt; if ($orgId) $headers[] = 'X-Organization-Id: ' . $orgId; curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => $headers, CURLOPT_CUSTOMREQUEST => strtoupper($method), CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE)); } $raw = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($raw === false) return ['success' => false, 'error' => 'cURL fallito: ' . $url, '_http' => 0]; $res = json_decode($raw, true) ?? ['success' => false, 'error' => 'JSON invalido', '_raw' => substr($raw, 0, 200)]; $res['_http'] = $code; return $res; } function apiOk(array $res, string $ctx = ''): bool { if (!empty($res['success'])) return true; $err = $res['error'] ?? ($res['message'] ?? 'errore'); warn("API Error" . ($ctx ? " [$ctx]" : '') . ": $err (HTTP {$res['_http']})"); return false; } // ── Idempotent helpers ─────────────────────────────────────────────────────── /** * Seed utente direttamente nel DB (bypassa rate limit HTTP). * Ritorna true se inserito/già esistente. */ function dbSeedUser(string $fullName, string $email, string $password, string $role): bool { $envFile = __DIR__ . '/.env'; if (!is_file($envFile)) return false; $env = []; foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue; [$k, $v] = explode('=', $line, 2); $env[trim($k)] = trim($v); } try { $dbName = $env['DB_NAME'] ?? 'nis2_agile_db'; $dbHost = $env['DB_HOST'] ?? '127.0.0.1'; $dbPort = $env['DB_PORT'] ?? '3306'; // Prova prima root senza password (socket auth su server prod) // Poi fallback a credenziali .env $pdo = null; foreach ([['root',''], [$env['DB_USER']??'nis2_user', $env['DB_PASS']??'']] as [$u,$p]) { try { $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4", $u, $p, [PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]); break; } catch (\Throwable) { $pdo = null; } } if (!$pdo) return false; $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]); $pdo->prepare( 'INSERT INTO users (email, password_hash, full_name, role, is_active) VALUES (?,?,?,?,1) ON DUPLICATE KEY UPDATE password_hash=VALUES(password_hash), full_name=VALUES(full_name), role=VALUES(role), is_active=1' )->execute([$email, $hash, $fullName, $role]); return true; } catch (\Throwable $e) { return false; } } /** Registra un utente se non esiste già, ritorna il JWT. */ function ensureUser(string $firstName, string $lastName, string $email, string $password, string $role = 'org_admin'): ?string { global $S; if (isset($S['users'][$email]['jwt'])) { skip("Utente $email già loggato"); return $S['users'][$email]['jwt']; } // Seed diretto nel DB prima (idempotente: ON DUPLICATE KEY UPDATE). // Evita il rate limit: solo 1 chiamata login invece di 2 (fail + success). $fullName = trim("$firstName $lastName"); $seeded = dbSeedUser($fullName, $email, $password, $role); // Login (funziona sia se utente era già nel DB sia se appena seedato) $loginRes = api('POST', '/auth/login', ['email' => $email, 'password' => $password]); if (!empty($loginRes['data']['access_token'])) { $jwt = $loginRes['data']['access_token']; $S['users'][$email] = ['jwt' => $jwt, 'id' => $loginRes['data']['user']['id'] ?? null]; ok($seeded ? "Registrato: $firstName $lastName <$email>" : "Login: $email (già registrato)"); return $jwt; } // Fallback: registrazione via API (se DB seed fallisce — es. ambiente dev senza accesso diretto) $regRes = api('POST', '/auth/register', [ 'full_name' => $fullName, 'email' => $email, 'password' => $password, 'role' => $role, ]); if (!empty($regRes['data']['access_token'])) { $jwt = $regRes['data']['access_token']; $S['users'][$email] = ['jwt' => $jwt, 'id' => $regRes['data']['user']['id'] ?? null]; ok("Registrato (API): $firstName $lastName <$email>"); return $jwt; } // Ultimo tentativo: se register fallisce per "email già registrata" (utente inserito da // dbSeedUser ma login aveva fallito per motivo transitorio), riprova il login. $regMsg = strtolower($regRes['message'] ?? ''); if (str_contains($regMsg, 'registrat') || str_contains($regMsg, 'esiste') || $regRes['_http'] === 409) { $retryLogin = api('POST', '/auth/login', ['email' => $email, 'password' => $password]); if (!empty($retryLogin['data']['access_token'])) { $jwt = $retryLogin['data']['access_token']; $S['users'][$email] = ['jwt' => $jwt, 'id' => $retryLogin['data']['user']['id'] ?? null]; ok("Login (retry): $email"); return $jwt; } } fail("Registrazione fallita per $email: " . ($regRes['message'] ?? 'errore')); return null; } /** Crea un'organizzazione se non esiste (check per name), ritorna org_id. */ function ensureOrg(string $jwt, array $data): ?int { global $S; $name = $data['name']; // Lista organizzazioni esistenti $listRes = api('GET', '/organizations/list', null, $jwt); if (!empty($listRes['data'])) { foreach ($listRes['data'] as $org) { if (($org['name'] ?? '') === $name) { $id = (int) $org['id']; skip("Org già esistente: $name (id=$id)"); return $id; } } } $createRes = api('POST', '/organizations/create', $data, $jwt); if (!empty($createRes['data']['id'])) { $id = (int) $createRes['data']['id']; ok("Org creata: $name (id=$id)"); return $id; } fail("Org creazione fallita: $name — " . ($createRes['error'] ?? 'errore')); return null; } /** Aggiorna dati org (onboarding simulato). */ function completeOnboarding(string $jwt, int $orgId, array $data): void { // La sim crea l'org via ensureOrg, poi usa PUT per aggiornare i dati // (evita il 409 di /onboarding/complete che presuppone wizard da zero) $updateData = []; if (isset($data['employee_count'])) $updateData['employee_count'] = $data['employee_count']; if (isset($data['annual_turnover_eur'])) $updateData['annual_turnover_eur'] = $data['annual_turnover_eur']; if (isset($data['vat_number'])) $updateData['vat_number'] = $data['vat_number']; if (!empty($updateData)) { $res = api('PUT', "/organizations/{$orgId}", $updateData, $jwt, $orgId); if (apiOk($res, 'org.update')) { ok("Dati org aggiornati: #$orgId"); } } } /** Classifica l'org come NIS2 Essential/Important. */ function classifyOrg(string $jwt, int $orgId, array $data): void { $res = api('POST', '/organizations/classify', $data, $jwt, $orgId); if (apiOk($res, 'classify')) { $entityType = $res['data']['entity_type'] ?? ($data['nis2_type'] ?? '?'); ok("Classificazione NIS2: {$entityType} — Settore: {$data['sector']}"); } } /** Crea assessment e risponde a tutte le domande. */ function runAssessment(string $jwt, int $orgId, string $orgName, array $responses): void { // Crea assessment $createRes = api('POST', '/assessments/create', [ 'title' => "Gap Assessment NIS2 — $orgName", 'description' => 'Assessment automatico da simulazione demo', ], $jwt, $orgId); if (empty($createRes['data']['id'])) { fail("Assessment create fallito per $orgName"); return; } $assessId = (int) $createRes['data']['id']; ok("Assessment creato #$assessId per $orgName"); // Rispondi a domande $answered = 0; foreach ($responses as $qCode => $val) { $res = api('POST', "/assessments/{$assessId}/respond", [ 'question_code' => $qCode, 'response_value' => $val, 'notes' => null, ], $jwt, $orgId); if (!empty($res['success'])) $answered++; } ok("Risposte inviate: $answered/".count($responses)." per $orgName"); // Completa assessment $completeRes = api('POST', "/assessments/{$assessId}/complete", [], $jwt, $orgId); if (apiOk($completeRes, 'assessment.complete')) { $score = $completeRes['data']['compliance_score'] ?? 'N/A'; ok("Assessment completato — Score: $score%"); } } /** Crea un rischio. Ritorna risk_id o null. */ function createRisk(string $jwt, int $orgId, array $data): ?int { $res = api('POST', '/risks/create', $data, $jwt, $orgId); if (!empty($res['data']['id'])) { $id = (int) $res['data']['id']; $level = $data['risk_level'] ?? '?'; ok("Rischio creato [{$level}]: {$data['title']} #$id"); return $id; } fail("Rischio fallito: {$data['title']} — " . ($res['error'] ?? '')); return null; } /** Crea un incidente. Ritorna incident_id o null. */ function createIncident(string $jwt, int $orgId, array $data): ?int { $res = api('POST', '/incidents/create', $data, $jwt, $orgId); if (!empty($res['data']['id'])) { $id = (int) $res['data']['id']; ok("Incidente creato [{$data['severity']}]: {$data['title']} #$id"); return $id; } fail("Incidente fallito: {$data['title']} — " . ($res['error'] ?? '')); return null; } /** Crea una policy. Ritorna policy_id o null. */ function createPolicy(string $jwt, int $orgId, array $data): ?int { $res = api('POST', '/policies/create', $data, $jwt, $orgId); if (!empty($res['data']['id'])) { $id = (int) $res['data']['id']; ok("Policy creata: {$data['title']} #$id"); return $id; } fail("Policy fallita: {$data['title']} — " . ($res['error'] ?? '')); return null; } /** Crea un fornitore. Ritorna supplier_id o null. */ function createSupplier(string $jwt, int $orgId, array $data): ?int { $res = api('POST', '/supply-chain/create', $data, $jwt, $orgId); if (!empty($res['data']['id'])) { $id = (int) $res['data']['id']; ok("Fornitore creato: {$data['name']} #$id"); return $id; } fail("Fornitore fallito: {$data['name']} — " . ($res['error'] ?? '')); return null; } /** Verifica audit trail hash chain. */ function checkAuditChain(string $jwt, int $orgId, string $label): void { $res = api('GET', '/audit/chain-verify', null, $jwt, $orgId); if (!apiOk($res, "chain-verify $label")) return; $d = $res['data'] ?? []; $valid = $d['valid'] ? 'INTEGRA' : 'CORROTTA'; $cov = $d['coverage_pct'] ?? 0; $total = $d['total'] ?? 0; $hashed = $d['hashed'] ?? 0; $type = $d['valid'] ? 'ok' : 'error'; simLog("Catena [{$label}] — {$valid} — {$hashed}/{$total} record hashati ({$cov}%)", $type); if (!$d['valid']) { fail("ATTENZIONE: hash chain rotta al record #" . ($d['broken_at'] ?? '?')); } } // ── Definizione Aziende Demo ───────────────────────────────────────────────── $COMPANIES = [ // ── A) DataCore S.r.l. ────────────────────────────────────────────────── 'datacore' => [ 'name' => 'DataCore S.r.l.', 'legal_form' => 'S.r.l.', 'vat_number' => '09876543217', 'ateco_code' => '62.01', 'ateco_desc' => 'Produzione di software non connesso all\'edizione', 'employees' => 320, 'annual_turnover'=> 18500000, 'sector' => 'ict_services', 'nis2_type' => 'essential', 'city' => 'Milano', 'province' => 'MI', 'region' => 'Lombardia', 'target_score' => 74, 'complexity' => 'ALTA', 'users' => [ ['first' => 'Alessandro', 'last' => 'Ferretti', 'email' => 'admin@datacore-srl.demo', 'role' => 'org_admin'], ['first' => 'Ing. Chiara','last' => 'Bianchi', 'email' => 'ciso@datacore-srl.demo', 'role' => 'compliance_manager'], ['first' => 'Marco', 'last' => 'Negri', 'email' => 'auditor@datacore-srl.demo', 'role' => 'auditor'], ], 'risks' => [ ['title' => 'Ransomware su infrastruttura cloud', 'category' => 'cyber', 'likelihood' => 4, 'impact' => 5, 'nis2_article' => '21.2.b'], ['title' => 'Accesso non autorizzato a sistemi di produzione', 'category' => 'cyber', 'likelihood' => 3, 'impact' => 5, 'nis2_article' => '21.2.i'], ['title' => 'Data breach clienti enterprise (API key leak)', 'category' => 'compliance', 'likelihood' => 3, 'impact' => 4, 'nis2_article' => '21.2.b'], ['title' => 'Vulnerabilità zero-day in dipendenze npm', 'category' => 'cyber', 'likelihood' => 4, 'impact' => 3, 'nis2_article' => '21.2.e'], ['title' => 'Supply chain compromise — fornitore DevOps', 'category' => 'supply_chain', 'likelihood' => 2, 'impact' => 5, 'nis2_article' => '21.2.d'], ['title' => 'DDoS su API gateway prod', 'category' => 'operational', 'likelihood' => 3, 'impact' => 3, 'nis2_article' => '21.2.c'], ], 'policies' => [ ['title' => 'Politica Gestione Incidenti di Sicurezza — Art.21.2.b NIS2', 'type' => 'incident_response', 'nis2_article' => '21.2.b'], ['title' => 'Politica Crittografia e Gestione Chiavi — Art.21.2.h NIS2', 'type' => 'cryptography', 'nis2_article' => '21.2.h'], ['title' => 'Politica Accesso Privilegiato e MFA — Art.21.2.j NIS2', 'type' => 'access_control', 'nis2_article' => '21.2.j'], ], 'suppliers' => [ ['name' => 'CloudVault SIEM S.r.l.', 'service_type' => 'security_service', 'risk_level' => 'high', 'critical' => 1], ['name' => 'DevOps Pipeline S.r.l.', 'service_type' => 'software_dev', 'risk_level' => 'medium', 'critical' => 1], ['name' => 'Fibernet Telecomunicazioni', 'service_type' => 'network_provider', 'risk_level' => 'medium', 'critical' => 0], ], 'wb_events' => [], ], // ── B) MedClinic Italia S.p.A. ────────────────────────────────────────── 'medclinic' => [ 'name' => 'MedClinic Italia S.p.A.', 'legal_form' => 'S.p.A.', 'vat_number' => '07654321095', 'ateco_code' => '86.10', 'ateco_desc' => 'Servizi ospedalieri', 'employees' => 750, 'annual_turnover'=> 42000000, 'sector' => 'health', 'nis2_type' => 'important', 'city' => 'Roma', 'province' => 'RM', 'region' => 'Lazio', 'target_score' => 68, 'complexity' => 'ALTA', 'users' => [ ['first' => 'Dott. Roberto', 'last' => 'Conti', 'email' => 'admin@medclinic-spa.demo', 'role' => 'org_admin'], ['first' => 'Dott.ssa Laura','last' => 'Moretti', 'email' => 'ciso@medclinic-spa.demo', 'role' => 'compliance_manager'], ], 'risks' => [ ['title' => 'Violazione dati sanitari pazienti (GDPR+NIS2)', 'category' => 'compliance', 'likelihood' => 4, 'impact' => 5, 'nis2_article' => '21.2.b'], ['title' => 'Fermo sistemi HIS (Hospital Information System)', 'category' => 'operational', 'likelihood' => 3, 'impact' => 5, 'nis2_article' => '21.2.c'], ['title' => 'Compromissione fornitore LIS (laboratorio analisi)', 'category' => 'supply_chain', 'likelihood' => 3, 'impact' => 5, 'nis2_article' => '21.2.d'], ['title' => 'Accesso abusivo cartelle cliniche digitali', 'category' => 'cyber', 'likelihood' => 4, 'impact' => 4, 'nis2_article' => '21.2.i'], ['title' => 'Phishing staff medico (credential harvesting)', 'category' => 'human', 'likelihood' => 5, 'impact' => 3, 'nis2_article' => '21.2.g'], ], 'policies' => [ ['title' => 'Politica Protezione Dati Sanitari — Art.21.2.b NIS2 + GDPR', 'type' => 'data_protection', 'nis2_article' => '21.2.b'], ['title' => 'Politica Business Continuity Sistemi Clinici — Art.21.2.c', 'type' => 'business_continuity','nis2_article' => '21.2.c'], ], 'suppliers' => [ ['name' => 'HealthSoft LIS S.r.l.', 'service_type' => 'software_vendor', 'risk_level' => 'critical', 'critical' => 1], ['name' => 'MedDevice IoT S.p.A.', 'service_type' => 'hardware_vendor', 'risk_level' => 'high', 'critical' => 1], ['name' => 'CloudBackup Med S.r.l.', 'service_type' => 'cloud_storage', 'risk_level' => 'medium', 'critical' => 0], ], 'wb_events' => [], ], // ── C) EnerNet Distribuzione S.r.l. ───────────────────────────────────── 'enernet' => [ 'name' => 'EnerNet Distribuzione S.r.l.', 'legal_form' => 'S.r.l.', 'vat_number' => '05432109873', 'ateco_code' => '35.13', 'ateco_desc' => 'Distribuzione di energia elettrica', 'employees' => 1800, 'annual_turnover'=> 125000000, 'sector' => 'energy', 'nis2_type' => 'essential', 'city' => 'Torino', 'province' => 'TO', 'region' => 'Piemonte', 'target_score' => 79, 'complexity' => 'ALTISSIMA', 'users' => [ ['first' => 'Ing. Paolo', 'last' => 'Martinelli', 'email' => 'admin@enernet-srl.demo', 'role' => 'org_admin'], ['first' => 'Dott. Simona', 'last' => 'Galli', 'email' => 'ciso@enernet-srl.demo', 'role' => 'compliance_manager'], ['first' => 'Prof. Carlo', 'last' => 'Ricci', 'email' => 'auditor@enernet-srl.demo', 'role' => 'auditor'], ['first' => 'Anna', 'last' => 'Zanetti', 'email' => 'board@enernet-srl.demo', 'role' => 'board_member'], ], 'risks' => [ ['title' => 'Attacco a sistemi SCADA/OT di controllo rete elettrica', 'category' => 'cyber', 'likelihood' => 3, 'impact' => 5, 'nis2_article' => '21.2.b'], ['title' => 'Interruzione fornitura energia (blackout doloso)', 'category' => 'operational', 'likelihood' => 2, 'impact' => 5, 'nis2_article' => '21.2.c'], ['title' => 'Accesso non autorizzato sistemi AMI (smart meter)', 'category' => 'cyber', 'likelihood' => 3, 'impact' => 4, 'nis2_article' => '21.2.i'], ['title' => 'Supply chain — firmware malevolo contatori', 'category' => 'supply_chain', 'likelihood' => 2, 'impact' => 5, 'nis2_article' => '21.2.d'], ['title' => 'Insider threat — tecnico campo con accesso SCADA', 'category' => 'human', 'likelihood' => 2, 'impact' => 5, 'nis2_article' => '21.2.i'], ['title' => 'Incidente climatico su infrastruttura fisica (alluvione)', 'category' => 'physical', 'likelihood' => 2, 'impact' => 4, 'nis2_article' => '21.2.c'], ['title' => 'Vulnerabilità protocollo IEC 61850 su sottostazioni', 'category' => 'cyber', 'likelihood' => 3, 'impact' => 4, 'nis2_article' => '21.2.a'], ], 'policies' => [ ['title' => 'Politica Sicurezza Sistemi OT/SCADA — Art.21.2.a NIS2', 'type' => 'risk_management', 'nis2_article' => '21.2.a'], ['title' => 'Politica Business Continuity Rete Elettrica — Art.21.2.c', 'type' => 'business_continuity','nis2_article' => '21.2.c'], ['title' => 'Politica Sicurezza Supply Chain — Art.21.2.d NIS2', 'type' => 'supply_chain', 'nis2_article' => '21.2.d'], ], 'suppliers' => [ ['name' => 'ABB Automazione S.p.A.', 'service_type' => 'ot_vendor', 'risk_level' => 'critical', 'critical' => 1], ['name' => 'SmartMeter Tech S.r.l.', 'service_type' => 'hardware_vendor', 'risk_level' => 'high', 'critical' => 1], ['name' => 'NordTelecom Fibra S.p.A.', 'service_type' => 'network_provider', 'risk_level' => 'medium', 'critical' => 1], ['name' => 'Alstom Grid Italia S.r.l.', 'service_type' => 'ot_vendor', 'risk_level' => 'critical','critical' => 1], ], 'wb_events' => [ [ 'category' => 'unauthorized_access', 'title' => 'Accesso non autorizzato sistema SCADA sottostazione Moncalieri', 'description' => 'Tecnico esterno di manutenzione ha tentato accesso a sistema SCADA secondario con credenziali non autorizzate. Accesso bloccato da firewall OT ma tentativo registrato nei log. Possibile insider threat o furto credenziali.', 'priority' => 'critical', 'nis2_article'=> '21.2.i', 'is_anonymous'=> 1, ], ], ], ]; // ── Risposte assessment standard per settore ───────────────────────────────── // Formato: question_code => valore (1=no, 2=parziale, 3=sì) function getAssessmentResponses(string $sector): array { $base = []; // 80 domande NIS2 (categorie RM01-RM10, da 01 a 08 per categoria) $categories = ['RM', 'IH', 'BC', 'SC', 'SD', 'EA', 'TR', 'CR', 'AM', 'MA']; foreach ($categories as $cat) { for ($i = 1; $i <= 8; $i++) { $base[sprintf('%s%02d', $cat, $i)] = 1; // default: non implementato } } // Sovrascritture per settore (rendono la simulazione realistica) $overrides = match ($sector) { 'ict' => [ 'RM01' => 3, 'RM02' => 3, 'RM03' => 2, 'RM04' => 2, 'IH01' => 3, 'IH02' => 2, 'IH03' => 2, 'SD01' => 3, 'SD02' => 3, 'SD03' => 2, 'CR01' => 3, 'CR02' => 3, 'AM01' => 3, 'AM02' => 2, 'AM03' => 2, 'MA01' => 3, 'MA02' => 2, ], 'healthcare' => [ 'RM01' => 2, 'RM02' => 2, 'AM01' => 3, 'AM02' => 3, 'TR01' => 2, 'TR02' => 2, 'BC01' => 2, 'BC02' => 1, 'CR01' => 2, ], 'energy' => [ 'RM01' => 3, 'RM02' => 2, 'RM03' => 3, 'BC01' => 3, 'BC02' => 3, 'BC03' => 2, 'AM01' => 3, 'AM02' => 3, 'AM03' => 3, 'SC01' => 2, 'SC02' => 2, 'MA01' => 3, 'MA02' => 3, 'MA03' => 2, ], default => [], }; return array_merge($base, $overrides); } // ──────────────────────────────────────────────────────────────────────────── // Auto-Reset Demo Data — eseguito prima di ogni run SIM-01→05 // Usa PDO con nis2_user (NO SUPER privilege → non tocca audit_logs/trigger) // ──────────────────────────────────────────────────────────────────────────── function autoResetDemo(): void { $host = readEnvValue('DB_HOST', 'localhost'); $port = readEnvValue('DB_PORT', '3306'); $name = readEnvValue('DB_NAME', 'nis2_agile_db'); $user = readEnvValue('DB_USER', 'nis2_user'); $pass = readEnvValue('DB_PASS', ''); try { $pdo = new PDO( "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4", $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 10] ); } catch (\Throwable $e) { simLog('Auto-reset: connessione DB fallita — ' . $e->getMessage(), 'warn'); return; } $pdo->exec('SET FOREIGN_KEY_CHECKS=0'); // Tabelle con organization_id (ordine: dipendenti prima dei parent) $orgTables = [ 'incident_timeline' => 'incident_id IN (SELECT id FROM incidents WHERE organization_id > 4)', 'incidents' => 'organization_id > 4', 'risk_treatments' => 'risk_id IN (SELECT id FROM risks WHERE organization_id > 4)', 'risks' => 'organization_id > 4', 'assessment_responses' => 'assessment_id IN (SELECT id FROM assessments WHERE organization_id > 4)', 'assessments' => 'organization_id > 4', 'policies' => 'organization_id > 4', 'suppliers' => 'organization_id > 4', 'training_assignments' => 'course_id IN (SELECT id FROM training_courses WHERE organization_id > 4)', 'training_courses' => 'organization_id > 4', 'assets' => 'organization_id > 4', 'compliance_controls' => 'organization_id > 4', 'evidence_files' => 'organization_id > 4', 'capa_actions' => 'ncr_id IN (SELECT id FROM non_conformities WHERE organization_id > 4)', 'non_conformities' => 'organization_id > 4', 'whistleblowing_timeline' => 'report_id IN (SELECT id FROM whistleblowing_reports WHERE organization_id > 4)', 'whistleblowing_reports' => 'organization_id > 4', 'normative_ack' => 'organization_id > 4', 'webhook_deliveries' => 'subscription_id IN (SELECT id FROM webhook_subscriptions WHERE organization_id > 4)', 'webhook_subscriptions' => 'organization_id > 4', 'api_keys' => 'organization_id > 4', 'feedback_reports' => 'organization_id > 4', 'ai_interactions' => 'organization_id > 4', // audit_logs: trigger immutabile blocca DELETE — skip (dati storici accettabili) ]; $deleted = 0; foreach ($orgTables as $table => $where) { try { $n = $pdo->exec("DELETE FROM `{$table}` WHERE {$where}"); $deleted += ($n ?: 0); } catch (\Throwable $e) { simLog(" skip {$table}: " . $e->getMessage(), 'skip'); } } // Email log (no organization_id) try { $pdo->exec("DELETE FROM email_log WHERE recipient LIKE '%.demo%'"); } catch (\Throwable) {} // User organizations + refresh tokens demo try { $pdo->exec('DELETE FROM user_organizations WHERE organization_id > 4'); } catch (\Throwable) {} try { $pdo->exec("DELETE rt FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE u.email LIKE '%.demo%'"); } catch (\Throwable) {} // Utenti demo try { $n = $pdo->exec("DELETE FROM users WHERE email LIKE '%.demo%'"); $deleted += ($n ?: 0); } catch (\Throwable) {} // Organizzazioni demo try { $n = $pdo->exec('DELETE FROM organizations WHERE id > 4'); $deleted += ($n ?: 0); } catch (\Throwable) {} $pdo->exec('SET FOREIGN_KEY_CHECKS=1'); // Pulisce i file rate limit (register + login) per permettere re-esecuzione immediata $rateDir = '/tmp/nis2_ratelimit/'; $cleared = 0; if (is_dir($rateDir)) { foreach (glob($rateDir . '*.json') ?: [] as $f) { if (@unlink($f)) $cleared++; } } // Verifica org rimaste $orgCount = $pdo->query('SELECT COUNT(*) FROM organizations')->fetchColumn(); simLog("Auto-reset completato — {$deleted} record rimossi, {$orgCount} org base mantenute" . ($cleared ? ", {$cleared} rate-limit file rimossi" : ''), 'ok'); } // ──────────────────────────────────────────────────────────────────────────── // FASE 0 — Health Check // ──────────────────────────────────────────────────────────────────────────── simPhase(0, 'Health Check API'); $health = api('GET', '/api-status.php' === '' ? '/health' : ''); // Usa api-status.php direttamente $ch = curl_init(str_replace('/api', '', API_BASE) . '/api-status.php'); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0]); $raw = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $hRes = json_decode($raw ?: '{}', true) ?? []; if ($code === 200 && !empty($hRes['status'])) { ok("API health: {$hRes['status']} — DB: " . ($hRes['database'] ?? '?')); } else { warn("API health check degradato (HTTP $code) — continuo comunque"); } // ──────────────────────────────────────────────────────────────────────────── // FASE 0b — Auto-Reset Dati Demo Precedenti // (skip se si lancia solo SIM-06 che è indipendente) // ──────────────────────────────────────────────────────────────────────────── if ($SIM_FILTER !== 'SIM06') { simPhase(0, 'Auto-Reset Dati Demo Precedenti'); autoResetDemo(); } // ──────────────────────────────────────────────────────────────────────────── // SIM-01→05 — skip se si esegue solo SIM-06 // ──────────────────────────────────────────────────────────────────────────── if ($SIM_FILTER === 'SIM06') { skip('SIM-01→05 skippate: filtro SIM06 attivo (simulazioni indipendenti)'); goto sim06_start; } // ──────────────────────────────────────────────────────────────────────────── // SIM-01 — FASE 1+2: Registrazione Aziende + Onboarding // ──────────────────────────────────────────────────────────────────────────── simPhase(1, 'SIM-01 — Registrazione Aziende Demo + Onboarding'); info('Creazione di 3 aziende NIS2 con settori e classificazioni differenti...'); foreach ($COMPANIES as $slug => $comp) { simLog("── Azienda [{$comp['nis2_type']}]: {$comp['name']} ({$comp['sector']})", 'phase'); // Registra admin $adminDef = $comp['users'][0]; $jwt = ensureUser($adminDef['first'], $adminDef['last'], $adminDef['email'], DEMO_PWD, 'org_admin'); if (!$jwt) continue; $S['orgs'][$slug] = ['name' => $comp['name'], 'jwt' => $jwt, 'id' => null]; // Crea organizzazione $orgId = ensureOrg($jwt, [ 'name' => $comp['name'], 'legal_form' => $comp['legal_form'], 'vat_number' => $comp['vat_number'], 'ateco_code' => $comp['ateco_code'], 'sector' => $comp['sector'], 'employee_count' => $comp['employees'], 'annual_turnover_eur' => $comp['annual_turnover'], 'city' => $comp['city'], 'province' => $comp['province'], 'region' => $comp['region'], 'country' => 'IT', ]); if (!$orgId) continue; $S['orgs'][$slug]['id'] = $orgId; // Onboarding completeOnboarding($jwt, $orgId, [ 'name' => $comp['name'], 'vat_number' => $comp['vat_number'], 'sector' => $comp['sector'], 'employee_count' => $comp['employees'], 'annual_turnover_eur'=> $comp['annual_turnover'], ]); // Classificazione NIS2 classifyOrg($jwt, $orgId, [ 'sector' => $comp['sector'], 'employee_count' => $comp['employees'], 'annual_turnover_eur'=> $comp['annual_turnover'], ]); info(" {$comp['name']}: {$comp['employees']} dip, fatturato " . number_format($comp['annual_turnover']/1000000, 1) . "M€, settore {$comp['sector']}"); } // ── Seed consulente demo (cross-org, accesso a tutte le aziende) ───────────── $consultantEmail = 'consultant@nis2agile.demo'; $consultantJwt = ensureUser('Marco', 'Consulente', $consultantEmail, DEMO_PWD, 'consultant'); if ($consultantJwt) { // Aggiungi il consultant a tutte le org create foreach ($S['orgs'] as $slug => $orgData) { if (empty($orgData['id'])) continue; $orgId = $orgData['id']; // Verifica se già membro $envFile = __DIR__ . '/.env'; $env = []; foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { if (!str_contains($line, '=')) continue; [$k, $v] = explode('=', $line, 2); $env[trim($k)] = trim($v); } try { $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $env['DB_HOST']??'127.0.0.1', $env['DB_PORT']??'3306', $env['DB_NAME']??'nis2_agile_db'); $pdo = new PDO($dsn, $env['DB_USER']??'', $env['DB_PASS']??'', [PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]); $uid = $S['users'][$consultantEmail]['id'] ?? null; if ($uid) { $pdo->prepare('INSERT IGNORE INTO user_organizations (user_id, organization_id, role) VALUES (?,?,\'consultant\')')->execute([$uid, $orgId]); ok("Consultant aggiunto a org #{$orgId} ({$orgData['name']})"); } } catch (\Throwable) {} } } // ──────────────────────────────────────────────────────────────────────────── // SIM-01 — FASE 3: Gap Assessment 80 domande per ogni azienda // ──────────────────────────────────────────────────────────────────────────── simPhase(2, 'SIM-01 — Gap Assessment 80 Domande Art.21 NIS2'); foreach ($COMPANIES as $slug => $comp) { if (empty($S['orgs'][$slug]['id'])) { skip("Skip assessment $slug: org non creata"); continue; } $jwt = $S['orgs'][$slug]['jwt']; $orgId = $S['orgs'][$slug]['id']; $responses = getAssessmentResponses($comp['sector']); runAssessment($jwt, $orgId, $comp['name'], $responses); } // ──────────────────────────────────────────────────────────────────────────── // FASE 4: Risk Register per ogni azienda // ──────────────────────────────────────────────────────────────────────────── simPhase(3, 'Risk Register — Rischi per settore NIS2'); foreach ($COMPANIES as $slug => $comp) { if (empty($S['orgs'][$slug]['id'])) continue; $jwt = $S['orgs'][$slug]['jwt']; $orgId = $S['orgs'][$slug]['id']; simLog("Rischi [{$comp['name']}]:", 'info'); foreach ($comp['risks'] as $riskDef) { $likelihood = $riskDef['likelihood']; $impact = $riskDef['impact']; $score = $likelihood * $impact; $level = match(true) { $score >= 20 => 'critical', $score >= 12 => 'high', $score >= 6 => 'medium', default => 'low', }; createRisk($jwt, $orgId, [ 'title' => $riskDef['title'], 'category' => $riskDef['category'], 'likelihood' => $likelihood, 'impact' => $impact, 'risk_score' => $score, 'risk_level' => $level, 'nis2_article' => $riskDef['nis2_article'], 'description' => "Rischio identificato durante gap assessment NIS2 — {$riskDef['title']}", ]); } } // ──────────────────────────────────────────────────────────────────────────── // FASE 5: Policy per ogni azienda // ──────────────────────────────────────────────────────────────────────────── simPhase(4, 'Policies NIS2 Art.21 per ogni azienda'); foreach ($COMPANIES as $slug => $comp) { if (empty($S['orgs'][$slug]['id'])) continue; $jwt = $S['orgs'][$slug]['jwt']; $orgId = $S['orgs'][$slug]['id']; foreach ($comp['policies'] as $polDef) { // Mappa type→category (enum policy: incident_response,access_control,business_continuity, // supply_chain,encryption,information_security,hr_security,asset_management, // network_security,vulnerability_management) $polCatMap = [ 'cryptography' => 'encryption', 'data_protection' => 'information_security', 'risk_management' => 'vulnerability_management', 'supply_chain_security' => 'supply_chain', ]; $polCategory = $polCatMap[$polDef['type']] ?? $polDef['type']; $polId = createPolicy($jwt, $orgId, [ 'title' => $polDef['title'], 'category' => $polCategory, 'nis2_article' => $polDef['nis2_article'], 'content' => "Politica aziendale in conformità Art.{$polDef['nis2_article']} Direttiva NIS2 (EU 2022/2555) e D.Lgs.138/2024. Versione 1.0 — generata da simulazione demo.", 'status' => 'draft', ]); // Approva la policy if ($polId) { $approveRes = api('POST', "/policies/{$polId}/approve", [ 'notes' => 'Approvazione automatica da simulazione demo NIS2', ], $jwt, $orgId); if (apiOk($approveRes, "policy.approve")) { ok("Policy approvata: {$polDef['title']}"); } } } } // ──────────────────────────────────────────────────────────────────────────── // FASE 6: Fornitori + Assessment supply chain // ──────────────────────────────────────────────────────────────────────────── simPhase(5, 'Supply Chain — Fornitori critici per ogni azienda'); foreach ($COMPANIES as $slug => $comp) { if (empty($S['orgs'][$slug]['id'])) continue; $jwt = $S['orgs'][$slug]['jwt']; $orgId = $S['orgs'][$slug]['id']; foreach ($comp['suppliers'] as $supDef) { $supId = createSupplier($jwt, $orgId, [ 'name' => $supDef['name'], 'service_type' => $supDef['service_type'], 'criticality' => $supDef['risk_level'], 'nis2_relevant' => 1, 'contact_email' => DEMO_EMAIL, ]); // Assessment fornitore if ($supId) { // assessment_responses: array di {question, weight, value: yes|partial|no} $highRisk = in_array($supDef['risk_level'], ['high', 'critical']); $assessRes = api('POST', "/supply-chain/{$supId}/assess", [ 'assessment_responses' => [ ['question' => 'Controlli sicurezza implementati', 'weight' => 3, 'value' => 'yes'], ['question' => 'Procedura gestione incidenti', 'weight' => 2, 'value' => $highRisk ? 'yes' : 'partial'], ['question' => 'Conformità GDPR', 'weight' => 2, 'value' => 'yes'], ['question' => 'Clausole NIS2 nel contratto', 'weight' => 2, 'value' => $supDef['critical'] ? 'yes' : 'partial'], ['question' => 'Audit sicurezza negli ultimi 12 mesi', 'weight' => 1, 'value' => $highRisk ? 'yes' : 'partial'], ['question' => 'Piano business continuity documentato', 'weight' => 2, 'value' => 'partial'], ], ], $jwt, $orgId); if (apiOk($assessRes, "supplier.assess")) { ok("Assessment fornitore: {$supDef['name']}"); } } } } // ──────────────────────────────────────────────────────────────────────────── // SIM-02 — FASE 7: Incidente Ransomware DataCore (Art.23 timeline 24h/72h/30d) // ──────────────────────────────────────────────────────────────────────────── simPhase(6, 'SIM-02 — Incidente Ransomware DataCore (Art.23 NIS2)'); info('Scenario: attacco ransomware su infrastruttura cloud DataCore.'); info('Trigger: crittografia 3TB dati clienti enterprise + downtime 18h.'); info('Timeline Art.23: early warning 24h → notification 72h → final report 30d'); if (!empty($S['orgs']['datacore']['id'])) { $jwt = $S['orgs']['datacore']['jwt']; $orgId = $S['orgs']['datacore']['id']; $incId = createIncident($jwt, $orgId, [ 'title' => 'Attacco Ransomware — Crittografia infrastruttura cloud prod', 'description' => 'Alle 03:42 del ' . date('Y-m-d') . ' sistemi SIEM hanno rilevato attività anomala su cluster Kubernetes prod. Analisi forense preliminare indica compromissione tramite vulnerabilità CVE-2024-XXXX in plugin WordPress del portale clienti. Ransomware "LockBit 3.0" ha crittografato 3.2TB di dati su storage condiviso. 47 clienti enterprise impattati. Downtime 18h. Riscatto richiesto: 380.000 USD in Bitcoin.', 'severity' => 'critical', 'classification' => 'cyber_attack', 'affected_services'=> 'Kubernetes cluster prod, NAS storage 3.2TB, Portale clienti, API Gateway', 'affected_users_count' => 47, 'malicious_action' => 1, 'is_significant' => 1, 'detected_at' => date('Y-m-d H:i:s', strtotime('-1 day')), 'status' => 'analyzing', ]); if ($incId) { // Timeline: early warning 24h $ewRes = api('POST', "/incidents/{$incId}/early-warning", [ 'notes' => 'Early warning 24h inviato ad ACN secondo Art.23 NIS2. Incidente significativo confermato.', ], $jwt, $orgId); apiOk($ewRes, 'incident.early-warning') && ok("Early warning 24h Art.23 inviato ad ACN"); // Timeline: aggiornamento investigazione $tlRes = api('POST', "/incidents/{$incId}/timeline", [ 'event_type' => 'update', 'description' => 'Analisi forense: vettore iniziale confermato. Contenimento attivato: isolamento cluster compromesso, failover su DR site. Backup verificati (ultimo: 6h prima). RTO stimato: 4h.', ], $jwt, $orgId); apiOk($tlRes, 'incident.timeline') && ok("Timeline aggiornata: contenimento attivato"); // Notifica 72h $notRes = api('POST', "/incidents/{$incId}/notification", [ 'notes' => 'Notifica 72h ad ACN e CSIRT nazionale. Impatto confermato: 47 clienti, 3.2TB, 18h downtime. Misure contenimento adottate. Indagine forense in corso.', ], $jwt, $orgId); apiOk($notRes, 'incident.notification') && ok("Notifica 72h Art.23 completata"); // Aggiornamento: risoluzione api('PUT', "/incidents/{$incId}", [ 'status' => 'recovering', 'remediation_actions' => 'Sistemi ripristinati da backup verificati. Vulnerabilità patchata. MFA obbligatorio su tutti gli accessi. Piano remediation 30 giorni approvato da CISO.', ], $jwt, $orgId); ok("Incidente aggiornato: recovering — sistemi ripristinati da backup"); // Final report 30d $frRes = api('POST', "/incidents/{$incId}/final-report", [ 'notes' => 'Final report 30d ad ACN. Root cause: CVE-2024-XXXX. Lesson learned: segmentazione rete OT/IT, WAF avanzato, patch management automatizzato. Costo totale incidente: 127.000€.', ], $jwt, $orgId); apiOk($frRes, 'incident.final-report') && ok("Final report 30d Art.23 completato"); info("Incidente DataCore completamente gestito secondo Art.23 NIS2 (24h/72h/30d)"); } } else { warn("SIM-02 skip: org DataCore non disponibile"); } // ──────────────────────────────────────────────────────────────────────────── // SIM-03 — FASE 8: Data Breach MedClinic (Supply Chain Compromise) // ──────────────────────────────────────────────────────────────────────────── simPhase(7, 'SIM-03 — Data Breach Supply Chain MedClinic'); info('Scenario: fornitore LIS (laboratorio analisi) compromesso.'); info('Dati 2.340 pazienti esfiltrati via API non autenticata fornitore.'); if (!empty($S['orgs']['medclinic']['id'])) { $jwt = $S['orgs']['medclinic']['jwt']; $orgId = $S['orgs']['medclinic']['id']; // Trova fornitore LIS e flaggalo ad alto rischio $suppRes = api('GET', '/supply-chain/list', null, $jwt, $orgId); $lisId = null; if (!empty($suppRes['data'])) { foreach ($suppRes['data'] as $s) { if (strpos($s['name'] ?? '', 'HealthSoft') !== false) { $lisId = (int) $s['id']; break; } } } if ($lisId) { api('PUT', "/supply-chain/{$lisId}", [ 'risk_level' => 'critical', 'notes' => 'COMPROMISSIONE CONFERMATA: API endpoint /api/results non autenticato esposto su internet. Dati 2.340 pazienti potenzialmente esfiltrati. Contratto sospeso.', ], $jwt, $orgId); ok("Fornitore HealthSoft LIS flaggato: CRITICAL"); } $incId2 = createIncident($jwt, $orgId, [ 'title' => 'Data Breach — Esfiltrazione dati pazienti via fornitore LIS', 'description' => 'Il fornitore di sistemi LIS (Laboratorio Analisi) HealthSoft ha esposto accidentalmente un endpoint API non autenticato. Dati di 2.340 pazienti (nome, codice fiscale, referti analisi) potenzialmente esfiltrati da attore esterno. Scoperto da team IR interno tramite alert SIEM su traffico anomalo verso IP russo (185.x.x.x). Violazione GDPR Art.33+34 + NIS2 Art.23.', 'severity' => 'critical', 'classification' => 'data_breach', 'affected_services'=> 'LIS HealthSoft API, Database referti, Portale pazienti', 'affected_users_count' => 2340, 'cross_border_impact' => 0, 'is_significant' => 1, 'detected_at' => date('Y-m-d H:i:s', strtotime('-12 hours')), 'status' => 'analyzing', ]); if ($incId2) { api('POST', "/incidents/{$incId2}/early-warning", ['notes' => 'Early warning 24h. Data breach confermato. Segnalazione parallela a Garante Privacy (GDPR Art.33) e ACN (NIS2 Art.23).'], $jwt, $orgId); ok("Early warning 24h MedClinic — ACN + Garante Privacy"); api('POST', "/incidents/{$incId2}/timeline", [ 'event_type' => 'update', 'description' => 'Fornitore HealthSoft ha confermato la vulnerabilità. Endpoint disabilitato. Audit completo API in corso. Pazienti impattati in fase di notifica individuale (GDPR Art.34).', ], $jwt, $orgId); api('POST', "/incidents/{$incId2}/notification", ['notes' => 'Notifica 72h ACN completata. Impatto: 2.340 pazienti, dati sanitari. Misure: endpoint bloccato, password reset massivo, monitoraggio dark web attivato.'], $jwt, $orgId); ok("Notifica 72h MedClinic completata"); info("Incident MedClinic gestito: supply chain breach Art.23 + GDPR Art.33"); } } else { warn("SIM-03 skip: org MedClinic non disponibile"); } // ──────────────────────────────────────────────────────────────────────────── // SIM-04 — FASE 9: Whistleblowing Anonimo EnerNet // ──────────────────────────────────────────────────────────────────────────── simPhase(8, 'SIM-04 — Whistleblowing Anonimo EnerNet (Art.32 NIS2)'); info('Scenario: tecnico anonimo segnala accesso non autorizzato SCADA sottostazione.'); if (!empty($S['orgs']['enernet']['id'])) { $jwt = $S['orgs']['enernet']['jwt']; $orgId = $S['orgs']['enernet']['id']; foreach ($COMPANIES['enernet']['wb_events'] as $wb) { // Invia segnalazione anonima (no JWT = accesso pubblico anonimo) $wbRes = api('POST', '/whistleblowing/submit', array_merge($wb, ['organization_id' => $orgId])); if (!empty($wbRes['data']['report_code'])) { $code = $wbRes['data']['report_code']; $token = $wbRes['data']['anonymous_token'] ?? ''; ok("Segnalazione anonima ricevuta: {$code}"); if ($token) info(" Token tracking anonimo: $token"); // Recupera ID segnalazione (solo admin) $listRes = api('GET', '/whistleblowing/list?status=received', null, $jwt, $orgId); $wbId = null; if (!empty($listRes['data'])) { foreach ($listRes['data'] as $r) { if (($r['report_code'] ?? '') === $code) { $wbId = (int) $r['id']; break; } } } if ($wbId) { // Assegna al CISO api('POST', "/whistleblowing/{$wbId}/assign", [ 'assigned_to' => $S['users'][$COMPANIES['enernet']['users'][1]['email']]['id'] ?? null, 'notes' => 'Assegnato a CISO per indagine urgente — priorità CRITICAL', ], $jwt, $orgId); ok("Segnalazione #{$code} assegnata al CISO"); // Aggiorna stato: investigating api('PUT', "/whistleblowing/{$wbId}", [ 'status' => 'investigating', 'notes' => 'Indagine avviata. Log accessi SCADA sotto analisi forense. Tecnico identificato ma non ancora notificato.', ], $jwt, $orgId); ok("Segnalazione #{$code} → stato: investigating"); // Chiudi con risoluzione api('POST', "/whistleblowing/{$wbId}/close", [ 'resolution_notes' => 'Accesso confermato non autorizzato. Tecnico esterno sospeso dal contratto. Credenziali SCADA revocate e rigenerate. Audit accessi OT completato. Segnalazione ad ACN per incidente rilevante.', 'status' => 'resolved', ], $jwt, $orgId); ok("Segnalazione #{$code} CHIUSA con risoluzione"); // Tracking anonimo (simula il segnalante che controlla lo stato) if ($token) { $trackRes = api('GET', "/whistleblowing/track-anonymous?token={$token}"); if (!empty($trackRes['data'])) { ok("Tracking anonimo: segnalante può vedere " . count($trackRes['data']['timeline'] ?? []) . " aggiornamenti pubblici"); } } } } else { fail("Whistleblowing submit fallito: " . ($wbRes['error'] ?? '')); } } // Normativa ACK per EnerNet $normRes = api('GET', '/normative/pending', null, $jwt, $orgId); $pending = $normRes['data']['updates'] ?? []; $acked = 0; foreach ($pending as $norm) { $ackRes = api('POST', "/normative/{$norm['id']}/ack", [ 'notes' => "Presa visione da CISO EnerNet il " . date('Y-m-d'), ], $jwt, $orgId); if (apiOk($ackRes, "normative.ack")) $acked++; } if ($acked > 0) ok("Normativa ACK: $acked aggiornamenti NIS2/ACN confermati da EnerNet"); } else { warn("SIM-04 skip: org EnerNet non disponibile"); } // ──────────────────────────────────────────────────────────────────────────── // SIM-05 — FASE 10: Verifica Audit Trail Hash Chain // ──────────────────────────────────────────────────────────────────────────── simPhase(9, 'SIM-05 — Audit Trail Hash Chain Verification'); info('Verifica integrità certificata per tutte e 3 le aziende...'); info('Ogni record è linkato SHA-256 al precedente (hash chain certificabile).'); foreach ($COMPANIES as $slug => $comp) { if (empty($S['orgs'][$slug]['id'])) { skip("Skip chain-verify $slug: org non disponibile"); continue; } $jwt = $S['orgs'][$slug]['jwt']; $orgId = $S['orgs'][$slug]['id']; checkAuditChain($jwt, $orgId, $comp['name']); } // ──────────────────────────────────────────────────────────────────────────── // SIM-06 — B2B License Provisioning // Simula il flusso completo: provision via X-Provision-Secret → JWT → dashboard // ──────────────────────────────────────────────────────────────────────────── sim06_start: if (!$SIM_FILTER || in_array($SIM_FILTER, ['SIM06', 'ALL'], true)) { simPhase(11, 'SIM-06 — B2B License Provisioning'); info('Scenario: lg231.agile.software acquista licenza NIS2 per cliente FintechPay S.r.l.'); info('Flusso: X-Provision-Secret → crea org + admin JWT → verifica dashboard access'); $provSecret = readEnvValue('PROVISION_SECRET', 'nis2_prov_dev_secret'); $simVat = '99887766550'; // P.IVA demo SIM-06 (non reale, checksum valido) $simEmail = str_replace('@', '+sim06@', DEMO_EMAIL); // 1. Provision info('Chiamata POST /api/services/provision...'); $provisionCh = curl_init(API_BASE . '/services/provision'); $provisionBody = json_encode([ 'company' => [ 'ragione_sociale' => 'FintechPay S.r.l.', 'partita_iva' => $simVat, 'forma_giuridica' => 'S.r.l.', 'ateco_code' => '64.19.00', 'ateco_description' => 'Altri intermediari monetari', 'sede_legale' => 'Piazza Affari 1, 20123 Milano MI', 'numero_dipendenti' => 85, 'sector' => 'banking', 'nis2_entity_type' => 'important', ], 'admin' => [ 'email' => $simEmail, 'first_name' => 'Laura', 'last_name' => 'Fintech', 'phone' => '+39 02 9988776', 'title' => 'CISO', ], 'license' => [ 'plan' => 'professional', 'duration_months'=> 12, 'lg231_order_id' => 'SIM06-TEST-' . date('Ymd'), ], 'caller' => [ 'system' => 'sim06-test', 'tenant_id' => 0, 'company_id'=> 99, ], ]); curl_setopt_array($provisionCh, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_CUSTOMREQUEST => 'POST', CURLOPT_POSTFIELDS => $provisionBody, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'X-Provision-Secret: ' . $provSecret, ], ]); $provRaw = curl_exec($provisionCh); $provCode = curl_getinfo($provisionCh, CURLINFO_HTTP_CODE); curl_close($provisionCh); $provRes = json_decode($provRaw ?: '{}', true) ?? []; $provRes['_http'] = $provCode; if (!empty($provRes['success']) && !empty($provRes['data']['org_id'])) { $provData = $provRes['data']; ok(sprintf( 'Org provisioned: %s (id=%d, plan=%s, exp=%s)', $provData['org_name'], $provData['org_id'], $provData['plan'] ?? '?', $provData['license_expires_at'] ?? '?' )); ok("Admin utente: {$provData['admin_email']} (id={$provData['admin_user_id']})"); // 2. Verifica dashboard con JWT provisioned $provJwt = $provData['access_token'] ?? null; $provOrgId = (int) $provData['org_id']; if ($provJwt && $provOrgId) { $dashRes = api('GET', '/dashboard/overview', null, $provJwt, $provOrgId); if (apiOk($dashRes, 'Dashboard dopo provision')) { ok("Dashboard accessibile con JWT provisioned — compliance_score={$dashRes['data']['compliance_score']}%"); } // 3. Verifica API Key provisioned if (!empty($provData['api_key'])) { $apiKey = $provData['api_key']; ok(sprintf('API Key generata: %s...', substr($apiKey, 0, 12))); // Test API Key su status endpoint $statusCh = curl_init(API_BASE . '/services/status'); curl_setopt_array($statusCh, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_HTTPHEADER => [ 'X-API-Key: ' . $apiKey, 'X-Organization-Id: ' . $provOrgId, ], ]); $statusRaw = curl_exec($statusCh); $statusCode = curl_getinfo($statusCh, CURLINFO_HTTP_CODE); curl_close($statusCh); $statusRes = json_decode($statusRaw ?: '{}', true) ?? []; if ($statusCode === 200 && !empty($statusRes['success'])) { ok('API Key verificata: GET /api/services/status → HTTP 200'); } else { warn("API Key su /services/status → HTTP {$statusCode}"); } } // 4. Salva nel registro per uso futuro (chain verify etc.) $S['orgs']['fintech_sim06'] = [ 'id' => $provOrgId, 'name' => $provData['org_name'], 'jwt' => $provJwt, ]; } info('Temp password: ' . ($provData['temp_password'] ?? 'N/A') . ' (cambio obbligatorio al primo login)'); info("Dashboard URL: {$provData['dashboard_url']}"); } elseif ($provCode === 409) { skip('SIM-06: organizzazione FintechPay già esistente (P.IVA ' . $simVat . ' duplicata) — skip idempotent'); } else { $errMsg = $provRes['message'] ?? ($provRes['error'] ?? 'errore sconosciuto'); warn("SIM-06 provision fallita (HTTP {$provCode}): {$errMsg}"); if (!empty($provRes['error_code'])) { info(" error_code: {$provRes['error_code']}"); } } } elseif ($SIM_FILTER && $SIM_FILTER !== 'SIM06') { skip('SIM-06 skip (filtro attivo: ' . $SIM_FILTER . ')'); } // ──────────────────────────────────────────────────────────────────────────── // Salta SIM-01→05 se si esegue solo SIM-06 // ──────────────────────────────────────────────────────────────────────────── // ── Report finale ──────────────────────────────────────────────────────────── simPhase(12, 'Riepilogo Simulazione'); $orgsOk = 0; foreach ($COMPANIES as $slug => $comp) { if (!empty($S['orgs'][$slug]['id'])) { $orgsOk++; info(" {$comp['name']}: org_id={$S['orgs'][$slug]['id']} — {$comp['nis2_type']} entity"); } } info(""); info("Dati demo creati:"); info(" · $orgsOk aziende NIS2 (Essential/Important)"); info(" · " . array_sum(array_map(fn($c) => count($c['risks']), $COMPANIES)) . " rischi registrati"); info(" · 2 incidenti Art.23 con timeline 24h/72h/30d"); info(" · 1 data breach supply chain"); info(" · 1 whistleblowing anonimo gestito e chiuso"); info(" · " . array_sum(array_map(fn($c) => count($c['policies']), $COMPANIES)) . " policy approvate"); info(" · " . array_sum(array_map(fn($c) => count($c['suppliers']), $COMPANIES)) . " fornitori critici valutati"); info(" · Audit trail hash chain verificata per tutte le org"); if (!empty($S['orgs']['fintech_sim06'])) { info(" · 1 org B2B provisioned via X-Provision-Secret (SIM-06)"); } info(""); info("URL: " . str_replace('/api', '', API_BASE) . "/dashboard.html"); simDone($S['stats']);