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