generateUuid(); } // Trova subscription attive che ascoltano questo evento $subscriptions = Database::fetchAll( 'SELECT * FROM webhook_subscriptions WHERE organization_id = ? AND is_active = 1 AND failure_count < 10 ORDER BY id', [$orgId] ); foreach ($subscriptions as $sub) { $events = json_decode($sub['events'], true) ?? []; if (!in_array($eventType, $events) && !in_array('*', $events)) { continue; } // Crea delivery record $fullPayload = $this->buildPayload($eventType, $eventId, $payload, $sub); $deliveryId = $this->createDelivery($sub, $eventType, $eventId, $fullPayload); // Tenta consegna immediata $this->attemptDelivery($deliveryId, $sub, $fullPayload); } } /** * Processa retry pendenti (chiamato via cron o endpoint admin). */ public function processRetries(): int { $pending = Database::fetchAll( 'SELECT wd.*, ws.url, ws.secret FROM webhook_deliveries wd JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id WHERE wd.status = "retrying" AND wd.next_retry_at <= NOW() AND wd.attempt <= 3 LIMIT 50' ); $processed = 0; foreach ($pending as $delivery) { $sub = ['id' => $delivery['subscription_id'], 'url' => $delivery['url'], 'secret' => $delivery['secret']]; $this->attemptDelivery($delivery['id'], $sub, json_decode($delivery['payload'], true)); $processed++; } return $processed; } // ══════════════════════════════════════════════════════════════════════ // INTERNAL // ══════════════════════════════════════════════════════════════════════ /** * Costruisce il payload completo con envelope standard NIS2. */ private function buildPayload(string $eventType, string $eventId, array $data, array $sub): array { return [ 'id' => $eventId, 'event' => $eventType, 'api_version' => '1.0.0', 'created' => time(), 'created_at' => date('c'), 'source' => 'nis2-agile', 'org_id' => $sub['organization_id'], 'data' => $data, ]; } /** * Crea record delivery nel DB, restituisce ID. */ private function createDelivery(array $sub, string $eventType, string $eventId, array $payload): int { return Database::insert('webhook_deliveries', [ 'subscription_id' => $sub['id'], 'organization_id' => $sub['organization_id'], 'event_type' => $eventType, 'event_id' => $eventId, 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE), 'status' => 'pending', 'attempt' => 1, ]); } /** * Tenta la consegna HTTP di un webhook. * Aggiorna il record delivery con il risultato. */ private function attemptDelivery(int $deliveryId, array $sub, array $payload): void { $bodyJson = json_encode($payload, JSON_UNESCAPED_UNICODE); $signature = 'sha256=' . hash_hmac('sha256', $bodyJson, $sub['secret']); $attempt = $payload['_attempt'] ?? 1; $httpCode = null; $responseBody = null; $success = false; try { $ch = curl_init($sub['url']); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $bodyJson, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => self::TIMEOUT_SEC, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'User-Agent: NIS2-Agile-Webhooks/1.0', 'X-NIS2-Signature: ' . $signature, 'X-NIS2-Event: ' . $payload['event'], 'X-NIS2-Delivery-Id: ' . $deliveryId, 'X-NIS2-Attempt: ' . $attempt, ], ]); $responseBody = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // Successo: qualsiasi 2xx $success = ($httpCode >= 200 && $httpCode < 300); } catch (Throwable $e) { $responseBody = 'Exception: ' . $e->getMessage(); $httpCode = 0; } // Tronca response if (strlen($responseBody) > self::MAX_RESPONSE_LEN) { $responseBody = substr($responseBody, 0, self::MAX_RESPONSE_LEN) . '...[truncated]'; } if ($success) { // Delivery riuscita Database::execute( 'UPDATE webhook_deliveries SET status = "delivered", http_status = ?, response_body = ?, delivered_at = NOW(), updated_at = NOW() WHERE id = ?', [$httpCode, $responseBody, $deliveryId] ); // Reset failure count Database::execute( 'UPDATE webhook_subscriptions SET failure_count = 0, last_triggered_at = NOW() WHERE id = ?', [$sub['id']] ); } else { // Calcola prossimo retry $nextAttempt = $attempt + 1; if ($nextAttempt <= 3) { $delay = self::RETRY_DELAYS[$attempt] ?? 1800; $nextRetry = date('Y-m-d H:i:s', time() + $delay); Database::execute( 'UPDATE webhook_deliveries SET status = "retrying", http_status = ?, response_body = ?, attempt = ?, next_retry_at = ?, updated_at = NOW() WHERE id = ?', [$httpCode, $responseBody, $nextAttempt, $nextRetry, $deliveryId] ); } else { // Tutti i tentativi esauriti Database::execute( 'UPDATE webhook_deliveries SET status = "failed", http_status = ?, response_body = ?, updated_at = NOW() WHERE id = ?', [$httpCode, $responseBody, $deliveryId] ); // Incrementa failure count subscription Database::execute( 'UPDATE webhook_subscriptions SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = ?', [$sub['id']] ); } } } // ── Payload builders per evento ─────────────────────────────────────── /** * Costruisce payload per evento incident.created / incident.updated */ public static function incidentPayload(array $incident, string $action = 'created'): array { return [ 'action' => $action, 'incident' => [ 'id' => $incident['id'], 'title' => $incident['title'], 'classification' => $incident['classification'], 'severity' => $incident['severity'], 'status' => $incident['status'] ?? 'detected', 'is_significant' => (bool)$incident['is_significant'], 'detected_at' => $incident['detected_at'], 'art23_deadlines' => [ 'early_warning' => date('c', strtotime($incident['detected_at']) + 86400), 'notification' => date('c', strtotime($incident['detected_at']) + 259200), 'final_report' => date('c', strtotime($incident['detected_at']) + 2592000), ], ], ]; } /** * Costruisce payload per evento risk.high_created / risk.critical_created */ public static function riskPayload(array $risk, string $action = 'created'): array { return [ 'action' => $action, 'risk' => [ 'id' => $risk['id'], 'title' => $risk['title'], 'category' => $risk['category'], 'likelihood' => $risk['likelihood'], 'impact' => $risk['impact'], 'risk_level' => $risk['risk_level'], 'risk_score' => $risk['inherent_risk_score'], 'status' => $risk['status'] ?? 'identified', 'created_at' => $risk['created_at'], ], ]; } /** * Costruisce payload per evento policy.approved */ public static function policyPayload(array $policy): array { return [ 'action' => 'approved', 'policy' => [ 'id' => $policy['id'], 'title' => $policy['title'], 'category' => $policy['category'], 'nis2_article'=> $policy['nis2_article'], 'version' => $policy['version'], 'approved_at' => $policy['approved_at'], 'ai_generated'=> (bool)$policy['ai_generated'], ], ]; } /** * Costruisce payload per evento compliance.score_changed */ public static function scorePayload(int $orgId, ?int $previousScore, int $newScore): array { return [ 'previous_score' => $previousScore, 'new_score' => $newScore, 'delta' => $newScore - ($previousScore ?? 0), 'label' => $newScore >= 80 ? 'compliant' : ($newScore >= 60 ? 'substantially_compliant' : ($newScore >= 40 ? 'partial' : 'significant_gaps')), ]; } // ── UUID ────────────────────────────────────────────────────────────── private function generateUuid(): string { return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) ); } }