- 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>
311 lines
12 KiB
PHP
311 lines
12 KiB
PHP
<?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';
|
|
}
|
|
}
|