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
+
+ HTTP error_code Descrizione
+
+ 400 VALIDATION_ERRORParametri obbligatori mancanti o non validi
+ 401 UNAUTHORIZEDAPI Key assente, scaduta o non valida
+ 403 INSUFFICIENT_SCOPEScope insufficiente per questa risorsa
+ 404 NOT_FOUNDRisorsa non trovata
+ 429 RATE_LIMITEDLimite richieste superato
+ 500 INTERNAL_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.
+
+
+
+
+
+
Stato della piattaforma, versione API e timestamp. Non richiede autenticazione.
+
Risposta 200
+
{
+ "status" : "ok" ,
+ "version" : "1.0.0" ,
+ "timestamp" : 1709900400 ,
+ "platform" : "NIS2 Agile"
+}
+
+
+
+
+
+
+
+
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 }
+}
+
+
+
+
+
+
+
+
Feed rischi con filtri su livello, area NIS2, stato e data. Include deadline di trattamento.
+
Query Parameters
+
+ Parametro Tipo Descrizione
+
+ 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" }
+}
+
+
+
+
+
+
+
+
Feed incidenti con status Art.23 (deadlines 24h/72h/30d) e flag di scadenza. Utile per integrare in SIEM e SOC dashboard.
+
Query Parameters
+
+ Parametro Tipo Descrizione
+
+ 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 }
+ }
+}
+
+
+
+
+
+
+
+
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
+}
+
+
+
+
+
+
+
+
Inventario asset critici con tipo, livello di criticità e dipendenze. Filtrabili per tipo e livello.
+
Query Parameters
+
+ Parametro Tipo Descrizione
+
+ typestringserver, network, software, data, service
+ criticalitystringcritical, high, medium, low
+
+
+
+
+
+
+
+
+
+
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
+ }
+}
+
+
+
+
+
+
+
+
Policy approvate con categoria, articolo NIS2 di riferimento e versione. Opzionalmente include il testo completo.
+
Query Parameters
+
+ Parametro Tipo Descrizione
+
+ 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.
+
+
+
+
+
Restituisce tutte le API Keys (attive e revocate) dell'organizzazione corrente. Non espone mai il testo completo della chiave.
+
+
+
+
+
+
+
La chiave completa viene restituita SOLO UNA VOLTA nella risposta di creazione. Salvarla immediatamente.
+
Body (JSON)
+
+ Campo Tipo Obbligatorio Descrizione
+
+ 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."
+}
+
+
+
+
+
+
+
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.
+
+
+
+
+
Include statistiche di delivery (total, success, failed) per ogni subscription. Non espone il secret HMAC.
+
+
+
+
+
+
+
Il secret HMAC viene restituito SOLO UNA VOLTA . Usarlo per verificare la firma X-NIS2-Signature.
+
Body (JSON)
+
+ Campo Tipo Obbligatorio Descrizione
+
+ 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."
+}
+
+
+
+
+
+
+
Aggiornamento parziale (PATCH semantics). Solo i campi inviati vengono aggiornati: name, events, is_active.
+
+
+
+
+
+
+
+
+
+
+
Invia un ping di test per verificare che l'endpoint remoto sia raggiungibile e risponda correttamente. Controlla il delivery log per il risultato.
+
+
+
+
+
+
+
Query Parameters
+
+ Parametro Tipo Descrizione
+
+ 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.
+
+ Evento Trigger Payload
+
+ incident.createdNuovo incidente registrato incidentPayload(incident, 'created')
+ incident.updatedIncidente modificato incidentPayload(incident, 'updated')
+ incident.significantIncidente flaggato Art.23 incidentPayload con deadlines
+ incident.deadline_warningScadenza 24h/72h imminente deadline + ore rimanenti
+ risk.high_createdRischio HIGH o CRITICAL creato riskPayload(risk, 'created')
+ risk.updatedRischio aggiornato riskPayload(risk, 'updated')
+ compliance.score_changedVariazione score >5% previous_score, new_score, delta, label
+ policy.approvedPolicy approvata policyPayload(policy)
+ policy.createdNuova policy creata id, title, category, nis2_article
+ supplier.risk_flaggedFornitore con rischio HIGH/CRITICAL supplier + risk_level
+ assessment.completedGap assessment completato score, gap_count, top_gaps[]
+ whistleblowing.receivedNuova segnalazione anonima id, category, priority (no PII)
+ normative.updateAggiornamento normativo NIS2/ACN title, source, effective_date
+ webhook.testPing manuale di test message, 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
+
+ Tentativo Ritardo Note
+
+ 1° Immediato Fire-and-forget sincrono
+ 2° +5 minuti Processato da cron
+ 3° +30 minuti Ultimo tentativo
+
+
+
Dopo 10 fallimenti consecutivi la subscription viene messa in pausa automatica (failure_count >= 10).
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
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 Agile Campo AllRisk Trasformazione
+
+ 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
+
+
+
+
+
+
+
+
+
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 Keys → Nuova 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:
+
+
<!-- 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 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
+
+
+
+
+
+
+
+
+
Mappatura NIS2 → ESG
+
I dati NIS2 Agile si mappano naturalmente ai framework ESG più diffusi:
+
+ Dato NIS2 Agile Endpoint Pilastro ESG Framework
+
+ 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 + G GRI 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.
+
+
+
+
+
+
+
+
+ Tutte le fonti
+ D.Lgs. 138/2024
+ Direttiva NIS2
+ ACN Guideline
+ DORA
+ ENISA
+ ISO 27001
+
+
+ Tutti i livelli
+ Critico
+ Alto
+ Medio
+ Basso
+ Informativo
+
+
+
+ Solo azione richiesta
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
Profilo
Membri
Sicurezza
+ API Keys
+ Webhook
@@ -479,6 +481,64 @@
+
+
+
+
+
+
+
+
+
+
+
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 = `Nome Prefisso Scopes Ultimo Uso Scadenza Stato Azioni `;
+ keys.forEach(k => {
+ const active = k.is_active ? 'Attiva ' : 'Revocata ';
+ const scopes = (k.scopes || []).map(s => `${escapeHtml(s)} `).join(' ');
+ html += `
+ ${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 ? ` ` : ''}
+ `;
+ });
+ html += '
';
+ 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', `
+
+ Nome *
+
+
+
+
+ Scadenza (opzionale)
+
+
+ `, 'Crea API Key ');
+ }
+
+ 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)}
+ `, 'Ho salvato la chiave ');
+ } 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 = `Nome URL Eventi Delivery Stato Azioni `;
+ 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 += `
+ ${escapeHtml(s.name)} ${s.failure_count >= 5 ? '⚠ '+s.failure_count+' errori ' : ''}
+ ${escapeHtml(s.url)}
+ ${evts}
+ ${deliveryInfo}
+ ${active}
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ });
+ html += '
';
+ container.innerHTML = html;
+ }
+
+ function showCreateWebhookModal() {
+ const evtCheckboxes = Object.entries(availableEvents).map(([key, label]) =>
+ `
+
+ ${key} — ${escapeHtml(label)}
+ `
+ ).join('');
+ showModal('Crea Webhook', `
+
+ Nome *
+
+
+
+ URL Endpoint * (https://...)
+
+
+
+ `, 'Crea Webhook ');
+ }
+
+ 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)
+ `, 'Ho salvato il 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 = `Evento Webhook Stato HTTP Tentativo Data `;
+ deliveries.forEach(d => {
+ const statusClass = d.status === 'delivered' ? 'success' : d.status === 'retrying' ? 'warning' : 'danger';
+ html += `
+ ${escapeHtml(d.event_type)}
+ ${escapeHtml(d.subscription_name || '-')}
+ ${escapeHtml(d.status)}
+ ${d.http_status ? `${d.http_status}` : '-'}
+ ${d.attempt}/3
+ ${formatDateTime(d.created_at)}
+ `;
+ });
+ html += '
';
+ 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à.
+
+
+
+
+
+
+
+
+
+
+
+
+ Tutti gli stati
+ Ricevute
+ In revisione
+ In indagine
+ Risolte
+ Chiuse
+
+
+ Tutte le priorità
+ Critica
+ Alta
+ Media
+ Bassa
+
+
+ Tutte le categorie
+ Incidente sicurezza
+ Data breach
+ Accesso non autorizzato
+ Violazione policy
+ Rischio supply chain
+ Non conformità NIS2
+ Altro
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Segnalazione anonima — La tua identità non verrà registrata
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Token Segnalazione
+
+
+
+ Verifica Stato
+ Annulla
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+