nis2-agile/application/services/AuditService.php
DevEnv nis2-agile 874eabb6fc [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>
2026-03-07 13:56:53 +01:00

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';
}
}