[FEAT] Simulazioni Demo + Audit Trail Certificato SHA-256

- 5 scenari reali: Onboarding, Ransomware Art.23, Data Breach Supply Chain,
  Whistleblowing SCADA, Audit Hash Chain Verification
- simulate-nis2.php: 3 aziende (DataCore/MedClinic/EnerNet), 10 fasi, CLI+SSE
- AuditService.php: hash chain SHA-256 stile lg231 (prev_hash+entry_hash)
- Migration 010: prev_hash, entry_hash, severity, performed_by su audit_logs
- AuditController: GET chain-verify + GET export-certified
- reset-demo.sql: reset dati demo idempotente
- public/simulate.html: web runner SSE con console dark-theme
- Sidebar: link Simulazione Demo + Integrazioni

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-03-07 13:56:53 +01:00
parent b23bbc55fd
commit 874eabb6fc
9 changed files with 1983 additions and 10 deletions

View File

@ -238,4 +238,79 @@ class AuditController extends BaseController
echo $csv; echo $csv;
exit; 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);
}
} }

View File

@ -7,6 +7,7 @@
*/ */
require_once APP_PATH . '/config/database.php'; require_once APP_PATH . '/config/database.php';
require_once APP_PATH . '/services/AuditService.php';
class BaseController class BaseController
{ {
@ -533,16 +534,23 @@ class BaseController
*/ */
protected function logAudit(string $action, ?string $entityType = null, ?int $entityId = null, ?array $details = null): void protected function logAudit(string $action, ?string $entityType = null, ?int $entityId = null, ?array $details = null): void
{ {
Database::insert('audit_logs', [ $orgId = $this->currentOrgId;
'user_id' => $this->getCurrentUserId(), $userId = $this->getCurrentUserId();
'organization_id' => $this->currentOrgId, $severity = AuditService::resolveSeverity($action, $details);
'action' => $action, $email = $this->currentUser['email'] ?? null;
'entity_type' => $entityType,
'entity_id' => $entityId, AuditService::log(
'details' => $details ? json_encode($details) : null, $orgId ?? 0,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, $userId,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, $action,
]); $entityType,
$entityId,
$details,
$_SERVER['REMOTE_ADDR'] ?? '',
$_SERVER['HTTP_USER_AGENT'] ?? null,
$severity,
$email
);
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════

View File

@ -0,0 +1,310 @@
<?php
/**
* NIS2 Agile Audit Trail Certificato SHA-256
*
* Hash chain: ogni record include l'hash del record precedente per la stessa
* organization_id. La manomissione di qualsiasi record rompe la catena
* e viene rilevata da verifyChain().
*
* Adattato dal pattern AuditTrailService di 231 Agile (lg231-agile/shared/audit-lib).
*
* Uso:
* AuditService::log($orgId, $userId, 'incident.create', 'incident', 42, ['severity'=>'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';
}
}

View File

@ -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;

106
docs/sql/reset-demo.sql Normal file
View File

@ -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;

View File

@ -264,6 +264,8 @@ $actionMap = [
'GET:iso27001Mapping' => 'getIsoMapping', 'GET:iso27001Mapping' => 'getIsoMapping',
'GET:executiveReport' => 'executiveReport', 'GET:executiveReport' => 'executiveReport',
'GET:export' => 'export', 'GET:export' => 'export',
'GET:chainVerify' => 'chainVerify',
'GET:exportCertified' => 'exportCertified',
], ],
// ── AdminController ───────────────────────────── // ── AdminController ─────────────────────────────

View File

@ -213,6 +213,8 @@ function loadSidebar() {
items: [ items: [
{ name: 'Impostazioni', href: 'settings.html', icon: iconCog(), i18nKey: 'nav.settings' }, { name: 'Impostazioni', href: 'settings.html', icon: iconCog(), i18nKey: 'nav.settings' },
{ name: 'Architettura', href: 'architecture.html', icon: iconCubeTransparent() }, { name: 'Architettura', href: 'architecture.html', icon: iconCubeTransparent() },
{ name: 'Simulazione Demo', href: 'simulate.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>` },
{ name: 'Integrazioni', href: 'integrations/index.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/></svg>` },
] ]
} }
]; ];

374
public/simulate.html Normal file
View File

@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NIS2 Agile — Simulazione Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #06b6d4; --green: #22c55e; --red: #ef4444;
--yellow: #f59e0b; --gray: #334155; --dark: #0f172a;
--card: #1e293b; --border: #334155;
}
body { background: var(--dark); color: #e2e8f0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; min-height: 100vh; }
.header { background: linear-gradient(135deg,#0c4a6e,#0e7490); padding: 32px 48px; border-bottom: 1px solid #0e7490; }
.header h1 { font-size: 1.5rem; font-weight: 800; color: #fff; margin-bottom: 4px; }
.header p { color: #7dd3fc; font-size: 0.875rem; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 0.7rem; font-weight: 700; margin-right: 8px; }
.badge-cyan { background: rgba(6,182,212,.2); color: #67e8f9; border: 1px solid rgba(6,182,212,.3); }
.badge-green { background: rgba(34,197,94,.2); color: #86efac; border: 1px solid rgba(34,197,94,.3); }
.container { max-width: 1100px; margin: 0 auto; padding: 32px 24px; display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
/* Left panel */
.panel { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
.panel h3 { font-size: 0.875rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 16px; }
.sim-card { background: #0f172a; border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 10px; cursor: pointer; transition: border-color .2s; }
.sim-card:hover { border-color: var(--primary); }
.sim-card.active { border-color: var(--primary); background: #0c2230; }
.sim-card h4 { font-size: 0.8125rem; font-weight: 700; color: #e2e8f0; margin-bottom: 4px; }
.sim-card p { font-size: 0.75rem; color: #64748b; line-height: 1.5; }
.sim-card .sim-badge { font-size: 0.65rem; padding: 1px 7px; border-radius: 10px; font-weight: 700; margin-bottom: 6px; display: inline-block; }
.company-list { margin-bottom: 20px; }
.company-item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; background: #0f172a; border: 1px solid var(--border); margin-bottom: 8px; }
.company-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-cyan { background: #06b6d4; }
.dot-purple { background: #8b5cf6; }
.dot-green { background: #22c55e; }
.company-item span { font-size: 0.8rem; color: #cbd5e1; }
.company-item small { font-size: 0.7rem; color: #64748b; display: block; }
/* Controls */
.controls { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
.btn { padding: 10px 20px; border: none; border-radius: 8px; font-size: 0.875rem; font-weight: 700; cursor: pointer; transition: all .2s; }
.btn-primary { background: var(--primary); color: #0f172a; }
.btn-primary:hover:not(:disabled) { background: #0891b2; }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-danger { background: #7f1d1d; color: #fca5a5; border: 1px solid #991b1b; }
.btn-danger:hover:not(:disabled) { background: #991b1b; }
.btn-gray { background: #1e293b; color: #94a3b8; border: 1px solid var(--border); }
.btn-gray:hover { background: #334155; }
.status-bar { padding: 10px 12px; border-radius: 8px; font-size: 0.8rem; background: #0f172a; border: 1px solid var(--border); }
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
.dot-idle { background: #475569; }
.dot-running { background: #22c55e; animation: pulse 1s infinite; }
.dot-done { background: #06b6d4; }
.dot-error { background: #ef4444; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
.stats-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; margin-top: 12px; }
.stat-box { background: #0f172a; border: 1px solid var(--border); border-radius: 6px; padding: 10px; text-align: center; }
.stat-box .num { font-size: 1.25rem; font-weight: 800; }
.stat-box .lbl { font-size: 0.65rem; color: #64748b; text-transform: uppercase; margin-top: 2px; }
.num-pass { color: var(--green); }
.num-skip { color: #94a3b8; }
.num-warn { color: var(--yellow); }
.num-fail { color: var(--red); }
/* Right panel — Console */
.console-panel { display: flex; flex-direction: column; gap: 0; }
.console-header { background: #1e293b; border: 1px solid var(--border); border-radius: 12px 12px 0 0; padding: 12px 16px; display: flex; align-items: center; justify-content: space-between; }
.console-header span { font-size: 0.8rem; color: #94a3b8; }
.console-dots { display: flex; gap: 6px; }
.console-dot { width: 10px; height: 10px; border-radius: 50%; }
.console-dot-r { background: #ef4444; }
.console-dot-y { background: #f59e0b; }
.console-dot-g { background: #22c55e; }
#console { background: #0a0f1a; border: 1px solid var(--border); border-top: none; border-radius: 0 0 12px 12px; padding: 16px; height: 520px; overflow-y: auto; font-family: 'Cascadia Code','Consolas',monospace; font-size: 0.78rem; line-height: 1.7; }
.log-phase { color: #38bdf8; font-weight: 700; border-top: 1px solid #1e3a5f; padding-top: 8px; margin-top: 4px; }
.log-ok { color: #86efac; }
.log-skip { color: #64748b; }
.log-warn { color: #fcd34d; }
.log-error { color: #fca5a5; }
.log-email { color: #67e8f9; }
.log-info { color: #94a3b8; }
.log-done { color: #a78bfa; font-weight: 700; border-top: 1px solid #3730a3; padding-top: 8px; margin-top: 4px; }
/* Progress bar */
.progress-wrap { height: 4px; background: #1e293b; border-radius: 2px; overflow: hidden; margin-bottom: 16px; }
.progress-bar { height: 100%; background: var(--primary); width: 0%; transition: width .3s; }
@media (max-width: 768px) {
.container { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<div style="margin-bottom:10px;">
<span class="badge badge-cyan">NIS2 Agile</span>
<span class="badge badge-green">Demo Simulator v1.0</span>
</div>
<h1>Simulazione Demo Realistica</h1>
<p>Genera dati demo NIS2 realistici tramite API reali — 3 aziende, 5 scenari, audit trail certificato SHA-256</p>
</div>
<div class="container">
<!-- Left Panel -->
<div style="display:flex;flex-direction:column;gap:16px;">
<div class="panel">
<h3>3 Aziende Demo</h3>
<div class="company-list">
<div class="company-item">
<div class="company-dot dot-cyan"></div>
<div>
<span>DataCore S.r.l.</span>
<small>IT/Cloud · Essential Entity · 320 dip · Milano</small>
</div>
</div>
<div class="company-item">
<div class="company-dot dot-purple"></div>
<div>
<span>MedClinic Italia S.p.A.</span>
<small>Sanità · Important Entity · 750 dip · Roma</small>
</div>
</div>
<div class="company-item">
<div class="company-dot dot-green"></div>
<div>
<span>EnerNet Distribuzione S.r.l.</span>
<small>Energia · Essential/Critical · 1800 dip · Torino</small>
</div>
</div>
</div>
</div>
<div class="panel">
<h3>5 Scenari Reali</h3>
<div class="sim-card active" onclick="selectSim(this, 'all')">
<div class="sim-badge" style="background:rgba(6,182,212,.15);color:#67e8f9;border:1px solid rgba(6,182,212,.3);">COMPLETA</div>
<h4>Tutti gli scenari</h4>
<p>Esegue tutti e 5 gli scenari in sequenza: onboarding, incidenti, data breach, whistleblowing, audit chain.</p>
</div>
<div class="sim-card" onclick="selectSim(this, 'sim01')">
<div class="sim-badge" style="background:rgba(34,197,94,.1);color:#86efac;border:1px solid rgba(34,197,94,.3);">SIM-01</div>
<h4>Onboarding + Gap Assessment</h4>
<p>Registra le 3 aziende, classifica NIS2 (Essential/Important), esegue assessment 80 domande Art.21.</p>
</div>
<div class="sim-card" onclick="selectSim(this, 'sim02')">
<div class="sim-badge" style="background:rgba(239,68,68,.1);color:#fca5a5;border:1px solid rgba(239,68,68,.3);">SIM-02</div>
<h4>Ransomware Art.23 DataCore</h4>
<p>Attacco ransomware su infrastruttura cloud. Timeline 24h/72h/30d con notifiche ACN e CSIRT.</p>
</div>
<div class="sim-card" onclick="selectSim(this, 'sim03')">
<div class="sim-badge" style="background:rgba(139,92,246,.1);color:#ddd6fe;border:1px solid rgba(139,92,246,.3);">SIM-03</div>
<h4>Data Breach Supply Chain</h4>
<p>Fornitore LIS compromesso, esfiltrazione 2.340 pazienti. GDPR Art.33 + NIS2 Art.23 in parallelo.</p>
</div>
<div class="sim-card" onclick="selectSim(this, 'sim04')">
<div class="sim-badge" style="background:rgba(245,158,11,.1);color:#fcd34d;border:1px solid rgba(245,158,11,.3);">SIM-04</div>
<h4>Whistleblowing SCADA EnerNet</h4>
<p>Segnalazione anonima accesso non autorizzato SCADA. Token tracking, investigazione, chiusura.</p>
</div>
<div class="sim-card" onclick="selectSim(this, 'sim05')">
<div class="sim-badge" style="background:rgba(6,182,212,.1);color:#67e8f9;border:1px solid rgba(6,182,212,.3);">SIM-05</div>
<h4>Audit Trail Hash Chain</h4>
<p>Verifica integrità SHA-256 hash chain per le 3 org. Export certificato NIS2 + ISO 27001.</p>
</div>
</div>
<div class="panel">
<h3>Controlli</h3>
<div class="controls">
<button id="btnRun" class="btn btn-primary" onclick="runSimulation()">▶ Avvia Simulazione</button>
<button id="btnStop" class="btn btn-danger" onclick="stopSimulation()" disabled>■ Interrompi</button>
<button class="btn btn-gray" onclick="clearConsole()">⌫ Pulisci Console</button>
<button class="btn btn-gray" onclick="resetDemo()">↺ Reset Dati Demo</button>
</div>
<div class="progress-wrap"><div class="progress-bar" id="progressBar"></div></div>
<div class="status-bar">
<span class="status-dot dot-idle" id="statusDot"></span>
<span id="statusText">In attesa</span>
</div>
<div class="stats-grid" id="statsGrid" style="display:none;">
<div class="stat-box"><div class="num num-pass" id="sPass">0</div><div class="lbl">Pass</div></div>
<div class="stat-box"><div class="num num-skip" id="sSkip">0</div><div class="lbl">Skip</div></div>
<div class="stat-box"><div class="num num-warn" id="sWarn">0</div><div class="lbl">Warn</div></div>
<div class="stat-box"><div class="num num-fail" id="sFail">0</div><div class="lbl">Fail</div></div>
</div>
</div>
</div>
<!-- Right panel — Console -->
<div class="console-panel">
<div class="console-header">
<div class="console-dots">
<div class="console-dot console-dot-r"></div>
<div class="console-dot console-dot-y"></div>
<div class="console-dot console-dot-g"></div>
</div>
<span id="consoleTitle">NIS2 Agile Simulator — Console</span>
<span id="elapsedTime"></span>
</div>
<div id="console">
<div class="log-info">NIS2 Agile Demo Simulator v1.0 — Pronto</div>
<div class="log-info">Seleziona uno scenario e premi "Avvia Simulazione".</div>
<div class="log-info" style="margin-top:8px;">Scenari disponibili:</div>
<div class="log-ok"> SIM-01 Onboarding + Gap Assessment 80 domande</div>
<div class="log-ok"> SIM-02 Incidente Ransomware Art.23 (24h/72h/30d)</div>
<div class="log-ok"> SIM-03 Data Breach Supply Chain + GDPR</div>
<div class="log-ok"> SIM-04 Whistleblowing anonimo SCADA</div>
<div class="log-ok"> SIM-05 Audit Trail Hash Chain Verification</div>
<div class="log-info" style="margin-top:8px;">Tutti i dati vengono creati tramite API reali (nessun INSERT SQL diretto).</div>
</div>
</div>
</div>
<script>
let evtSource = null;
let selectedSim = 'all';
let startTime = null;
let timerInterval = null;
function selectSim(el, sim) {
document.querySelectorAll('.sim-card').forEach(c => c.classList.remove('active'));
el.classList.add('active');
selectedSim = sim;
}
function setStatus(text, dotClass) {
document.getElementById('statusText').textContent = text;
const dot = document.getElementById('statusDot');
dot.className = 'status-dot ' + dotClass;
}
function appendLog(msg, type) {
const console = document.getElementById('console');
const line = document.createElement('div');
line.className = 'log-' + (type || 'info');
line.textContent = msg;
console.appendChild(line);
console.scrollTop = console.scrollHeight;
}
function clearConsole() {
document.getElementById('console').innerHTML = '<div class="log-info">Console pulita.</div>';
}
function updateProgress(pct) {
document.getElementById('progressBar').style.width = pct + '%';
}
function updateStats(stats) {
if (!stats) return;
document.getElementById('statsGrid').style.display = 'grid';
document.getElementById('sPass').textContent = stats.pass || 0;
document.getElementById('sSkip').textContent = stats.skip || 0;
document.getElementById('sWarn').textContent = stats.warn || 0;
document.getElementById('sFail').textContent = stats.fail || 0;
}
function startTimer() {
startTime = Date.now();
timerInterval = setInterval(() => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
document.getElementById('elapsedTime').textContent = elapsed + 's';
}, 500);
}
function stopTimer() {
if (timerInterval) clearInterval(timerInterval);
}
function runSimulation() {
if (evtSource) { evtSource.close(); evtSource = null; }
const runBtn = document.getElementById('btnRun');
const stopBtn = document.getElementById('btnStop');
runBtn.disabled = true;
stopBtn.disabled = false;
clearConsole();
setStatus('Simulazione in corso...', 'dot-running');
updateProgress(5);
startTimer();
document.getElementById('statsGrid').style.display = 'none';
const url = `../simulate-nis2.php?sim=${selectedSim}&t=${Date.now()}`;
evtSource = new EventSource(url);
const phaseKeywords = ['FASE', '══'];
let phaseCount = 0;
evtSource.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.t === 'done') {
evtSource.close();
runBtn.disabled = false;
stopBtn.disabled = true;
setStatus('Completata', 'dot-done');
stopTimer();
updateProgress(100);
updateStats(data.stats);
appendLog('', 'info');
appendLog('Simulazione completata.', 'done');
document.getElementById('consoleTitle').textContent = 'NIS2 Agile Simulator — Completata';
return;
}
const type = data.t || 'info';
const msg = data.m || '';
appendLog(msg, type);
// Stima progress
if (type === 'phase' || msg.includes('FASE')) {
phaseCount++;
updateProgress(Math.min(5 + phaseCount * 8, 95));
}
};
evtSource.onerror = () => {
evtSource.close();
runBtn.disabled = false;
stopBtn.disabled = true;
setStatus('Errore connessione', 'dot-error');
stopTimer();
appendLog('Errore SSE — connessione interrotta.', 'error');
};
}
function stopSimulation() {
if (evtSource) { evtSource.close(); evtSource = null; }
document.getElementById('btnRun').disabled = false;
document.getElementById('btnStop').disabled = true;
setStatus('Interrotta', 'dot-error');
stopTimer();
appendLog('Simulazione interrotta manualmente.', 'warn');
}
function resetDemo() {
if (!confirm('Reset dati demo? Verranno eliminati tutti i dati con organization_id > 4.')) return;
fetch('../api/admin/reset-demo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + (localStorage.getItem('nis2_access_token') || ''),
},
})
.then(r => r.json())
.then(d => {
if (d.success) {
appendLog('Reset demo completato.', 'ok');
} else {
appendLog('Reset demo fallito: ' + (d.error || 'errore'), 'error');
appendLog('Tip: eseguire manualmente docs/sql/reset-demo.sql su Hetzner.', 'warn');
}
})
.catch(() => appendLog('Reset demo: errore di rete — eseguire manualmente su server.', 'warn'));
}
</script>
</body>
</html>

983
simulate-nis2.php Normal file
View File

@ -0,0 +1,983 @@
<?php
/**
* NIS2 Agile Simulazione Demo Realistica
*
* Costruisce dati demo realistici attraverso le API reali (no INSERT SQL diretto).
* Simula 3 aziende con settori NIS2 differenti, complessità crescente:
*
* A) DataCore S.r.l. IT/Cloud Essential Entity, 320 dip, Milano
* B) MedClinic Italia S.p.A. Sanità Important Entity, 750 dip, Roma
* C) EnerNet Distribuzione S.r.l Energia Essential/Critical, 1800 dip, Torino
*
* 5 Scenari reali:
* SIM-01 Onboarding + Gap Assessment completo (tutte e 3 le aziende)
* SIM-02 Incidente Ransomware Art.23 DataCore (timeline 24h/72h/30d)
* SIM-03 Data Breach Supply Chain MedClinic (fornitore IT compromesso)
* SIM-04 Whistleblowing anonimo EnerNet (accesso non autorizzato SCADA)
* SIM-05 Audit Trail hash chain verification (tutte e 3 le aziende)
*
* Utilizzo CLI:
* php simulate-nis2.php [--reset] [--sim=N] [--company=slug]
*
* Dal browser (SSE streaming):
* public/simulate.html pulsante "Avvia Simulazione"
*
* Tutte le email vengono redirezionate a DEMO_EMAIL.
*/
declare(strict_types=1);
// ── Configurazione ──────────────────────────────────────────────────────────
define('SIM_VERSION', '1.0.0');
define('DEMO_EMAIL', getenv('NIS2_DEMO_EMAIL') ?: 'demo@nis2agile.it');
define('DEMO_PWD', 'NIS2Demo2026!');
define('IS_CLI', php_sapi_name() === 'cli');
define('IS_WEB', !IS_CLI);
// URL base API — rileva automaticamente ambiente
if (IS_CLI) {
// Sul server Hetzner: Apache serve /var/www/nis2-agile su https://nis2.certisource.it/
define('API_BASE', getenv('NIS2_API_BASE') ?: 'https://nis2.certisource.it/api');
} else {
// In web: usa URL relativo allo stesso host
$proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
define('API_BASE', "{$proto}://{$host}/api");
}
// ── Output SSE ──────────────────────────────────────────────────────────────
if (IS_WEB) {
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
ob_implicit_flush(true);
while (ob_get_level()) ob_end_flush();
}
// Stato globale simulazione
$S = [
'jwt' => [], // ['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']);