diff --git a/application/controllers/AuditController.php b/application/controllers/AuditController.php
index 54bf5b9..2261852 100644
--- a/application/controllers/AuditController.php
+++ b/application/controllers/AuditController.php
@@ -238,4 +238,79 @@ class AuditController extends BaseController
echo $csv;
exit;
}
+
+ /**
+ * GET /api/audit/chain-verify
+ * Verifica l'integrità dell'hash chain per l'organizzazione corrente.
+ * Risponde con: valid, total, hashed, coverage_pct, broken_at, last_hash
+ */
+ public function chainVerify(): void
+ {
+ $this->requireOrgRole(['org_admin', 'auditor']);
+ $orgId = $this->getCurrentOrgId();
+
+ $db = Database::getInstance();
+ $result = AuditService::verifyChain($db, $orgId);
+
+ // Se la catena è rotta, registra la violazione
+ if (!$result['valid'] && $result['broken_at'] !== null) {
+ $performer = $this->currentUser['email'] ?? 'system';
+ try {
+ $ins = $db->prepare(
+ "INSERT INTO audit_violations
+ (organization_id, detected_by, broken_at_id, chain_length, notes)
+ VALUES (:org, :by, :bid, :len, :notes)"
+ );
+ $ins->execute([
+ 'org' => $orgId,
+ 'by' => $performer,
+ 'bid' => $result['broken_at'],
+ 'len' => $result['total'],
+ 'notes' => 'Violazione rilevata tramite API chain-verify',
+ ]);
+ } catch (Throwable $e) {
+ error_log('[AuditController] chain violation log error: ' . $e->getMessage());
+ }
+
+ $this->logAudit('audit.chain_broken', 'audit_logs', $result['broken_at'], [
+ 'total' => $result['total'],
+ 'coverage_pct' => $result['coverage_pct'],
+ ]);
+ }
+
+ $this->jsonSuccess($result);
+ }
+
+ /**
+ * GET /api/audit/export-certified
+ * Genera un export JSON certificato con hash SHA-256 dell'intero contenuto.
+ * Adatto per ispezioni ACN, audit NIS2 Art.32, certificazione ISO 27001.
+ */
+ public function exportCertified(): void
+ {
+ $this->requireOrgRole(['org_admin', 'auditor']);
+ $orgId = $this->getCurrentOrgId();
+ $userId = $this->getCurrentUserId();
+ $email = $this->currentUser['email'] ?? 'system';
+
+ $db = Database::getInstance();
+ $result = AuditService::exportCertified(
+ $db,
+ $orgId,
+ $userId,
+ $email,
+ $_GET['purpose'] ?? 'export_certificato',
+ $_GET['from'] ?? null,
+ $_GET['to'] ?? null
+ );
+
+ $this->logAudit('audit.export_certified', 'audit_logs', null, [
+ 'records_count' => $result['records_count'],
+ 'chain_valid' => $result['chain_valid'],
+ 'export_hash' => $result['export_hash'],
+ ]);
+
+ header('Content-Disposition: attachment; filename="nis2_audit_certified_' . date('Y-m-d') . '.json"');
+ $this->jsonSuccess($result);
+ }
}
diff --git a/application/controllers/BaseController.php b/application/controllers/BaseController.php
index be3d7a1..d6762d7 100644
--- a/application/controllers/BaseController.php
+++ b/application/controllers/BaseController.php
@@ -7,6 +7,7 @@
*/
require_once APP_PATH . '/config/database.php';
+require_once APP_PATH . '/services/AuditService.php';
class BaseController
{
@@ -533,16 +534,23 @@ class BaseController
*/
protected function logAudit(string $action, ?string $entityType = null, ?int $entityId = null, ?array $details = null): void
{
- Database::insert('audit_logs', [
- 'user_id' => $this->getCurrentUserId(),
- 'organization_id' => $this->currentOrgId,
- 'action' => $action,
- 'entity_type' => $entityType,
- 'entity_id' => $entityId,
- 'details' => $details ? json_encode($details) : null,
- 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
- 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
- ]);
+ $orgId = $this->currentOrgId;
+ $userId = $this->getCurrentUserId();
+ $severity = AuditService::resolveSeverity($action, $details);
+ $email = $this->currentUser['email'] ?? null;
+
+ AuditService::log(
+ $orgId ?? 0,
+ $userId,
+ $action,
+ $entityType,
+ $entityId,
+ $details,
+ $_SERVER['REMOTE_ADDR'] ?? '',
+ $_SERVER['HTTP_USER_AGENT'] ?? null,
+ $severity,
+ $email
+ );
}
// ═══════════════════════════════════════════════════════════════════════
diff --git a/application/services/AuditService.php b/application/services/AuditService.php
new file mode 100644
index 0000000..3a32b6e
--- /dev/null
+++ b/application/services/AuditService.php
@@ -0,0 +1,310 @@
+'high']);
+ * $result = AuditService::verifyChain($db, $orgId);
+ */
+class AuditService
+{
+ /**
+ * Registra un'azione nell'audit trail con hash chain SHA-256.
+ * Non lancia eccezioni — il fallimento del log non blocca l'operazione principale.
+ *
+ * @param int $orgId organization_id
+ * @param int|null $userId user_id (null = sistema/anonimo)
+ * @param string $action Azione es. 'incident.create', 'risk.update', 'policy.approve'
+ * @param string|null $entityType Tipo entità: 'incident','risk','policy','supplier','asset',...
+ * @param int|null $entityId ID entità
+ * @param array|null $details Dati aggiuntivi (serializzati come JSON)
+ * @param string $ipAddress IP del client
+ * @param string|null $userAgent User-Agent
+ * @param string $severity 'info' | 'warning' | 'critical'
+ * @param string|null $performedBy Email/identificatore utente (per export certificato)
+ */
+ public static function log(
+ int $orgId,
+ ?int $userId,
+ string $action,
+ ?string $entityType = null,
+ ?int $entityId = null,
+ ?array $details = null,
+ string $ipAddress = '',
+ ?string $userAgent = null,
+ string $severity = 'info',
+ ?string $performedBy = null
+ ): void {
+ try {
+ $db = Database::getInstance();
+
+ $prevHash = self::getLastHash($db, $orgId);
+ $detailsJson = $details ? self::canonicalJson($details) : '';
+ $createdAt = date('Y-m-d H:i:s');
+
+ // Calcola hash catena: deterministico su dati noti prima dell'INSERT
+ $entryHash = hash('sha256',
+ $orgId .
+ ($userId ?? 'system') .
+ $action .
+ ($entityType ?? '') .
+ ($entityId ?? '') .
+ $detailsJson .
+ $createdAt .
+ ($prevHash ?? '')
+ );
+
+ $stmt = $db->prepare(
+ "INSERT INTO audit_logs
+ (organization_id, user_id, action, entity_type, entity_id,
+ details, ip_address, user_agent,
+ prev_hash, entry_hash, severity, performed_by, created_at)
+ VALUES
+ (:org, :uid, :act, :et, :eid,
+ :det, :ip, :ua,
+ :prev, :hash, :sev, :by, :ts)"
+ );
+ $stmt->execute([
+ 'org' => $orgId,
+ 'uid' => $userId,
+ 'act' => $action,
+ 'et' => $entityType,
+ 'eid' => $entityId,
+ 'det' => $details ? json_encode($details, JSON_UNESCAPED_UNICODE) : null,
+ 'ip' => $ipAddress ?: ($_SERVER['REMOTE_ADDR'] ?? ''),
+ 'ua' => $userAgent ?: ($_SERVER['HTTP_USER_AGENT'] ?? null),
+ 'prev' => $prevHash,
+ 'hash' => $entryHash,
+ 'sev' => $severity,
+ 'by' => $performedBy,
+ 'ts' => $createdAt,
+ ]);
+ } catch (Throwable $e) {
+ error_log('[AuditService] log error: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Verifica l'integrità della hash chain per un'organizzazione.
+ * Ricalcola ogni entry_hash e confronta con il valore salvato.
+ *
+ * @param PDO $db Connessione PDO
+ * @param int $orgId organization_id
+ * @return array {valid: bool, total: int, hashed: int, coverage_pct: float,
+ * broken_at: int|null, last_hash: string|null, verified_at: string}
+ */
+ public static function verifyChain(PDO $db, int $orgId): array
+ {
+ $stmt = $db->prepare(
+ "SELECT id, organization_id, user_id, action, entity_type, entity_id,
+ details, prev_hash, entry_hash, severity, performed_by, created_at
+ FROM audit_logs
+ WHERE organization_id = :org
+ ORDER BY id ASC"
+ );
+ $stmt->execute(['org' => $orgId]);
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ $valid = true;
+ $brokenAt = null;
+ $lastHash = null;
+ $hashed = 0;
+
+ foreach ($rows as $row) {
+ if (!empty($row['entry_hash'])) {
+ $hashed++;
+ }
+
+ // Record senza hash (pre-migrazione) → skip verifica ma non rompe catena
+ if (empty($row['entry_hash'])) {
+ continue;
+ }
+
+ $detailsDecoded = $row['details'] ? json_decode($row['details'], true) : null;
+ $detailsJson = $detailsDecoded ? self::canonicalJson($detailsDecoded) : '';
+
+ $expected = hash('sha256',
+ $row['organization_id'] .
+ ($row['user_id'] ?? 'system') .
+ $row['action'] .
+ ($row['entity_type'] ?? '') .
+ ($row['entity_id'] ?? '') .
+ $detailsJson .
+ $row['created_at'] .
+ ($row['prev_hash'] ?? '')
+ );
+
+ if ($expected !== $row['entry_hash']) {
+ $valid = false;
+ $brokenAt = (int) $row['id'];
+ break;
+ }
+
+ $lastHash = $row['entry_hash'];
+ }
+
+ $total = count($rows);
+ return [
+ 'valid' => $valid,
+ 'total' => $total,
+ 'hashed' => $hashed,
+ 'coverage_pct' => $total > 0 ? round($hashed / $total * 100, 1) : 0.0,
+ 'broken_at' => $brokenAt,
+ 'last_hash' => $lastHash,
+ 'verified_at' => date('Y-m-d H:i:s'),
+ ];
+ }
+
+ /**
+ * Esporta il registro audit in formato JSON certificato con SHA-256.
+ *
+ * @return array {data: array, export_hash: string, records_count: int,
+ * chain_valid: bool, exported_at: string}
+ */
+ public static function exportCertified(
+ PDO $db,
+ int $orgId,
+ int $exportedBy,
+ string $performedBy = 'system',
+ string $purpose = 'export_certificato',
+ ?string $dateFrom = null,
+ ?string $dateTo = null
+ ): array {
+ $sql = "SELECT * FROM audit_logs WHERE organization_id = :org";
+ $params = ['org' => $orgId];
+
+ if ($dateFrom) { $sql .= " AND created_at >= :from"; $params['from'] = $dateFrom . ' 00:00:00'; }
+ if ($dateTo) { $sql .= " AND created_at <= :to"; $params['to'] = $dateTo . ' 23:59:59'; }
+ $sql .= " ORDER BY id ASC";
+
+ $stmt = $db->prepare($sql);
+ $stmt->execute($params);
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+
+ $chainResult = self::verifyChain($db, $orgId);
+ $exportedAt = date('Y-m-d\TH:i:sP');
+
+ $exportData = [
+ 'meta' => [
+ 'standard' => 'NIS2 EU 2022/2555 — D.Lgs.138/2024 — ISO27001:2022',
+ 'generator' => 'NIS2 Agile AuditService v1.0',
+ 'org_id' => $orgId,
+ 'exported_by' => $performedBy,
+ 'exported_at' => $exportedAt,
+ 'purpose' => $purpose,
+ 'records_count' => count($rows),
+ 'chain_valid' => $chainResult['valid'],
+ 'chain_coverage' => $chainResult['coverage_pct'],
+ 'last_hash' => $chainResult['last_hash'],
+ 'date_from' => $dateFrom,
+ 'date_to' => $dateTo,
+ ],
+ 'records' => $rows,
+ ];
+
+ $exportJson = json_encode($exportData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
+ $exportHash = hash('sha256', $exportJson);
+
+ // Salva metadati export
+ try {
+ $ins = $db->prepare(
+ "INSERT INTO audit_exports
+ (organization_id, exported_by, performed_by, format, records_count,
+ date_from, date_to, export_hash, chain_valid, purpose)
+ VALUES (:org, :uid, :by, 'json', :cnt, :df, :dt, :hash, :cv, :pur)"
+ );
+ $ins->execute([
+ 'org' => $orgId,
+ 'uid' => $exportedBy,
+ 'by' => $performedBy,
+ 'cnt' => count($rows),
+ 'df' => $dateFrom,
+ 'dt' => $dateTo,
+ 'hash' => $exportHash,
+ 'cv' => $chainResult['valid'] ? 1 : 0,
+ 'pur' => $purpose,
+ ]);
+ } catch (Throwable $e) {
+ error_log('[AuditService] exportCertified save error: ' . $e->getMessage());
+ }
+
+ return [
+ 'data' => $exportData,
+ 'export_hash' => $exportHash,
+ 'records_count' => count($rows),
+ 'chain_valid' => $chainResult['valid'],
+ 'exported_at' => $exportedAt,
+ ];
+ }
+
+ /**
+ * Recupera l'entry_hash dell'ultimo record per questa org (chain linking).
+ */
+ private static function getLastHash(PDO $db, int $orgId): ?string
+ {
+ $stmt = $db->prepare(
+ "SELECT entry_hash FROM audit_logs
+ WHERE organization_id = :org AND entry_hash != ''
+ ORDER BY id DESC LIMIT 1"
+ );
+ $stmt->execute(['org' => $orgId]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ return $row ? ($row['entry_hash'] ?: null) : null;
+ }
+
+ /**
+ * Serializzazione JSON canonica con chiavi ordinate ricorsivamente.
+ * Garantisce che logAction e verifyChain producano la stessa stringa di input all'hash.
+ */
+ private static function canonicalJson(array $data): string
+ {
+ $data = self::sortKeysRecursively($data);
+ return json_encode($data, JSON_UNESCAPED_UNICODE);
+ }
+
+ private static function sortKeysRecursively(array $data): array
+ {
+ ksort($data);
+ foreach ($data as $k => $v) {
+ if (is_array($v)) {
+ $data[$k] = self::sortKeysRecursively($v);
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * Mappa action → severity per auto-classificazione degli eventi.
+ */
+ public static function resolveSeverity(string $action, ?array $details = null): string
+ {
+ $criticalActions = [
+ 'incident.create', 'incident.significant', 'incident.deadline_warning',
+ 'risk.create_high', 'risk.create_critical',
+ 'whistleblowing.received', 'whistleblowing.critical',
+ 'audit.chain_broken', 'auth.login_failed_repeated',
+ ];
+ $warningActions = [
+ 'risk.create_medium', 'incident.update', 'policy.rejected',
+ 'supplier.risk_flagged', 'normative.update', 'auth.password_changed',
+ ];
+
+ if (in_array($action, $criticalActions, true)) return 'critical';
+ if (in_array($action, $warningActions, true)) return 'warning';
+
+ // Escalate su risk level nei details
+ if ($details) {
+ $level = $details['risk_level'] ?? $details['severity'] ?? $details['priority'] ?? null;
+ if (in_array($level, ['critical', 'high'], true)) return 'critical';
+ if (in_array($level, ['medium'], true)) return 'warning';
+ }
+
+ return 'info';
+ }
+}
diff --git a/docs/sql/010_audit_hash_chain.sql b/docs/sql/010_audit_hash_chain.sql
new file mode 100644
index 0000000..6b2b8e5
--- /dev/null
+++ b/docs/sql/010_audit_hash_chain.sql
@@ -0,0 +1,113 @@
+-- ============================================================
+-- NIS2 Agile - Migration 010: Audit Trail Certificato SHA-256
+-- Hash chain per immutabilità certificabile (ispirato da lg231 AuditTrailService)
+-- Estende la tabella audit_logs con prev_hash, entry_hash, severity, performed_by
+-- ============================================================
+-- Eseguire su Hetzner:
+-- mysql -u nis2_agile_user -p nis2_agile_db < docs/sql/010_audit_hash_chain.sql
+
+USE nis2_agile_db;
+
+-- ── Aggiunta colonne (idempotente via information_schema) ──────────────────
+
+-- prev_hash: hash del record precedente per la stessa organization_id
+SET @has_prev = (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_logs' AND COLUMN_NAME = 'prev_hash'
+);
+SET @sql_prev = IF(@has_prev = 0,
+ 'ALTER TABLE audit_logs ADD COLUMN prev_hash VARCHAR(64) NULL AFTER details',
+ 'SELECT ''prev_hash already exists'' AS note'
+);
+PREPARE s FROM @sql_prev; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- entry_hash: SHA-256 di questo record (include prev_hash per la catena)
+SET @has_entry = (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_logs' AND COLUMN_NAME = 'entry_hash'
+);
+SET @sql_entry = IF(@has_entry = 0,
+ 'ALTER TABLE audit_logs ADD COLUMN entry_hash VARCHAR(64) NOT NULL DEFAULT \'\' AFTER prev_hash',
+ 'SELECT ''entry_hash already exists'' AS note'
+);
+PREPARE s FROM @sql_entry; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- severity: criticità dell'evento per filtro dashboard
+SET @has_sev = (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_logs' AND COLUMN_NAME = 'severity'
+);
+SET @sql_sev = IF(@has_sev = 0,
+ "ALTER TABLE audit_logs ADD COLUMN severity ENUM('info','warning','critical') NOT NULL DEFAULT 'info' AFTER entry_hash",
+ 'SELECT ''severity already exists'' AS note'
+);
+PREPARE s FROM @sql_sev; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- performed_by: email/identificatore utente (denormalizzato per export certificato)
+SET @has_by = (
+ SELECT COUNT(*) FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_logs' AND COLUMN_NAME = 'performed_by'
+);
+SET @sql_by = IF(@has_by = 0,
+ 'ALTER TABLE audit_logs ADD COLUMN performed_by VARCHAR(255) NULL AFTER severity',
+ 'SELECT ''performed_by already exists'' AS note'
+);
+PREPARE s FROM @sql_by; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Indici ────────────────────────────────────────────────────────────────
+-- Indice su entry_hash per verifica integrità e ricerca
+SET @has_idx_hash = (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_logs' AND INDEX_NAME = 'idx_entry_hash'
+);
+SET @sql_idx = IF(@has_idx_hash = 0,
+ 'ALTER TABLE audit_logs ADD INDEX idx_entry_hash (entry_hash)',
+ 'SELECT ''idx_entry_hash already exists'' AS note'
+);
+PREPARE s FROM @sql_idx; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- Indice su severity per filtro dashboard
+SET @has_idx_sev = (
+ SELECT COUNT(*) FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_logs' AND INDEX_NAME = 'idx_severity'
+);
+SET @sql_isev = IF(@has_idx_sev = 0,
+ 'ALTER TABLE audit_logs ADD INDEX idx_severity (severity, organization_id)',
+ 'SELECT ''idx_severity already exists'' AS note'
+);
+PREPARE s FROM @sql_isev; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ── Tabella export certificati audit ─────────────────────────────────────
+CREATE TABLE IF NOT EXISTS audit_exports (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ organization_id INT NOT NULL,
+ exported_by INT NULL, -- user_id (NULL = sistema)
+ performed_by VARCHAR(255) NULL, -- email utente
+ format ENUM('json','xml','csv') NOT NULL DEFAULT 'json',
+ records_count INT UNSIGNED NOT NULL DEFAULT 0,
+ date_from DATE NULL,
+ date_to DATE NULL,
+ export_hash VARCHAR(64) NOT NULL, -- SHA-256 del contenuto export
+ chain_valid TINYINT(1) NOT NULL DEFAULT 1, -- integrità al momento export
+ purpose VARCHAR(100) NOT NULL DEFAULT 'export_certificato',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ INDEX idx_org (organization_id),
+ INDEX idx_created (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- ── Tabella violazioni integrità catena ───────────────────────────────────
+CREATE TABLE IF NOT EXISTS audit_violations (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ organization_id INT NOT NULL,
+ detected_by VARCHAR(255) NOT NULL DEFAULT 'system',
+ broken_at_id INT NULL, -- audit_logs.id dove la catena si rompe
+ chain_length INT UNSIGNED NOT NULL DEFAULT 0, -- record totali verificati
+ notes TEXT NULL,
+ resolved TINYINT(1) NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ INDEX idx_org (organization_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+SELECT 'Migration 010 completata: audit_logs con hash chain SHA-256 certificato' AS result;
diff --git a/docs/sql/reset-demo.sql b/docs/sql/reset-demo.sql
new file mode 100644
index 0000000..64f2e06
--- /dev/null
+++ b/docs/sql/reset-demo.sql
@@ -0,0 +1,106 @@
+-- ============================================================
+-- NIS2 Agile — Reset Dati Demo
+-- Cancella tutti i dati generati dalla simulazione, mantenendo
+-- solo le organizzazioni seed (id <= 4) e i loro utenti.
+--
+-- Eseguire su Hetzner:
+-- ssh -i docs/credentials/hetzner_key root@135.181.149.254
+-- mysql -u nis2_agile_user -p nis2_agile_db < /var/www/nis2-agile/docs/sql/reset-demo.sql
+-- ============================================================
+
+USE nis2_agile_db;
+
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ── Dati operativi generati dalla simulazione ─────────────────────────────
+
+-- Notifiche incidenti / timeline
+DELETE FROM incident_timeline WHERE organization_id > 4;
+DELETE FROM incidents WHERE organization_id > 4;
+
+-- Rischi e trattamenti
+DELETE FROM risk_treatments WHERE risk_id IN (SELECT id FROM risks WHERE organization_id > 4);
+DELETE FROM risks WHERE organization_id > 4;
+
+-- Assessment e risposte
+DELETE FROM assessment_responses WHERE assessment_id IN (SELECT id FROM assessments WHERE organization_id > 4);
+DELETE FROM assessments WHERE organization_id > 4;
+
+-- Policy
+DELETE FROM policies WHERE organization_id > 4;
+
+-- Fornitori
+DELETE FROM suppliers WHERE organization_id > 4;
+
+-- Training
+DELETE FROM training_assignments WHERE course_id IN (SELECT id FROM training_courses WHERE organization_id > 4);
+DELETE FROM training_courses WHERE organization_id > 4;
+
+-- Asset
+DELETE FROM assets WHERE organization_id > 4;
+
+-- Controlli compliance
+DELETE FROM compliance_controls WHERE organization_id > 4;
+
+-- Evidenze
+DELETE FROM evidence_files WHERE organization_id > 4;
+
+-- Non conformità e CAPA
+DELETE FROM corrective_actions WHERE non_conformity_id IN (SELECT id FROM non_conformities WHERE organization_id > 4);
+DELETE FROM non_conformities WHERE organization_id > 4;
+
+-- Whistleblowing
+DELETE FROM whistleblowing_timeline
+ WHERE report_id IN (SELECT id FROM whistleblowing_reports WHERE organization_id > 4);
+DELETE FROM whistleblowing_reports WHERE organization_id > 4;
+
+-- Normativa ACK
+DELETE FROM normative_ack WHERE organization_id > 4;
+
+-- API keys e webhook
+DELETE FROM webhook_deliveries
+ WHERE subscription_id IN (SELECT id FROM webhook_subscriptions WHERE organization_id > 4);
+DELETE FROM webhook_subscriptions WHERE organization_id > 4;
+DELETE FROM api_keys WHERE organization_id > 4;
+
+-- Audit log (solo dati demo, non i record di sistema id <= 100)
+DELETE FROM audit_logs WHERE organization_id > 4;
+DELETE FROM audit_exports WHERE organization_id > 4;
+DELETE FROM audit_violations WHERE organization_id > 4;
+
+-- AI interactions
+DELETE FROM ai_interactions WHERE organization_id > 4;
+
+-- Email log
+DELETE FROM email_log WHERE organization_id > 4;
+
+-- ── Membership utenti ─────────────────────────────────────────────────────
+
+-- Rimuove le associazioni utente-organizzazione demo
+DELETE FROM user_organizations WHERE organization_id > 4;
+
+-- Rimuove token refresh degli utenti demo
+DELETE rt FROM refresh_tokens rt
+ JOIN users u ON rt.user_id = u.id
+ WHERE u.email LIKE '%.demo%';
+
+-- Rimuove utenti demo (email terminano con .demo)
+DELETE FROM users WHERE email LIKE '%.demo%';
+
+-- ── Organizzazioni demo ───────────────────────────────────────────────────
+DELETE FROM organizations WHERE id > 4;
+
+-- ── Ripristino FK ─────────────────────────────────────────────────────────
+SET FOREIGN_KEY_CHECKS = 1;
+
+-- ── Verifica stato ────────────────────────────────────────────────────────
+SELECT
+ (SELECT COUNT(*) FROM organizations) AS organizations,
+ (SELECT COUNT(*) FROM users) AS users,
+ (SELECT COUNT(*) FROM incidents) AS incidents,
+ (SELECT COUNT(*) FROM risks) AS risks,
+ (SELECT COUNT(*) FROM audit_logs) AS audit_logs,
+ (SELECT COUNT(*) FROM whistleblowing_reports) AS whistleblowing
+;
+
+SELECT 'Reset demo completato. Dati seed mantenuti (id <= 4).' AS stato;
diff --git a/public/index.php b/public/index.php
index c651d83..167c1b2 100644
--- a/public/index.php
+++ b/public/index.php
@@ -264,6 +264,8 @@ $actionMap = [
'GET:iso27001Mapping' => 'getIsoMapping',
'GET:executiveReport' => 'executiveReport',
'GET:export' => 'export',
+ 'GET:chainVerify' => 'chainVerify',
+ 'GET:exportCertified' => 'exportCertified',
],
// ── AdminController ─────────────────────────────
diff --git a/public/js/common.js b/public/js/common.js
index 21b8c92..419c210 100644
--- a/public/js/common.js
+++ b/public/js/common.js
@@ -213,6 +213,8 @@ function loadSidebar() {
items: [
{ name: 'Impostazioni', href: 'settings.html', icon: iconCog(), i18nKey: 'nav.settings' },
{ name: 'Architettura', href: 'architecture.html', icon: iconCubeTransparent() },
+ { name: 'Simulazione Demo', href: 'simulate.html', icon: `` },
+ { name: 'Integrazioni', href: 'integrations/index.html', icon: `` },
]
}
];
diff --git a/public/simulate.html b/public/simulate.html
new file mode 100644
index 0000000..3c604a5
--- /dev/null
+++ b/public/simulate.html
@@ -0,0 +1,374 @@
+
+
+
+
+
+ NIS2 Agile — Simulazione Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
3 Aziende Demo
+
+
+
+
+ DataCore S.r.l.
+ IT/Cloud · Essential Entity · 320 dip · Milano
+
+
+
+
+
+ MedClinic Italia S.p.A.
+ Sanità · Important Entity · 750 dip · Roma
+
+
+
+
+
+ EnerNet Distribuzione S.r.l.
+ Energia · Essential/Critical · 1800 dip · Torino
+
+
+
+
+
+
+
5 Scenari Reali
+
+
+
COMPLETA
+
Tutti gli scenari
+
Esegue tutti e 5 gli scenari in sequenza: onboarding, incidenti, data breach, whistleblowing, audit chain.
+
+
+
SIM-01
+
Onboarding + Gap Assessment
+
Registra le 3 aziende, classifica NIS2 (Essential/Important), esegue assessment 80 domande Art.21.
+
+
+
SIM-02
+
Ransomware Art.23 DataCore
+
Attacco ransomware su infrastruttura cloud. Timeline 24h/72h/30d con notifiche ACN e CSIRT.
+
+
+
SIM-03
+
Data Breach Supply Chain
+
Fornitore LIS compromesso, esfiltrazione 2.340 pazienti. GDPR Art.33 + NIS2 Art.23 in parallelo.
+
+
+
SIM-04
+
Whistleblowing SCADA EnerNet
+
Segnalazione anonima accesso non autorizzato SCADA. Token tracking, investigazione, chiusura.
+
+
+
SIM-05
+
Audit Trail Hash Chain
+
Verifica integrità SHA-256 hash chain per le 3 org. Export certificato NIS2 + ISO 27001.
+
+
+
+
+
Controlli
+
+
+
+
+
+
+
+
+
+ In attesa
+
+
+
+
+
+
+
+
+
+
+
NIS2 Agile Demo Simulator v1.0 — Pronto
+
Seleziona uno scenario e premi "Avvia Simulazione".
+
Scenari disponibili:
+
SIM-01 Onboarding + Gap Assessment 80 domande
+
SIM-02 Incidente Ransomware Art.23 (24h/72h/30d)
+
SIM-03 Data Breach Supply Chain + GDPR
+
SIM-04 Whistleblowing anonimo SCADA
+
SIM-05 Audit Trail Hash Chain Verification
+
Tutti i dati vengono creati tramite API reali (nessun INSERT SQL diretto).
+
+
+
+
+
+
+
+
diff --git a/simulate-nis2.php b/simulate-nis2.php
new file mode 100644
index 0000000..d68b452
--- /dev/null
+++ b/simulate-nis2.php
@@ -0,0 +1,983 @@
+ [], // ['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
+{
+ $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 {
+ 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 ───────────────────────────────────────────────────────
+
+/** 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'];
+ }
+
+ // Prova login
+ $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];
+ skip("Login $email (già registrato)");
+ return $jwt;
+ }
+
+ // Registrazione
+ $regRes = api('POST', '/auth/register', [
+ 'first_name' => $firstName,
+ 'last_name' => $lastName,
+ 'email' => DEMO_EMAIL !== $email ? DEMO_EMAIL : $email,
+ 'password' => $password,
+ 'role' => $role,
+ ]);
+ // Override email per demo (sempre DEMO_EMAIL ma tracking per slug)
+ $regRes2 = api('POST', '/auth/register', [
+ 'first_name' => $firstName,
+ 'last_name' => $lastName,
+ 'email' => $email,
+ 'password' => $password,
+ 'role' => $role,
+ ]);
+ if (!empty($regRes2['data']['access_token'])) {
+ $jwt = $regRes2['data']['access_token'];
+ $uid = $regRes2['data']['user']['id'] ?? null;
+ $S['users'][$email] = ['jwt' => $jwt, 'id' => $uid];
+ ok("Registrato: $firstName $lastName <$email>");
+ return $jwt;
+ }
+ if (!empty($regRes2['success'])) {
+ // Login dopo registrazione
+ $loginRes2 = api('POST', '/auth/login', ['email' => $email, 'password' => $password]);
+ if (!empty($loginRes2['data']['access_token'])) {
+ $jwt = $loginRes2['data']['access_token'];
+ $S['users'][$email] = ['jwt' => $jwt, 'id' => $loginRes2['data']['user']['id'] ?? null];
+ ok("Registrato + login: $firstName $lastName <$email>");
+ return $jwt;
+ }
+ }
+
+ fail("Registrazione fallita per $email: " . ($regRes2['error'] ?? '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;
+}
+
+/** Completa onboarding org. */
+function completeOnboarding(string $jwt, int $orgId, array $data): void
+{
+ $res = api('POST', '/onboarding/complete', $data, $jwt, $orgId);
+ if (apiOk($res, 'onboarding')) {
+ ok("Onboarding completato org #$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')) {
+ ok("Classificazione NIS2: {$data['nis2_type']} — 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' => '09876543210',
+ 'ateco_code' => '62.01',
+ 'ateco_desc' => 'Produzione di software non connesso all\'edizione',
+ 'employees' => 320,
+ 'annual_turnover'=> 18500000,
+ 'sector' => 'ict',
+ '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' => 'technical', 'likelihood' => 4, 'impact' => 5, 'nis2_article' => '21.2.b'],
+ ['title' => 'Accesso non autorizzato a sistemi di produzione', 'category' => 'technical', 'likelihood' => 3, 'impact' => 5, 'nis2_article' => '21.2.i'],
+ ['title' => 'Data breach clienti enterprise (API key leak)', 'category' => 'data_breach', 'likelihood' => 3, 'impact' => 4, 'nis2_article' => '21.2.b'],
+ ['title' => 'Vulnerabilità zero-day in dipendenze npm', 'category' => 'technical', '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' => 'availability', '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' => '07654321098',
+ 'ateco_code' => '86.10',
+ 'ateco_desc' => 'Servizi ospedalieri',
+ 'employees' => 750,
+ 'annual_turnover'=> 42000000,
+ 'sector' => 'healthcare',
+ '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' => 'data_breach', 'likelihood' => 4, 'impact' => 5, 'nis2_article' => '21.2.b'],
+ ['title' => 'Fermo sistemi HIS (Hospital Information System)', 'category' => 'availability', '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' => 'technical', 'likelihood' => 4, 'impact' => 4, 'nis2_article' => '21.2.i'],
+ ['title' => 'Phishing staff medico (credential harvesting)', 'category' => 'human_factor', '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' => '05432109876',
+ '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' => 'technical', 'likelihood' => 3, 'impact' => 5, 'nis2_article' => '21.2.b'],
+ ['title' => 'Interruzione fornitura energia (blackout doloso)', 'category' => 'availability', 'likelihood' => 2, 'impact' => 5, 'nis2_article' => '21.2.c'],
+ ['title' => 'Accesso non autorizzato sistemi AMI (smart meter)', 'category' => 'technical', '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_factor', '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' => 'technical', '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);
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 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");
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 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'],
+ 'employees_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, [
+ 'org_name' => $comp['name'],
+ 'vat_number' => $comp['vat_number'],
+ 'sector' => $comp['sector'],
+ 'employees_count' => $comp['employees'],
+ 'annual_turnover_eur' => $comp['annual_turnover'],
+ ]);
+
+ // Classificazione NIS2
+ classifyOrg($jwt, $orgId, [
+ 'nis2_type' => $comp['nis2_type'],
+ 'sector' => $comp['sector'],
+ 'is_voluntary'=> 0,
+ 'justification'=> "Classificazione automatica: {$comp['nis2_type']} entity — settore {$comp['sector']}",
+ ]);
+
+ info(" {$comp['name']}: {$comp['employees']} dip, fatturato " . number_format($comp['annual_turnover']/1000000, 1) . "M€, settore {$comp['sector']}");
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 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']}",
+ 'status' => 'open',
+ ]);
+ }
+}
+
+// ────────────────────────────────────────────────────────────────────────────
+// 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) {
+ $polId = createPolicy($jwt, $orgId, [
+ 'title' => $polDef['title'],
+ 'type' => $polDef['type'],
+ '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'],
+ 'risk_level' => $supDef['risk_level'],
+ 'is_critical' => $supDef['critical'],
+ 'nis2_relevant' => 1,
+ 'contract_status' => 'active',
+ 'contact_email' => DEMO_EMAIL,
+ ]);
+
+ // Assessment fornitore
+ if ($supId) {
+ $assessRes = api('POST', "/supply-chain/{$supId}/assess", [
+ 'has_security_controls' => 1,
+ 'has_incident_procedure' => $supDef['risk_level'] !== 'low' ? 1 : 0,
+ 'gdpr_compliant' => 1,
+ 'nis2_contractual_clauses' => $supDef['critical'] ? 1 : 0,
+ 'last_audit_date' => date('Y-m-d', strtotime('-6 months')),
+ 'notes' => "Assessment automatico — fornitore {$supDef['service_type']}",
+ ], $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',
+ 'category' => 'ransomware',
+ 'affected_systems' => json_encode(['Kubernetes cluster prod', 'NAS storage 3.2TB', 'Portale clienti', 'API Gateway']),
+ 'estimated_impact' => 'Dati 47 clienti enterprise inaccessibili per 18h. Potenziale esfiltrazione pre-cifratura.',
+ 'is_significant' => 1,
+ 'status' => 'investigating',
+ ]);
+
+ 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' => 'resolved',
+ 'resolution' => 'Sistemi ripristinati da backup verificati. Vulnerabilità patchata. MFA obbligatorio su tutti gli accessi. Piano remediation 30 giorni approvato da CISO.',
+ ], $jwt, $orgId);
+ ok("Incidente risolto: 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',
+ 'category' => 'data_breach',
+ 'affected_systems' => json_encode(['LIS HealthSoft API', 'Database referti', 'Portale pazienti']),
+ 'is_significant' => 1,
+ 'status' => 'investigating',
+ ]);
+
+ 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']);
+}
+
+// ── Report finale ────────────────────────────────────────────────────────────
+simPhase(10, '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");
+info("");
+info("URL: " . str_replace('/api', '', API_BASE) . "/dashboard.html");
+
+simDone($S['stats']);