- ServicesController::provision(): created_by usa userId (INT) non string
- EmailService::logEmail(): rimosso sent_at (colonna non esiste in email_log)
- WebhookService::incidentPayload(): status ?? 'detected' (null-safe)
- simulate-nis2.php: supplier assessment usa formato assessment_responses
corretto [{question, weight, value: yes|partial|no}]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
321 lines
12 KiB
PHP
321 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Webhook Service
|
|
*
|
|
* Gestisce la consegna webhook outbound a sistemi esterni.
|
|
* Pattern: Stripe-like HMAC-SHA256, retry 3x con backoff esponenziale.
|
|
*
|
|
* Firma header: X-NIS2-Signature: sha256=HMAC_SHA256(body, secret)
|
|
* Retry: 1° tentativo immediato → 2° dopo 5min → 3° dopo 30min
|
|
*/
|
|
|
|
require_once APP_PATH . '/config/database.php';
|
|
|
|
class WebhookService
|
|
{
|
|
// Retry schedule: secondi di attesa per tentativo
|
|
private const RETRY_DELAYS = [0, 300, 1800]; // 0s, 5min, 30min
|
|
private const TIMEOUT_SEC = 10; // Timeout HTTP per delivery
|
|
private const MAX_RESPONSE_LEN = 2048; // Max byte di risposta salvata
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// DISPATCH (entry point principale)
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Dispatcha un evento a tutte le subscription attive per quell'org/evento.
|
|
* Si chiama sincrono nel request cycle (fire-and-forget con best effort).
|
|
*
|
|
* @param int $orgId Organizzazione proprietaria
|
|
* @param string $eventType Es. "incident.created"
|
|
* @param array $payload Dati dell'evento
|
|
* @param string $eventId UUID univoco evento (idempotency)
|
|
*/
|
|
public function dispatch(int $orgId, string $eventType, array $payload, string $eventId = ''): void
|
|
{
|
|
if (empty($eventId)) {
|
|
$eventId = $this->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'],
|
|
'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)
|
|
);
|
|
}
|
|
}
|