From 86e9bdded2d4df9acccbca1c588e4f3f8fcf9274 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 7 Mar 2026 13:20:24 +0100 Subject: [PATCH] [FEAT] Services API, Webhook, Whistleblowing, Normative + integrazioni MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint completo — prodotto presentation-ready: Services API (read-only, API Key + scope): - GET /api/services/status|compliance-summary|risks-feed|incidents-feed - GET /api/services/controls-status|assets-critical|suppliers-risk|policies-approved - GET /api/services/openapi (spec OpenAPI 3.0.3 JSON) Webhook Outbound (Stripe-like HMAC-SHA256): - CRUD api_keys + webhook_subscriptions (Settings → 2 nuovi tab) - WebhookService: retry 3x backoff (0s/5min/30min), delivery log - Trigger auto in IncidentController, RiskController, PolicyController - Delivery log, test ping, processRetry Nuovi moduli: - WhistleblowingController (Art.32 NIS2): anonimato garantito, timeline, token tracking - NormativeController: feed NIS2/ACN/DORA con ACK tracciato per audit Frontend: - whistleblowing.html: form submit anonimo/firmato + gestione CISO - normative.html: feed con presa visione documentata + progress bar ACK - public/docs/api.html: documentazione API dark theme (Swagger-like) - settings.html: tab API Keys + tab Webhook - integrations/: guide per lg231, SustainAI, AllRisk, SIEM (widget + codice) - Sidebar: Segnalazioni + Normative aggiunte a common.js DB: migration 007 (api_keys, webhook_subscriptions, webhook_deliveries), 008 (whistleblowing_reports + timeline), 009 (normative_updates + normative_ack + seed NIS2/ACN/DORA/ISO) Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/IncidentController.php | 25 + .../controllers/NormativeController.php | 255 ++++++ application/controllers/PolicyController.php | 14 + application/controllers/RiskController.php | 18 + .../controllers/ServicesController.php | 856 ++++++++++++++++++ application/controllers/WebhookController.php | 405 +++++++++ .../controllers/WhistleblowingController.php | 386 ++++++++ application/services/WebhookService.php | 320 +++++++ docs/sql/007_services_api.sql | 87 ++ docs/sql/008_whistleblowing.sql | 65 ++ docs/sql/009_normative_updates.sql | 78 ++ public/docs/api.html | 710 +++++++++++++++ public/index.php | 55 ++ public/integrations/allrisk.html | 156 ++++ public/integrations/index.html | 66 ++ public/integrations/lg231.html | 225 +++++ public/integrations/siem.html | 179 ++++ public/integrations/sustainai.html | 156 ++++ public/js/common.js | 12 +- public/normative.html | 256 ++++++ public/settings.html | 330 ++++++- public/whistleblowing.html | 436 +++++++++ 22 files changed, 5080 insertions(+), 10 deletions(-) create mode 100644 application/controllers/NormativeController.php create mode 100644 application/controllers/ServicesController.php create mode 100644 application/controllers/WebhookController.php create mode 100644 application/controllers/WhistleblowingController.php create mode 100644 application/services/WebhookService.php create mode 100644 docs/sql/007_services_api.sql create mode 100644 docs/sql/008_whistleblowing.sql create mode 100644 docs/sql/009_normative_updates.sql create mode 100644 public/docs/api.html create mode 100644 public/integrations/allrisk.html create mode 100644 public/integrations/index.html create mode 100644 public/integrations/lg231.html create mode 100644 public/integrations/siem.html create mode 100644 public/integrations/sustainai.html create mode 100644 public/normative.html create mode 100644 public/whistleblowing.html diff --git a/application/controllers/IncidentController.php b/application/controllers/IncidentController.php index 2257424..813088b 100644 --- a/application/controllers/IncidentController.php +++ b/application/controllers/IncidentController.php @@ -8,6 +8,7 @@ require_once __DIR__ . '/BaseController.php'; require_once APP_PATH . '/services/AIService.php'; require_once APP_PATH . '/services/EmailService.php'; +require_once APP_PATH . '/services/WebhookService.php'; class IncidentController extends BaseController { @@ -97,6 +98,18 @@ class IncidentController extends BaseController 'severity' => $data['severity'], 'is_significant' => $isSignificant ]); + // Dispatch webhook events + try { + $incident = array_merge($data, ['id' => $incidentId]); + $webhookSvc = new WebhookService(); + $webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.created', WebhookService::incidentPayload($incident, 'created')); + if ($isSignificant) { + $webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.significant', WebhookService::incidentPayload($incident, 'significant')); + } + } catch (Throwable $e) { + error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); + } + $this->jsonSuccess([ 'id' => $incidentId, 'incident_code' => $data['incident_code'], @@ -187,6 +200,18 @@ class IncidentController extends BaseController if (!empty($updates)) { Database::update('incidents', $updates, 'id = ?', [$id]); $this->logAudit('incident_updated', 'incident', $id, $updates); + + // Dispatch webhook: incident.updated e incident.significant se appena flaggato + try { + $updatedIncident = Database::fetchOne('SELECT * FROM incidents WHERE id = ?', [$id]); + $webhookSvc = new WebhookService(); + $webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.updated', WebhookService::incidentPayload($updatedIncident, 'updated')); + if (isset($updates['is_significant']) && $updates['is_significant'] && !$incident['is_significant']) { + $webhookSvc->dispatch($this->getCurrentOrgId(), 'incident.significant', WebhookService::incidentPayload($updatedIncident, 'significant')); + } + } catch (Throwable $e) { + error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); + } } $this->jsonSuccess($updates, 'Incidente aggiornato'); diff --git a/application/controllers/NormativeController.php b/application/controllers/NormativeController.php new file mode 100644 index 0000000..f2ce0f5 --- /dev/null +++ b/application/controllers/NormativeController.php @@ -0,0 +1,255 @@ +requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $conditions = ['nu.is_published = 1']; + $params = []; + + if ($this->hasParam('source')) { + $conditions[] = 'nu.source = ?'; + $params[] = $this->getParam('source'); + } + if ($this->hasParam('impact')) { + $conditions[] = 'nu.impact_level = ?'; + $params[] = $this->getParam('impact'); + } + if ($this->hasParam('action_required')) { + $conditions[] = 'nu.action_required = ?'; + $params[] = (int)$this->getParam('action_required'); + } + + $where = implode(' AND ', $conditions); + $updates = Database::fetchAll( + "SELECT nu.*, + na.acknowledged_at, + na.acknowledged_by, + u.full_name as ack_by_name + FROM normative_updates nu + LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ? + LEFT JOIN users u ON u.id = na.acknowledged_by + WHERE {$where} + ORDER BY + CASE nu.impact_level WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 ELSE 5 END, + nu.published_at DESC", + array_merge([$orgId], $params) + ); + + // Decodifica affected_domains + foreach ($updates as &$u) { + $u['affected_domains'] = json_decode($u['affected_domains'] ?? '[]', true) ?? []; + $u['is_acknowledged'] = !empty($u['acknowledged_at']); + } + unset($u); + + $this->jsonSuccess(['updates' => $updates, 'total' => count($updates)]); + } + + /** + * GET /api/normative/{id} + */ + public function get(int $id): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $update = Database::fetchOne( + 'SELECT nu.*, + na.acknowledged_at, na.acknowledged_by, na.notes as ack_notes, + u.full_name as ack_by_name + FROM normative_updates nu + LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ? + LEFT JOIN users u ON u.id = na.acknowledged_by + WHERE nu.id = ? AND nu.is_published = 1', + [$orgId, $id] + ); + + if (!$update) { + $this->jsonError('Aggiornamento non trovato', 404, 'NOT_FOUND'); + } + + $update['affected_domains'] = json_decode($update['affected_domains'] ?? '[]', true) ?? []; + $update['is_acknowledged'] = !empty($update['acknowledged_at']); + + $this->jsonSuccess($update); + } + + /** + * POST /api/normative/{id}/ack + * Documenta la presa visione dell'aggiornamento normativo. + */ + public function acknowledge(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $orgId = $this->getCurrentOrgId(); + + $update = Database::fetchOne( + 'SELECT id, title, impact_level FROM normative_updates WHERE id = ? AND is_published = 1', + [$id] + ); + if (!$update) { $this->jsonError('Aggiornamento non trovato', 404, 'NOT_FOUND'); } + + // Verifica se già ACK + $existing = Database::fetchOne( + 'SELECT id FROM normative_ack WHERE normative_update_id = ? AND organization_id = ?', + [$id, $orgId] + ); + + $notes = trim($this->getParam('notes', '')); + + if ($existing) { + // Aggiorna note se già ACK + Database::execute( + 'UPDATE normative_ack SET notes = ?, acknowledged_by = ?, acknowledged_at = NOW() + WHERE normative_update_id = ? AND organization_id = ?', + [$notes ?: null, $this->getCurrentUserId(), $id, $orgId] + ); + } else { + Database::insert('normative_ack', [ + 'normative_update_id' => $id, + 'organization_id' => $orgId, + 'acknowledged_by' => $this->getCurrentUserId(), + 'notes' => $notes ?: null, + ]); + } + + $this->logAudit('normative_acknowledged', 'normative_update', $id, [ + 'title' => $update['title'], + 'impact' => $update['impact_level'], + ]); + + // Dispatch webhook + try { + (new WebhookService())->dispatch($orgId, 'normative.update', [ + 'id' => $update['id'], + 'title' => $update['title'], + 'impact_level' => $update['impact_level'], + 'acknowledged' => true, + 'acknowledged_at' => date('c'), + ]); + } catch (Throwable $e) { + error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); + } + + $this->jsonSuccess(['acknowledged_at' => date('c')], 'Presa visione registrata'); + } + + /** + * GET /api/normative/pending + * Aggiornamenti non ancora ACK dall'organizzazione corrente. + */ + public function pending(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $pending = Database::fetchAll( + 'SELECT nu.id, nu.title, nu.source, nu.impact_level, nu.action_required, + nu.effective_date, nu.published_at + FROM normative_updates nu + LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ? + WHERE nu.is_published = 1 AND na.id IS NULL + ORDER BY + CASE nu.impact_level WHEN \'critical\' THEN 1 WHEN \'high\' THEN 2 WHEN \'medium\' THEN 3 ELSE 4 END, + nu.published_at DESC', + [$orgId] + ); + + $this->jsonSuccess([ + 'pending' => $pending, + 'count' => count($pending), + 'critical_count' => count(array_filter($pending, fn($u) => $u['impact_level'] === 'critical')), + ]); + } + + /** + * GET /api/normative/stats + */ + public function stats(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $total = Database::fetchOne('SELECT COUNT(*) as n FROM normative_updates WHERE is_published = 1'); + $acked = Database::fetchOne( + 'SELECT COUNT(*) as n FROM normative_ack WHERE organization_id = ?', + [$orgId] + ); + + $ackRate = $total['n'] > 0 ? round(($acked['n'] / $total['n']) * 100) : 0; + + $bySource = Database::fetchAll( + 'SELECT nu.source, + COUNT(nu.id) as total, + COUNT(na.id) as acknowledged + FROM normative_updates nu + LEFT JOIN normative_ack na ON na.normative_update_id = nu.id AND na.organization_id = ? + WHERE nu.is_published = 1 + GROUP BY nu.source', + [$orgId] + ); + + $this->jsonSuccess([ + 'total_updates' => (int)$total['n'], + 'acknowledged' => (int)$acked['n'], + 'pending' => (int)$total['n'] - (int)$acked['n'], + 'ack_rate' => $ackRate, + 'by_source' => $bySource, + ]); + } + + /** + * POST /api/normative/create + * Solo super_admin può pubblicare nuovi aggiornamenti normativi. + */ + public function create(): void + { + $this->requireSuperAdmin(); + $this->validateRequired(['title', 'source', 'summary', 'impact_level']); + + $domains = $this->getParam('affected_domains', []); + if (is_string($domains)) $domains = json_decode($domains, true) ?? []; + + $id = Database::insert('normative_updates', [ + 'title' => trim($this->getParam('title')), + 'source' => $this->getParam('source'), + 'source_label' => $this->getParam('source_label'), + 'reference' => $this->getParam('reference'), + 'summary' => trim($this->getParam('summary')), + 'content' => $this->getParam('content'), + 'impact_level' => $this->getParam('impact_level'), + 'affected_domains'=> json_encode(array_values($domains)), + 'action_required' => $this->getParam('action_required', 0) ? 1 : 0, + 'effective_date' => $this->getParam('effective_date') ?: null, + 'url' => $this->getParam('url') ?: null, + 'is_published' => 1, + ]); + + $this->jsonSuccess(['id' => $id], 'Aggiornamento normativo pubblicato', 201); + } +} diff --git a/application/controllers/PolicyController.php b/application/controllers/PolicyController.php index cd269f1..0f1254a 100644 --- a/application/controllers/PolicyController.php +++ b/application/controllers/PolicyController.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/BaseController.php'; require_once APP_PATH . '/services/AIService.php'; +require_once APP_PATH . '/services/WebhookService.php'; class PolicyController extends BaseController { @@ -119,6 +120,19 @@ class PolicyController extends BaseController ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('policy_approved', 'policy', $id); + + // Dispatch webhook policy.approved + try { + $policy = Database::fetchOne('SELECT * FROM policies WHERE id = ?', [$id]); + (new WebhookService())->dispatch( + $this->getCurrentOrgId(), + 'policy.approved', + WebhookService::policyPayload($policy) + ); + } catch (Throwable $e) { + error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); + } + $this->jsonSuccess(null, 'Policy approvata'); } diff --git a/application/controllers/RiskController.php b/application/controllers/RiskController.php index 12db623..d793450 100644 --- a/application/controllers/RiskController.php +++ b/application/controllers/RiskController.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/BaseController.php'; require_once APP_PATH . '/services/AIService.php'; +require_once APP_PATH . '/services/WebhookService.php'; class RiskController extends BaseController { @@ -77,6 +78,23 @@ class RiskController extends BaseController $this->logAudit('risk_created', 'risk', $riskId); + // Dispatch webhook per rischi HIGH/CRITICAL + $riskScore = $likelihood * $impact; + if ($riskScore >= 12) { // HIGH: 12-16, CRITICAL: >16 (su scala 5x5) + try { + $riskData = Database::fetchOne('SELECT * FROM risks WHERE id = ?', [$riskId]); + $riskLevel = $riskScore >= 20 ? 'critical' : 'high'; + $riskData['risk_level'] = $riskLevel; + (new WebhookService())->dispatch( + $this->getCurrentOrgId(), + 'risk.high_created', + WebhookService::riskPayload($riskData, 'created') + ); + } catch (Throwable $e) { + error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); + } + } + $this->jsonSuccess(['id' => $riskId], 'Rischio registrato', 201); } diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php new file mode 100644 index 0000000..3bf3db6 --- /dev/null +++ b/application/controllers/ServicesController.php @@ -0,0 +1,856 @@ +jsonError('API Key mancante', 401, 'MISSING_API_KEY'); + } + + // Hash SHA-256 della chiave + $keyHash = hash('sha256', $rawKey); + + // Cerca in DB + $record = Database::fetchOne( + 'SELECT ak.*, o.name as org_name, o.nis2_entity_type, o.sector + FROM api_keys ak + JOIN organizations o ON o.id = ak.organization_id + WHERE ak.key_hash = ? AND ak.is_active = 1 + AND (ak.expires_at IS NULL OR ak.expires_at > NOW())', + [$keyHash] + ); + + if (!$record) { + $this->jsonError('API Key non valida o scaduta', 401, 'INVALID_API_KEY'); + } + + // Verifica scope + $scopes = json_decode($record['scopes'], true) ?? []; + if (!in_array($scope, $scopes) && !in_array('read:all', $scopes)) { + $this->jsonError("Scope '{$scope}' non autorizzato per questa chiave", 403, 'SCOPE_DENIED'); + } + + // Rate limiting per API key + $this->checkRateLimit($record['key_prefix']); + + // Aggiorna last_used_at (async: non blocchiamo su errore) + try { + Database::execute( + 'UPDATE api_keys SET last_used_at = NOW() WHERE id = ?', + [$record['id']] + ); + } catch (Throwable $e) { + // non critico + } + + $this->apiKeyRecord = $record; + $this->currentOrgId = (int) $record['organization_id']; + } + + /** + * Rate limiting file-based per API Key + */ + private function checkRateLimit(string $keyPrefix): void + { + if (!is_dir(self::RATE_LIMIT_DIR)) { + @mkdir(self::RATE_LIMIT_DIR, 0755, true); + } + + $file = self::RATE_LIMIT_DIR . 'key_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $keyPrefix) . '.json'; + $now = time(); + $data = ['count' => 0, 'window_start' => $now]; + + if (file_exists($file)) { + $raw = @json_decode(file_get_contents($file), true); + if ($raw && ($now - $raw['window_start']) < self::RATE_LIMIT_WINDOW) { + $data = $raw; + } + } + + if ($data['count'] >= self::RATE_LIMIT_MAX) { + $retryAfter = self::RATE_LIMIT_WINDOW - ($now - $data['window_start']); + header('Retry-After: ' . $retryAfter); + header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX); + header('X-RateLimit-Remaining: 0'); + $this->jsonError('Rate limit superato. Max ' . self::RATE_LIMIT_MAX . ' req/h per API key.', 429, 'RATE_LIMITED'); + } + + $data['count']++; + file_put_contents($file, json_encode($data), LOCK_EX); + + header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX); + header('X-RateLimit-Remaining: ' . (self::RATE_LIMIT_MAX - $data['count'])); + } + + /** + * Headers standard per tutte le risposte Services API + */ + private function setServiceHeaders(): void + { + header('X-NIS2-API-Version: ' . self::API_VERSION); + header('X-NIS2-Org-Id: ' . $this->currentOrgId); + } + + // ══════════════════════════════════════════════════════════════════════ + // ENDPOINT + // ══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/services/status + * Health check + info piattaforma. Nessuna auth richiesta. + */ + public function status(): void + { + $this->setServiceHeaders(); + + $this->jsonSuccess([ + 'platform' => 'NIS2 Agile', + 'version' => self::API_VERSION, + 'status' => 'operational', + 'regulation' => ['EU 2022/2555', 'D.Lgs. 138/2024', 'ISO 27001/27005'], + 'ai_provider' => 'Anthropic Claude', + 'timestamp' => date('c'), + 'endpoints' => [ + 'compliance_summary' => '/api/services/compliance-summary', + 'risks_feed' => '/api/services/risks/feed', + 'incidents_feed' => '/api/services/incidents/feed', + 'controls_status' => '/api/services/controls/status', + 'critical_assets' => '/api/services/assets/critical', + 'suppliers_risk' => '/api/services/suppliers/risk', + 'approved_policies' => '/api/services/policies/approved', + 'openapi' => '/api/services/openapi', + 'docs' => '/docs/api', + ], + ], 'NIS2 Agile Services API - Operational'); + } + + /** + * GET /api/services/compliance-summary + * Compliance score aggregato per dominio Art.21. + * Scope: read:compliance + */ + public function complianceSummary(): void + { + $this->requireApiKey('read:compliance'); + $this->setServiceHeaders(); + + $orgId = $this->currentOrgId; + + // Score da assessment più recente completato + $assessment = Database::fetchOne( + 'SELECT * FROM assessments WHERE organization_id = ? AND status = "completed" + ORDER BY completed_at DESC LIMIT 1', + [$orgId] + ); + + $overallScore = null; + $domainScores = []; + $recommendations = []; + + if ($assessment) { + // Calcola score per dominio (10 categorie Art.21) + $responses = Database::fetchAll( + 'SELECT ar.*, q.category, q.weight + FROM assessment_responses ar + JOIN ( + SELECT question_code, category, weight + FROM ( + SELECT question_code, + JSON_UNQUOTE(JSON_EXTRACT(question_data, "$.category")) as category, + CAST(JSON_UNQUOTE(JSON_EXTRACT(question_data, "$.weight")) AS DECIMAL(3,1)) as weight + FROM assessment_responses + WHERE assessment_id = ? + ) t GROUP BY question_code + ) q ON q.question_code = ar.question_code + WHERE ar.assessment_id = ?', + [$assessment['id'], $assessment['id']] + ); + + // Semplificato: score per categoria + $byCategory = []; + foreach ($responses as $r) { + $cat = $r['category'] ?? 'uncategorized'; + if (!isset($byCategory[$cat])) { + $byCategory[$cat] = ['total' => 0, 'count' => 0]; + } + $val = (int) ($r['response_value'] ?? 0); + $byCategory[$cat]['total'] += $val; + $byCategory[$cat]['count']++; + } + + $totalScore = 0; + $catCount = 0; + foreach ($byCategory as $cat => $data) { + $score = $data['count'] > 0 + ? round(($data['total'] / ($data['count'] * 4)) * 100) + : 0; + $domainScores[] = [ + 'domain' => $cat, + 'score' => $score, + 'status' => $score >= 70 ? 'compliant' : ($score >= 40 ? 'partial' : 'gap'), + ]; + $totalScore += $score; + $catCount++; + } + + $overallScore = $catCount > 0 ? round($totalScore / $catCount) : 0; + + // Raccomandazioni AI se disponibili + if (!empty($assessment['ai_analysis'])) { + $aiData = json_decode($assessment['ai_analysis'], true); + $recommendations = $aiData['recommendations'] ?? []; + } + } + + // Risk summary + $riskStats = Database::fetchOne( + 'SELECT + COUNT(*) as total, + SUM(CASE WHEN status = "open" THEN 1 ELSE 0 END) as open_count, + SUM(CASE WHEN risk_level IN ("high","critical") AND status = "open" THEN 1 ELSE 0 END) as high_critical, + SUM(CASE WHEN status = "mitigated" THEN 1 ELSE 0 END) as mitigated + FROM risks WHERE organization_id = ?', + [$orgId] + ); + + // Incident summary + $incidentStats = Database::fetchOne( + 'SELECT + COUNT(*) as total, + SUM(CASE WHEN status = "open" OR status = "investigating" THEN 1 ELSE 0 END) as open_count, + SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant, + SUM(CASE WHEN early_warning_sent = 1 THEN 1 ELSE 0 END) as notified_acn + FROM incidents WHERE organization_id = ?', + [$orgId] + ); + + // Policy summary + $policyStats = Database::fetchOne( + 'SELECT + COUNT(*) as total, + SUM(CASE WHEN status = "approved" THEN 1 ELSE 0 END) as approved, + SUM(CASE WHEN status IN ("draft","review") THEN 1 ELSE 0 END) as pending + FROM policies WHERE organization_id = ?', + [$orgId] + ); + + $org = Database::fetchOne( + 'SELECT name, nis2_entity_type, sector, employee_count FROM organizations WHERE id = ?', + [$orgId] + ); + + $this->jsonSuccess([ + 'organization' => [ + 'name' => $org['name'], + 'entity_type' => $org['nis2_entity_type'], + 'sector' => $org['sector'], + ], + 'overall_score' => $overallScore, + 'score_label' => $this->scoreLabel($overallScore), + 'domain_scores' => $domainScores, + 'assessment' => $assessment ? [ + 'id' => $assessment['id'], + 'completed_at' => $assessment['completed_at'], + 'status' => $assessment['status'], + ] : null, + 'risks' => [ + 'total' => (int)($riskStats['total'] ?? 0), + 'open' => (int)($riskStats['open_count'] ?? 0), + 'high_critical'=> (int)($riskStats['high_critical'] ?? 0), + 'mitigated' => (int)($riskStats['mitigated'] ?? 0), + ], + 'incidents' => [ + 'total' => (int)($incidentStats['total'] ?? 0), + 'open' => (int)($incidentStats['open_count'] ?? 0), + 'significant' => (int)($incidentStats['significant'] ?? 0), + 'notified_acn' => (int)($incidentStats['notified_acn'] ?? 0), + ], + 'policies' => [ + 'total' => (int)($policyStats['total'] ?? 0), + 'approved' => (int)($policyStats['approved'] ?? 0), + 'pending' => (int)($policyStats['pending'] ?? 0), + ], + 'top_recommendations' => array_slice($recommendations, 0, 5), + 'generated_at' => date('c'), + ]); + } + + /** + * GET /api/services/risks/feed + * Feed rischi filtrabili. + * Scope: read:risks + * Query: ?level=high,critical &from=2026-01-01 &area=it &limit=50 + */ + public function risksFeed(): void + { + $this->requireApiKey('read:risks'); + $this->setServiceHeaders(); + + $orgId = $this->currentOrgId; + $where = 'r.organization_id = ? AND r.deleted_at IS NULL'; + $params = [$orgId]; + + if (!empty($_GET['level'])) { + $levels = array_filter(explode(',', $_GET['level'])); + $placeholders = implode(',', array_fill(0, count($levels), '?')); + $where .= " AND r.risk_level IN ({$placeholders})"; + $params = array_merge($params, $levels); + } + + if (!empty($_GET['area'])) { + $where .= ' AND r.category = ?'; + $params[] = $_GET['area']; + } + + if (!empty($_GET['status'])) { + $where .= ' AND r.status = ?'; + $params[] = $_GET['status']; + } + + if (!empty($_GET['from'])) { + $where .= ' AND r.created_at >= ?'; + $params[] = $_GET['from'] . ' 00:00:00'; + } + + $limit = min(200, max(1, (int)($_GET['limit'] ?? 50))); + + $risks = Database::fetchAll( + "SELECT r.id, r.title, r.description, r.category, r.likelihood, + r.impact, r.inherent_risk_score, r.risk_level, r.status, + r.treatment_plan, r.owner_name, r.residual_risk_score, + r.created_at, r.updated_at + FROM risks r + WHERE {$where} + ORDER BY r.inherent_risk_score DESC, r.created_at DESC + LIMIT {$limit}", + $params + ); + + $total = Database::count('risks', 'organization_id = ? AND deleted_at IS NULL', [$orgId]); + + $this->jsonSuccess([ + 'risks' => $risks, + 'total' => $total, + 'fetched' => count($risks), + 'filters' => [ + 'level' => $_GET['level'] ?? null, + 'area' => $_GET['area'] ?? null, + 'status' => $_GET['status'] ?? null, + 'from' => $_GET['from'] ?? null, + ], + 'generated_at' => date('c'), + ]); + } + + /** + * GET /api/services/incidents/feed + * Feed incidenti Art.23 filtrabili. + * Scope: read:incidents + * Query: ?status=open &severity=high,critical &from=2026-01-01 &significant=1 + */ + public function incidentsFeed(): void + { + $this->requireApiKey('read:incidents'); + $this->setServiceHeaders(); + + $orgId = $this->currentOrgId; + $where = 'organization_id = ?'; + $params = [$orgId]; + + if (!empty($_GET['status'])) { + $where .= ' AND status = ?'; + $params[] = $_GET['status']; + } + + if (!empty($_GET['severity'])) { + $severities = array_filter(explode(',', $_GET['severity'])); + $ph = implode(',', array_fill(0, count($severities), '?')); + $where .= " AND severity IN ({$ph})"; + $params = array_merge($params, $severities); + } + + if (!empty($_GET['significant'])) { + $where .= ' AND is_significant = 1'; + } + + if (!empty($_GET['from'])) { + $where .= ' AND detected_at >= ?'; + $params[] = $_GET['from'] . ' 00:00:00'; + } + + $limit = min(200, max(1, (int)($_GET['limit'] ?? 50))); + + $incidents = Database::fetchAll( + "SELECT id, title, classification, severity, status, is_significant, + detected_at, contained_at, resolved_at, + early_warning_sent, early_warning_sent_at, + notification_sent, notification_sent_at, + final_report_sent, final_report_sent_at, + notification_deadline, final_report_deadline, + affected_systems, impact_description, + created_at, updated_at + FROM incidents + WHERE {$where} + ORDER BY detected_at DESC + LIMIT {$limit}", + $params + ); + + // Aggiungi stato scadenze Art.23 + $now = time(); + foreach ($incidents as &$inc) { + $detectedTs = strtotime($inc['detected_at']); + $inc['art23_status'] = [ + 'early_warning_24h' => [ + 'required' => (bool)$inc['is_significant'], + 'deadline' => date('c', $detectedTs + 86400), + 'sent' => (bool)$inc['early_warning_sent'], + 'overdue' => !$inc['early_warning_sent'] && $now > $detectedTs + 86400, + ], + 'notification_72h' => [ + 'required' => (bool)$inc['is_significant'], + 'deadline' => date('c', $detectedTs + 259200), + 'sent' => (bool)$inc['notification_sent'], + 'overdue' => !$inc['notification_sent'] && $now > $detectedTs + 259200, + ], + 'final_report_30d' => [ + 'required' => (bool)$inc['is_significant'], + 'deadline' => date('c', $detectedTs + 2592000), + 'sent' => (bool)$inc['final_report_sent'], + 'overdue' => !$inc['final_report_sent'] && $now > $detectedTs + 2592000, + ], + ]; + } + unset($inc); + + $total = Database::count('incidents', 'organization_id = ?', [$orgId]); + + $this->jsonSuccess([ + 'incidents' => $incidents, + 'total' => $total, + 'fetched' => count($incidents), + 'generated_at' => date('c'), + ]); + } + + /** + * GET /api/services/controls/status + * Stato controlli di sicurezza Art.21 per dominio. + * Scope: read:compliance + */ + public function controlsStatus(): void + { + $this->requireApiKey('read:compliance'); + $this->setServiceHeaders(); + + $orgId = $this->currentOrgId; + + $controls = Database::fetchAll( + 'SELECT id, control_code, title, category, status, + implementation_notes, due_date, updated_at + FROM compliance_controls + WHERE organization_id = ? + ORDER BY category, control_code', + [$orgId] + ); + + // Raggruppa per categoria + $byCategory = []; + foreach ($controls as $ctrl) { + $cat = $ctrl['category'] ?? 'uncategorized'; + if (!isset($byCategory[$cat])) { + $byCategory[$cat] = [ + 'category' => $cat, + 'controls' => [], + 'stats' => ['total' => 0, 'implemented' => 0, 'partial' => 0, 'planned' => 0, 'not_applicable' => 0], + ]; + } + $byCategory[$cat]['controls'][] = $ctrl; + $byCategory[$cat]['stats']['total']++; + $s = $ctrl['status'] ?? 'not_applicable'; + if (isset($byCategory[$cat]['stats'][$s])) { + $byCategory[$cat]['stats'][$s]++; + } + } + + // Score per categoria + foreach ($byCategory as &$cat) { + $t = $cat['stats']['total']; + $i = $cat['stats']['implemented']; + $p = $cat['stats']['partial']; + $cat['score'] = $t > 0 ? round((($i + $p * 0.5) / $t) * 100) : 0; + } + unset($cat); + + $totals = [ + 'total' => count($controls), + 'implemented' => 0, + 'partial' => 0, + 'planned' => 0, + 'not_applicable' => 0, + ]; + foreach ($controls as $ctrl) { + $s = $ctrl['status'] ?? 'not_applicable'; + if (isset($totals[$s])) $totals[$s]++; + } + $totals['overall_score'] = $totals['total'] > 0 + ? round((($totals['implemented'] + $totals['partial'] * 0.5) / $totals['total']) * 100) + : 0; + + $this->jsonSuccess([ + 'summary' => $totals, + 'by_category' => array_values($byCategory), + 'generated_at' => date('c'), + ]); + } + + /** + * GET /api/services/assets/critical + * Asset critici e dipendenze. + * Scope: read:assets + * Query: ?type=server,network &criticality=high,critical + */ + public function assetsCritical(): void + { + $this->requireApiKey('read:assets'); + $this->setServiceHeaders(); + + $orgId = $this->currentOrgId; + $where = 'organization_id = ?'; + $params = [$orgId]; + + if (!empty($_GET['type'])) { + $types = array_filter(explode(',', $_GET['type'])); + $ph = implode(',', array_fill(0, count($types), '?')); + $where .= " AND asset_type IN ({$ph})"; + $params = array_merge($params, $types); + } + + if (!empty($_GET['criticality'])) { + $crits = array_filter(explode(',', $_GET['criticality'])); + $ph = implode(',', array_fill(0, count($crits), '?')); + $where .= " AND criticality IN ({$ph})"; + $params = array_merge($params, $crits); + } else { + // Default: solo high e critical + $where .= " AND criticality IN ('high','critical')"; + } + + $assets = Database::fetchAll( + "SELECT id, name, asset_type, criticality, status, + owner_name, location, ip_address, description, + dependencies, created_at + FROM assets + WHERE {$where} + ORDER BY FIELD(criticality,'critical','high','medium','low'), name", + $params + ); + + $this->jsonSuccess([ + 'assets' => $assets, + 'total' => count($assets), + 'generated_at' => date('c'), + ]); + } + + /** + * GET /api/services/suppliers/risk + * Supplier risk overview (supply chain security). + * Scope: read:supply_chain + * Query: ?risk_level=high,critical &status=active + */ + public function suppliersRisk(): void + { + $this->requireApiKey('read:supply_chain'); + $this->setServiceHeaders(); + + $orgId = $this->currentOrgId; + $where = 's.organization_id = ? AND s.deleted_at IS NULL'; + $params = [$orgId]; + + if (!empty($_GET['risk_level'])) { + $levels = array_filter(explode(',', $_GET['risk_level'])); + $ph = implode(',', array_fill(0, count($levels), '?')); + $where .= " AND s.risk_level IN ({$ph})"; + $params = array_merge($params, $levels); + } + + if (!empty($_GET['status'])) { + $where .= ' AND s.status = ?'; + $params[] = $_GET['status']; + } + + $suppliers = Database::fetchAll( + "SELECT s.id, s.company_name, s.category, s.risk_level, s.status, + s.last_assessment_date, s.assessment_score, s.contact_email, + s.services_provided, s.critical_dependency, + s.created_at, s.updated_at + FROM suppliers s + WHERE {$where} + ORDER BY FIELD(s.risk_level,'critical','high','medium','low'), s.company_name", + $params + ); + + $stats = Database::fetchOne( + "SELECT + COUNT(*) as total, + SUM(CASE WHEN risk_level IN ('high','critical') AND deleted_at IS NULL THEN 1 ELSE 0 END) as high_risk, + SUM(CASE WHEN critical_dependency = 1 AND deleted_at IS NULL THEN 1 ELSE 0 END) as critical_deps, + SUM(CASE WHEN last_assessment_date IS NULL AND deleted_at IS NULL THEN 1 ELSE 0 END) as unassessed + FROM suppliers WHERE organization_id = ?", + [$orgId] + ); + + $this->jsonSuccess([ + 'summary' => $stats, + 'suppliers' => $suppliers, + 'generated_at' => date('c'), + ]); + } + + /** + * GET /api/services/policies/approved + * Policy approvate con metadati (no contenuto full per default). + * Scope: read:policies + * Query: ?category=... &include_content=1 + */ + public function policiesApproved(): void + { + $this->requireApiKey('read:policies'); + $this->setServiceHeaders(); + + $orgId = $this->currentOrgId; + $includeContent = !empty($_GET['include_content']); + + $select = $includeContent + ? 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated, content' + : 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated'; + + $where = 'organization_id = ? AND status = "approved"'; + $params = [$orgId]; + + if (!empty($_GET['category'])) { + $where .= ' AND category = ?'; + $params[] = $_GET['category']; + } + + $policies = Database::fetchAll( + "SELECT {$select} FROM policies WHERE {$where} ORDER BY category, title", + $params + ); + + $this->jsonSuccess([ + 'policies' => $policies, + 'total' => count($policies), + 'generated_at' => date('c'), + ]); + } + + /** + * GET /api/services/openapi + * Specifica OpenAPI 3.0 JSON per questa API. + */ + public function openapi(): void + { + $this->setServiceHeaders(); + header('Content-Type: application/json; charset=utf-8'); + + $spec = [ + 'openapi' => '3.0.3', + 'info' => [ + 'title' => 'NIS2 Agile Services API', + 'description' => 'API pubblica per integrazione con sistemi esterni. Espone dati di compliance NIS2, rischi, incidenti, controlli, asset e supply chain.', + 'version' => self::API_VERSION, + 'contact' => ['email' => 'presidenza@agile.software'], + 'license' => ['name' => 'Proprietary', 'url' => 'https://agile.software'], + ], + 'servers' => [ + ['url' => 'https://nis2.certisource.it', 'description' => 'Production'], + ], + 'security' => [ + ['ApiKeyHeader' => []], + ['BearerToken' => []], + ], + 'components' => [ + 'securitySchemes' => [ + 'ApiKeyHeader' => ['type' => 'apiKey', 'in' => 'header', 'name' => 'X-API-Key'], + 'BearerToken' => ['type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'nis2_xxxxx'], + ], + ], + 'paths' => [ + '/api/services/status' => [ + 'get' => [ + 'summary' => 'Status piattaforma', + 'description' => 'Health check. Nessuna autenticazione richiesta.', + 'security' => [], + 'responses' => ['200' => ['description' => 'Platform operational']], + 'tags' => ['System'], + ], + ], + '/api/services/compliance-summary' => [ + 'get' => [ + 'summary' => 'Compliance summary', + 'description' => 'Score aggregato per dominio Art.21, risk/incident/policy stats.', + 'responses' => ['200' => ['description' => 'Compliance summary'], '401' => ['description' => 'API Key mancante']], + 'tags' => ['Compliance'], + ], + ], + '/api/services/risks/feed' => [ + 'get' => [ + 'summary' => 'Risk feed', + 'description' => 'Feed rischi filtrabili per level, area, status, data.', + 'parameters' => [ + ['name' => 'level', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'], + ['name' => 'area', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']], + ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 50, 'maximum' => 200]], + ], + 'responses' => ['200' => ['description' => 'List of risks']], + 'tags' => ['Risks'], + ], + ], + '/api/services/incidents/feed' => [ + 'get' => [ + 'summary' => 'Incident feed Art.23', + 'description' => 'Feed incidenti con stato scadenze Art.23 (24h/72h/30d).', + 'parameters' => [ + ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'], + ['name' => 'significant', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]], + ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']], + ], + 'responses' => ['200' => ['description' => 'List of incidents']], + 'tags' => ['Incidents'], + ], + ], + '/api/services/controls/status' => [ + 'get' => [ + 'summary' => 'Controlli Art.21 status', + 'description' => 'Stato implementazione controlli per dominio di sicurezza.', + 'responses' => ['200' => ['description' => 'Controls by domain']], + 'tags' => ['Compliance'], + ], + ], + '/api/services/assets/critical' => [ + 'get' => [ + 'summary' => 'Asset critici', + 'description' => 'Inventario asset con criticality high/critical.', + 'parameters' => [ + ['name' => 'type', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'criticality', 'in' => 'query', 'schema' => ['type' => 'string']], + ], + 'responses' => ['200' => ['description' => 'Critical assets']], + 'tags' => ['Assets'], + ], + ], + '/api/services/suppliers/risk' => [ + 'get' => [ + 'summary' => 'Supplier risk overview', + 'description' => 'Supply chain risk: fornitori per livello rischio.', + 'parameters' => [ + ['name' => 'risk_level', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']], + ], + 'responses' => ['200' => ['description' => 'Suppliers risk data']], + 'tags' => ['Supply Chain'], + ], + ], + '/api/services/policies/approved' => [ + 'get' => [ + 'summary' => 'Policy approvate', + 'description' => 'Lista policy con status approved.', + 'parameters' => [ + ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']], + ['name' => 'include_content', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]], + ], + 'responses' => ['200' => ['description' => 'Approved policies']], + 'tags' => ['Policies'], + ], + ], + ], + 'tags' => [ + ['name' => 'System'], + ['name' => 'Compliance'], + ['name' => 'Risks'], + ['name' => 'Incidents'], + ['name' => 'Assets'], + ['name' => 'Supply Chain'], + ['name' => 'Policies'], + ], + ]; + + echo json_encode($spec, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + exit; + } + + // ── Utility ─────────────────────────────────────────────────────────── + + private function scoreLabel(?int $score): string + { + if ($score === null) return 'not_assessed'; + if ($score >= 80) return 'compliant'; + if ($score >= 60) return 'substantially_compliant'; + if ($score >= 40) return 'partial'; + return 'significant_gaps'; + } +} diff --git a/application/controllers/WebhookController.php b/application/controllers/WebhookController.php new file mode 100644 index 0000000..7e82f20 --- /dev/null +++ b/application/controllers/WebhookController.php @@ -0,0 +1,405 @@ + 'Accesso completo in lettura a tutti i dati', + 'read:compliance' => 'Compliance score e controlli Art.21', + 'read:risks' => 'Risk register e matrice rischi', + 'read:incidents' => 'Incidenti e timeline Art.23', + 'read:assets' => 'Inventario asset critici', + 'read:supply_chain' => 'Supply chain e rischio fornitori', + 'read:policies' => 'Policy approvate', + ]; + + // ══════════════════════════════════════════════════════════════════════ + // API KEYS + // ══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/webhooks/api-keys + */ + public function listApiKeys(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $keys = Database::fetchAll( + 'SELECT ak.id, ak.name, ak.key_prefix, ak.scopes, ak.last_used_at, + ak.expires_at, ak.is_active, ak.created_at, + u.full_name as created_by_name + FROM api_keys ak + LEFT JOIN users u ON u.id = ak.created_by + WHERE ak.organization_id = ? + ORDER BY ak.created_at DESC', + [$this->getCurrentOrgId()] + ); + + // Decodifica scopes + foreach ($keys as &$key) { + $key['scopes'] = json_decode($key['scopes'], true) ?? []; + } + unset($key); + + $this->jsonSuccess([ + 'api_keys' => $keys, + 'available_scopes' => self::AVAILABLE_SCOPES, + ]); + } + + /** + * POST /api/webhooks/api-keys + * Crea nuova API key. Restituisce la chiave completa UNA SOLA VOLTA. + */ + public function createApiKey(): void + { + $this->requireOrgRole(['org_admin']); + $this->validateRequired(['name', 'scopes']); + + $name = trim($this->getParam('name')); + $scopes = $this->getParam('scopes'); + if (is_string($scopes)) { + $scopes = json_decode($scopes, true) ?? []; + } + + // Valida scopes + foreach ($scopes as $scope) { + if (!array_key_exists($scope, self::AVAILABLE_SCOPES)) { + $this->jsonError("Scope non valido: {$scope}", 400, 'INVALID_SCOPE'); + } + } + + if (empty($scopes)) { + $this->jsonError('Almeno uno scope è richiesto', 400, 'EMPTY_SCOPES'); + } + + // Genera chiave: nis2_ + 32 caratteri random + $rawKey = 'nis2_' . bin2hex(random_bytes(16)); + $prefix = substr($rawKey, 0, 12); // "nis2_xxxxxxx" (visibile) + $keyHash = hash('sha256', $rawKey); + + $expiresAt = $this->getParam('expires_at'); + $id = Database::insert('api_keys', [ + 'organization_id' => $this->getCurrentOrgId(), + 'created_by' => $this->getCurrentUserId(), + 'name' => $name, + 'key_prefix' => $prefix, + 'key_hash' => $keyHash, + 'scopes' => json_encode($scopes), + 'expires_at' => $expiresAt ?: null, + 'is_active' => 1, + ]); + + $this->logAudit('api_key_created', 'api_key', $id, ['name' => $name, 'scopes' => $scopes]); + + $this->jsonSuccess([ + 'id' => $id, + 'name' => $name, + 'key' => $rawKey, // ATTENZIONE: solo al momento della creazione! + 'key_prefix' => $prefix, + 'scopes' => $scopes, + 'expires_at' => $expiresAt ?: null, + 'created_at' => date('c'), + 'warning' => 'Salva questa chiave in modo sicuro. Non sarà più visibile.', + ], 'API Key creata con successo', 201); + } + + /** + * DELETE /api/webhooks/api-keys/{id} + */ + public function deleteApiKey(int $id): void + { + $this->requireOrgRole(['org_admin']); + + $key = Database::fetchOne( + 'SELECT * FROM api_keys WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$key) { + $this->jsonError('API Key non trovata', 404, 'NOT_FOUND'); + } + + Database::execute( + 'UPDATE api_keys SET is_active = 0, updated_at = NOW() WHERE id = ?', + [$id] + ); + + $this->logAudit('api_key_revoked', 'api_key', $id, ['name' => $key['name']]); + $this->jsonSuccess(null, 'API Key revocata'); + } + + // ══════════════════════════════════════════════════════════════════════ + // WEBHOOK SUBSCRIPTIONS + // ══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/webhooks/subscriptions + */ + public function listSubscriptions(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $subs = Database::fetchAll( + 'SELECT ws.*, u.full_name as created_by_name, + (SELECT COUNT(*) FROM webhook_deliveries wd WHERE wd.subscription_id = ws.id) as total_deliveries, + (SELECT COUNT(*) FROM webhook_deliveries wd WHERE wd.subscription_id = ws.id AND wd.status = "delivered") as success_deliveries, + (SELECT COUNT(*) FROM webhook_deliveries wd WHERE wd.subscription_id = ws.id AND wd.status = "failed") as failed_deliveries + FROM webhook_subscriptions ws + LEFT JOIN users u ON u.id = ws.created_by + WHERE ws.organization_id = ? + ORDER BY ws.created_at DESC', + [$this->getCurrentOrgId()] + ); + + foreach ($subs as &$sub) { + $sub['events'] = json_decode($sub['events'], true) ?? []; + unset($sub['secret']); // non esporre il secret + } + unset($sub); + + $this->jsonSuccess([ + 'subscriptions' => $subs, + 'available_events' => $this->availableEvents(), + ]); + } + + /** + * POST /api/webhooks/subscriptions + */ + public function createSubscription(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['name', 'url', 'events']); + + $name = trim($this->getParam('name')); + $url = trim($this->getParam('url')); + $events = $this->getParam('events'); + if (is_string($events)) { + $events = json_decode($events, true) ?? []; + } + + // Valida URL + if (!filter_var($url, FILTER_VALIDATE_URL)) { + $this->jsonError('URL non valido', 400, 'INVALID_URL'); + } + if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) { + $this->jsonError('URL deve essere http o https', 400, 'INVALID_URL_SCHEME'); + } + + // Valida eventi + $validEvents = array_keys($this->availableEvents()); + foreach ($events as $evt) { + if ($evt !== '*' && !in_array($evt, $validEvents)) { + $this->jsonError("Evento non valido: {$evt}", 400, 'INVALID_EVENT'); + } + } + + // Genera secret HMAC + $secret = bin2hex(random_bytes(24)); + + $id = Database::insert('webhook_subscriptions', [ + 'organization_id' => $this->getCurrentOrgId(), + 'created_by' => $this->getCurrentUserId(), + 'name' => $name, + 'url' => $url, + 'secret' => $secret, + 'events' => json_encode(array_values($events)), + 'is_active' => 1, + ]); + + $this->logAudit('webhook_created', 'webhook_subscription', $id, ['name' => $name, 'url' => $url]); + + $this->jsonSuccess([ + 'id' => $id, + 'name' => $name, + 'url' => $url, + 'secret' => $secret, // Solo al momento della creazione! + 'events' => $events, + 'warning' => 'Salva il secret. Sarà usato per verificare la firma X-NIS2-Signature.', + ], 'Webhook creato', 201); + } + + /** + * PUT /api/webhooks/subscriptions/{id} + */ + public function updateSubscription(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $sub = Database::fetchOne( + 'SELECT * FROM webhook_subscriptions WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + if (!$sub) { + $this->jsonError('Webhook non trovato', 404, 'NOT_FOUND'); + } + + $updates = []; + if ($this->hasParam('name')) $updates['name'] = trim($this->getParam('name')); + if ($this->hasParam('is_active')) $updates['is_active'] = (int)$this->getParam('is_active'); + if ($this->hasParam('events')) { + $events = $this->getParam('events'); + if (is_string($events)) $events = json_decode($events, true) ?? []; + $updates['events'] = json_encode(array_values($events)); + } + + if (!empty($updates)) { + $updates['updated_at'] = date('Y-m-d H:i:s'); + $setClauses = implode(', ', array_map(fn($k) => "{$k} = ?", array_keys($updates))); + Database::execute( + "UPDATE webhook_subscriptions SET {$setClauses} WHERE id = ?", + array_merge(array_values($updates), [$id]) + ); + } + + $this->jsonSuccess(null, 'Webhook aggiornato'); + } + + /** + * DELETE /api/webhooks/subscriptions/{id} + */ + public function deleteSubscription(int $id): void + { + $this->requireOrgRole(['org_admin']); + + $sub = Database::fetchOne( + 'SELECT * FROM webhook_subscriptions WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + if (!$sub) { + $this->jsonError('Webhook non trovato', 404, 'NOT_FOUND'); + } + + Database::execute('DELETE FROM webhook_subscriptions WHERE id = ?', [$id]); + $this->logAudit('webhook_deleted', 'webhook_subscription', $id, ['name' => $sub['name']]); + $this->jsonSuccess(null, 'Webhook eliminato'); + } + + /** + * POST /api/webhooks/subscriptions/{id}/test + * Invia un evento ping di test al webhook. + */ + public function testSubscription(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $sub = Database::fetchOne( + 'SELECT * FROM webhook_subscriptions WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + if (!$sub) { + $this->jsonError('Webhook non trovato', 404, 'NOT_FOUND'); + } + + $webhookService = new WebhookService(); + $testPayload = [ + 'message' => 'Questo è un evento di test da NIS2 Agile.', + 'timestamp' => date('c'), + ]; + + $webhookService->dispatch($this->getCurrentOrgId(), 'webhook.test', $testPayload); + + $this->jsonSuccess([ + 'subscription_id' => $id, + 'url' => $sub['url'], + 'event' => 'webhook.test', + ], 'Ping di test inviato. Controlla i delivery log per il risultato.'); + } + + // ══════════════════════════════════════════════════════════════════════ + // DELIVERIES + // ══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/webhooks/deliveries + * Ultimi 100 delivery log per l'organizzazione. + */ + public function listDeliveries(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $subFilter = ''; + $params = [$this->getCurrentOrgId()]; + + if ($this->hasParam('subscription_id')) { + $subFilter = ' AND wd.subscription_id = ?'; + $params[] = (int)$this->getParam('subscription_id'); + } + + $deliveries = Database::fetchAll( + "SELECT wd.id, wd.event_type, wd.event_id, wd.status, wd.http_status, + wd.attempt, wd.delivered_at, wd.next_retry_at, wd.created_at, + ws.name as subscription_name, ws.url + FROM webhook_deliveries wd + JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id + WHERE wd.organization_id = ? {$subFilter} + ORDER BY wd.created_at DESC + LIMIT 100", + $params + ); + + $this->jsonSuccess(['deliveries' => $deliveries]); + } + + /** + * POST /api/webhooks/retry + * Processa retry pendenti (anche richiamabile da cron). + */ + public function processRetry(): void + { + $this->requireOrgRole(['org_admin']); + $webhookService = new WebhookService(); + $count = $webhookService->processRetries(); + $this->jsonSuccess(['processed' => $count], "Processati {$count} retry"); + } + + // ── Utility ─────────────────────────────────────────────────────────── + + private function availableEvents(): array + { + return [ + 'incident.created' => 'Nuovo incidente creato', + 'incident.updated' => 'Incidente aggiornato', + 'incident.significant' => 'Incidente significativo (Art.23 attivato)', + 'incident.deadline_warning' => 'Scadenza Art.23 imminente (24h/72h)', + 'risk.high_created' => 'Nuovo rischio HIGH o CRITICAL', + 'risk.updated' => 'Rischio aggiornato', + 'compliance.score_changed' => 'Variazione compliance score >5%', + 'policy.approved' => 'Policy approvata', + 'policy.created' => 'Nuova policy creata', + 'supplier.risk_flagged' => 'Fornitore con rischio HIGH/CRITICAL', + 'assessment.completed' => 'Gap assessment completato', + 'whistleblowing.received' => 'Nuova segnalazione ricevuta', + 'normative.update' => 'Aggiornamento normativo NIS2/ACN', + 'webhook.test' => 'Evento di test', + '*' => 'Tutti gli eventi (wildcard)', + ]; + } +} diff --git a/application/controllers/WhistleblowingController.php b/application/controllers/WhistleblowingController.php new file mode 100644 index 0000000..036d9a9 --- /dev/null +++ b/application/controllers/WhistleblowingController.php @@ -0,0 +1,386 @@ +validateRequired(['category', 'title', 'description']); + + $orgId = (int)($this->getParam('organization_id') ?: $this->getCurrentOrgId()); + if (!$orgId) { + $this->jsonError('organization_id obbligatorio per segnalazioni anonime', 400, 'ORG_REQUIRED'); + } + + $category = $this->getParam('category'); + $title = trim($this->getParam('title')); + $description = trim($this->getParam('description')); + $priority = $this->getParam('priority', 'medium'); + $contactEmail = $this->getParam('contact_email'); + $nisArticle = $this->getParam('nis2_article'); + + // Valida categoria + $validCategories = ['security_incident','policy_violation','unauthorized_access','data_breach', + 'supply_chain_risk','corruption','fraud','nis2_non_compliance','other']; + if (!in_array($category, $validCategories)) { + $this->jsonError("Categoria non valida: {$category}", 400, 'INVALID_CATEGORY'); + } + + // Determina se anonima + $userId = null; + $isAnonymous = 1; + try { + $userId = $this->getCurrentUserId(); + $isAnonymous = $this->getParam('is_anonymous', 0) ? 1 : 0; + } catch (Throwable) { + // Nessuna auth → forza anonima + $isAnonymous = 1; + } + + // Token anonimo per tracking + $anonymousToken = $isAnonymous ? bin2hex(random_bytes(24)) : null; + + $code = $this->generateCode('WB'); + $reportId = Database::insert('whistleblowing_reports', [ + 'organization_id' => $orgId, + 'report_code' => $code, + 'is_anonymous' => $isAnonymous, + 'submitted_by' => $isAnonymous ? null : $userId, + 'anonymous_token' => $anonymousToken, + 'contact_email' => $contactEmail ?: null, + 'category' => $category, + 'title' => $title, + 'description' => $description, + 'nis2_article' => $nisArticle ?: null, + 'priority' => in_array($priority, ['critical','high','medium','low']) ? $priority : 'medium', + 'status' => 'received', + ]); + + // Prima voce timeline + Database::insert('whistleblowing_timeline', [ + 'report_id' => $reportId, + 'event_type' => 'received', + 'description' => 'Segnalazione ricevuta tramite canale interno.', + 'is_visible_to_reporter' => 1, + ]); + + // Dispatch webhook + try { + (new WebhookService())->dispatch($orgId, 'whistleblowing.received', [ + 'id' => $reportId, + 'code' => $code, + 'category' => $category, + 'priority' => $priority, + 'anonymous' => (bool)$isAnonymous, + ]); + } catch (Throwable $e) { + error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); + } + + $this->jsonSuccess([ + 'id' => $reportId, + 'report_code' => $code, + 'anonymous_token' => $anonymousToken, // Usabile per tracking se anonima + 'note' => $anonymousToken + ? 'Conserva questo token per verificare lo stato della segnalazione: /api/whistleblowing/track-anonymous?token=' . $anonymousToken + : null, + ], 'Segnalazione ricevuta', 201); + } + + // ══════════════════════════════════════════════════════════════════════ + // LIST (solo CISO/admin) + // ══════════════════════════════════════════════════════════════════════ + + /** + * GET /api/whistleblowing/list + */ + public function list(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $conditions = ['wr.organization_id = ?']; + $params = [$this->getCurrentOrgId()]; + + if ($this->hasParam('status')) { + $conditions[] = 'wr.status = ?'; + $params[] = $this->getParam('status'); + } + if ($this->hasParam('priority')) { + $conditions[] = 'wr.priority = ?'; + $params[] = $this->getParam('priority'); + } + if ($this->hasParam('category')) { + $conditions[] = 'wr.category = ?'; + $params[] = $this->getParam('category'); + } + + $where = implode(' AND ', $conditions); + $reports = Database::fetchAll( + "SELECT wr.id, wr.report_code, wr.category, wr.title, wr.priority, wr.status, + wr.is_anonymous, wr.created_at, wr.closed_at, + u.full_name as assigned_to_name + FROM whistleblowing_reports wr + LEFT JOIN users u ON u.id = wr.assigned_to + WHERE {$where} + ORDER BY + CASE wr.priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, + wr.created_at DESC", + $params + ); + + $this->jsonSuccess(['reports' => $reports, 'total' => count($reports)]); + } + + /** + * GET /api/whistleblowing/{id} + */ + public function get(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $report = Database::fetchOne( + 'SELECT wr.*, u1.full_name as assigned_to_name, u2.full_name as submitted_by_name + FROM whistleblowing_reports wr + LEFT JOIN users u1 ON u1.id = wr.assigned_to + LEFT JOIN users u2 ON u2.id = wr.submitted_by + WHERE wr.id = ? AND wr.organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$report) { + $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); + } + + // Non esporre token e contact_email (privacy) + unset($report['anonymous_token']); + if ($report['is_anonymous']) unset($report['contact_email']); + + $report['timeline'] = Database::fetchAll( + 'SELECT wt.*, u.full_name as created_by_name + FROM whistleblowing_timeline wt + LEFT JOIN users u ON u.id = wt.created_by + WHERE wt.report_id = ? + ORDER BY wt.created_at ASC', + [$id] + ); + + $this->jsonSuccess($report); + } + + /** + * PUT /api/whistleblowing/{id} + * Aggiorna status, priorità, note di risoluzione. + */ + public function update(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $report = Database::fetchOne( + 'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); } + + $updates = []; + $oldStatus = $report['status']; + + foreach (['priority', 'resolution_notes', 'nis2_article'] as $field) { + if ($this->hasParam($field)) $updates[$field] = $this->getParam($field); + } + + if ($this->hasParam('status')) { + $newStatus = $this->getParam('status'); + $validStatuses = ['received','under_review','investigating','resolved','closed','rejected']; + if (!in_array($newStatus, $validStatuses)) { + $this->jsonError("Status non valido: {$newStatus}", 400, 'INVALID_STATUS'); + } + $updates['status'] = $newStatus; + + // Aggiungi voce timeline su cambio status + if ($newStatus !== $oldStatus) { + Database::insert('whistleblowing_timeline', [ + 'report_id' => $id, + 'event_type' => 'status_change', + 'description' => "Status cambiato da '{$oldStatus}' a '{$newStatus}'.", + 'new_status' => $newStatus, + 'created_by' => $this->getCurrentUserId(), + 'is_visible_to_reporter' => 1, + ]); + } + } + + if (!empty($updates)) { + Database::execute( + 'UPDATE whistleblowing_reports SET ' . + implode(', ', array_map(fn($k) => "{$k} = ?", array_keys($updates))) . + ', updated_at = NOW() WHERE id = ?', + array_merge(array_values($updates), [$id]) + ); + $this->logAudit('whistleblowing_updated', 'whistleblowing', $id, $updates); + } + + $this->jsonSuccess(null, 'Segnalazione aggiornata'); + } + + /** + * POST /api/whistleblowing/{id}/assign + */ + public function assign(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['user_id']); + + $report = Database::fetchOne( + 'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); } + + $userId = (int)$this->getParam('user_id'); + Database::execute( + 'UPDATE whistleblowing_reports SET assigned_to = ?, updated_at = NOW() WHERE id = ?', + [$userId, $id] + ); + + $user = Database::fetchOne('SELECT full_name FROM users WHERE id = ?', [$userId]); + Database::insert('whistleblowing_timeline', [ + 'report_id' => $id, + 'event_type' => 'assigned', + 'description' => "Segnalazione assegnata a " . ($user['full_name'] ?? "utente #{$userId}"), + 'created_by' => $this->getCurrentUserId(), + ]); + + $this->jsonSuccess(null, 'Segnalazione assegnata'); + } + + /** + * POST /api/whistleblowing/{id}/close + */ + public function close(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $report = Database::fetchOne( + 'SELECT * FROM whistleblowing_reports WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + if (!$report) { $this->jsonError('Segnalazione non trovata', 404, 'NOT_FOUND'); } + + $resolution = trim($this->getParam('resolution_notes', '')); + Database::execute( + 'UPDATE whistleblowing_reports SET status = "closed", closed_at = NOW(), + closed_by = ?, resolution_notes = ?, updated_at = NOW() WHERE id = ?', + [$this->getCurrentUserId(), $resolution, $id] + ); + + Database::insert('whistleblowing_timeline', [ + 'report_id' => $id, + 'event_type' => 'closed', + 'description' => 'Segnalazione chiusa.' . ($resolution ? " Note: {$resolution}" : ''), + 'created_by' => $this->getCurrentUserId(), + 'is_visible_to_reporter' => 1, + ]); + + $this->logAudit('whistleblowing_closed', 'whistleblowing', $id, ['resolution' => $resolution]); + $this->jsonSuccess(null, 'Segnalazione chiusa'); + } + + /** + * GET /api/whistleblowing/stats + */ + public function stats(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $orgId = $this->getCurrentOrgId(); + + $stats = Database::fetchOne( + 'SELECT + COUNT(*) as total, + SUM(CASE WHEN status = "received" THEN 1 ELSE 0 END) as received, + SUM(CASE WHEN status = "under_review" THEN 1 ELSE 0 END) as under_review, + SUM(CASE WHEN status = "investigating" THEN 1 ELSE 0 END) as investigating, + SUM(CASE WHEN status IN ("resolved","closed") THEN 1 ELSE 0 END) as closed, + SUM(CASE WHEN priority = "critical" THEN 1 ELSE 0 END) as critical, + SUM(CASE WHEN priority = "high" THEN 1 ELSE 0 END) as high, + SUM(CASE WHEN is_anonymous = 1 THEN 1 ELSE 0 END) as anonymous_count + FROM whistleblowing_reports + WHERE organization_id = ?', + [$orgId] + ); + + $byCategory = Database::fetchAll( + 'SELECT category, COUNT(*) as count + FROM whistleblowing_reports + WHERE organization_id = ? + GROUP BY category + ORDER BY count DESC', + [$orgId] + ); + + $this->jsonSuccess(['stats' => $stats, 'by_category' => $byCategory]); + } + + /** + * GET /api/whistleblowing/track-anonymous + * Permette a segnalante anonimo di verificare stato via token. + */ + public function trackAnonymous(): void + { + $token = $this->getParam('token'); + if (!$token) { $this->jsonError('Token obbligatorio', 400, 'TOKEN_REQUIRED'); } + + $report = Database::fetchOne( + 'SELECT id, report_code, category, status, created_at + FROM whistleblowing_reports + WHERE anonymous_token = ?', + [$token] + ); + + if (!$report) { $this->jsonError('Token non valido', 404, 'INVALID_TOKEN'); } + + // Solo eventi visibili al reporter + $timeline = Database::fetchAll( + 'SELECT event_type, description, created_at + FROM whistleblowing_timeline + WHERE report_id = ? AND is_visible_to_reporter = 1 + ORDER BY created_at ASC', + [$report['id']] + ); + + $this->jsonSuccess([ + 'report_code' => $report['report_code'], + 'category' => $report['category'], + 'status' => $report['status'], + 'created_at' => $report['created_at'], + 'timeline' => $timeline, + ]); + } +} diff --git a/application/services/WebhookService.php b/application/services/WebhookService.php new file mode 100644 index 0000000..379be82 --- /dev/null +++ b/application/services/WebhookService.php @@ -0,0 +1,320 @@ +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'], + '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) + ); + } +} diff --git a/docs/sql/007_services_api.sql b/docs/sql/007_services_api.sql new file mode 100644 index 0000000..050c7c6 --- /dev/null +++ b/docs/sql/007_services_api.sql @@ -0,0 +1,87 @@ +-- ============================================================ +-- NIS2 Agile - Migration 007: Services API & Webhooks +-- Tabelle per API Keys esterne, Webhook subscriptions e delivery log +-- ============================================================ + +-- ── API KEYS ───────────────────────────────────────────────── +-- Chiavi API per accesso esterno ai servizi NIS2 +-- Separate dai JWT sessione, con scopo e permessi specifici + +CREATE TABLE IF NOT EXISTS api_keys ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT UNSIGNED NOT NULL, + created_by INT UNSIGNED NOT NULL, + name VARCHAR(100) NOT NULL, -- Nome descrittivo es. "SIEM Integration" + key_prefix VARCHAR(12) NOT NULL, -- Prefisso visibile es. "nis2_abc123" + key_hash VARCHAR(64) NOT NULL, -- SHA-256 della chiave completa + scopes JSON NOT NULL, -- Array di scope: ["read:risks","read:incidents","read:all"] + last_used_at TIMESTAMP NULL DEFAULT NULL, + expires_at TIMESTAMP NULL DEFAULT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_org (organization_id), + INDEX idx_key_hash (key_hash), + INDEX idx_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── WEBHOOK SUBSCRIPTIONS ──────────────────────────────────── +-- Sottoscrizioni webhook outbound: quando avvengono eventi NIS2, +-- NIS2 notifica sistemi esterni (SIEM, GRC, 231 Agile, SustainAI) + +CREATE TABLE IF NOT EXISTS webhook_subscriptions ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT UNSIGNED NOT NULL, + created_by INT UNSIGNED NOT NULL, + name VARCHAR(100) NOT NULL, -- Es. "231 Agile Notify" + url VARCHAR(512) NOT NULL, -- URL destinazione POST + secret VARCHAR(64) NOT NULL, -- Usato per HMAC-SHA256 firma + events JSON NOT NULL, -- Array eventi: ["incident.created","risk.high_created"] + is_active TINYINT(1) NOT NULL DEFAULT 1, + last_triggered_at TIMESTAMP NULL DEFAULT NULL, + failure_count INT UNSIGNED DEFAULT 0, -- Errori consecutivi (auto-pause se >10) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_org (organization_id), + INDEX idx_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── WEBHOOK DELIVERIES ─────────────────────────────────────── +-- Log di ogni tentativo di consegna webhook (audit + retry) + +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + subscription_id INT UNSIGNED NOT NULL, + organization_id INT UNSIGNED NOT NULL, + event_type VARCHAR(60) NOT NULL, -- Es. "incident.created" + event_id VARCHAR(36) NOT NULL, -- UUID dell'evento + payload MEDIUMTEXT NOT NULL, -- JSON payload inviato + status ENUM('pending','delivered','failed','retrying') DEFAULT 'pending', + http_status SMALLINT UNSIGNED NULL, -- Codice HTTP risposta + response_body TEXT NULL, -- Risposta del server (max 2KB) + attempt TINYINT UNSIGNED DEFAULT 1, -- Tentativo corrente (max 3) + next_retry_at TIMESTAMP NULL DEFAULT NULL, -- Prossimo retry schedulato + delivered_at TIMESTAMP NULL DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_subscription (subscription_id), + INDEX idx_org (organization_id), + INDEX idx_status (status), + INDEX idx_event (event_type, event_id), + INDEX idx_retry (status, next_retry_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── FOREIGN KEYS ───────────────────────────────────────────── +ALTER TABLE api_keys + ADD CONSTRAINT fk_apikeys_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_apikeys_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE webhook_subscriptions + ADD CONSTRAINT fk_webhooksub_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_webhooksub_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE webhook_deliveries + ADD CONSTRAINT fk_webhookdel_sub FOREIGN KEY (subscription_id) REFERENCES webhook_subscriptions(id) ON DELETE CASCADE; diff --git a/docs/sql/008_whistleblowing.sql b/docs/sql/008_whistleblowing.sql new file mode 100644 index 0000000..51b177e --- /dev/null +++ b/docs/sql/008_whistleblowing.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- NIS2 Agile - Migration 008: Whistleblowing (Art.32 NIS2) +-- Canale segnalazioni anomalie di sicurezza con anonimato garantito +-- ============================================================ + +CREATE TABLE IF NOT EXISTS whistleblowing_reports ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + organization_id INT UNSIGNED NOT NULL, + report_code VARCHAR(20) NOT NULL UNIQUE, -- Es. WB-2026-001 + + -- Mittente (opzionale — anonimato garantito) + is_anonymous TINYINT(1) NOT NULL DEFAULT 1, + submitted_by INT UNSIGNED NULL, -- NULL se anonimo + anonymous_token VARCHAR(64) NULL, -- Token per tracking anonimo + contact_email VARCHAR(255) NULL, -- Email facoltativa per follow-up + + -- Contenuto segnalazione + category ENUM( + 'security_incident', 'policy_violation', 'unauthorized_access', + 'data_breach', 'supply_chain_risk', 'corruption', 'fraud', + 'nis2_non_compliance', 'other' + ) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + evidence_files JSON NULL, -- Array path files allegati + nis2_article VARCHAR(20) NULL, -- Articolo NIS2 violato + + -- Gestione + priority ENUM('critical', 'high', 'medium', 'low') NOT NULL DEFAULT 'medium', + status ENUM('received', 'under_review', 'investigating', 'resolved', 'closed', 'rejected') NOT NULL DEFAULT 'received', + assigned_to INT UNSIGNED NULL, -- Utente incaricato + resolution_notes TEXT NULL, -- Note risoluzione (visibili al segnalante se email fornita) + closed_at TIMESTAMP NULL, + closed_by INT UNSIGNED NULL, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_org (organization_id), + INDEX idx_status (status), + INDEX idx_priority (priority), + INDEX idx_token (anonymous_token), + INDEX idx_code (report_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Timeline segnalazione ────────────────────────────────── +CREATE TABLE IF NOT EXISTS whistleblowing_timeline ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + report_id INT UNSIGNED NOT NULL, + event_type ENUM('received','status_change','assigned','note_added','closed') NOT NULL, + description TEXT NOT NULL, + new_status VARCHAR(30) NULL, + created_by INT UNSIGNED NULL, -- NULL = sistema + is_visible_to_reporter TINYINT(1) NOT NULL DEFAULT 0, -- Se visibile al segnalante anonimo + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_report (report_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Foreign Keys ────────────────────────────────────────── +ALTER TABLE whistleblowing_reports + ADD CONSTRAINT fk_wb_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + +ALTER TABLE whistleblowing_timeline + ADD CONSTRAINT fk_wb_timeline_report FOREIGN KEY (report_id) REFERENCES whistleblowing_reports(id) ON DELETE CASCADE; diff --git a/docs/sql/009_normative_updates.sql b/docs/sql/009_normative_updates.sql new file mode 100644 index 0000000..6c5fdcf --- /dev/null +++ b/docs/sql/009_normative_updates.sql @@ -0,0 +1,78 @@ +-- ============================================================ +-- NIS2 Agile - Migration 009: Normative Updates (NIS2/ACN Feed) +-- Feed aggiornamenti normativi con ACK per utente (audit trail) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS normative_updates ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + -- Update metadata + title VARCHAR(255) NOT NULL, + source ENUM('nis2_directive','dlgs_138_2024','acn_guideline','dora','enisa','iso27001','other') NOT NULL, + source_label VARCHAR(100) NULL, + reference VARCHAR(100) NULL, -- Es. "Art. 21 par. 5", "Circolare ACN 2026-01" + summary TEXT NOT NULL, + content LONGTEXT NULL, -- Testo completo (opzionale) + impact_level ENUM('critical','high','medium','low','informational') NOT NULL DEFAULT 'medium', + affected_domains JSON NULL, -- Array domini NIS2 impattati + action_required TINYINT(1) NOT NULL DEFAULT 0, -- Richiede azione da parte dell'org + effective_date DATE NULL, -- Data entrata in vigore + url VARCHAR(512) NULL, -- Link fonte ufficiale + is_published TINYINT(1) NOT NULL DEFAULT 1, + published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_source (source), + INDEX idx_impact (impact_level), + INDEX idx_published (is_published, published_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── ACK per organizzazione ──────────────────────────────── +-- Ogni org traccia la presa visione degli aggiornamenti normativi +CREATE TABLE IF NOT EXISTS normative_ack ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + normative_update_id INT UNSIGNED NOT NULL, + organization_id INT UNSIGNED NOT NULL, + acknowledged_by INT UNSIGNED NOT NULL, + acknowledged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + notes TEXT NULL, -- Note opzionali dell'org sull'impatto + + UNIQUE KEY uk_ack (normative_update_id, organization_id), + INDEX idx_org (organization_id), + INDEX idx_update (normative_update_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ── Foreign Keys ────────────────────────────────────────── +ALTER TABLE normative_ack + ADD CONSTRAINT fk_norm_ack_update FOREIGN KEY (normative_update_id) REFERENCES normative_updates(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_norm_ack_org FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + ADD CONSTRAINT fk_norm_ack_user FOREIGN KEY (acknowledged_by) REFERENCES users(id) ON DELETE CASCADE; + +-- ── Seed: aggiornamenti normativi iniziali ──────────────── +INSERT INTO normative_updates (title, source, reference, summary, impact_level, action_required, effective_date, url) VALUES +('D.Lgs. 138/2024 — Attuazione Direttiva NIS2 in Italia', + 'dlgs_138_2024', 'D.Lgs. 4 settembre 2024, n. 138', + 'Recepimento della Direttiva NIS2 nell\'ordinamento italiano. Estende l\'ambito di applicazione, obblighi di notifica incidenti (Art.23), misure di sicurezza (Art.21), sanzioni fino a €10M.', + 'critical', 1, '2024-10-18', + 'https://www.gazzettaufficiale.it/eli/id/2024/10/01/24G00171/SG'), + +('ACN — Linee Guida Misure di Sicurezza NIS2 (Art.21)', + 'acn_guideline', 'Circolare ACN n.1/2025', + 'L\'Agenzia per la Cybersicurezza Nazionale ha pubblicato le linee guida operative per l\'implementazione delle 10 misure di sicurezza previste dall\'Art.21 NIS2.', + 'high', 1, '2025-01-15', NULL), + +('ENISA — NIS2 Implementation Report 2025', + 'enisa', 'ENISA/2025/NIS2-IMPL', + 'Report ENISA sullo stato di implementazione NIS2 nei 27 stati membri. Include benchmark di settore e best practice emergenti.', + 'medium', 0, '2025-06-01', + 'https://www.enisa.europa.eu'), + +('DORA — Digital Operational Resilience Act (Applicabilità NIS2)', + 'dora', 'Reg. UE 2022/2554', + 'Il DORA entra in piena applicazione a gennaio 2025 per enti finanziari. Le organizzazioni NIS2 del settore bancario/finanziario devono allineare i requisiti DORA con NIS2.', + 'high', 1, '2025-01-17', NULL), + +('ISO/IEC 27001:2022 — Controlli aggiornati Annex A', + 'iso27001', 'ISO/IEC 27001:2022', + 'La revisione 2022 introduce 11 nuovi controlli (es. threat intelligence, ICT supply chain security, data masking, monitoring). Mappatura con NIS2 Art.21 disponibile.', + 'medium', 0, '2022-10-25', NULL); diff --git a/public/docs/api.html b/public/docs/api.html new file mode 100644 index 0000000..57423ea --- /dev/null +++ b/public/docs/api.html @@ -0,0 +1,710 @@ + + + + + + NIS2 Agile — API Reference + + + +
+ + + + +
+ + + + +
+

Panoramica

+

NIS2 Agile espone due famiglie di API: Services API per lettura dati di compliance in tempo reale, e Webhook API per notifiche push su eventi NIS2.

+
Base URL: https://nis2.certisource.it/api
+
+ Rate Limiting: Services API: 100 richieste/ora per API Key. Webhook delivery: max 1000/ora per subscription. +
+
+ Formato risposta: Tutte le risposte sono JSON con envelope { "success": bool, "data": {}, "message": "..." } +
+
+ + +
+

Autenticazione

+

Le Services API usano API Keys con scope granulari. Le API di gestione (webhook, key management) usano JWT Bearer token della sessione utente.

+ +

API Key — 3 modalità

+
# Header (raccomandato)
+X-API-Key: nis2_abc123def456...
+
+# Authorization Bearer
+Authorization: Bearer nis2_abc123def456...
+
+# Query string (sconsigliato in prod)
+GET /api/services/risks-feed?api_key=nis2_abc123def456...
+ +

JWT Bearer — Per gestione API

+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
+ +

Scope disponibili

+
+
read:all
Accesso completo in lettura a tutti i dati
+
read:compliance
Compliance score e controlli Art.21
+
read:risks
Risk register e matrice rischi ISO 27005
+
read:incidents
Incidenti e timeline Art.23
+
read:assets
Inventario asset critici
+
read:supply_chain
Supply chain e rischio fornitori
+
read:policies
Policy approvate
+
+ +

Header obbligatorio

+
X-Organization-Id: 42   # ID organizzazione target
+
+ + +
+

Gestione Errori

+ + + + + + + + + + +
HTTPerror_codeDescrizione
400VALIDATION_ERRORParametri obbligatori mancanti o non validi
401UNAUTHORIZEDAPI Key assente, scaduta o non valida
403INSUFFICIENT_SCOPEScope insufficiente per questa risorsa
404NOT_FOUNDRisorsa non trovata
429RATE_LIMITEDLimite richieste superato
500INTERNAL_ERRORErrore interno del server
+
{
+  "success": false,
+  "message": "Scope insufficiente: richiesto read:risks",
+  "error_code": "INSUFFICIENT_SCOPE"
+}
+
+ + +
+

Services API

+

Endpoint GET per leggere dati di compliance da sistemi esterni. Autenticazione tramite API Key con scope granulari.

+ + +
+
+ GET + /services/status + Public + Health check piattaforma +
+
+

Stato della piattaforma, versione API e timestamp. Non richiede autenticazione.

+

Risposta 200

+
{
+  "status": "ok",
+  "version": "1.0.0",
+  "timestamp": 1709900400,
+  "platform": "NIS2 Agile"
+}
+
+
+ + +
+
+ GET + /services/compliance-summary + read:compliance +
+
+

Score di conformità NIS2 aggregato per dominio Art.21, con statistiche rischi, incidenti aperti e policy approvate.

+

Risposta 200

+
{
+  "overall_score": 73,
+  "label": "substantially_compliant",
+  "domain_scores": [
+    { "category": "governance", "score": 80, "answered": 8, "total": 10 }
+  ],
+  "risks": { "total": 24, "critical": 2, "high": 5 },
+  "incidents": { "open": 3, "significant": 1 },
+  "policies": { "approved": 12, "draft": 4 }
+}
+
+
+ + +
+
+ GET + /services/risks-feed + read:risks +
+
+

Feed rischi con filtri su livello, area NIS2, stato e data. Include deadline di trattamento.

+

Query Parameters

+ + + + + + + + + +
ParametroTipoDescrizione
levelstringFiltro livello: critical, high, medium, low
areastringCategoria rischio (es. network_security)
statusstringStato: open, in_treatment, closed
fromdatetimeFiltra da data ISO (es. 2026-01-01T00:00:00)
limitintegerMax risultati (default 50, max 200)
+

Risposta 200

+
{
+  "risks": [
+    {
+      "id": 15,
+      "risk_code": "RSK-2026-015",
+      "title": "Vulnerabilità VPN senza MFA",
+      "category": "network_security",
+      "risk_level": "high",
+      "risk_score": 15,
+      "treatment": "mitigate",
+      "nis2_article": "21.2.i",
+      "created_at": "2026-01-15T10:30:00+01:00"
+    }
+  ],
+  "total": 24, "filters": { "level": "high" }
+}
+
+
+ + +
+
+ GET + /services/incidents-feed + read:incidents +
+
+

Feed incidenti con status Art.23 (deadlines 24h/72h/30d) e flag di scadenza. Utile per integrare in SIEM e SOC dashboard.

+

Query Parameters

+ + + + + + + + +
ParametroTipoDescrizione
statusstringopen, investigating, resolved, closed
severitystringcritical, high, medium, low
significant_onlybooleanSolo incidenti Art.23 significativi
fromdatetimeFiltra da data ISO
+

Risposta 200 — Elemento

+
{
+  "id": 7,
+  "incident_code": "INC-2026-007",
+  "title": "Accesso non autorizzato sistema ERP",
+  "severity": "high",
+  "is_significant": true,
+  "art23_status": {
+    "early_warning": { "due": "2026-02-21T14:00:00+01:00", "sent": true, "overdue": false },
+    "notification": { "due": "2026-02-23T14:00:00+01:00", "sent": false, "overdue": false },
+    "final_report": { "due": "2026-03-22T14:00:00+01:00", "sent": false, "overdue": false }
+  }
+}
+
+
+ + +
+
+ GET + /services/controls-status + read:compliance +
+
+

Stato dei controlli di sicurezza Art.21 raggruppati per categoria (governance, network_security, access_control, ecc.).

+

Risposta 200 — Elemento categoria

+
{
+  "category": "network_security",
+  "total": 8,
+  "implemented": 5,
+  "partial": 2,
+  "not_implemented": 1,
+  "score": 75
+}
+
+
+ + +
+
+ GET + /services/assets-critical + read:assets +
+
+

Inventario asset critici con tipo, livello di criticità e dipendenze. Filtrabili per tipo e livello.

+

Query Parameters

+ + + + + + +
ParametroTipoDescrizione
typestringserver, network, software, data, service
criticalitystringcritical, high, medium, low
+
+
+ + +
+
+ GET + /services/suppliers-risk + read:supply_chain +
+
+

Panoramica rischio fornitori con risk_score, data ultima valutazione e flag critici. Include stats aggregate.

+

Risposta 200 — Stats

+
{
+  "suppliers": [...],
+  "stats": {
+    "total": 18,
+    "critical": 2,
+    "high": 4,
+    "avg_risk_score": 42
+  }
+}
+
+
+ + +
+
+ GET + /services/policies-approved + read:policies +
+
+

Policy approvate con categoria, articolo NIS2 di riferimento e versione. Opzionalmente include il testo completo.

+

Query Parameters

+ + + + + + +
ParametroTipoDescrizione
categorystringFiltra per categoria (es. incident_response)
include_contentbooleanInclude testo policy (default: false)
+
+
+
+ + +
+

Gestione API Keys

+

CRUD API Keys. Richiede autenticazione JWT con ruolo org_admin.

+ +
+
+ GET + /webhooks/api-keys + JWT + Lista API Keys dell'organizzazione +
+
+

Restituisce tutte le API Keys (attive e revocate) dell'organizzazione corrente. Non espone mai il testo completo della chiave.

+
+
+ +
+
+ POST + /webhooks/api-keys + JWT org_admin +
+
+
La chiave completa viene restituita SOLO UNA VOLTA nella risposta di creazione. Salvarla immediatamente.
+

Body (JSON)

+ + + + + + + +
CampoTipoObbligatorioDescrizione
namestring*Nome descrittivo (es. "SIEM Integration")
scopesarray*Array di scope (es. ["read:risks","read:incidents"])
expires_atdatetimeScadenza opzionale (ISO 8601)
+

Risposta 201

+
{
+  "id": 5,
+  "name": "SIEM Integration",
+  "key": "nis2_a3f8c2d1e4b7...",
+  "key_prefix": "nis2_a3f8c2",
+  "scopes": ["read:risks", "read:incidents"],
+  "warning": "Salva questa chiave in modo sicuro. Non sara' piu' visibile."
+}
+
+
+ +
+
+ DELETE + /webhooks/api-keys/{id} + JWT org_admin + Revoca (soft delete) API Key +
+
+

Revoca una API Key impostando is_active = 0. La chiave non viene eliminata dal DB per mantenere l'audit trail.

+
+
+
+ + +
+

Webhook Subscriptions

+

Gestione sottoscrizioni webhook outbound. NIS2 Agile invierà POST HTTP all'URL configurato al verificarsi degli eventi sottoscritti.

+ +
+
+ GET + /webhooks/subscriptions + JWT + Lista subscriptions + statistiche delivery +
+
+

Include statistiche di delivery (total, success, failed) per ogni subscription. Non espone il secret HMAC.

+
+
+ +
+
+ POST + /webhooks/subscriptions + JWT org_admin +
+
+
Il secret HMAC viene restituito SOLO UNA VOLTA. Usarlo per verificare la firma X-NIS2-Signature.
+

Body (JSON)

+ + + + + + + +
CampoTipoObbligatorioDescrizione
namestring*Nome descrittivo
urlstring*URL https:// destinazione POST
eventsarray*Array eventi (o ["*"] per wildcard)
+

Risposta 201

+
{
+  "id": 3,
+  "secret": "a3f8c2d1e4b7f9a2c8d3e1f4...",
+  "warning": "Salva il secret. Sara' usato per verificare la firma X-NIS2-Signature."
+}
+
+
+ +
+
+ PUT + /webhooks/subscriptions/{id} + JWT + Aggiorna name, events, is_active +
+
+

Aggiornamento parziale (PATCH semantics). Solo i campi inviati vengono aggiornati: name, events, is_active.

+
+
+ +
+
+ DELETE + /webhooks/subscriptions/{id} + JWT org_admin + Elimina subscription e storico delivery +
+
+ +
+
+ POST + /webhooks/subscriptions/{id}/test + JWT + Invia evento webhook.test +
+
+

Invia un ping di test per verificare che l'endpoint remoto sia raggiungibile e risponda correttamente. Controlla il delivery log per il risultato.

+
+
+ +
+
+ GET + /webhooks/deliveries + JWT + Log ultimi 100 delivery +
+
+

Query Parameters

+ + + + + +
ParametroTipoDescrizione
subscription_idintegerFiltra per subscription specifica
+
+
+
+ + +
+

Catalogo Eventi Webhook

+

NIS2 Agile emette eventi su tutte le operazioni rilevanti per la compliance. Ogni evento ha un payload tipizzato.

+ + + + + + + + + + + + + + + + + + + +
EventoTriggerPayload
incident.createdNuovo incidente registratoincidentPayload(incident, 'created')
incident.updatedIncidente modificatoincidentPayload(incident, 'updated')
incident.significantIncidente flaggato Art.23incidentPayload con deadlines
incident.deadline_warningScadenza 24h/72h imminentedeadline + ore rimanenti
risk.high_createdRischio HIGH o CRITICAL creatoriskPayload(risk, 'created')
risk.updatedRischio aggiornatoriskPayload(risk, 'updated')
compliance.score_changedVariazione score >5%previous_score, new_score, delta, label
policy.approvedPolicy approvatapolicyPayload(policy)
policy.createdNuova policy creataid, title, category, nis2_article
supplier.risk_flaggedFornitore con rischio HIGH/CRITICALsupplier + risk_level
assessment.completedGap assessment completatoscore, gap_count, top_gaps[]
whistleblowing.receivedNuova segnalazione anonimaid, category, priority (no PII)
normative.updateAggiornamento normativo NIS2/ACNtitle, source, effective_date
webhook.testPing manuale di testmessage, timestamp
*Wildcard — tutti gli eventi
+ +

Struttura envelope payload

+
{
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "event": "incident.created",
+  "api_version": "1.0.0",
+  "created": 1709900400,
+  "created_at": "2026-03-07T10:00:00+01:00",
+  "source": "nis2-agile",
+  "org_id": 42,
+  "data": { "action": "created", "incident": { ... } }
+}
+
+ + +
+

Verifica Firma Webhook

+

Ogni delivery include l'header X-NIS2-Signature con firma HMAC-SHA256 del body. Verifica sempre la firma prima di processare il payload.

+ +
+ Pattern identico a Stripe webhook verification: sha256=HMAC_SHA256(body, secret) +
+ +

Headers inviati

+
X-NIS2-Signature: sha256=a3f8c2d1e4b7...
+X-NIS2-Event: incident.created
+X-NIS2-Delivery-Id: 147
+X-NIS2-Attempt: 1
+Content-Type: application/json
+ +

Verifica in PHP

+
$body = file_get_contents('php://input');
+$secret = 'il-tuo-secret';
+$signature = $_SERVER['HTTP_X_NIS2_SIGNATURE'];
+$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
+
+if (!hash_equals($expected, $signature)) {
+    http_response_code(401);
+    exit('Invalid signature');
+}
+ +

Verifica in Python

+
import hmac, hashlib
+
+secret = b'il-tuo-secret'
+body = request.data  # bytes
+expected = 'sha256=' + hmac.new(secret, body, hashlib.sha256).hexdigest()
+received = request.headers.get('X-NIS2-Signature', '')
+
+if not hmac.compare_digest(expected, received):
+    abort(401)
+ +

Retry Policy

+ + + + + + + +
TentativoRitardoNote
ImmediatoFire-and-forget sincrono
+5 minutiProcessato da cron
+30 minutiUltimo tentativo
+

Dopo 10 fallimenti consecutivi la subscription viene messa in pausa automatica (failure_count >= 10).

+
+ +
+ NIS2 Agile API Reference v1.0.0 — © 2026 CertiSource Srl — + nis2.certisource.it +
+ +
+
+ + + + diff --git a/public/index.php b/public/index.php index d49d2af..c651d83 100644 --- a/public/index.php +++ b/public/index.php @@ -98,6 +98,10 @@ $controllerMap = [ 'admin' => 'AdminController', 'onboarding' => 'OnboardingController', 'ncr' => 'NonConformityController', + 'services' => 'ServicesController', + 'webhooks' => 'WebhookController', + 'whistleblowing'=> 'WhistleblowingController', + 'normative' => 'NormativeController', ]; if (!isset($controllerMap[$controllerName])) { @@ -289,6 +293,55 @@ $actionMap = [ 'POST:{id}/sync' => 'syncExternal', 'POST:webhook' => 'webhook', ], + + // ── ServicesController (API pubblica) ────────── + 'services' => [ + 'GET:status' => 'status', + 'GET:complianceSummary' => 'complianceSummary', + 'GET:risksFeed' => 'risksFeed', + 'GET:incidentsFeed' => 'incidentsFeed', + 'GET:controlsStatus' => 'controlsStatus', + 'GET:assetsCritical' => 'assetsCritical', + 'GET:suppliersRisk' => 'suppliersRisk', + 'GET:policiesApproved' => 'policiesApproved', + 'GET:openapi' => 'openapi', + ], + + // ── WebhookController (CRUD keys + subscriptions) ── + 'webhooks' => [ + 'GET:apiKeys' => 'listApiKeys', + 'POST:apiKeys' => 'createApiKey', + 'DELETE:apiKeys/{subId}' => 'deleteApiKey', + 'GET:subscriptions' => 'listSubscriptions', + 'POST:subscriptions' => 'createSubscription', + 'PUT:subscriptions/{subId}' => 'updateSubscription', + 'DELETE:subscriptions/{subId}' => 'deleteSubscription', + 'POST:subscriptions/{subId}/test' => 'testSubscription', + 'GET:deliveries' => 'listDeliveries', + 'POST:retry' => 'processRetry', + ], + + // ── WhistleblowingController (Art.32 NIS2) ───── + 'whistleblowing' => [ + 'POST:submit' => 'submit', + 'GET:list' => 'list', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'POST:{id}/assign' => 'assign', + 'POST:{id}/close' => 'close', + 'GET:stats' => 'stats', + 'GET:trackAnonymous' => 'trackAnonymous', + ], + + // ── NormativeController (Feed NIS2/ACN) ───────── + 'normative' => [ + 'GET:list' => 'list', + 'GET:{id}' => 'get', + 'POST:{id}/ack' => 'acknowledge', + 'GET:pending' => 'pending', + 'GET:stats' => 'stats', + 'POST:create' => 'create', + ], ]; // ═══════════════════════════════════════════════════════════════════════════ @@ -340,6 +393,8 @@ if (is_numeric($actionName)) { $sid = (int) $subResourceId; $candidates[] = ['p' => "{$method}:{id}/{$camelSub}/{subId}", 'a' => [$rid, $sid]]; } + // e.g. POST:subscriptions/{subId}/test + $candidates[] = ['p' => "{$method}:{$actionName}/{subId}/{$camelSub}", 'a' => [$rid]]; $candidates[] = ['p' => "{$method}:{id}/{$camelSub}", 'a' => [$rid]]; $candidates[] = ['p' => "{$method}:{$actionName}/{subId}", 'a' => [$rid]]; } diff --git a/public/integrations/allrisk.html b/public/integrations/allrisk.html new file mode 100644 index 0000000..5007fd6 --- /dev/null +++ b/public/integrations/allrisk.html @@ -0,0 +1,156 @@ + + + + + + AllRisk × NIS2 Agile — Integrazione + + + +
+
+ AllRisk + × + NIS2 Agile +
+

Integrazione AllRisk ← NIS2 Agile

+

Esporta il risk register cyber NIS2 verso AllRisk per consolidamento nel registro rischi enterprise. Importazione automatica dei rischi HIGH/CRITICAL tramite API Key.

+
+ +
+ +
+

Scenario di integrazione

+

AllRisk è il registro rischi enterprise centralizzato. NIS2 Agile gestisce i rischi cyber in ottica normativa (ISO 27005 / NIS2). L'integrazione permette di:

+
1

Consolidamento automatico

I rischi HIGH/CRITICAL di NIS2 vengono automaticamente importati in AllRisk come rischi di categoria "Cyber/IT" con score e deadline normalizzati.

+
2

Aggiornamento bidirezionale

AllRisk notifica NIS2 tramite webhook se un rischio enterprise si trasforma in minaccia cyber (es. incidente supply chain → rischio NIS2).

+
3

Reporting unificato

Report rischi enterprise unico che include dimensione NIS2 con compliance score per ogni categoria di rischio cyber (Art.21 domains).

+
+ +
+

Mappatura campi NIS2 → AllRisk

+ + + + + + + + + + + + +
Campo NIS2 AgileCampo AllRiskTrasformazione
risk_codeexternal_refPrefisso NIS2- + codice
titlerisk_nameDiretta
risk_levelseveritycritical→5, high→4, medium→3, low→2
likelihood × impactrisk_scoreScala 0–25 → 0–100
categoryrisk_categoryMappato su tassonomia AllRisk "Cyber"
nis2_articleregulatory_refPrefisso "NIS2 Art."
treatmentresponse_strategymitigate→Reduce, accept→Accept, ecc.
review_datenext_reviewDiretta (ISO 8601)
+
+ +
+

Import automatico — Script PHP

+
// AllRisk — import_nis2_risks.php (cron settimanale)
+
+$apiKey    = getenv('NIS2_API_KEY');
+$orgId     = getenv('NIS2_ORG_ID');
+$baseUrl   = 'https://nis2.certisource.it/api';
+$allriskOrgId = getenv('ALLRISK_ORG_ID');
+
+// Fetch rischi HIGH/CRITICAL aperti da NIS2
+$response = nis2Get($baseUrl . '/services/risks-feed', [
+    'level'  => 'high',
+    'status' => 'open',
+    'limit'  => 200,
+], $apiKey, $orgId);
+
+$risks = $response['data']['risks'] ?? [];
+$imported = 0;
+
+foreach ($risks as $risk) {
+    $allriskPayload = [
+        'external_ref'       => 'NIS2-' . $risk['risk_code'],
+        'risk_name'          => $risk['title'],
+        'risk_category'      => 'Cyber/IT',
+        'severity'           => mapSeverity($risk['risk_level']),
+        'risk_score'         => round($risk['risk_score'] / 25 * 100),
+        'regulatory_ref'     => 'NIS2 ' . ($risk['nis2_article'] ?? 'Art.21'),
+        'response_strategy'  => mapTreatment($risk['treatment']),
+        'source_system'      => 'NIS2 Agile',
+        'organization_id'    => $allriskOrgId,
+    ];
+
+    // Upsert: aggiorna se external_ref esiste, crea altrimenti
+    AllRiskApiClient::upsertRisk($allriskPayload);
+    $imported++;
+}
+
+error_log("[NIS2→AllRisk] Importati {$imported} rischi");
+
+function mapSeverity(string $level): int {
+    return match($level) { 'critical' => 5, 'high' => 4, 'medium' => 3, default => 2 };
+}
+function mapTreatment(string $t): string {
+    return match($t) {
+        'mitigate' => 'Reduce', 'accept' => 'Accept',
+        'transfer' => 'Transfer', default => 'Avoid'
+    };
+}
+
+ +
+

Webhook NIS2 → AllRisk (real-time)

+

Configura webhook per importazione immediata dei nuovi rischi HIGH/CRITICAL:

+
# Settings → Webhook in NIS2 Agile
+URL:    https://allrisk.certisource.it/api/v1/webhooks/nis2
+Events: risk.high_created, compliance.score_changed, incident.significant
+ +
// AllRisk — webhook receiver per NIS2
+$payload = verifyAndDecode($_SERVER['HTTP_X_NIS2_SIGNATURE'], file_get_contents('php://input'));
+
+if ($payload['event'] === 'risk.high_created') {
+    $risk = $payload['data']['risk'];
+    AllRiskApiClient::upsertRisk([
+        'external_ref' => 'NIS2-' . $risk['id'],
+        'risk_name'    => $risk['title'],
+        'severity'     => $risk['risk_level'] === 'critical' ? 5 : 4,
+        'source_system'=> 'NIS2 Agile',
+        'alert'        => true, // Notifica CRO AllRisk
+    ]);
+}
+http_response_code(200);
+
+ +
+ Nota architetturale: Il flusso è unidirezionale NIS2 → AllRisk. Per il flusso inverso (AllRisk → NIS2), AllRisk chiama l'API NIS2 per creare rischi cyber direttamente tramite le API JWT (POST /api/risks/create) usando le credenziali dell'utente integration. +
+ +
+ + diff --git a/public/integrations/index.html b/public/integrations/index.html new file mode 100644 index 0000000..329d8da --- /dev/null +++ b/public/integrations/index.html @@ -0,0 +1,66 @@ + + + + + + NIS2 Agile — Integrazioni + + + +
+

NIS2 Agile — Integrazioni

+

Pagine di integrazione per connettere NIS2 Agile con altri prodotti della suite Agile.

+
+
+
📋
+ Webhook + Widget +
231 Agile ← NIS2
+
Integra i dati di compliance NIS2 in 231 Agile. Visualizza compliance score, rischi cyber e incidenti significativi nel contesto del Modello 231.
+ Guida integrazione → +
+
+
🌿
+ API Widget +
SustainAI ← NIS2
+
Alimenta i report ESG/sostenibilità di SustainAI con dati di governance cybersecurity NIS2. Compliance score e policy approvate per l'area G (Governance).
+ Guida integrazione → +
+
+
+ REST API +
AllRisk ← NIS2
+
Esporta il risk register cyber NIS2 verso AllRisk per consolidamento nel registro rischi enterprise. Sincronizzazione automatica via API Key.
+ Guida integrazione → +
+
+
🔒
+ SIEM +
SIEM / SOC
+
Integra NIS2 Agile con SIEM (Splunk, Elastic, IBM QRadar) tramite webhook outbound. Ricevi eventi incident.created, risk.high_created in tempo reale.
+ Guida integrazione → +
+
+
+ + diff --git a/public/integrations/lg231.html b/public/integrations/lg231.html new file mode 100644 index 0000000..83fb267 --- /dev/null +++ b/public/integrations/lg231.html @@ -0,0 +1,225 @@ + + + + + + 231 Agile × NIS2 Agile — Integrazione + + + +
+
+ NIS2 Agile + × + 231 Agile +
+

Integrazione 231 Agile ← NIS2 Agile

+

Porta i dati di compliance cybersecurity NIS2 nel contesto del Modello Organizzativo 231. Rischi cyber come presidi 231, incidenti significativi come non conformità.

+
+ +
+ +
+

Architettura dell'integrazione

+

NIS2 Agile espone un'API REST con API Key e un sistema di webhook outbound. 231 Agile può consumare i dati in due modalità:

+
+
A
+
+

Pull (Services API)

+

231 Agile chiama periodicamente le API NIS2 per aggiornare il cruscotto 231. Indicato per dati storici e report periodici (mensile/trimestrale).

+
+
+
+
B
+
+

Push (Webhook outbound)

+

NIS2 Agile notifica 231 Agile in tempo reale su eventi critici. Indicato per incidenti significativi, rischi HIGH/CRITICAL, variazioni compliance.

+
+
+
+ +
+

Step 1 — Crea API Key in NIS2 Agile

+
Richiede ruolo org_admin in NIS2 Agile.
+
1

Accedi a Settings → API Keys

In NIS2 Agile, vai in Settings → tab API KeysNuova API Key.

+
2

Seleziona gli scope

Per 231 Agile ti servono: read:compliance, read:risks, read:incidents. Opzionale: read:policies.

+
3

Salva la chiave

La chiave nis2_xxxxx viene mostrata una sola volta. Copiala in 231 Agile → Integrazioni → NIS2 Agile → API Key.

+
+ +
+

Step 2 — Integrazione Pull (PHP / 231 Agile)

+

Aggiungi questo codice nel cron job notturno di 231 Agile per sincronizzare i dati NIS2:

+
// 231 Agile — CronJob: sync_nis2_compliance.php
+
+$apiKey   = getenv('NIS2_API_KEY');       // nis2_abc123...
+$orgId    = getenv('NIS2_ORG_ID');
+$baseUrl  = 'https://nis2.certisource.it/api';
+
+function nis2Get(string $endpoint, array $query = []): array {
+    global $apiKey, $orgId, $baseUrl;
+    $url = $baseUrl . $endpoint;
+    if (!empty($query)) $url .= '?' . http_build_query($query);
+    $ch = curl_init($url);
+    curl_setopt_array($ch, [
+        CURLOPT_RETURNTRANSFER => true,
+        CURLOPT_HTTPHEADER => [
+            'X-API-Key: ' . $apiKey,
+            'X-Organization-Id: ' . $orgId,
+        ],
+    ]);
+    return json_decode(curl_exec($ch), true) ?? [];
+}
+
+// 1. Recupera compliance summary
+$compliance = nis2Get('/services/compliance-summary');
+$score = $compliance['data']['overall_score'] ?? 0;
+
+// 2. Recupera rischi HIGH/CRITICAL per mappa rischi 231
+$risks = nis2Get('/services/risks-feed', ['level' => 'high', 'status' => 'open']);
+
+// 3. Recupera incidenti Art.23 aperti
+$incidents = nis2Get('/services/incidents-feed', [
+    'significant_only' => 1, 'status' => 'open'
+]);
+
+// 4. Salva in DB 231 Agile — tabella nis2_integration
+// DB231::upsert('nis2_integration', ['org_id'=>$orgId231, 'score'=>$score, ...]);
+
+ +
+

Step 3 — Webhook Push (real-time)

+

Configura in NIS2 Agile → Settings → Webhook un endpoint del tuo server 231 Agile per ricevere eventi in tempo reale:

+
# Endpoint da configurare in NIS2 Agile → Settings → Webhook
+URL:    https://app-231.certisource.it/webhooks/nis2
+Events: incident.significant, incident.deadline_warning, risk.high_created, compliance.score_changed
+ +

Receiver PHP su 231 Agile

+
// 231 Agile — routes/webhook_nis2.php
+
+$secret = getenv('NIS2_WEBHOOK_SECRET');
+$body   = file_get_contents('php://input');
+$sig    = $_SERVER['HTTP_X_NIS2_SIGNATURE'] ?? '';
+
+if (!hash_equals('sha256=' . hash_hmac('sha256', $body, $secret), $sig)) {
+    http_response_code(401); exit;
+}
+
+$payload = json_decode($body, true);
+$event   = $payload['event'];
+
+switch ($event) {
+    case 'incident.significant':
+        // Crea non-conformità in 231 Agile
+        NonConformityService::createFromNis2Incident($payload['data']['incident']);
+        break;
+    case 'risk.high_created':
+        // Aggiorna mappa rischi presidi 231
+        RiskService::importFromNis2($payload['data']['risk']);
+        break;
+    case 'compliance.score_changed':
+        // Aggiorna dashboard 231 con nuovo score NIS2
+        DashboardService::updateNis2Score($payload['data']['new_score']);
+        break;
+}
+http_response_code(200);
+
+ +
+

Widget NIS2 per Dashboard 231

+

Embed HTML con chiamata API diretta. Copialo nella dashboard di 231 Agile:

+
+
+
+ + NIS2 Compliance (Preview widget) +
+ nis2.certisource.it +
+
+
73%
+
+

Sostanzialmente Conforme

+

D.Lgs. 138/2024 — Art.21 NIS2

+
+
+
+
2
Incidenti aperti
+
5
Rischi HIGH
+
12
Policy approvate
+
+
+
<!-- Widget NIS2 per 231 Agile dashboard -->
+<div id="nis2-widget"></div>
+<script>
+(async () => {
+  const r = await fetch('https://nis2.certisource.it/api/services/compliance-summary', {
+    headers: {
+      'X-API-Key': 'nis2_YOUR_KEY',
+      'X-Organization-Id': 'YOUR_ORG_ID'
+    }
+  });
+  const { data } = await r.json();
+  document.getElementById('nis2-widget').innerHTML = `
+    <div style="padding:20px; border:1px solid #e2e8f0; border-radius:8px;">
+      <h4 style="font-size:.875rem; font-weight:700; margin-bottom:12px;">
+        🔒 NIS2 Compliance Score
+      </h4>
+      <div style="font-size:2rem; font-weight:800; color:#06b6d4;">
+        ${data.overall_score}%
+      </div>
+      <div style="font-size:.75rem; color:#64748b;">${data.label}</div>
+      <div style="margin-top:12px; display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px;">
+        <div><strong>${data.incidents.open}</strong><br><small>Incidenti</small></div>
+        <div><strong>${data.risks.high}</strong><br><small>Rischi HIGH</small></div>
+        <div><strong>${data.policies.approved}</strong><br><small>Policy</small></div>
+      </div>
+    </div>`;
+})();
+</script>
+
+ +
+ Dati mappati 231 ↔ NIS2: Rischi cyber NIS2 → Presidi 231 (ex. D.Lgs. 231/01 Art.25-septies) | Incidenti significativi → Non Conformità 231 | Compliance score → KPI Operatività 231 +
+
+ + diff --git a/public/integrations/siem.html b/public/integrations/siem.html new file mode 100644 index 0000000..9e62557 --- /dev/null +++ b/public/integrations/siem.html @@ -0,0 +1,179 @@ + + + + + + SIEM × NIS2 Agile — Integrazione + + + +
+
+ SIEM / SOC + × + NIS2 Agile +
+

Integrazione SIEM / SOC ← NIS2 Agile

+

Ricevi eventi NIS2 in tempo reale nel tuo SIEM. Incidenti Art.23, rischi HIGH/CRITICAL, variazioni compliance come alert automatici.

+
+ +
+ +
+

SIEM supportati

+
+

Splunk Enterprise

HTTP Event Collector (HEC) + custom TA per NIS2 Agile. Dashboard Art.23 precompilata.

+

Elastic SIEM

Logstash HTTP input + index template NIS2. Alert rule per incident.significant.

+

IBM QRadar

Universal DSM + webhook-to-syslog bridge. Evento NIS2 → offense QRadar.

+

Microsoft Sentinel

Logic App webhook receiver → Azure Monitor. Playbook SOAR per Art.23.

+
+
+ +
+

Configurazione Webhook NIS2 → SIEM

+

Configura in NIS2 Agile → Settings → Webhook l'endpoint del tuo SIEM:

+
# SIEM HEC / HTTP Input endpoint
+URL:    https://your-siem.example.com:8088/services/collector   # Splunk HEC
+URL:    https://your-elk.example.com:9200/_ingest/pipeline/nis2  # Elastic
+Events: incident.created, incident.significant, incident.deadline_warning,
+        risk.high_created, compliance.score_changed
+
+ +
+

Splunk HEC — Bridge PHP

+

Se il SIEM non supporta nativamente la firma HMAC-SHA256, usa questo bridge come proxy tra NIS2 e Splunk HEC:

+
// nis2_to_splunk_bridge.php — Deploy su server intermedio
+
+$nis2Secret  = getenv('NIS2_WEBHOOK_SECRET');
+$splunkHec   = getenv('SPLUNK_HEC_URL');     // https://splunk:8088/services/collector
+$splunkToken = getenv('SPLUNK_HEC_TOKEN');
+
+// 1. Verifica firma NIS2
+$body = file_get_contents('php://input');
+$sig  = $_SERVER['HTTP_X_NIS2_SIGNATURE'] ?? '';
+if (!hash_equals('sha256=' . hash_hmac('sha256', $body, $nis2Secret), $sig)) {
+    http_response_code(401); exit;
+}
+
+$payload = json_decode($body, true);
+
+// 2. Trasforma in formato Splunk HEC
+$splunkEvent = [
+    'time'       => $payload['created'],
+    'host'       => 'nis2-agile',
+    'source'     => 'nis2-agile-webhook',
+    'sourcetype' => 'nis2:event',
+    'index'      => 'nis2_compliance',
+    'event'      => [
+        'event_type'  => $payload['event'],
+        'event_id'    => $payload['id'],
+        'org_id'      => $payload['org_id'],
+        'data'        => $payload['data'],
+        'api_version' => $payload['api_version'],
+    ],
+];
+
+// 3. Forward a Splunk HEC
+$ch = curl_init($splunkHec);
+curl_setopt_array($ch, [
+    CURLOPT_POST => true,
+    CURLOPT_POSTFIELDS => json_encode($splunkEvent),
+    CURLOPT_RETURNTRANSFER => true,
+    CURLOPT_HTTPHEADER => [
+        'Authorization: Splunk ' . $splunkToken,
+        'Content-Type: application/json',
+    ],
+]);
+$result = curl_exec($ch);
+curl_close($ch);
+http_response_code(200);
+
+ +
+

Elastic — Logstash Pipeline

+
# logstash/pipelines/nis2.conf
+input {
+  http {
+    port => 8181
+    codec => json
+    # Aggiungi filtro HMAC-SHA256 con plugin custom
+  }
+}
+filter {
+  mutate {
+    add_field => { "[@metadata][index]" => "nis2-compliance-%{+YYYY.MM}" }
+  }
+  date {
+    match => [ "[created]", "UNIX" ]
+    target => "@timestamp"
+  }
+}
+output {
+  elasticsearch {
+    hosts => ["https://elasticsearch:9200"]
+    index => "%{[@metadata][index]}"
+  }
+}
+
+ +
+

Microsoft Sentinel — Logic App

+
// Azure Logic App — trigger HTTP + azione Send to Log Analytics
+{
+  "triggers": {
+    "When_a_HTTP_request_is_received": {
+      "type": "Request",
+      "kind": "Http",
+      "inputs": { "schema": {} }
+    }
+  },
+  "actions": {
+    "Send_Data_to_Log_Analytics": {
+      "type": "ApiConnection",
+      "inputs": {
+        "body": "@{triggerBody()}",
+        "headers": { "Log-Type": "NIS2AgileEvent" },
+        "host": { "connection": { "name": "@parameters('$connections')['azureloganalyticsdatacollector']" } },
+        "method": "post",
+        "path": "/api/logs"
+      }
+    }
+  }
+}
+
+ +
+ Art.23 NIS2 + SIEM: Configurare alert SIEM su incident.significant e incident.deadline_warning permette di automatizzare il tracking delle scadenze 24h/72h/30d direttamente nel SOC. Il team può così gestire incidenti NIS2 senza uscire dal workflow SIEM. +
+ +
+ + diff --git a/public/integrations/sustainai.html b/public/integrations/sustainai.html new file mode 100644 index 0000000..515d1b7 --- /dev/null +++ b/public/integrations/sustainai.html @@ -0,0 +1,156 @@ + + + + + + SustainAI × NIS2 Agile — Integrazione + + + +
+
+ SustainAI + × + NIS2 Agile +
+

Integrazione SustainAI ← NIS2 Agile

+

Alimenta l'area Governance (G) e Sociale (S) dei report ESG/sostenibilità con i dati di compliance cybersecurity NIS2. La governance della sicurezza informatica è un KPI ESG rilevante (GRI 418, SASB, CSRD).

+
+ +
+ +
+

Mappatura NIS2 → ESG

+

I dati NIS2 Agile si mappano naturalmente ai framework ESG più diffusi:

+ + + + + + + + + + +
Dato NIS2 AgileEndpointPilastro ESGFramework
Compliance score Art.21/services/compliance-summaryG GovernanceGRI 205, CSRD
Policy sicurezza approvate/services/policies-approvedG GovernanceISO 27001, GRI 418
Incidenti data breach / Art.23/services/incidents-feedS SocialeGRI 418 (Privacy)
Controlli di sicurezza implementati/services/controls-statusG GovernanceSASB
Rischio supply chain fornitori/services/suppliers-riskE Ambientale + GGRI 308
Segnalazioni whistleblowing/api/whistleblowing/statsS SocialeGRI 205 (Anti-corruzione)
+
+ +
+

Step 1 — API Key con scope minimi

+

Crea in NIS2 Agile una chiave con scope limitati per SustainAI:

+
Scope richiesti:
+  read:compliance   ← score e controlli Art.21
+  read:incidents    ← incidenti per KPI privacy/GDPR
+  read:policies     ← policy approvate (governance evidence)
+  read:supply_chain ← rischio fornitori ESG
+
+ +
+

Step 2 — Sync mensile per report ESG

+
// SustainAI — sync_nis2_esg.php (cron mensile)
+
+$apiKey = getenv('NIS2_API_KEY');
+$orgId  = getenv('NIS2_ORG_ID');
+$base   = 'https://nis2.certisource.it/api';
+
+$headers = [
+    'X-API-Key: ' . $apiKey,
+    'X-Organization-Id: ' . $orgId,
+];
+
+// G — Compliance score (KPI governance cybersecurity)
+$compliance = nis2Get($base . '/services/compliance-summary', $headers);
+$cyberScore = $compliance['data']['overall_score'] ?? 0;
+$policyCount = $compliance['data']['policies']['approved'] ?? 0;
+
+// S — Privacy breaches (GRI 418)
+$incidents = nis2Get($base . '/services/incidents-feed?significant_only=1', $headers);
+$breaches = array_filter(
+    $incidents['data']['incidents'] ?? [],
+    fn($i) => $i['classification'] === 'data_breach'
+);
+
+// G — Supply chain risk (ESG fornitori)
+$suppliers = nis2Get($base . '/services/suppliers-risk', $headers);
+$highRiskSuppliers = $suppliers['data']['stats']['high'] +
+                     $suppliers['data']['stats']['critical'];
+
+// Aggiorna KPI ESG in SustainAI
+EsgKpiService::updateCyberGovernance([
+    'nis2_score'          => $cyberScore,
+    'policies_approved'   => $policyCount,
+    'data_breaches'       => count($breaches),
+    'high_risk_suppliers' => $highRiskSuppliers,
+    'period'              => date('Y-m'),
+]);
+
+ +
+

Widget NIS2 per Report ESG SustainAI

+
<!-- SustainAI: sezione Governance → Cybersecurity KPIs -->
+<div id="nis2-esg-widget" style="padding:20px; border:1px solid #e2e8f0; border-radius:8px; background:#f0fdf4;"></div>
+<script>
+fetch('https://nis2.certisource.it/api/services/compliance-summary', {
+  headers: { 'X-API-Key': 'nis2_YOUR_KEY', 'X-Organization-Id': 'ORG_ID' }
+}).then(r => r.json()).then(({ data }) => {
+  document.getElementById('nis2-esg-widget').innerHTML = `
+    <h4 style="font-size:.875rem; font-weight:700; color:#065f46; margin-bottom:16px;">
+      🔒 Governance Cybersecurity — NIS2 Compliance
+    </h4>
+    <div style="display:grid; grid-template-columns:repeat(4,1fr); gap:12px; text-align:center;">
+      <div><div style="font-size:1.5rem; font-weight:800; color:#06b6d4;">${data.overall_score}%</div>
+        <div style="font-size:.7rem; color:#64748b;">NIS2 Score</div></div>
+      <div><div style="font-size:1.5rem; font-weight:800; color:#10b981;">${data.policies.approved}</div>
+        <div style="font-size:.7rem; color:#64748b;">Policy Approvate</div></div>
+      <div><div style="font-size:1.5rem; font-weight:800; color:#f59e0b;">${data.risks.high}</div>
+        <div style="font-size:.7rem; color:#64748b;">Rischi HIGH</div></div>
+      <div><div style="font-size:1.5rem; font-weight:800; color:#ef4444;">${data.incidents.significant}</div>
+        <div style="font-size:.7rem; color:#64748b;">Incidenti Art.23</div></div>
+    </div>
+    <p style="font-size:.7rem; color:#94a3b8; margin-top:12px; text-align:right;">
+      Fonte: NIS2 Agile — nis2.certisource.it — Aggiornato: ${new Date().toLocaleDateString('it')}
+    </p>`;
+});
+</script>
+
+ +
+ CSRD / ESRS E5: La cybersecurity è esplicitamente inclusa nelle ESRS come rischio materiale (ESRS 2 IRO-1). NIS2 Agile fornisce le evidenze documentali per il reporting CSRD sul governo dei rischi digitali. +
+ +
+ + diff --git a/public/js/common.js b/public/js/common.js index b7a97c3..21b8c92 100644 --- a/public/js/common.js +++ b/public/js/common.js @@ -192,10 +192,12 @@ function loadSidebar() { { label: 'Gestione', i18nKey: 'nav.management', items: [ - { name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' }, - { name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' }, - { name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' }, - { name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' }, + { name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation(), i18nKey: 'nav.risks' }, + { name: 'Incidenti', href: 'incidents.html', icon: iconBell(), i18nKey: 'nav.incidents' }, + { name: 'Policy', href: 'policies.html', icon: iconDocumentText(), i18nKey: 'nav.policies' }, + { name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink(), i18nKey: 'nav.supply_chain' }, + { name: 'Segnalazioni', href: 'whistleblowing.html', icon: `` }, + { name: 'Normative', href: 'normative.html', icon: `` }, ] }, { @@ -203,7 +205,7 @@ function loadSidebar() { items: [ { name: 'Formazione', href: 'training.html', icon: iconAcademicCap(), i18nKey: 'nav.training' }, { name: 'Asset', href: 'assets.html', icon: iconServer(), i18nKey: 'nav.assets' }, - { name: 'Audit & Report',href: 'reports.html', icon: iconChartBar(), i18nKey: 'nav.audit' }, + { name: 'Audit & Report',href: 'reports.html', icon: iconChartBar(), i18nKey: 'nav.audit' }, ] }, { diff --git a/public/normative.html b/public/normative.html new file mode 100644 index 0000000..4ca260d --- /dev/null +++ b/public/normative.html @@ -0,0 +1,256 @@ + + + + + + Aggiornamenti Normativi - NIS2 Agile + + + + +
+ +
+ + + +
+
+
+ + +
+
+
+ Avanzamento presa visione + +
+
+
+
+

Documentare la presa visione degli aggiornamenti normativi è richiesto per la compliance NIS2 Art.21.

+
+
+ + +
+
+
+ + + +
+
+
+ +
+
+
+ + + + + + + + diff --git a/public/settings.html b/public/settings.html index 97ecdf3..569d27e 100644 --- a/public/settings.html +++ b/public/settings.html @@ -212,6 +212,8 @@ + + @@ -479,6 +481,64 @@ + + +
+
+
+
+

API Keys

+

+ Chiavi per accesso esterno alle API NIS2 Agile (SIEM, GRC, dashboard esterne). +

+
+ +
+
+
+
+
+
+

Scope Disponibili

+
+
+
+
+
+ + +
+
+
+
+

Webhook Subscriptions

+

+ Notifiche push verso sistemi esterni (SIEM, 231 Agile, SustainAI) su eventi NIS2. +

+
+ +
+
+
+
+
+
+
+

Delivery Log

+ +
+
+

Seleziona un webhook per vedere i delivery.

+
+
+
+ @@ -506,20 +566,19 @@ // ── Tab Navigation ─────────────────────────────────────── function switchTab(tab) { - // Rimuovi active da tutti i tab e pannelli document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); - // Attiva il tab selezionato - const tabMap = { org: 0, profile: 1, members: 2, security: 3 }; - const panelMap = { org: 'tab-org', profile: 'tab-profile', members: 'tab-members', security: 'tab-security' }; + const tabMap = { org: 0, profile: 1, members: 2, security: 3, apikeys: 4, webhooks: 5 }; + const panelMap = { org: 'tab-org', profile: 'tab-profile', members: 'tab-members', security: 'tab-security', apikeys: 'tab-apikeys', webhooks: 'tab-webhooks' }; document.querySelectorAll('.settings-tab')[tabMap[tab]].classList.add('active'); document.getElementById(panelMap[tab]).classList.add('active'); - // Carica dati del tab se necessario if (tab === 'members') loadMembers(); if (tab === 'security') loadAuditLog(); + if (tab === 'apikeys') loadApiKeys(); + if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); } } // ── Settori NIS2 ───────────────────────────────────────── @@ -979,6 +1038,267 @@ } } + // ── API Keys ───────────────────────────────────────────── + async function loadApiKeys() { + const container = document.getElementById('apikeys-container'); + container.innerHTML = '
'; + try { + const result = await api.request('GET', '/webhooks/api-keys'); + if (result.success) { + renderApiKeys(result.data.api_keys || []); + renderAvailableScopes(result.data.available_scopes || {}); + } + } catch (e) { container.innerHTML = '

Errore caricamento API Keys

'; } + } + + function renderApiKeys(keys) { + const container = document.getElementById('apikeys-container'); + if (!keys.length) { + container.innerHTML = `

Nessuna API Key

Crea una chiave per integrare sistemi esterni.

`; + return; + } + let html = `
`; + keys.forEach(k => { + const active = k.is_active ? 'Attiva' : 'Revocata'; + const scopes = (k.scopes || []).map(s => `${escapeHtml(s)}`).join(' '); + html += ` + + + + + + + + `; + }); + html += '
NomePrefissoScopesUltimo UsoScadenzaStatoAzioni
${escapeHtml(k.name)}
Creata da ${escapeHtml(k.created_by_name || '-')}
${escapeHtml(k.key_prefix)}...${scopes}${k.last_used_at ? formatDateTime(k.last_used_at) : 'Mai'}${k.expires_at ? formatDate(k.expires_at) : 'Nessuna'}${active}${k.is_active ? `` : ''}
'; + container.innerHTML = html; + } + + function renderAvailableScopes(scopes) { + const container = document.getElementById('available-scopes-container'); + let html = '
'; + Object.entries(scopes).forEach(([key, desc]) => { + html += `
+ ${escapeHtml(key)} +

${escapeHtml(desc)}

+
`; + }); + html += '
'; + container.innerHTML = html; + } + + function showCreateApiKeyModal() { + showModal('Crea API Key', ` +
+ + +
+
+ +
+ ${['read:all','read:compliance','read:risks','read:incidents','read:assets','read:supply_chain','read:policies'].map(s => + `` + ).join('')} +
+
+
+ + +
+ `, ''); + } + + async function createApiKey() { + const name = document.getElementById('apikey-name').value.trim(); + const scopes = [...document.querySelectorAll('input[name="scope"]:checked')].map(cb => cb.value); + const expires = document.getElementById('apikey-expires').value; + if (!name) { showNotification('Inserisci un nome per la chiave.', 'error'); return; } + if (!scopes.length) { showNotification('Seleziona almeno uno scope.', 'error'); return; } + try { + const result = await api.request('POST', '/webhooks/api-keys', { name, scopes, expires_at: expires || null }); + if (result.success) { + closeModal(); + showModal('API Key Creata', ` +
+ ⚠ Salva questa chiave ora. Non sarà più visibile. +
+
+ ${escapeHtml(result.data.key)} +
+

Usa come header: X-API-Key: ${escapeHtml(result.data.key)}

+ `, ''); + } else { showNotification(result.message || 'Errore.', 'error'); } + } catch (e) { showNotification('Errore di connessione.', 'error'); } + } + + async function revokeApiKey(id, name) { + if (!confirm(`Revocare la chiave "${name}"? L'operazione è irreversibile.`)) return; + try { + const result = await api.request('DELETE', `/webhooks/api-keys/${id}`); + if (result.success) { showNotification('API Key revocata.', 'success'); loadApiKeys(); } + else showNotification(result.message || 'Errore.', 'error'); + } catch (e) { showNotification('Errore di connessione.', 'error'); } + } + + // ── Webhooks ───────────────────────────────────────────── + const availableEvents = { + 'incident.created': 'Nuovo incidente', 'incident.updated': 'Incidente aggiornato', + 'incident.significant': 'Incidente significativo (Art.23)', 'incident.deadline_warning': 'Scadenza Art.23 imminente', + 'risk.high_created': 'Rischio HIGH/CRITICAL', 'risk.updated': 'Rischio aggiornato', + 'compliance.score_changed': 'Variazione compliance >5%', 'policy.approved': 'Policy approvata', + 'policy.created': 'Nuova policy', 'supplier.risk_flagged': 'Fornitore a rischio', + 'assessment.completed': 'Assessment completato', 'whistleblowing.received': 'Nuova segnalazione', + 'normative.update': 'Aggiornamento normativo', 'webhook.test': 'Test', + '*': 'Tutti gli eventi (wildcard)' + }; + + async function loadWebhooks() { + const container = document.getElementById('webhooks-container'); + container.innerHTML = '
'; + try { + const result = await api.request('GET', '/webhooks/subscriptions'); + if (result.success) renderWebhooks(result.data.subscriptions || []); + } catch (e) { container.innerHTML = '

Errore caricamento webhook

'; } + } + + function renderWebhooks(subs) { + const container = document.getElementById('webhooks-container'); + if (!subs.length) { + container.innerHTML = `

Nessun Webhook

Configura webhook per notifiche push verso SIEM e sistemi esterni.

`; + return; + } + let html = `
`; + subs.forEach(s => { + const evts = (s.events || []).map(e => `${escapeHtml(e)}`).join(' '); + const active = s.is_active ? 'Attivo' : 'Pausa'; + const deliveryInfo = `${s.success_deliveries||0}✓ ${s.failed_deliveries||0}✗`; + html += ` + + + + + + + `; + }); + html += '
NomeURLEventiDeliveryStatoAzioni
${escapeHtml(s.name)}${s.failure_count >= 5 ? '
⚠ '+s.failure_count+' errori' : ''}
${escapeHtml(s.url)}${evts}${deliveryInfo}${active} + + + +
'; + container.innerHTML = html; + } + + function showCreateWebhookModal() { + const evtCheckboxes = Object.entries(availableEvents).map(([key, label]) => + `` + ).join(''); + showModal('Crea Webhook', ` +
+ + +
+
+ + +
+
+ +
${evtCheckboxes}
+
+ `, ''); + } + + async function createWebhook() { + const name = document.getElementById('wh-name').value.trim(); + const url = document.getElementById('wh-url').value.trim(); + const events = [...document.querySelectorAll('input[name="wh-event"]:checked')].map(cb => cb.value); + if (!name || !url) { showNotification('Nome e URL obbligatori.', 'error'); return; } + if (!events.length) { showNotification('Seleziona almeno un evento.', 'error'); return; } + try { + const result = await api.request('POST', '/webhooks/subscriptions', { name, url, events }); + if (result.success) { + closeModal(); + showModal('Webhook Creato', ` +
+ ⚠ Salva il secret per verificare la firma HMAC. Non sarà più visibile. +
+

Usa questo secret per verificare l'header X-NIS2-Signature:

+
+ ${escapeHtml(result.data.secret)} +
+

Firma: sha256=HMAC_SHA256(body, secret)

+ `, ''); + } else showNotification(result.message || 'Errore.', 'error'); + } catch (e) { showNotification('Errore di connessione.', 'error'); } + } + + async function testWebhook(id) { + try { + const result = await api.request('POST', `/webhooks/subscriptions/${id}/test`); + if (result.success) { showNotification('Ping di test inviato. Controlla i delivery log.', 'success'); loadDeliveries(); } + else showNotification(result.message || 'Errore.', 'error'); + } catch (e) { showNotification('Errore di connessione.', 'error'); } + } + + async function toggleWebhook(id, currentActive) { + try { + const result = await api.request('PUT', `/webhooks/subscriptions/${id}`, { is_active: currentActive ? 0 : 1 }); + if (result.success) { loadWebhooks(); showNotification(currentActive ? 'Webhook disabilitato.' : 'Webhook abilitato.', 'success'); } + } catch (e) { showNotification('Errore di connessione.', 'error'); } + } + + async function deleteWebhook(id, name) { + if (!confirm(`Eliminare il webhook "${name}"?`)) return; + try { + const result = await api.request('DELETE', `/webhooks/subscriptions/${id}`); + if (result.success) { showNotification('Webhook eliminato.', 'success'); loadWebhooks(); } + } catch (e) { showNotification('Errore di connessione.', 'error'); } + } + + async function loadDeliveries(subscriptionId) { + const container = document.getElementById('deliveries-container'); + container.innerHTML = '
'; + try { + const url = subscriptionId ? `/webhooks/deliveries?subscription_id=${subscriptionId}` : '/webhooks/deliveries'; + const result = await api.request('GET', url); + if (result.success) { + const deliveries = result.data.deliveries || []; + if (!deliveries.length) { + container.innerHTML = '

Nessun delivery registrato

'; + return; + } + let html = `
`; + deliveries.forEach(d => { + const statusClass = d.status === 'delivered' ? 'success' : d.status === 'retrying' ? 'warning' : 'danger'; + html += ` + + + + + + + `; + }); + html += '
EventoWebhookStatoHTTPTentativoData
${escapeHtml(d.event_type)}${escapeHtml(d.subscription_name || '-')}${escapeHtml(d.status)}${d.http_status ? `${d.http_status}` : '-'}${d.attempt}/3${formatDateTime(d.created_at)}
'; + container.innerHTML = html; + } + } catch (e) { container.innerHTML = '

Errore caricamento delivery

'; } + } + // ── Audit Log ──────────────────────────────────────────── async function loadAuditLog() { const container = document.getElementById('audit-log-container'); diff --git a/public/whistleblowing.html b/public/whistleblowing.html new file mode 100644 index 0000000..e4a5027 --- /dev/null +++ b/public/whistleblowing.html @@ -0,0 +1,436 @@ + + + + + + Segnalazioni - NIS2 Agile + + + + +
+ +
+ + + +
+

Art. 32 D.Lgs. 138/2024 — Canale Segnalazioni Interno

+

Le entità NIS2 devono predisporre canali interni per la segnalazione di violazioni alla sicurezza informatica. Le segnalazioni anonime sono protette e il segnalante non può essere identificato. Ogni segnalazione viene gestita con priorità e tracciabilità.

+
+ + +
+
+
+ + +
+ +
+
+
+ + + +
+
+
+
+
+ + + + + + +
+
+ + + + + + + + + +