[FEAT] Services API, Webhook, Whistleblowing, Normative + integrazioni

Sprint completo — prodotto presentation-ready:

Services API (read-only, API Key + scope):
- GET /api/services/status|compliance-summary|risks-feed|incidents-feed
- GET /api/services/controls-status|assets-critical|suppliers-risk|policies-approved
- GET /api/services/openapi (spec OpenAPI 3.0.3 JSON)

Webhook Outbound (Stripe-like HMAC-SHA256):
- CRUD api_keys + webhook_subscriptions (Settings → 2 nuovi tab)
- WebhookService: retry 3x backoff (0s/5min/30min), delivery log
- Trigger auto in IncidentController, RiskController, PolicyController
- Delivery log, test ping, processRetry

Nuovi moduli:
- WhistleblowingController (Art.32 NIS2): anonimato garantito, timeline, token tracking
- NormativeController: feed NIS2/ACN/DORA con ACK tracciato per audit

Frontend:
- whistleblowing.html: form submit anonimo/firmato + gestione CISO
- normative.html: feed con presa visione documentata + progress bar ACK
- public/docs/api.html: documentazione API dark theme (Swagger-like)
- settings.html: tab API Keys + tab Webhook
- integrations/: guide per lg231, SustainAI, AllRisk, SIEM (widget + codice)
- Sidebar: Segnalazioni + Normative aggiunte a common.js

DB: migration 007 (api_keys, webhook_subscriptions, webhook_deliveries),
    008 (whistleblowing_reports + timeline),
    009 (normative_updates + normative_ack + seed NIS2/ACN/DORA/ISO)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-03-07 13:20:24 +01:00
parent 3f4b451e2a
commit 86e9bdded2
22 changed files with 5080 additions and 10 deletions

View File

@ -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');

View File

@ -0,0 +1,255 @@
<?php
/**
* NIS2 Agile - Normative Controller
*
* Feed aggiornamenti normativi NIS2/ACN/DORA con ACK tracciato per audit.
* Le organizzazioni devono documentare la presa visione degli aggiornamenti
* normativi per dimostrare compliance continuativa.
*
* Endpoint:
* GET /api/normative/list lista aggiornamenti (filtrabili)
* GET /api/normative/{id} dettaglio aggiornamento
* POST /api/normative/{id}/ack conferma presa visione (con note)
* GET /api/normative/pending aggiornamenti non ancora ACK dall'org
* GET /api/normative/stats statistiche ACK per dashboard
* POST /api/normative/create crea aggiornamento (solo super_admin)
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/WebhookService.php';
class NormativeController extends BaseController
{
/**
* GET /api/normative/list
*/
public function list(): void
{
$this->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);
}
}

View File

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

View File

@ -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);
}

View File

@ -0,0 +1,856 @@
<?php
/**
* NIS2 Agile - Services Controller
*
* API pubblica per sistemi esterni (SIEM, GRC, 231 Agile, SustainAI, AllRisk).
* Autenticazione via API Key (header X-API-Key o Bearer nis2_xxx).
* Rate limiting: 100 req/h per chiave.
*
* Endpoint:
* GET /api/services/status
* GET /api/services/compliance-summary
* GET /api/services/risks/feed
* GET /api/services/incidents/feed
* GET /api/services/controls/status
* GET /api/services/assets/critical
* GET /api/services/suppliers/risk
* GET /api/services/policies/approved
* GET /api/services/openapi
*/
require_once __DIR__ . '/BaseController.php';
class ServicesController extends BaseController
{
// ─── API Key autenticata ──────────────────────────────────────────────
private ?array $apiKeyRecord = null;
private const RATE_LIMIT_DIR = '/tmp/nis2_api_ratelimit/';
private const RATE_LIMIT_MAX = 100; // req per finestra
private const RATE_LIMIT_WINDOW = 3600; // secondi (1 ora)
// ─── Versione API ─────────────────────────────────────────────────────
private const API_VERSION = '1.0.0';
// ══════════════════════════════════════════════════════════════════════
// AUTH API KEY
// ══════════════════════════════════════════════════════════════════════
/**
* Autentica la richiesta via API Key.
* Cerca in:
* 1. Header X-API-Key
* 2. Authorization: Bearer nis2_xxx
* 3. Query string ?api_key=nis2_xxx
*/
private function requireApiKey(string $scope = 'read:all'): void
{
$rawKey = null;
// 1. Header X-API-Key
$rawKey = $_SERVER['HTTP_X_API_KEY'] ?? null;
// 2. Bearer token
if (!$rawKey) {
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (str_starts_with($auth, 'Bearer nis2_')) {
$rawKey = substr($auth, 7);
}
}
// 3. Query string
if (!$rawKey) {
$rawKey = $_GET['api_key'] ?? null;
}
if (!$rawKey) {
$this->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';
}
}

View File

@ -0,0 +1,405 @@
<?php
/**
* NIS2 Agile - Webhook Controller
*
* CRUD per API Keys e Webhook Subscriptions.
* Gestione completa dal pannello Settings.
*
* Endpoint:
* --- API KEYS ---
* GET /api/webhooks/api-keys lista API keys org
* POST /api/webhooks/api-keys crea nuova API key
* DELETE /api/webhooks/api-keys/{id} revoca API key
*
* --- WEBHOOK SUBSCRIPTIONS ---
* GET /api/webhooks/subscriptions lista subscriptions
* POST /api/webhooks/subscriptions crea subscription
* PUT /api/webhooks/subscriptions/{id} aggiorna subscription
* DELETE /api/webhooks/subscriptions/{id} elimina subscription
* POST /api/webhooks/subscriptions/{id}/test invia ping di test
*
* --- DELIVERIES ---
* GET /api/webhooks/deliveries log delivery ultimi 100
* POST /api/webhooks/retry processa retry pendenti
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/WebhookService.php';
class WebhookController extends BaseController
{
// Scopes disponibili
private const AVAILABLE_SCOPES = [
'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',
'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)',
];
}
}

View File

@ -0,0 +1,386 @@
<?php
/**
* NIS2 Agile - Whistleblowing Controller (Art.32 NIS2)
*
* Canale segnalazioni anomalie di sicurezza, con anonimato garantito.
* Art. 32 D.Lgs. 138/2024: le entità NIS2 devono predisporre canali
* interni per la segnalazione di violazioni alla sicurezza informatica.
*
* Endpoint:
* POST /api/whistleblowing/submit invia segnalazione (anonima o firmata)
* GET /api/whistleblowing/list lista segnalazioni (CISO/admin)
* GET /api/whistleblowing/{id} dettaglio segnalazione
* PUT /api/whistleblowing/{id} aggiorna status/priorità/note
* POST /api/whistleblowing/{id}/assign assegna a utente
* POST /api/whistleblowing/{id}/close chiudi segnalazione
* GET /api/whistleblowing/stats statistiche per dashboard
* GET /api/whistleblowing/track-anonymous tracking anonimo (via token)
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/WebhookService.php';
class WhistleblowingController extends BaseController
{
// ══════════════════════════════════════════════════════════════════════
// SUBMIT (pubblica — anche per utenti non autenticati)
// ══════════════════════════════════════════════════════════════════════
/**
* POST /api/whistleblowing/submit
* Invia segnalazione. Supporta anonima (no auth) o firmata (auth opzionale).
*/
public function submit(): void
{
// Nota: non richiede auth — supporta segnalazioni anonime
$this->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,
]);
}
}

View File

@ -0,0 +1,320 @@
<?php
/**
* NIS2 Agile - Webhook Service
*
* Gestisce la consegna webhook outbound a sistemi esterni.
* Pattern: Stripe-like HMAC-SHA256, retry 3x con backoff esponenziale.
*
* Firma header: X-NIS2-Signature: sha256=HMAC_SHA256(body, secret)
* Retry: 1° tentativo immediato 2° dopo 5min 3° dopo 30min
*/
require_once APP_PATH . '/config/database.php';
class WebhookService
{
// Retry schedule: secondi di attesa per tentativo
private const RETRY_DELAYS = [0, 300, 1800]; // 0s, 5min, 30min
private const TIMEOUT_SEC = 10; // Timeout HTTP per delivery
private const MAX_RESPONSE_LEN = 2048; // Max byte di risposta salvata
// ══════════════════════════════════════════════════════════════════════
// DISPATCH (entry point principale)
// ══════════════════════════════════════════════════════════════════════
/**
* Dispatcha un evento a tutte le subscription attive per quell'org/evento.
* Si chiama sincrono nel request cycle (fire-and-forget con best effort).
*
* @param int $orgId Organizzazione proprietaria
* @param string $eventType Es. "incident.created"
* @param array $payload Dati dell'evento
* @param string $eventId UUID univoco evento (idempotency)
*/
public function dispatch(int $orgId, string $eventType, array $payload, string $eventId = ''): void
{
if (empty($eventId)) {
$eventId = $this->generateUuid();
}
// Trova subscription attive che ascoltano questo evento
$subscriptions = Database::fetchAll(
'SELECT * FROM webhook_subscriptions
WHERE organization_id = ? AND is_active = 1 AND failure_count < 10
ORDER BY id',
[$orgId]
);
foreach ($subscriptions as $sub) {
$events = json_decode($sub['events'], true) ?? [];
if (!in_array($eventType, $events) && !in_array('*', $events)) {
continue;
}
// Crea delivery record
$fullPayload = $this->buildPayload($eventType, $eventId, $payload, $sub);
$deliveryId = $this->createDelivery($sub, $eventType, $eventId, $fullPayload);
// Tenta consegna immediata
$this->attemptDelivery($deliveryId, $sub, $fullPayload);
}
}
/**
* Processa retry pendenti (chiamato via cron o endpoint admin).
*/
public function processRetries(): int
{
$pending = Database::fetchAll(
'SELECT wd.*, ws.url, ws.secret
FROM webhook_deliveries wd
JOIN webhook_subscriptions ws ON ws.id = wd.subscription_id
WHERE wd.status = "retrying"
AND wd.next_retry_at <= NOW()
AND wd.attempt <= 3
LIMIT 50'
);
$processed = 0;
foreach ($pending as $delivery) {
$sub = ['id' => $delivery['subscription_id'], 'url' => $delivery['url'], 'secret' => $delivery['secret']];
$this->attemptDelivery($delivery['id'], $sub, json_decode($delivery['payload'], true));
$processed++;
}
return $processed;
}
// ══════════════════════════════════════════════════════════════════════
// INTERNAL
// ══════════════════════════════════════════════════════════════════════
/**
* Costruisce il payload completo con envelope standard NIS2.
*/
private function buildPayload(string $eventType, string $eventId, array $data, array $sub): array
{
return [
'id' => $eventId,
'event' => $eventType,
'api_version' => '1.0.0',
'created' => time(),
'created_at' => date('c'),
'source' => 'nis2-agile',
'org_id' => $sub['organization_id'],
'data' => $data,
];
}
/**
* Crea record delivery nel DB, restituisce ID.
*/
private function createDelivery(array $sub, string $eventType, string $eventId, array $payload): int
{
return Database::insert('webhook_deliveries', [
'subscription_id' => $sub['id'],
'organization_id' => $sub['organization_id'],
'event_type' => $eventType,
'event_id' => $eventId,
'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE),
'status' => 'pending',
'attempt' => 1,
]);
}
/**
* Tenta la consegna HTTP di un webhook.
* Aggiorna il record delivery con il risultato.
*/
private function attemptDelivery(int $deliveryId, array $sub, array $payload): void
{
$bodyJson = json_encode($payload, JSON_UNESCAPED_UNICODE);
$signature = 'sha256=' . hash_hmac('sha256', $bodyJson, $sub['secret']);
$attempt = $payload['_attempt'] ?? 1;
$httpCode = null;
$responseBody = null;
$success = false;
try {
$ch = curl_init($sub['url']);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $bodyJson,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::TIMEOUT_SEC,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'User-Agent: NIS2-Agile-Webhooks/1.0',
'X-NIS2-Signature: ' . $signature,
'X-NIS2-Event: ' . $payload['event'],
'X-NIS2-Delivery-Id: ' . $deliveryId,
'X-NIS2-Attempt: ' . $attempt,
],
]);
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Successo: qualsiasi 2xx
$success = ($httpCode >= 200 && $httpCode < 300);
} catch (Throwable $e) {
$responseBody = 'Exception: ' . $e->getMessage();
$httpCode = 0;
}
// Tronca response
if (strlen($responseBody) > self::MAX_RESPONSE_LEN) {
$responseBody = substr($responseBody, 0, self::MAX_RESPONSE_LEN) . '...[truncated]';
}
if ($success) {
// Delivery riuscita
Database::execute(
'UPDATE webhook_deliveries
SET status = "delivered", http_status = ?, response_body = ?,
delivered_at = NOW(), updated_at = NOW()
WHERE id = ?',
[$httpCode, $responseBody, $deliveryId]
);
// Reset failure count
Database::execute(
'UPDATE webhook_subscriptions SET failure_count = 0, last_triggered_at = NOW() WHERE id = ?',
[$sub['id']]
);
} else {
// Calcola prossimo retry
$nextAttempt = $attempt + 1;
if ($nextAttempt <= 3) {
$delay = self::RETRY_DELAYS[$attempt] ?? 1800;
$nextRetry = date('Y-m-d H:i:s', time() + $delay);
Database::execute(
'UPDATE webhook_deliveries
SET status = "retrying", http_status = ?, response_body = ?,
attempt = ?, next_retry_at = ?, updated_at = NOW()
WHERE id = ?',
[$httpCode, $responseBody, $nextAttempt, $nextRetry, $deliveryId]
);
} else {
// Tutti i tentativi esauriti
Database::execute(
'UPDATE webhook_deliveries
SET status = "failed", http_status = ?, response_body = ?,
updated_at = NOW()
WHERE id = ?',
[$httpCode, $responseBody, $deliveryId]
);
// Incrementa failure count subscription
Database::execute(
'UPDATE webhook_subscriptions
SET failure_count = failure_count + 1, updated_at = NOW()
WHERE id = ?',
[$sub['id']]
);
}
}
}
// ── Payload builders per evento ───────────────────────────────────────
/**
* Costruisce payload per evento incident.created / incident.updated
*/
public static function incidentPayload(array $incident, string $action = 'created'): array
{
return [
'action' => $action,
'incident' => [
'id' => $incident['id'],
'title' => $incident['title'],
'classification' => $incident['classification'],
'severity' => $incident['severity'],
'status' => $incident['status'],
'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)
);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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);

710
public/docs/api.html Normal file
View File

@ -0,0 +1,710 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NIS2 Agile — API Reference</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f172a; --bg2: #1e293b; --bg3: #334155;
--primary: #06b6d4; --primary-dark: #0891b2;
--green: #10b981; --yellow: #f59e0b; --red: #ef4444; --purple: #8b5cf6;
--text: #e2e8f0; --text-muted: #94a3b8;
--border: #334155;
--radius: 6px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--mono: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
body { background: var(--bg); color: var(--text); font-family: var(--font); line-height: 1.6; }
/* Layout */
.layout { display: flex; min-height: 100vh; }
.sidebar { width: 280px; flex-shrink: 0; background: var(--bg2); border-right: 1px solid var(--border); position: sticky; top: 0; height: 100vh; overflow-y: auto; padding: 24px 0; }
.content { flex: 1; max-width: 900px; padding: 40px 48px; }
/* Sidebar */
.sidebar-logo { padding: 0 20px 24px; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.sidebar-logo h1 { font-size: 1.125rem; font-weight: 700; color: var(--primary); }
.sidebar-logo p { font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; }
.sidebar-section { padding: 8px 20px 4px; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); font-weight: 700; margin-top: 12px; }
.sidebar-link { display: flex; align-items: center; gap: 10px; padding: 7px 20px; font-size: 0.8125rem; color: var(--text-muted); text-decoration: none; transition: all 0.15s; cursor: pointer; border-left: 2px solid transparent; }
.sidebar-link:hover { color: var(--text); background: rgba(255,255,255,0.04); }
.sidebar-link.active { color: var(--primary); border-left-color: var(--primary); background: rgba(6,182,212,0.08); }
.method-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.dot-get { background: var(--green); }
.dot-post { background: var(--yellow); }
.dot-put { background: var(--purple); }
.dot-delete { background: var(--red); }
/* Header */
.page-header { margin-bottom: 48px; padding-bottom: 32px; border-bottom: 1px solid var(--border); }
.page-header h1 { font-size: 2rem; font-weight: 800; color: var(--text); margin-bottom: 8px; }
.page-header p { color: var(--text-muted); font-size: 1rem; }
.version-badge { display: inline-flex; align-items: center; gap: 8px; padding: 4px 12px; background: rgba(6,182,212,0.15); border: 1px solid rgba(6,182,212,0.3); border-radius: 20px; font-size: 0.75rem; color: var(--primary); margin-top: 12px; }
/* Section */
.section { margin-bottom: 60px; }
.section-title { font-size: 1.375rem; font-weight: 700; color: var(--text); margin-bottom: 8px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.section-desc { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 24px; }
/* Endpoint */
.endpoint { margin-bottom: 28px; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.endpoint-header { display: flex; align-items: center; gap: 12px; padding: 14px 18px; background: var(--bg2); cursor: pointer; user-select: none; }
.endpoint-header:hover { background: var(--bg3); }
.method-badge { padding: 3px 10px; border-radius: 4px; font-size: 0.6875rem; font-weight: 800; font-family: var(--mono); letter-spacing: 0.05em; flex-shrink: 0; }
.method-get { background: rgba(16,185,129,0.15); color: var(--green); border: 1px solid rgba(16,185,129,0.3); }
.method-post { background: rgba(245,158,11,0.15); color: var(--yellow); border: 1px solid rgba(245,158,11,0.3); }
.method-put { background: rgba(139,92,246,0.15); color: var(--purple); border: 1px solid rgba(139,92,246,0.3); }
.method-delete { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.3); }
.endpoint-path { font-family: var(--mono); font-size: 0.875rem; color: var(--text); flex: 1; }
.endpoint-path span { color: var(--primary); }
.endpoint-summary { font-size: 0.8125rem; color: var(--text-muted); margin-left: auto; }
.endpoint-body { padding: 20px 18px; border-top: 1px solid var(--border); display: none; }
.endpoint-body.open { display: block; }
.endpoint-desc { font-size: 0.875rem; color: var(--text-muted); margin-bottom: 16px; }
/* Auth tag */
.auth-tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 20px; font-size: 0.7rem; font-weight: 600; margin-left: 8px; }
.auth-public { background: rgba(16,185,129,0.1); color: var(--green); border: 1px solid rgba(16,185,129,0.2); }
.auth-apikey { background: rgba(6,182,212,0.1); color: var(--primary); border: 1px solid rgba(6,182,212,0.2); }
.auth-jwt { background: rgba(139,92,246,0.1); color: var(--purple); border: 1px solid rgba(139,92,246,0.2); }
/* Params table */
.params-title { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); font-weight: 700; margin-bottom: 10px; margin-top: 16px; }
.params-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.params-table th { padding: 8px 12px; background: var(--bg3); color: var(--text-muted); font-weight: 600; text-align: left; font-size: 0.75rem; }
.params-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
.param-name { font-family: var(--mono); color: var(--primary); font-size: 0.8rem; }
.param-required { color: var(--red); font-size: 0.7rem; margin-left: 4px; }
.param-type { font-family: var(--mono); color: var(--text-muted); font-size: 0.75rem; }
/* Code */
pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; overflow-x: auto; font-size: 0.8125rem; font-family: var(--mono); line-height: 1.7; margin-top: 8px; }
code { font-family: var(--mono); font-size: 0.85em; background: rgba(255,255,255,0.06); padding: 1px 6px; border-radius: 3px; }
.json-key { color: #7dd3fc; }
.json-str { color: #86efac; }
.json-num { color: #fbbf24; }
.json-bool { color: #c4b5fd; }
.json-null { color: #94a3b8; }
/* Info boxes */
.info-box { padding: 14px 16px; border-radius: var(--radius); margin-bottom: 16px; font-size: 0.8125rem; }
.info-box.warning { background: rgba(245,158,11,0.1); border-left: 3px solid var(--yellow); color: #fde68a; }
.info-box.info { background: rgba(6,182,212,0.1); border-left: 3px solid var(--primary); color: #a5f3fc; }
.info-box.success { background: rgba(16,185,129,0.1); border-left: 3px solid var(--green); color: #a7f3d0; }
/* Base URL */
.base-url { padding: 12px 16px; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); font-family: var(--mono); font-size: 0.875rem; color: var(--primary); margin-bottom: 24px; }
/* Scope table */
.scope-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
.scope-card { padding: 12px 14px; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); }
.scope-name { font-family: var(--mono); font-size: 0.8rem; color: var(--primary); margin-bottom: 4px; }
.scope-desc { font-size: 0.75rem; color: var(--text-muted); }
/* Responsive */
@media (max-width: 900px) {
.sidebar { display: none; }
.content { padding: 24px; }
}
</style>
</head>
<body>
<div class="layout">
<!-- Sidebar -->
<nav class="sidebar">
<div class="sidebar-logo">
<h1>NIS2 Agile API</h1>
<p>Reference v1.0.0</p>
</div>
<div class="sidebar-section">Introduzione</div>
<a class="sidebar-link active" onclick="scrollTo('intro')">Panoramica</a>
<a class="sidebar-link" onclick="scrollTo('auth')">Autenticazione</a>
<a class="sidebar-link" onclick="scrollTo('errors')">Errori</a>
<div class="sidebar-section">Services API</div>
<a class="sidebar-link" onclick="scrollTo('svc-status')"><span class="method-dot dot-get"></span>Status</a>
<a class="sidebar-link" onclick="scrollTo('svc-compliance')"><span class="method-dot dot-get"></span>Compliance Summary</a>
<a class="sidebar-link" onclick="scrollTo('svc-risks')"><span class="method-dot dot-get"></span>Risks Feed</a>
<a class="sidebar-link" onclick="scrollTo('svc-incidents')"><span class="method-dot dot-get"></span>Incidents Feed</a>
<a class="sidebar-link" onclick="scrollTo('svc-controls')"><span class="method-dot dot-get"></span>Controls Status</a>
<a class="sidebar-link" onclick="scrollTo('svc-assets')"><span class="method-dot dot-get"></span>Assets Critical</a>
<a class="sidebar-link" onclick="scrollTo('svc-suppliers')"><span class="method-dot dot-get"></span>Suppliers Risk</a>
<a class="sidebar-link" onclick="scrollTo('svc-policies')"><span class="method-dot dot-get"></span>Policies Approved</a>
<div class="sidebar-section">API Keys</div>
<a class="sidebar-link" onclick="scrollTo('keys-list')"><span class="method-dot dot-get"></span>Lista Keys</a>
<a class="sidebar-link" onclick="scrollTo('keys-create')"><span class="method-dot dot-post"></span>Crea Key</a>
<a class="sidebar-link" onclick="scrollTo('keys-delete')"><span class="method-dot dot-delete"></span>Revoca Key</a>
<div class="sidebar-section">Webhook</div>
<a class="sidebar-link" onclick="scrollTo('wh-list')"><span class="method-dot dot-get"></span>Lista Subscriptions</a>
<a class="sidebar-link" onclick="scrollTo('wh-create')"><span class="method-dot dot-post"></span>Crea Subscription</a>
<a class="sidebar-link" onclick="scrollTo('wh-update')"><span class="method-dot dot-put"></span>Aggiorna</a>
<a class="sidebar-link" onclick="scrollTo('wh-delete')"><span class="method-dot dot-delete"></span>Elimina</a>
<a class="sidebar-link" onclick="scrollTo('wh-test')"><span class="method-dot dot-post"></span>Test Ping</a>
<a class="sidebar-link" onclick="scrollTo('wh-deliveries')"><span class="method-dot dot-get"></span>Delivery Log</a>
<div class="sidebar-section">Webhook Events</div>
<a class="sidebar-link" onclick="scrollTo('wh-events')">Catalogo eventi</a>
<a class="sidebar-link" onclick="scrollTo('wh-signature')">Verifica firma</a>
</nav>
<!-- Content -->
<main class="content">
<!-- INTRO -->
<div id="intro" class="page-header">
<h1>NIS2 Agile API</h1>
<p>API REST per integrare NIS2 Agile con SIEM, GRC, piattaforme ESG e strumenti di compliance.</p>
<div class="version-badge">
<svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path fill-rule="evenodd" d="M10 2a8 8 0 100 16A8 8 0 0010 2zm0 14a6 6 0 110-12 6 6 0 010 12zm-1-5a1 1 0 012 0v2a1 1 0 11-2 0v-2zm0-4a1 1 0 112 0 1 1 0 01-2 0z" clip-rule="evenodd"/></svg>
v1.0.0 — Produzione: https://nis2.certisource.it/
</div>
</div>
<div class="section">
<h2 class="section-title">Panoramica</h2>
<p class="section-desc">NIS2 Agile espone due famiglie di API: <strong>Services API</strong> per lettura dati di compliance in tempo reale, e <strong>Webhook API</strong> per notifiche push su eventi NIS2.</p>
<div class="base-url">Base URL: https://nis2.certisource.it/api</div>
<div class="info-box info">
<strong>Rate Limiting:</strong> Services API: 100 richieste/ora per API Key. Webhook delivery: max 1000/ora per subscription.
</div>
<div class="info-box success">
<strong>Formato risposta:</strong> Tutte le risposte sono JSON con envelope <code>{ "success": bool, "data": {}, "message": "..." }</code>
</div>
</div>
<!-- AUTH -->
<div id="auth" class="section">
<h2 class="section-title">Autenticazione</h2>
<p class="section-desc">Le Services API usano API Keys con scope granulari. Le API di gestione (webhook, key management) usano JWT Bearer token della sessione utente.</p>
<h3 class="params-title">API Key — 3 modalità</h3>
<pre><code class="json-str"># 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...</code></pre>
<h3 class="params-title">JWT Bearer — Per gestione API</h3>
<pre><code class="json-str">Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...</code></pre>
<h3 class="params-title" style="margin-top:20px;">Scope disponibili</h3>
<div class="scope-grid">
<div class="scope-card"><div class="scope-name">read:all</div><div class="scope-desc">Accesso completo in lettura a tutti i dati</div></div>
<div class="scope-card"><div class="scope-name">read:compliance</div><div class="scope-desc">Compliance score e controlli Art.21</div></div>
<div class="scope-card"><div class="scope-name">read:risks</div><div class="scope-desc">Risk register e matrice rischi ISO 27005</div></div>
<div class="scope-card"><div class="scope-name">read:incidents</div><div class="scope-desc">Incidenti e timeline Art.23</div></div>
<div class="scope-card"><div class="scope-name">read:assets</div><div class="scope-desc">Inventario asset critici</div></div>
<div class="scope-card"><div class="scope-name">read:supply_chain</div><div class="scope-desc">Supply chain e rischio fornitori</div></div>
<div class="scope-card"><div class="scope-name">read:policies</div><div class="scope-desc">Policy approvate</div></div>
</div>
<h3 class="params-title" style="margin-top:20px;">Header obbligatorio</h3>
<pre><code class="json-str">X-Organization-Id: 42 # ID organizzazione target</code></pre>
</div>
<!-- ERRORS -->
<div id="errors" class="section">
<h2 class="section-title">Gestione Errori</h2>
<table class="params-table">
<thead><tr><th>HTTP</th><th>error_code</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td>400</td><td><code>VALIDATION_ERROR</code></td><td>Parametri obbligatori mancanti o non validi</td></tr>
<tr><td>401</td><td><code>UNAUTHORIZED</code></td><td>API Key assente, scaduta o non valida</td></tr>
<tr><td>403</td><td><code>INSUFFICIENT_SCOPE</code></td><td>Scope insufficiente per questa risorsa</td></tr>
<tr><td>404</td><td><code>NOT_FOUND</code></td><td>Risorsa non trovata</td></tr>
<tr><td>429</td><td><code>RATE_LIMITED</code></td><td>Limite richieste superato</td></tr>
<tr><td>500</td><td><code>INTERNAL_ERROR</code></td><td>Errore interno del server</td></tr>
</tbody>
</table>
<pre><span class="json-key">{
"success"</span><span class="json-str">: false</span>,
<span class="json-key">"message"</span>: <span class="json-str">"Scope insufficiente: richiesto read:risks"</span>,
<span class="json-key">"error_code"</span>: <span class="json-str">"INSUFFICIENT_SCOPE"</span>
}</pre>
</div>
<!-- SERVICES API -->
<div class="section">
<h2 class="section-title">Services API</h2>
<p class="section-desc">Endpoint GET per leggere dati di compliance da sistemi esterni. Autenticazione tramite API Key con scope granulari.</p>
<!-- Status -->
<div id="svc-status" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/services/status</span>
<span class="auth-tag auth-public">Public</span>
<span class="endpoint-summary">Health check piattaforma</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Stato della piattaforma, versione API e timestamp. Non richiede autenticazione.</p>
<h4 class="params-title">Risposta 200</h4>
<pre><span class="json-key">{
"status"</span>: <span class="json-str">"ok"</span>,
<span class="json-key">"version"</span>: <span class="json-str">"1.0.0"</span>,
<span class="json-key">"timestamp"</span>: <span class="json-num">1709900400</span>,
<span class="json-key">"platform"</span>: <span class="json-str">"NIS2 Agile"</span>
}</pre>
</div>
</div>
<!-- Compliance Summary -->
<div id="svc-compliance" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/services/compliance-summary</span>
<span class="auth-tag auth-apikey">read:compliance</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Score di conformità NIS2 aggregato per dominio Art.21, con statistiche rischi, incidenti aperti e policy approvate.</p>
<h4 class="params-title">Risposta 200</h4>
<pre><span class="json-key">{
"overall_score"</span>: <span class="json-num">73</span>,
<span class="json-key">"label"</span>: <span class="json-str">"substantially_compliant"</span>,
<span class="json-key">"domain_scores"</span>: [
{ <span class="json-key">"category"</span>: <span class="json-str">"governance"</span>, <span class="json-key">"score"</span>: <span class="json-num">80</span>, <span class="json-key">"answered"</span>: <span class="json-num">8</span>, <span class="json-key">"total"</span>: <span class="json-num">10</span> }
],
<span class="json-key">"risks"</span>: { <span class="json-key">"total"</span>: <span class="json-num">24</span>, <span class="json-key">"critical"</span>: <span class="json-num">2</span>, <span class="json-key">"high"</span>: <span class="json-num">5</span> },
<span class="json-key">"incidents"</span>: { <span class="json-key">"open"</span>: <span class="json-num">3</span>, <span class="json-key">"significant"</span>: <span class="json-num">1</span> },
<span class="json-key">"policies"</span>: { <span class="json-key">"approved"</span>: <span class="json-num">12</span>, <span class="json-key">"draft"</span>: <span class="json-num">4</span> }
}</pre>
</div>
</div>
<!-- Risks Feed -->
<div id="svc-risks" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/services/risks-feed</span>
<span class="auth-tag auth-apikey">read:risks</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Feed rischi con filtri su livello, area NIS2, stato e data. Include deadline di trattamento.</p>
<h4 class="params-title">Query Parameters</h4>
<table class="params-table">
<thead><tr><th>Parametro</th><th>Tipo</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td><code class="param-name">level</code></td><td><code class="param-type">string</code></td><td>Filtro livello: <code>critical</code>, <code>high</code>, <code>medium</code>, <code>low</code></td></tr>
<tr><td><code class="param-name">area</code></td><td><code class="param-type">string</code></td><td>Categoria rischio (es. <code>network_security</code>)</td></tr>
<tr><td><code class="param-name">status</code></td><td><code class="param-type">string</code></td><td>Stato: <code>open</code>, <code>in_treatment</code>, <code>closed</code></td></tr>
<tr><td><code class="param-name">from</code></td><td><code class="param-type">datetime</code></td><td>Filtra da data ISO (es. <code>2026-01-01T00:00:00</code>)</td></tr>
<tr><td><code class="param-name">limit</code></td><td><code class="param-type">integer</code></td><td>Max risultati (default 50, max 200)</td></tr>
</tbody>
</table>
<h4 class="params-title">Risposta 200</h4>
<pre><span class="json-key">{
"risks"</span>: [
{
<span class="json-key">"id"</span>: <span class="json-num">15</span>,
<span class="json-key">"risk_code"</span>: <span class="json-str">"RSK-2026-015"</span>,
<span class="json-key">"title"</span>: <span class="json-str">"Vulnerabilità VPN senza MFA"</span>,
<span class="json-key">"category"</span>: <span class="json-str">"network_security"</span>,
<span class="json-key">"risk_level"</span>: <span class="json-str">"high"</span>,
<span class="json-key">"risk_score"</span>: <span class="json-num">15</span>,
<span class="json-key">"treatment"</span>: <span class="json-str">"mitigate"</span>,
<span class="json-key">"nis2_article"</span>: <span class="json-str">"21.2.i"</span>,
<span class="json-key">"created_at"</span>: <span class="json-str">"2026-01-15T10:30:00+01:00"</span>
}
],
<span class="json-key">"total"</span>: <span class="json-num">24</span>, <span class="json-key">"filters"</span>: { <span class="json-key">"level"</span>: <span class="json-str">"high"</span> }
}</pre>
</div>
</div>
<!-- Incidents Feed -->
<div id="svc-incidents" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/services/incidents-feed</span>
<span class="auth-tag auth-apikey">read:incidents</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Feed incidenti con status Art.23 (deadlines 24h/72h/30d) e flag di scadenza. Utile per integrare in SIEM e SOC dashboard.</p>
<h4 class="params-title">Query Parameters</h4>
<table class="params-table">
<thead><tr><th>Parametro</th><th>Tipo</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td><code class="param-name">status</code></td><td><code class="param-type">string</code></td><td><code>open</code>, <code>investigating</code>, <code>resolved</code>, <code>closed</code></td></tr>
<tr><td><code class="param-name">severity</code></td><td><code class="param-type">string</code></td><td><code>critical</code>, <code>high</code>, <code>medium</code>, <code>low</code></td></tr>
<tr><td><code class="param-name">significant_only</code></td><td><code class="param-type">boolean</code></td><td>Solo incidenti Art.23 significativi</td></tr>
<tr><td><code class="param-name">from</code></td><td><code class="param-type">datetime</code></td><td>Filtra da data ISO</td></tr>
</tbody>
</table>
<h4 class="params-title">Risposta 200 — Elemento</h4>
<pre><span class="json-key">{
"id"</span>: <span class="json-num">7</span>,
<span class="json-key">"incident_code"</span>: <span class="json-str">"INC-2026-007"</span>,
<span class="json-key">"title"</span>: <span class="json-str">"Accesso non autorizzato sistema ERP"</span>,
<span class="json-key">"severity"</span>: <span class="json-str">"high"</span>,
<span class="json-key">"is_significant"</span>: <span class="json-bool">true</span>,
<span class="json-key">"art23_status"</span>: {
<span class="json-key">"early_warning"</span>: { <span class="json-key">"due"</span>: <span class="json-str">"2026-02-21T14:00:00+01:00"</span>, <span class="json-key">"sent"</span>: <span class="json-bool">true</span>, <span class="json-key">"overdue"</span>: <span class="json-bool">false</span> },
<span class="json-key">"notification"</span>: { <span class="json-key">"due"</span>: <span class="json-str">"2026-02-23T14:00:00+01:00"</span>, <span class="json-key">"sent"</span>: <span class="json-bool">false</span>, <span class="json-key">"overdue"</span>: <span class="json-bool">false</span> },
<span class="json-key">"final_report"</span>: { <span class="json-key">"due"</span>: <span class="json-str">"2026-03-22T14:00:00+01:00"</span>, <span class="json-key">"sent"</span>: <span class="json-bool">false</span>, <span class="json-key">"overdue"</span>: <span class="json-bool">false</span> }
}
}</pre>
</div>
</div>
<!-- Controls Status -->
<div id="svc-controls" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/services/controls-status</span>
<span class="auth-tag auth-apikey">read:compliance</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Stato dei controlli di sicurezza Art.21 raggruppati per categoria (governance, network_security, access_control, ecc.).</p>
<h4 class="params-title">Risposta 200 — Elemento categoria</h4>
<pre><span class="json-key">{
"category"</span>: <span class="json-str">"network_security"</span>,
<span class="json-key">"total"</span>: <span class="json-num">8</span>,
<span class="json-key">"implemented"</span>: <span class="json-num">5</span>,
<span class="json-key">"partial"</span>: <span class="json-num">2</span>,
<span class="json-key">"not_implemented"</span>: <span class="json-num">1</span>,
<span class="json-key">"score"</span>: <span class="json-num">75</span>
}</pre>
</div>
</div>
<!-- Assets Critical -->
<div id="svc-assets" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/services/assets-critical</span>
<span class="auth-tag auth-apikey">read:assets</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Inventario asset critici con tipo, livello di criticità e dipendenze. Filtrabili per tipo e livello.</p>
<h4 class="params-title">Query Parameters</h4>
<table class="params-table">
<thead><tr><th>Parametro</th><th>Tipo</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td><code class="param-name">type</code></td><td><code class="param-type">string</code></td><td><code>server</code>, <code>network</code>, <code>software</code>, <code>data</code>, <code>service</code></td></tr>
<tr><td><code class="param-name">criticality</code></td><td><code class="param-type">string</code></td><td><code>critical</code>, <code>high</code>, <code>medium</code>, <code>low</code></td></tr>
</tbody>
</table>
</div>
</div>
<!-- Suppliers Risk -->
<div id="svc-suppliers" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/services/suppliers-risk</span>
<span class="auth-tag auth-apikey">read:supply_chain</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Panoramica rischio fornitori con risk_score, data ultima valutazione e flag critici. Include stats aggregate.</p>
<h4 class="params-title">Risposta 200 — Stats</h4>
<pre><span class="json-key">{
"suppliers"</span>: [...],
<span class="json-key">"stats"</span>: {
<span class="json-key">"total"</span>: <span class="json-num">18</span>,
<span class="json-key">"critical"</span>: <span class="json-num">2</span>,
<span class="json-key">"high"</span>: <span class="json-num">4</span>,
<span class="json-key">"avg_risk_score"</span>: <span class="json-num">42</span>
}
}</pre>
</div>
</div>
<!-- Policies Approved -->
<div id="svc-policies" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/services/policies-approved</span>
<span class="auth-tag auth-apikey">read:policies</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Policy approvate con categoria, articolo NIS2 di riferimento e versione. Opzionalmente include il testo completo.</p>
<h4 class="params-title">Query Parameters</h4>
<table class="params-table">
<thead><tr><th>Parametro</th><th>Tipo</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td><code class="param-name">category</code></td><td><code class="param-type">string</code></td><td>Filtra per categoria (es. <code>incident_response</code>)</td></tr>
<tr><td><code class="param-name">include_content</code></td><td><code class="param-type">boolean</code></td><td>Include testo policy (default: false)</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- API KEYS MANAGEMENT -->
<div class="section">
<h2 class="section-title">Gestione API Keys</h2>
<p class="section-desc">CRUD API Keys. Richiede autenticazione JWT con ruolo <code>org_admin</code>.</p>
<div id="keys-list" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/webhooks/api-keys</span>
<span class="auth-tag auth-jwt">JWT</span>
<span class="endpoint-summary">Lista API Keys dell'organizzazione</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Restituisce tutte le API Keys (attive e revocate) dell'organizzazione corrente. Non espone mai il testo completo della chiave.</p>
</div>
</div>
<div id="keys-create" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-post">POST</span>
<span class="endpoint-path">/webhooks/api-keys</span>
<span class="auth-tag auth-jwt">JWT org_admin</span>
</div>
<div class="endpoint-body">
<div class="info-box warning">La chiave completa viene restituita <strong>SOLO UNA VOLTA</strong> nella risposta di creazione. Salvarla immediatamente.</div>
<h4 class="params-title">Body (JSON)</h4>
<table class="params-table">
<thead><tr><th>Campo</th><th>Tipo</th><th>Obbligatorio</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td><code class="param-name">name</code></td><td><code class="param-type">string</code></td><td><span class="param-required">*</span></td><td>Nome descrittivo (es. "SIEM Integration")</td></tr>
<tr><td><code class="param-name">scopes</code></td><td><code class="param-type">array</code></td><td><span class="param-required">*</span></td><td>Array di scope (es. <code>["read:risks","read:incidents"]</code>)</td></tr>
<tr><td><code class="param-name">expires_at</code></td><td><code class="param-type">datetime</code></td><td></td><td>Scadenza opzionale (ISO 8601)</td></tr>
</tbody>
</table>
<h4 class="params-title">Risposta 201</h4>
<pre><span class="json-key">{
"id"</span>: <span class="json-num">5</span>,
<span class="json-key">"name"</span>: <span class="json-str">"SIEM Integration"</span>,
<span class="json-key">"key"</span>: <span class="json-str">"nis2_a3f8c2d1e4b7..."</span>,
<span class="json-key">"key_prefix"</span>: <span class="json-str">"nis2_a3f8c2"</span>,
<span class="json-key">"scopes"</span>: [<span class="json-str">"read:risks"</span>, <span class="json-str">"read:incidents"</span>],
<span class="json-key">"warning"</span>: <span class="json-str">"Salva questa chiave in modo sicuro. Non sara' piu' visibile."</span>
}</pre>
</div>
</div>
<div id="keys-delete" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-delete">DELETE</span>
<span class="endpoint-path">/webhooks/api-keys/<span>{id}</span></span>
<span class="auth-tag auth-jwt">JWT org_admin</span>
<span class="endpoint-summary">Revoca (soft delete) API Key</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Revoca una API Key impostando <code>is_active = 0</code>. La chiave non viene eliminata dal DB per mantenere l'audit trail.</p>
</div>
</div>
</div>
<!-- WEBHOOK SUBSCRIPTIONS -->
<div class="section">
<h2 class="section-title">Webhook Subscriptions</h2>
<p class="section-desc">Gestione sottoscrizioni webhook outbound. NIS2 Agile invierà POST HTTP all'URL configurato al verificarsi degli eventi sottoscritti.</p>
<div id="wh-list" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/webhooks/subscriptions</span>
<span class="auth-tag auth-jwt">JWT</span>
<span class="endpoint-summary">Lista subscriptions + statistiche delivery</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Include statistiche di delivery (total, success, failed) per ogni subscription. Non espone il secret HMAC.</p>
</div>
</div>
<div id="wh-create" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-post">POST</span>
<span class="endpoint-path">/webhooks/subscriptions</span>
<span class="auth-tag auth-jwt">JWT org_admin</span>
</div>
<div class="endpoint-body">
<div class="info-box warning">Il secret HMAC viene restituito <strong>SOLO UNA VOLTA</strong>. Usarlo per verificare la firma <code>X-NIS2-Signature</code>.</div>
<h4 class="params-title">Body (JSON)</h4>
<table class="params-table">
<thead><tr><th>Campo</th><th>Tipo</th><th>Obbligatorio</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td><code class="param-name">name</code></td><td><code class="param-type">string</code></td><td><span class="param-required">*</span></td><td>Nome descrittivo</td></tr>
<tr><td><code class="param-name">url</code></td><td><code class="param-type">string</code></td><td><span class="param-required">*</span></td><td>URL https:// destinazione POST</td></tr>
<tr><td><code class="param-name">events</code></td><td><code class="param-type">array</code></td><td><span class="param-required">*</span></td><td>Array eventi (o <code>["*"]</code> per wildcard)</td></tr>
</tbody>
</table>
<h4 class="params-title">Risposta 201</h4>
<pre><span class="json-key">{
"id"</span>: <span class="json-num">3</span>,
<span class="json-key">"secret"</span>: <span class="json-str">"a3f8c2d1e4b7f9a2c8d3e1f4..."</span>,
<span class="json-key">"warning"</span>: <span class="json-str">"Salva il secret. Sara' usato per verificare la firma X-NIS2-Signature."</span>
}</pre>
</div>
</div>
<div id="wh-update" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-put">PUT</span>
<span class="endpoint-path">/webhooks/subscriptions/<span>{id}</span></span>
<span class="auth-tag auth-jwt">JWT</span>
<span class="endpoint-summary">Aggiorna name, events, is_active</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Aggiornamento parziale (PATCH semantics). Solo i campi inviati vengono aggiornati: <code>name</code>, <code>events</code>, <code>is_active</code>.</p>
</div>
</div>
<div id="wh-delete" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-delete">DELETE</span>
<span class="endpoint-path">/webhooks/subscriptions/<span>{id}</span></span>
<span class="auth-tag auth-jwt">JWT org_admin</span>
<span class="endpoint-summary">Elimina subscription e storico delivery</span>
</div>
</div>
<div id="wh-test" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-post">POST</span>
<span class="endpoint-path">/webhooks/subscriptions/<span>{id}</span>/test</span>
<span class="auth-tag auth-jwt">JWT</span>
<span class="endpoint-summary">Invia evento webhook.test</span>
</div>
<div class="endpoint-body">
<p class="endpoint-desc">Invia un ping di test per verificare che l'endpoint remoto sia raggiungibile e risponda correttamente. Controlla il delivery log per il risultato.</p>
</div>
</div>
<div id="wh-deliveries" class="endpoint">
<div class="endpoint-header" onclick="toggle(this)">
<span class="method-badge method-get">GET</span>
<span class="endpoint-path">/webhooks/deliveries</span>
<span class="auth-tag auth-jwt">JWT</span>
<span class="endpoint-summary">Log ultimi 100 delivery</span>
</div>
<div class="endpoint-body">
<h4 class="params-title">Query Parameters</h4>
<table class="params-table">
<thead><tr><th>Parametro</th><th>Tipo</th><th>Descrizione</th></tr></thead>
<tbody>
<tr><td><code class="param-name">subscription_id</code></td><td><code class="param-type">integer</code></td><td>Filtra per subscription specifica</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- WEBHOOK EVENTS CATALOG -->
<div id="wh-events" class="section">
<h2 class="section-title">Catalogo Eventi Webhook</h2>
<p class="section-desc">NIS2 Agile emette eventi su tutte le operazioni rilevanti per la compliance. Ogni evento ha un payload tipizzato.</p>
<table class="params-table">
<thead><tr><th>Evento</th><th>Trigger</th><th>Payload</th></tr></thead>
<tbody>
<tr><td><code>incident.created</code></td><td>Nuovo incidente registrato</td><td><code>incidentPayload(incident, 'created')</code></td></tr>
<tr><td><code>incident.updated</code></td><td>Incidente modificato</td><td><code>incidentPayload(incident, 'updated')</code></td></tr>
<tr><td><code>incident.significant</code></td><td>Incidente flaggato Art.23</td><td><code>incidentPayload</code> con deadlines</td></tr>
<tr><td><code>incident.deadline_warning</code></td><td>Scadenza 24h/72h imminente</td><td>deadline + ore rimanenti</td></tr>
<tr><td><code>risk.high_created</code></td><td>Rischio HIGH o CRITICAL creato</td><td><code>riskPayload(risk, 'created')</code></td></tr>
<tr><td><code>risk.updated</code></td><td>Rischio aggiornato</td><td><code>riskPayload(risk, 'updated')</code></td></tr>
<tr><td><code>compliance.score_changed</code></td><td>Variazione score >5%</td><td>previous_score, new_score, delta, label</td></tr>
<tr><td><code>policy.approved</code></td><td>Policy approvata</td><td><code>policyPayload(policy)</code></td></tr>
<tr><td><code>policy.created</code></td><td>Nuova policy creata</td><td>id, title, category, nis2_article</td></tr>
<tr><td><code>supplier.risk_flagged</code></td><td>Fornitore con rischio HIGH/CRITICAL</td><td>supplier + risk_level</td></tr>
<tr><td><code>assessment.completed</code></td><td>Gap assessment completato</td><td>score, gap_count, top_gaps[]</td></tr>
<tr><td><code>whistleblowing.received</code></td><td>Nuova segnalazione anonima</td><td>id, category, priority (no PII)</td></tr>
<tr><td><code>normative.update</code></td><td>Aggiornamento normativo NIS2/ACN</td><td>title, source, effective_date</td></tr>
<tr><td><code>webhook.test</code></td><td>Ping manuale di test</td><td>message, timestamp</td></tr>
<tr><td><code>*</code></td><td>Wildcard — tutti gli eventi</td><td></td></tr>
</tbody>
</table>
<h3 class="params-title" style="margin-top:24px;">Struttura envelope payload</h3>
<pre><span class="json-key">{
"id"</span>: <span class="json-str">"550e8400-e29b-41d4-a716-446655440000"</span>,
<span class="json-key">"event"</span>: <span class="json-str">"incident.created"</span>,
<span class="json-key">"api_version"</span>: <span class="json-str">"1.0.0"</span>,
<span class="json-key">"created"</span>: <span class="json-num">1709900400</span>,
<span class="json-key">"created_at"</span>: <span class="json-str">"2026-03-07T10:00:00+01:00"</span>,
<span class="json-key">"source"</span>: <span class="json-str">"nis2-agile"</span>,
<span class="json-key">"org_id"</span>: <span class="json-num">42</span>,
<span class="json-key">"data"</span>: { <span class="json-key">"action"</span>: <span class="json-str">"created"</span>, <span class="json-key">"incident"</span>: { ... } }
}</pre>
</div>
<!-- WEBHOOK SIGNATURE -->
<div id="wh-signature" class="section">
<h2 class="section-title">Verifica Firma Webhook</h2>
<p class="section-desc">Ogni delivery include l'header <code>X-NIS2-Signature</code> con firma HMAC-SHA256 del body. Verifica sempre la firma prima di processare il payload.</p>
<div class="info-box info">
Pattern identico a Stripe webhook verification: <code>sha256=HMAC_SHA256(body, secret)</code>
</div>
<h3 class="params-title">Headers inviati</h3>
<pre><code>X-NIS2-Signature: sha256=a3f8c2d1e4b7...
X-NIS2-Event: incident.created
X-NIS2-Delivery-Id: 147
X-NIS2-Attempt: 1
Content-Type: application/json</code></pre>
<h3 class="params-title" style="margin-top:20px;">Verifica in PHP</h3>
<pre><span class="json-str">$body</span> = file_get_contents(<span class="json-str">'php://input'</span>);
<span class="json-str">$secret</span> = <span class="json-str">'il-tuo-secret'</span>;
<span class="json-str">$signature</span> = $_SERVER[<span class="json-str">'HTTP_X_NIS2_SIGNATURE'</span>];
<span class="json-str">$expected</span> = <span class="json-str">'sha256='</span> . hash_hmac(<span class="json-str">'sha256'</span>, <span class="json-str">$body</span>, <span class="json-str">$secret</span>);
if (!hash_equals(<span class="json-str">$expected</span>, <span class="json-str">$signature</span>)) {
http_response_code(401);
exit(<span class="json-str">'Invalid signature'</span>);
}</pre>
<h3 class="params-title" style="margin-top:20px;">Verifica in Python</h3>
<pre>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)</pre>
<h3 class="params-title" style="margin-top:20px;">Retry Policy</h3>
<table class="params-table">
<thead><tr><th>Tentativo</th><th>Ritardo</th><th>Note</th></tr></thead>
<tbody>
<tr><td></td><td>Immediato</td><td>Fire-and-forget sincrono</td></tr>
<tr><td></td><td>+5 minuti</td><td>Processato da cron</td></tr>
<tr><td></td><td>+30 minuti</td><td>Ultimo tentativo</td></tr>
</tbody>
</table>
<p style="font-size:0.8rem; color:var(--text-muted); margin-top:12px;">Dopo 10 fallimenti consecutivi la subscription viene messa in pausa automatica (<code>failure_count >= 10</code>).</p>
</div>
<div style="padding-top:40px; border-top:1px solid var(--border); color:var(--text-muted); font-size:0.8rem;">
NIS2 Agile API Reference v1.0.0 — &copy; 2026 CertiSource Srl —
<a href="https://nis2.certisource.it" style="color:var(--primary);">nis2.certisource.it</a>
</div>
</main>
</div>
<script>
function toggle(header) {
const body = header.nextElementSibling;
body.classList.toggle('open');
}
function scrollTo(id) {
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
event.currentTarget.classList.add('active');
}
}
// Auto-open first endpoint in each section
document.querySelectorAll('.endpoint:first-of-type .endpoint-body').forEach(b => b.classList.add('open'));
</script>
</body>
</html>

View File

@ -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]];
}

View File

@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AllRisk × NIS2 Agile — Integrazione</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #06b6d4; --amber: #f59e0b;
--gray-200: #e2e8f0; --gray-500: #64748b; --gray-700: #334155; --gray-900: #0f172a;
--radius: 8px; --font: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
--mono: 'Cascadia Code','Consolas',monospace;
}
body { background: #f8fafc; font-family: var(--font); color: var(--gray-900); }
.header { background: linear-gradient(135deg, #78350f, #92400e); padding: 40px 48px; color: #fff; }
.header-badges { display: flex; gap: 10px; margin-bottom: 16px; }
.badge { padding: 4px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 700; }
.badge-nis2 { background: rgba(6,182,212,0.2); color: #67e8f9; border: 1px solid rgba(6,182,212,0.3); }
.badge-ar { background: rgba(245,158,11,0.2); color: #fde68a; border: 1px solid rgba(245,158,11,0.3); }
h1 { font-size: 1.875rem; font-weight: 800; margin-bottom: 8px; }
.header p { color: #fde68a; font-size: 1rem; }
.container { max-width: 960px; margin: 0 auto; padding: 40px 24px; }
h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 2px solid var(--gray-200); }
.section { margin-bottom: 48px; }
p { color: var(--gray-500); font-size: 0.9rem; line-height: 1.7; margin-bottom: 12px; }
pre { background: #1e293b; color: #e2e8f0; padding: 20px; border-radius: var(--radius); font-family: var(--mono); font-size: 0.8125rem; overflow-x: auto; line-height: 1.7; margin: 12px 0; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-family: var(--mono); font-size: 0.85em; color: var(--gray-700); }
.step { display: flex; gap: 16px; margin-bottom: 20px; padding: 18px; background: #fff; border: 1px solid var(--gray-200); border-radius: var(--radius); }
.step-num { width: 30px; height: 30px; border-radius: 50%; background: var(--amber); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; }
.step-content h3 { font-size: 0.9375rem; font-weight: 700; margin-bottom: 6px; }
.info-box { padding: 14px 16px; border-radius: var(--radius); margin: 16px 0; font-size: 0.875rem; }
.info-amber { background: #fffbeb; border-left: 3px solid var(--amber); color: #92400e; }
.field-map { width: 100%; border-collapse: collapse; }
.field-map th { padding: 9px 12px; background: #f1f5f9; font-size: 0.8rem; font-weight: 700; text-align: left; }
.field-map td { padding: 9px 12px; border-bottom: 1px solid var(--gray-200); font-size: 0.8rem; }
</style>
</head>
<body>
<div class="header">
<div class="header-badges">
<span class="badge badge-ar">AllRisk</span>
<span>×</span>
<span class="badge badge-nis2">NIS2 Agile</span>
</div>
<h1>Integrazione AllRisk ← NIS2 Agile</h1>
<p>Esporta il risk register cyber NIS2 verso AllRisk per consolidamento nel registro rischi enterprise. Importazione automatica dei rischi HIGH/CRITICAL tramite API Key.</p>
</div>
<div class="container">
<div class="section">
<h2>Scenario di integrazione</h2>
<p>AllRisk è il registro rischi enterprise centralizzato. NIS2 Agile gestisce i rischi cyber in ottica normativa (ISO 27005 / NIS2). L'integrazione permette di:</p>
<div class="step"><div class="step-num">1</div><div class="step-content"><h3>Consolidamento automatico</h3><p>I rischi HIGH/CRITICAL di NIS2 vengono automaticamente importati in AllRisk come rischi di categoria "Cyber/IT" con score e deadline normalizzati.</p></div></div>
<div class="step"><div class="step-num">2</div><div class="step-content"><h3>Aggiornamento bidirezionale</h3><p>AllRisk notifica NIS2 tramite webhook se un rischio enterprise si trasforma in minaccia cyber (es. incidente supply chain → rischio NIS2).</p></div></div>
<div class="step"><div class="step-num">3</div><div class="step-content"><h3>Reporting unificato</h3><p>Report rischi enterprise unico che include dimensione NIS2 con compliance score per ogni categoria di rischio cyber (Art.21 domains).</p></div></div>
</div>
<div class="section">
<h2>Mappatura campi NIS2 → AllRisk</h2>
<table class="field-map">
<thead><tr><th>Campo NIS2 Agile</th><th>Campo AllRisk</th><th>Trasformazione</th></tr></thead>
<tbody>
<tr><td><code>risk_code</code></td><td><code>external_ref</code></td><td>Prefisso <code>NIS2-</code> + codice</td></tr>
<tr><td><code>title</code></td><td><code>risk_name</code></td><td>Diretta</td></tr>
<tr><td><code>risk_level</code></td><td><code>severity</code></td><td>critical→5, high→4, medium→3, low→2</td></tr>
<tr><td><code>likelihood</code> × <code>impact</code></td><td><code>risk_score</code></td><td>Scala 025 → 0100</td></tr>
<tr><td><code>category</code></td><td><code>risk_category</code></td><td>Mappato su tassonomia AllRisk "Cyber"</td></tr>
<tr><td><code>nis2_article</code></td><td><code>regulatory_ref</code></td><td>Prefisso "NIS2 Art."</td></tr>
<tr><td><code>treatment</code></td><td><code>response_strategy</code></td><td>mitigate→Reduce, accept→Accept, ecc.</td></tr>
<tr><td><code>review_date</code></td><td><code>next_review</code></td><td>Diretta (ISO 8601)</td></tr>
</tbody>
</table>
</div>
<div class="section">
<h2>Import automatico — Script PHP</h2>
<pre><span style="color:#7dd3fc;">// AllRisk — import_nis2_risks.php (cron settimanale)</span>
<span style="color:#f1fa8c;">$apiKey</span> = getenv(<span style="color:#86efac;">'NIS2_API_KEY'</span>);
<span style="color:#f1fa8c;">$orgId</span> = getenv(<span style="color:#86efac;">'NIS2_ORG_ID'</span>);
<span style="color:#f1fa8c;">$baseUrl</span> = <span style="color:#86efac;">'https://nis2.certisource.it/api'</span>;
<span style="color:#f1fa8c;">$allriskOrgId</span> = getenv(<span style="color:#86efac;">'ALLRISK_ORG_ID'</span>);
<span style="color:#7dd3fc;">// Fetch rischi HIGH/CRITICAL aperti da NIS2</span>
<span style="color:#f1fa8c;">$response</span> = nis2Get(<span style="color:#f1fa8c;">$baseUrl</span> . <span style="color:#86efac;">'/services/risks-feed'</span>, [
<span style="color:#86efac;">'level'</span> => <span style="color:#86efac;">'high'</span>,
<span style="color:#86efac;">'status'</span> => <span style="color:#86efac;">'open'</span>,
<span style="color:#86efac;">'limit'</span> => 200,
], <span style="color:#f1fa8c;">$apiKey</span>, <span style="color:#f1fa8c;">$orgId</span>);
<span style="color:#f1fa8c;">$risks</span> = <span style="color:#f1fa8c;">$response</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'risks'</span>] ?? [];
<span style="color:#f1fa8c;">$imported</span> = 0;
foreach (<span style="color:#f1fa8c;">$risks</span> as <span style="color:#f1fa8c;">$risk</span>) {
<span style="color:#f1fa8c;">$allriskPayload</span> = [
<span style="color:#86efac;">'external_ref'</span> => <span style="color:#86efac;">'NIS2-'</span> . <span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'risk_code'</span>],
<span style="color:#86efac;">'risk_name'</span> => <span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'title'</span>],
<span style="color:#86efac;">'risk_category'</span> => <span style="color:#86efac;">'Cyber/IT'</span>,
<span style="color:#86efac;">'severity'</span> => mapSeverity(<span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'risk_level'</span>]),
<span style="color:#86efac;">'risk_score'</span> => round(<span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'risk_score'</span>] / 25 * 100),
<span style="color:#86efac;">'regulatory_ref'</span> => <span style="color:#86efac;">'NIS2 '</span> . (<span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'nis2_article'</span>] ?? <span style="color:#86efac;">'Art.21'</span>),
<span style="color:#86efac;">'response_strategy'</span> => mapTreatment(<span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'treatment'</span>]),
<span style="color:#86efac;">'source_system'</span> => <span style="color:#86efac;">'NIS2 Agile'</span>,
<span style="color:#86efac;">'organization_id'</span> => <span style="color:#f1fa8c;">$allriskOrgId</span>,
];
<span style="color:#7dd3fc;">// Upsert: aggiorna se external_ref esiste, crea altrimenti</span>
AllRiskApiClient::upsertRisk(<span style="color:#f1fa8c;">$allriskPayload</span>);
<span style="color:#f1fa8c;">$imported</span>++;
}
error_log("[NIS2→AllRisk] Importati {<span style="color:#f1fa8c;">$imported</span>} rischi");
<span style="color:#f1fa8c;">function</span> mapSeverity(<span style="color:#f1fa8c;">string $level</span>): int {
return match(<span style="color:#f1fa8c;">$level</span>) { <span style="color:#86efac;">'critical'</span> => 5, <span style="color:#86efac;">'high'</span> => 4, <span style="color:#86efac;">'medium'</span> => 3, default => 2 };
}
<span style="color:#f1fa8c;">function</span> mapTreatment(<span style="color:#f1fa8c;">string $t</span>): string {
return match(<span style="color:#f1fa8c;">$t</span>) {
<span style="color:#86efac;">'mitigate'</span> => <span style="color:#86efac;">'Reduce'</span>, <span style="color:#86efac;">'accept'</span> => <span style="color:#86efac;">'Accept'</span>,
<span style="color:#86efac;">'transfer'</span> => <span style="color:#86efac;">'Transfer'</span>, default => <span style="color:#86efac;">'Avoid'</span>
};
}</pre>
</div>
<div class="section">
<h2>Webhook NIS2 → AllRisk (real-time)</h2>
<p>Configura webhook per importazione immediata dei nuovi rischi HIGH/CRITICAL:</p>
<pre><span style="color:#7dd3fc;"># Settings → Webhook in NIS2 Agile</span>
URL: https://allrisk.certisource.it/api/v1/webhooks/nis2
Events: risk.high_created, compliance.score_changed, incident.significant</pre>
<pre><span style="color:#7dd3fc;">// AllRisk — webhook receiver per NIS2</span>
<span style="color:#f1fa8c;">$payload</span> = verifyAndDecode($_SERVER[<span style="color:#86efac;">'HTTP_X_NIS2_SIGNATURE'</span>], file_get_contents(<span style="color:#86efac;">'php://input'</span>));
if (<span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'event'</span>] === <span style="color:#86efac;">'risk.high_created'</span>) {
<span style="color:#f1fa8c;">$risk</span> = <span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'risk'</span>];
AllRiskApiClient::upsertRisk([
<span style="color:#86efac;">'external_ref'</span> => <span style="color:#86efac;">'NIS2-'</span> . <span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'id'</span>],
<span style="color:#86efac;">'risk_name'</span> => <span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'title'</span>],
<span style="color:#86efac;">'severity'</span> => <span style="color:#f1fa8c;">$risk</span>[<span style="color:#86efac;">'risk_level'</span>] === <span style="color:#86efac;">'critical'</span> ? 5 : 4,
<span style="color:#86efac;">'source_system'</span>=> <span style="color:#86efac;">'NIS2 Agile'</span>,
<span style="color:#86efac;">'alert'</span> => true, <span style="color:#7dd3fc;">// Notifica CRO AllRisk</span>
]);
}
http_response_code(200);</pre>
</div>
<div class="info-box info-amber">
<strong>Nota architetturale:</strong> 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.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NIS2 Agile — Integrazioni</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f8fafc; --white: #ffffff; --primary: #06b6d4;
--gray-100: #f1f5f9; --gray-200: #e2e8f0; --gray-500: #64748b;
--gray-700: #334155; --gray-900: #0f172a;
--radius: 12px; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
}
body { background: var(--bg); font-family: var(--font); color: var(--gray-900); padding: 40px 24px; }
.container { max-width: 1000px; margin: 0 auto; }
h1 { font-size: 1.75rem; font-weight: 800; margin-bottom: 8px; }
p.subtitle { color: var(--gray-500); margin-bottom: 40px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 24px; }
.card { background: var(--white); border-radius: var(--radius); border: 1px solid var(--gray-200); padding: 28px; transition: all 0.2s; }
.card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
.card-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; font-size: 1.5rem; }
.card-title { font-size: 1.125rem; font-weight: 700; margin-bottom: 8px; }
.card-desc { font-size: 0.875rem; color: var(--gray-500); line-height: 1.6; margin-bottom: 20px; }
.card-link { display: inline-flex; align-items: center; gap: 6px; color: var(--primary); font-size: 0.875rem; font-weight: 600; text-decoration: none; }
.card-link:hover { text-decoration: underline; }
.badge { display: inline-flex; padding: 3px 8px; border-radius: 20px; font-size: 0.7rem; font-weight: 700; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="container">
<h1>NIS2 Agile — Integrazioni</h1>
<p class="subtitle">Pagine di integrazione per connettere NIS2 Agile con altri prodotti della suite Agile.</p>
<div class="grid">
<div class="card">
<div class="card-icon" style="background:#f0fdf4;">📋</div>
<span class="badge" style="background:#dcfce7; color:#166534;">Webhook + Widget</span>
<div class="card-title">231 Agile ← NIS2</div>
<div class="card-desc">Integra i dati di compliance NIS2 in 231 Agile. Visualizza compliance score, rischi cyber e incidenti significativi nel contesto del Modello 231.</div>
<a href="lg231.html" class="card-link">Guida integrazione →</a>
</div>
<div class="card">
<div class="card-icon" style="background:#f0f9ff;">🌿</div>
<span class="badge" style="background:#e0f2fe; color:#075985;">API Widget</span>
<div class="card-title">SustainAI ← NIS2</div>
<div class="card-desc">Alimenta i report ESG/sostenibilità di SustainAI con dati di governance cybersecurity NIS2. Compliance score e policy approvate per l'area G (Governance).</div>
<a href="sustainai.html" class="card-link">Guida integrazione →</a>
</div>
<div class="card">
<div class="card-icon" style="background:#fef9c3;"></div>
<span class="badge" style="background:#fef9c3; color:#854d0e;">REST API</span>
<div class="card-title">AllRisk ← NIS2</div>
<div class="card-desc">Esporta il risk register cyber NIS2 verso AllRisk per consolidamento nel registro rischi enterprise. Sincronizzazione automatica via API Key.</div>
<a href="allrisk.html" class="card-link">Guida integrazione →</a>
</div>
<div class="card">
<div class="card-icon" style="background:#faf5ff;">🔒</div>
<span class="badge" style="background:#f3e8ff; color:#6b21a8;">SIEM</span>
<div class="card-title">SIEM / SOC</div>
<div class="card-desc">Integra NIS2 Agile con SIEM (Splunk, Elastic, IBM QRadar) tramite webhook outbound. Ricevi eventi incident.created, risk.high_created in tempo reale.</div>
<a href="siem.html" class="card-link">Guida integrazione →</a>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>231 Agile × NIS2 Agile — Integrazione</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f8fafc; --white: #fff; --primary: #06b6d4; --green: #10b981;
--gray-100: #f1f5f9; --gray-200: #e2e8f0; --gray-500: #64748b; --gray-700: #334155; --gray-900: #0f172a;
--danger: #ef4444; --warning: #f59e0b; --mono: 'Cascadia Code','Consolas',monospace;
--radius: 8px; --font: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
}
body { background: var(--bg); font-family: var(--font); color: var(--gray-900); }
.header { background: linear-gradient(135deg, #1e293b, #0f172a); padding: 40px 48px; color: #fff; }
.header-badges { display: flex; gap: 10px; margin-bottom: 16px; }
.badge { padding: 4px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 700; }
.badge-nis2 { background: rgba(6,182,212,0.2); color: #67e8f9; border: 1px solid rgba(6,182,212,0.3); }
.badge-231 { background: rgba(16,185,129,0.2); color: #6ee7b7; border: 1px solid rgba(16,185,129,0.3); }
h1 { font-size: 1.875rem; font-weight: 800; margin-bottom: 8px; }
.header p { color: #94a3b8; font-size: 1rem; }
.container { max-width: 960px; margin: 0 auto; padding: 40px 24px; }
h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 2px solid var(--gray-200); }
h3 { font-size: 1rem; font-weight: 700; margin-bottom: 8px; color: var(--gray-700); }
.section { margin-bottom: 48px; }
p { color: var(--gray-500); font-size: 0.9rem; line-height: 1.7; margin-bottom: 12px; }
pre, code { font-family: var(--mono); }
pre { background: #1e293b; color: #e2e8f0; padding: 20px; border-radius: var(--radius); font-size: 0.8125rem; overflow-x: auto; line-height: 1.7; margin: 12px 0; }
code { background: var(--gray-100); padding: 2px 6px; border-radius: 4px; font-size: 0.85em; color: var(--gray-700); }
.step { display: flex; gap: 16px; margin-bottom: 24px; padding: 20px; background: var(--white); border: 1px solid var(--gray-200); border-radius: var(--radius); }
.step-num { width: 32px; height: 32px; border-radius: 50%; background: var(--primary); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; }
.step-content h3 { margin-bottom: 6px; }
.info-box { padding: 14px 16px; border-radius: var(--radius); margin-bottom: 16px; font-size: 0.875rem; }
.info-warn { background: #fff7ed; border-left: 3px solid var(--warning); color: #92400e; }
.info-info { background: #eff6ff; border-left: 3px solid #3b82f6; color: #1e40af; }
.info-success { background: #f0fdf4; border-left: 3px solid var(--green); color: #065f46; }
.widget-preview { background: var(--white); border: 1px solid var(--gray-200); border-radius: var(--radius); padding: 24px; margin-top: 20px; }
.widget-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.widget-title { font-size: 0.875rem; font-weight: 700; display: flex; align-items: center; gap: 8px; }
.score-ring { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; background: conic-gradient(#06b6d4 73%, #e2e8f0 0); border-radius: 50%; font-size: 1.25rem; font-weight: 800; }
.metric-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 16px; }
.metric { text-align: center; padding: 12px; background: var(--gray-100); border-radius: 6px; }
.metric-val { font-size: 1.25rem; font-weight: 700; color: var(--gray-900); }
.metric-label { font-size: 0.7rem; color: var(--gray-500); margin-top: 2px; }
</style>
</head>
<body>
<div class="header">
<div class="header-badges">
<span class="badge badge-nis2">NIS2 Agile</span>
<span>×</span>
<span class="badge badge-231">231 Agile</span>
</div>
<h1>Integrazione 231 Agile ← NIS2 Agile</h1>
<p>Porta i dati di compliance cybersecurity NIS2 nel contesto del Modello Organizzativo 231. Rischi cyber come presidi 231, incidenti significativi come non conformità.</p>
</div>
<div class="container">
<div class="section">
<h2>Architettura dell'integrazione</h2>
<p>NIS2 Agile espone un'API REST con API Key e un sistema di webhook outbound. 231 Agile può consumare i dati in due modalità:</p>
<div class="step">
<div class="step-num">A</div>
<div class="step-content">
<h3>Pull (Services API)</h3>
<p>231 Agile chiama periodicamente le API NIS2 per aggiornare il cruscotto 231. Indicato per dati storici e report periodici (mensile/trimestrale).</p>
</div>
</div>
<div class="step">
<div class="step-num">B</div>
<div class="step-content">
<h3>Push (Webhook outbound)</h3>
<p>NIS2 Agile notifica 231 Agile in tempo reale su eventi critici. Indicato per incidenti significativi, rischi HIGH/CRITICAL, variazioni compliance.</p>
</div>
</div>
</div>
<div class="section">
<h2>Step 1 — Crea API Key in NIS2 Agile</h2>
<div class="info-warn">Richiede ruolo <code>org_admin</code> in NIS2 Agile.</div>
<div class="step"><div class="step-num">1</div><div class="step-content"><h3>Accedi a Settings → API Keys</h3><p>In NIS2 Agile, vai in <code>Settings</code> → tab <strong>API Keys</strong><strong>Nuova API Key</strong>.</p></div></div>
<div class="step"><div class="step-num">2</div><div class="step-content"><h3>Seleziona gli scope</h3><p>Per 231 Agile ti servono: <code>read:compliance</code>, <code>read:risks</code>, <code>read:incidents</code>. Opzionale: <code>read:policies</code>.</p></div></div>
<div class="step"><div class="step-num">3</div><div class="step-content"><h3>Salva la chiave</h3><p>La chiave <code>nis2_xxxxx</code> viene mostrata <strong>una sola volta</strong>. Copiala in <strong>231 Agile → Integrazioni → NIS2 Agile → API Key</strong>.</p></div></div>
</div>
<div class="section">
<h2>Step 2 — Integrazione Pull (PHP / 231 Agile)</h2>
<p>Aggiungi questo codice nel cron job notturno di 231 Agile per sincronizzare i dati NIS2:</p>
<pre><span style="color:#7dd3fc;">// 231 Agile — CronJob: sync_nis2_compliance.php</span>
<span style="color:#f1fa8c;">$apiKey</span> = getenv(<span style="color:#86efac;">'NIS2_API_KEY'</span>); <span style="color:#7dd3fc;">// nis2_abc123...</span>
<span style="color:#f1fa8c;">$orgId</span> = getenv(<span style="color:#86efac;">'NIS2_ORG_ID'</span>);
<span style="color:#f1fa8c;">$baseUrl</span> = <span style="color:#86efac;">'https://nis2.certisource.it/api'</span>;
<span style="color:#f1fa8c;">function</span> nis2Get(<span style="color:#f1fa8c;">string $endpoint</span>, array <span style="color:#f1fa8c;">$query</span> = []): array {
global <span style="color:#f1fa8c;">$apiKey</span>, <span style="color:#f1fa8c;">$orgId</span>, <span style="color:#f1fa8c;">$baseUrl</span>;
<span style="color:#f1fa8c;">$url</span> = <span style="color:#f1fa8c;">$baseUrl</span> . <span style="color:#f1fa8c;">$endpoint</span>;
if (!empty(<span style="color:#f1fa8c;">$query</span>)) <span style="color:#f1fa8c;">$url</span> .= <span style="color:#86efac;">'?'</span> . http_build_query(<span style="color:#f1fa8c;">$query</span>);
<span style="color:#f1fa8c;">$ch</span> = curl_init(<span style="color:#f1fa8c;">$url</span>);
curl_setopt_array(<span style="color:#f1fa8c;">$ch</span>, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
<span style="color:#86efac;">'X-API-Key: '</span> . <span style="color:#f1fa8c;">$apiKey</span>,
<span style="color:#86efac;">'X-Organization-Id: '</span> . <span style="color:#f1fa8c;">$orgId</span>,
],
]);
return json_decode(curl_exec(<span style="color:#f1fa8c;">$ch</span>), true) ?? [];
}
<span style="color:#7dd3fc;">// 1. Recupera compliance summary</span>
<span style="color:#f1fa8c;">$compliance</span> = nis2Get(<span style="color:#86efac;">'/services/compliance-summary'</span>);
<span style="color:#f1fa8c;">$score</span> = <span style="color:#f1fa8c;">$compliance</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'overall_score'</span>] ?? 0;
<span style="color:#7dd3fc;">// 2. Recupera rischi HIGH/CRITICAL per mappa rischi 231</span>
<span style="color:#f1fa8c;">$risks</span> = nis2Get(<span style="color:#86efac;">'/services/risks-feed'</span>, [<span style="color:#86efac;">'level'</span> => <span style="color:#86efac;">'high'</span>, <span style="color:#86efac;">'status'</span> => <span style="color:#86efac;">'open'</span>]);
<span style="color:#7dd3fc;">// 3. Recupera incidenti Art.23 aperti</span>
<span style="color:#f1fa8c;">$incidents</span> = nis2Get(<span style="color:#86efac;">'/services/incidents-feed'</span>, [
<span style="color:#86efac;">'significant_only'</span> => 1, <span style="color:#86efac;">'status'</span> => <span style="color:#86efac;">'open'</span>
]);
<span style="color:#7dd3fc;">// 4. Salva in DB 231 Agile — tabella nis2_integration</span>
<span style="color:#7dd3fc;">// DB231::upsert('nis2_integration', ['org_id'=>$orgId231, 'score'=>$score, ...]);</span></pre>
</div>
<div class="section">
<h2>Step 3 — Webhook Push (real-time)</h2>
<p>Configura in NIS2 Agile → Settings → Webhook un endpoint del tuo server 231 Agile per ricevere eventi in tempo reale:</p>
<pre><span style="color:#7dd3fc;"># Endpoint da configurare in NIS2 Agile → Settings → Webhook</span>
URL: https://app-231.certisource.it/webhooks/nis2
Events: incident.significant, incident.deadline_warning, risk.high_created, compliance.score_changed</pre>
<h3 style="margin-top:20px;">Receiver PHP su 231 Agile</h3>
<pre><span style="color:#7dd3fc;">// 231 Agile — routes/webhook_nis2.php</span>
<span style="color:#f1fa8c;">$secret</span> = getenv(<span style="color:#86efac;">'NIS2_WEBHOOK_SECRET'</span>);
<span style="color:#f1fa8c;">$body</span> = file_get_contents(<span style="color:#86efac;">'php://input'</span>);
<span style="color:#f1fa8c;">$sig</span> = $_SERVER[<span style="color:#86efac;">'HTTP_X_NIS2_SIGNATURE'</span>] ?? <span style="color:#86efac;">''</span>;
if (!hash_equals(<span style="color:#86efac;">'sha256='</span> . hash_hmac(<span style="color:#86efac;">'sha256'</span>, <span style="color:#f1fa8c;">$body</span>, <span style="color:#f1fa8c;">$secret</span>), <span style="color:#f1fa8c;">$sig</span>)) {
http_response_code(401); exit;
}
<span style="color:#f1fa8c;">$payload</span> = json_decode(<span style="color:#f1fa8c;">$body</span>, true);
<span style="color:#f1fa8c;">$event</span> = <span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'event'</span>];
switch (<span style="color:#f1fa8c;">$event</span>) {
case <span style="color:#86efac;">'incident.significant'</span>:
<span style="color:#7dd3fc;">// Crea non-conformità in 231 Agile</span>
NonConformityService::createFromNis2Incident(<span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'incident'</span>]);
break;
case <span style="color:#86efac;">'risk.high_created'</span>:
<span style="color:#7dd3fc;">// Aggiorna mappa rischi presidi 231</span>
RiskService::importFromNis2(<span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'risk'</span>]);
break;
case <span style="color:#86efac;">'compliance.score_changed'</span>:
<span style="color:#7dd3fc;">// Aggiorna dashboard 231 con nuovo score NIS2</span>
DashboardService::updateNis2Score(<span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'new_score'</span>]);
break;
}
http_response_code(200);</pre>
</div>
<div class="section">
<h2>Widget NIS2 per Dashboard 231</h2>
<p>Embed HTML con chiamata API diretta. Copialo nella dashboard di 231 Agile:</p>
<div class="widget-preview">
<div class="widget-header">
<div class="widget-title">
<svg viewBox="0 0 24 24" fill="#06b6d4" width="18" height="18"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
NIS2 Compliance (Preview widget)
</div>
<span style="font-size:0.7rem; color:#64748b;">nis2.certisource.it</span>
</div>
<div style="display:flex; align-items:center; gap:24px;">
<div class="score-ring">73%</div>
<div>
<p style="font-size:0.9rem; font-weight:700; margin-bottom:4px;">Sostanzialmente Conforme</p>
<p style="font-size:0.75rem; color:#64748b;">D.Lgs. 138/2024 — Art.21 NIS2</p>
</div>
</div>
<div class="metric-row">
<div class="metric"><div class="metric-val" style="color:#ef4444;">2</div><div class="metric-label">Incidenti aperti</div></div>
<div class="metric"><div class="metric-val" style="color:#f59e0b;">5</div><div class="metric-label">Rischi HIGH</div></div>
<div class="metric"><div class="metric-val" style="color:#10b981;">12</div><div class="metric-label">Policy approvate</div></div>
</div>
</div>
<pre style="margin-top:16px;"><span style="color:#7dd3fc;">&lt;!-- Widget NIS2 per 231 Agile dashboard --&gt;</span>
&lt;div id="nis2-widget"&gt;&lt;/div&gt;
&lt;script&gt;
(async () => {
const r = await fetch('https://nis2.certisource.it/api/services/compliance-summary', {
headers: {
'X-API-Key': '<span style="color:#86efac;">nis2_YOUR_KEY</span>',
'X-Organization-Id': '<span style="color:#86efac;">YOUR_ORG_ID</span>'
}
});
const { data } = await r.json();
document.getElementById('nis2-widget').innerHTML = `
&lt;div style="padding:20px; border:1px solid #e2e8f0; border-radius:8px;"&gt;
&lt;h4 style="font-size:.875rem; font-weight:700; margin-bottom:12px;"&gt;
🔒 NIS2 Compliance Score
&lt;/h4&gt;
&lt;div style="font-size:2rem; font-weight:800; color:#06b6d4;"&gt;
${data.overall_score}%
&lt;/div&gt;
&lt;div style="font-size:.75rem; color:#64748b;"&gt;${data.label}&lt;/div&gt;
&lt;div style="margin-top:12px; display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px;"&gt;
&lt;div&gt;&lt;strong&gt;${data.incidents.open}&lt;/strong&gt;&lt;br&gt;&lt;small&gt;Incidenti&lt;/small&gt;&lt;/div&gt;
&lt;div&gt;&lt;strong&gt;${data.risks.high}&lt;/strong&gt;&lt;br&gt;&lt;small&gt;Rischi HIGH&lt;/small&gt;&lt;/div&gt;
&lt;div&gt;&lt;strong&gt;${data.policies.approved}&lt;/strong&gt;&lt;br&gt;&lt;small&gt;Policy&lt;/small&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;`;
})();
&lt;/script&gt;</pre>
</div>
<div class="info-success">
<strong>Dati mappati 231 ↔ NIS2:</strong> Rischi cyber NIS2 → Presidi 231 (ex. D.Lgs. 231/01 Art.25-septies) | Incidenti significativi → Non Conformità 231 | Compliance score → KPI Operatività 231
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIEM × NIS2 Agile — Integrazione</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #06b6d4; --purple: #8b5cf6;
--gray-200: #e2e8f0; --gray-500: #64748b; --gray-700: #334155;
--radius: 8px; --font: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
--mono: 'Cascadia Code','Consolas',monospace;
}
body { background: #f8fafc; font-family: var(--font); color: #0f172a; }
.header { background: linear-gradient(135deg, #2e1065, #3b0764); padding: 40px 48px; color: #fff; }
.header-badges { display: flex; gap: 10px; margin-bottom: 16px; }
.badge { padding: 4px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 700; }
.badge-nis2 { background: rgba(6,182,212,0.2); color: #67e8f9; border: 1px solid rgba(6,182,212,0.3); }
.badge-siem { background: rgba(139,92,246,0.2); color: #ddd6fe; border: 1px solid rgba(139,92,246,0.3); }
h1 { font-size: 1.875rem; font-weight: 800; margin-bottom: 8px; }
.header p { color: #ddd6fe; font-size: 1rem; }
.container { max-width: 960px; margin: 0 auto; padding: 40px 24px; }
h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 2px solid var(--gray-200); }
.section { margin-bottom: 48px; }
p { color: var(--gray-500); font-size: 0.9rem; line-height: 1.7; margin-bottom: 12px; }
pre { background: #1e293b; color: #e2e8f0; padding: 20px; border-radius: var(--radius); font-family: var(--mono); font-size: 0.8125rem; overflow-x: auto; line-height: 1.7; margin: 12px 0; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-family: var(--mono); font-size: 0.85em; }
.siem-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; margin-top: 16px; }
.siem-card { background: #fff; border: 1px solid var(--gray-200); border-radius: var(--radius); padding: 16px; }
.siem-card h4 { font-size: 0.875rem; font-weight: 700; margin-bottom: 6px; }
.siem-card p { font-size: 0.8rem; color: var(--gray-500); margin: 0; }
.info-box { padding: 14px 16px; border-radius: var(--radius); margin: 16px 0; font-size: 0.875rem; }
.info-purple { background: #f5f3ff; border-left: 3px solid var(--purple); color: #4c1d95; }
</style>
</head>
<body>
<div class="header">
<div class="header-badges">
<span class="badge badge-siem">SIEM / SOC</span>
<span>×</span>
<span class="badge badge-nis2">NIS2 Agile</span>
</div>
<h1>Integrazione SIEM / SOC ← NIS2 Agile</h1>
<p>Ricevi eventi NIS2 in tempo reale nel tuo SIEM. Incidenti Art.23, rischi HIGH/CRITICAL, variazioni compliance come alert automatici.</p>
</div>
<div class="container">
<div class="section">
<h2>SIEM supportati</h2>
<div class="siem-grid">
<div class="siem-card"><h4>Splunk Enterprise</h4><p>HTTP Event Collector (HEC) + custom TA per NIS2 Agile. Dashboard Art.23 precompilata.</p></div>
<div class="siem-card"><h4>Elastic SIEM</h4><p>Logstash HTTP input + index template NIS2. Alert rule per incident.significant.</p></div>
<div class="siem-card"><h4>IBM QRadar</h4><p>Universal DSM + webhook-to-syslog bridge. Evento NIS2 → offense QRadar.</p></div>
<div class="siem-card"><h4>Microsoft Sentinel</h4><p>Logic App webhook receiver → Azure Monitor. Playbook SOAR per Art.23.</p></div>
</div>
</div>
<div class="section">
<h2>Configurazione Webhook NIS2 → SIEM</h2>
<p>Configura in NIS2 Agile → Settings → Webhook l'endpoint del tuo SIEM:</p>
<pre><span style="color:#7dd3fc;"># SIEM HEC / HTTP Input endpoint</span>
URL: https://your-siem.example.com:8088/services/collector <span style="color:#7dd3fc;"># Splunk HEC</span>
URL: https://your-elk.example.com:9200/_ingest/pipeline/nis2 <span style="color:#7dd3fc;"># Elastic</span>
Events: incident.created, incident.significant, incident.deadline_warning,
risk.high_created, compliance.score_changed</pre>
</div>
<div class="section">
<h2>Splunk HEC — Bridge PHP</h2>
<p>Se il SIEM non supporta nativamente la firma HMAC-SHA256, usa questo bridge come proxy tra NIS2 e Splunk HEC:</p>
<pre><span style="color:#7dd3fc;">// nis2_to_splunk_bridge.php — Deploy su server intermedio</span>
<span style="color:#f1fa8c;">$nis2Secret</span> = getenv(<span style="color:#86efac;">'NIS2_WEBHOOK_SECRET'</span>);
<span style="color:#f1fa8c;">$splunkHec</span> = getenv(<span style="color:#86efac;">'SPLUNK_HEC_URL'</span>); <span style="color:#7dd3fc;">// https://splunk:8088/services/collector</span>
<span style="color:#f1fa8c;">$splunkToken</span> = getenv(<span style="color:#86efac;">'SPLUNK_HEC_TOKEN'</span>);
<span style="color:#7dd3fc;">// 1. Verifica firma NIS2</span>
<span style="color:#f1fa8c;">$body</span> = file_get_contents(<span style="color:#86efac;">'php://input'</span>);
<span style="color:#f1fa8c;">$sig</span> = $_SERVER[<span style="color:#86efac;">'HTTP_X_NIS2_SIGNATURE'</span>] ?? <span style="color:#86efac;">''</span>;
if (!hash_equals(<span style="color:#86efac;">'sha256='</span> . hash_hmac(<span style="color:#86efac;">'sha256'</span>, <span style="color:#f1fa8c;">$body</span>, <span style="color:#f1fa8c;">$nis2Secret</span>), <span style="color:#f1fa8c;">$sig</span>)) {
http_response_code(401); exit;
}
<span style="color:#f1fa8c;">$payload</span> = json_decode(<span style="color:#f1fa8c;">$body</span>, true);
<span style="color:#7dd3fc;">// 2. Trasforma in formato Splunk HEC</span>
<span style="color:#f1fa8c;">$splunkEvent</span> = [
<span style="color:#86efac;">'time'</span> => <span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'created'</span>],
<span style="color:#86efac;">'host'</span> => <span style="color:#86efac;">'nis2-agile'</span>,
<span style="color:#86efac;">'source'</span> => <span style="color:#86efac;">'nis2-agile-webhook'</span>,
<span style="color:#86efac;">'sourcetype'</span> => <span style="color:#86efac;">'nis2:event'</span>,
<span style="color:#86efac;">'index'</span> => <span style="color:#86efac;">'nis2_compliance'</span>,
<span style="color:#86efac;">'event'</span> => [
<span style="color:#86efac;">'event_type'</span> => <span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'event'</span>],
<span style="color:#86efac;">'event_id'</span> => <span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'id'</span>],
<span style="color:#86efac;">'org_id'</span> => <span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'org_id'</span>],
<span style="color:#86efac;">'data'</span> => <span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'data'</span>],
<span style="color:#86efac;">'api_version'</span> => <span style="color:#f1fa8c;">$payload</span>[<span style="color:#86efac;">'api_version'</span>],
],
];
<span style="color:#7dd3fc;">// 3. Forward a Splunk HEC</span>
<span style="color:#f1fa8c;">$ch</span> = curl_init(<span style="color:#f1fa8c;">$splunkHec</span>);
curl_setopt_array(<span style="color:#f1fa8c;">$ch</span>, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(<span style="color:#f1fa8c;">$splunkEvent</span>),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
<span style="color:#86efac;">'Authorization: Splunk '</span> . <span style="color:#f1fa8c;">$splunkToken</span>,
<span style="color:#86efac;">'Content-Type: application/json'</span>,
],
]);
<span style="color:#f1fa8c;">$result</span> = curl_exec(<span style="color:#f1fa8c;">$ch</span>);
curl_close(<span style="color:#f1fa8c;">$ch</span>);
http_response_code(200);</pre>
</div>
<div class="section">
<h2>Elastic — Logstash Pipeline</h2>
<pre><span style="color:#7dd3fc;"># logstash/pipelines/nis2.conf</span>
input {
http {
port => 8181
codec => json
<span style="color:#7dd3fc;"># Aggiungi filtro HMAC-SHA256 con plugin custom</span>
}
}
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]}"
}
}</pre>
</div>
<div class="section">
<h2>Microsoft Sentinel — Logic App</h2>
<pre><span style="color:#7dd3fc;">// Azure Logic App — trigger HTTP + azione Send to Log Analytics</span>
{
"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"
}
}
}
}</pre>
</div>
<div class="info-box info-purple">
<strong>Art.23 NIS2 + SIEM:</strong> Configurare alert SIEM su <code>incident.significant</code> e <code>incident.deadline_warning</code> 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.
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SustainAI × NIS2 Agile — Integrazione</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #06b6d4; --green: #10b981; --gray-200: #e2e8f0;
--gray-500: #64748b; --gray-700: #334155; --gray-900: #0f172a;
--radius: 8px; --font: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
--mono: 'Cascadia Code','Consolas',monospace;
}
body { background: #f8fafc; font-family: var(--font); color: var(--gray-900); }
.header { background: linear-gradient(135deg, #064e3b, #065f46); padding: 40px 48px; color: #fff; }
.header-badges { display: flex; gap: 10px; margin-bottom: 16px; }
.badge { padding: 4px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 700; }
.badge-nis2 { background: rgba(6,182,212,0.2); color: #67e8f9; border: 1px solid rgba(6,182,212,0.3); }
.badge-sus { background: rgba(16,185,129,0.2); color: #6ee7b7; border: 1px solid rgba(16,185,129,0.3); }
h1 { font-size: 1.875rem; font-weight: 800; margin-bottom: 8px; }
.header p { color: #a7f3d0; font-size: 1rem; }
.container { max-width: 960px; margin: 0 auto; padding: 40px 24px; }
h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 2px solid var(--gray-200); }
.section { margin-bottom: 48px; }
p { color: var(--gray-500); font-size: 0.9rem; line-height: 1.7; margin-bottom: 12px; }
pre { background: #1e293b; color: #e2e8f0; padding: 20px; border-radius: var(--radius); font-family: var(--mono); font-size: 0.8125rem; overflow-x: auto; line-height: 1.7; margin: 12px 0; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 4px; font-family: var(--mono); font-size: 0.85em; color: var(--gray-700); }
.step { display: flex; gap: 16px; margin-bottom: 20px; padding: 18px; background: #fff; border: 1px solid var(--gray-200); border-radius: var(--radius); }
.step-num { width: 30px; height: 30px; border-radius: 50%; background: var(--green); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; }
.info-box { padding: 14px 16px; border-radius: var(--radius); margin: 16px 0; font-size: 0.875rem; }
.info-green { background: #f0fdf4; border-left: 3px solid var(--green); color: #065f46; }
.mapping-table { width: 100%; border-collapse: collapse; }
.mapping-table th { padding: 10px 14px; background: #f1f5f9; font-size: 0.8rem; text-align: left; font-weight: 700; }
.mapping-table td { padding: 10px 14px; border-bottom: 1px solid var(--gray-200); font-size: 0.8375rem; }
.esg-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; }
.esg-g { background: #e0f2fe; color: #075985; }
.esg-e { background: #dcfce7; color: #166534; }
.esg-s { background: #faf5ff; color: #6b21a8; }
</style>
</head>
<body>
<div class="header">
<div class="header-badges">
<span class="badge badge-sus">SustainAI</span>
<span>×</span>
<span class="badge badge-nis2">NIS2 Agile</span>
</div>
<h1>Integrazione SustainAI ← NIS2 Agile</h1>
<p>Alimenta l'area Governance (G) e Sociale (S) dei report ESG/sostenibilità con i dati di compliance cybersecurity NIS2. La governance della sicurezza informatica è un KPI ESG rilevante (GRI 418, SASB, CSRD).</p>
</div>
<div class="container">
<div class="section">
<h2>Mappatura NIS2 → ESG</h2>
<p>I dati NIS2 Agile si mappano naturalmente ai framework ESG più diffusi:</p>
<table class="mapping-table">
<thead><tr><th>Dato NIS2 Agile</th><th>Endpoint</th><th>Pilastro ESG</th><th>Framework</th></tr></thead>
<tbody>
<tr><td>Compliance score Art.21</td><td><code>/services/compliance-summary</code></td><td><span class="esg-badge esg-g">G</span> Governance</td><td>GRI 205, CSRD</td></tr>
<tr><td>Policy sicurezza approvate</td><td><code>/services/policies-approved</code></td><td><span class="esg-badge esg-g">G</span> Governance</td><td>ISO 27001, GRI 418</td></tr>
<tr><td>Incidenti data breach / Art.23</td><td><code>/services/incidents-feed</code></td><td><span class="esg-badge esg-s">S</span> Sociale</td><td>GRI 418 (Privacy)</td></tr>
<tr><td>Controlli di sicurezza implementati</td><td><code>/services/controls-status</code></td><td><span class="esg-badge esg-g">G</span> Governance</td><td>SASB</td></tr>
<tr><td>Rischio supply chain fornitori</td><td><code>/services/suppliers-risk</code></td><td><span class="esg-badge esg-e">E</span> Ambientale + <span class="esg-badge esg-g">G</span></td><td>GRI 308</td></tr>
<tr><td>Segnalazioni whistleblowing</td><td><code>/api/whistleblowing/stats</code></td><td><span class="esg-badge esg-s">S</span> Sociale</td><td>GRI 205 (Anti-corruzione)</td></tr>
</tbody>
</table>
</div>
<div class="section">
<h2>Step 1 — API Key con scope minimi</h2>
<p>Crea in NIS2 Agile una chiave con scope limitati per SustainAI:</p>
<pre>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</pre>
</div>
<div class="section">
<h2>Step 2 — Sync mensile per report ESG</h2>
<pre><span style="color:#7dd3fc;">// SustainAI — sync_nis2_esg.php (cron mensile)</span>
<span style="color:#f1fa8c;">$apiKey</span> = getenv(<span style="color:#86efac;">'NIS2_API_KEY'</span>);
<span style="color:#f1fa8c;">$orgId</span> = getenv(<span style="color:#86efac;">'NIS2_ORG_ID'</span>);
<span style="color:#f1fa8c;">$base</span> = <span style="color:#86efac;">'https://nis2.certisource.it/api'</span>;
<span style="color:#f1fa8c;">$headers</span> = [
<span style="color:#86efac;">'X-API-Key: '</span> . <span style="color:#f1fa8c;">$apiKey</span>,
<span style="color:#86efac;">'X-Organization-Id: '</span> . <span style="color:#f1fa8c;">$orgId</span>,
];
<span style="color:#7dd3fc;">// G — Compliance score (KPI governance cybersecurity)</span>
<span style="color:#f1fa8c;">$compliance</span> = nis2Get(<span style="color:#f1fa8c;">$base</span> . <span style="color:#86efac;">'/services/compliance-summary'</span>, <span style="color:#f1fa8c;">$headers</span>);
<span style="color:#f1fa8c;">$cyberScore</span> = <span style="color:#f1fa8c;">$compliance</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'overall_score'</span>] ?? 0;
<span style="color:#f1fa8c;">$policyCount</span> = <span style="color:#f1fa8c;">$compliance</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'policies'</span>][<span style="color:#86efac;">'approved'</span>] ?? 0;
<span style="color:#7dd3fc;">// S — Privacy breaches (GRI 418)</span>
<span style="color:#f1fa8c;">$incidents</span> = nis2Get(<span style="color:#f1fa8c;">$base</span> . <span style="color:#86efac;">'/services/incidents-feed?significant_only=1'</span>, <span style="color:#f1fa8c;">$headers</span>);
<span style="color:#f1fa8c;">$breaches</span> = array_filter(
<span style="color:#f1fa8c;">$incidents</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'incidents'</span>] ?? [],
fn(<span style="color:#f1fa8c;">$i</span>) => <span style="color:#f1fa8c;">$i</span>[<span style="color:#86efac;">'classification'</span>] === <span style="color:#86efac;">'data_breach'</span>
);
<span style="color:#7dd3fc;">// G — Supply chain risk (ESG fornitori)</span>
<span style="color:#f1fa8c;">$suppliers</span> = nis2Get(<span style="color:#f1fa8c;">$base</span> . <span style="color:#86efac;">'/services/suppliers-risk'</span>, <span style="color:#f1fa8c;">$headers</span>);
<span style="color:#f1fa8c;">$highRiskSuppliers</span> = <span style="color:#f1fa8c;">$suppliers</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'stats'</span>][<span style="color:#86efac;">'high'</span>] +
<span style="color:#f1fa8c;">$suppliers</span>[<span style="color:#86efac;">'data'</span>][<span style="color:#86efac;">'stats'</span>][<span style="color:#86efac;">'critical'</span>];
<span style="color:#7dd3fc;">// Aggiorna KPI ESG in SustainAI</span>
EsgKpiService::updateCyberGovernance([
<span style="color:#86efac;">'nis2_score'</span> => <span style="color:#f1fa8c;">$cyberScore</span>,
<span style="color:#86efac;">'policies_approved'</span> => <span style="color:#f1fa8c;">$policyCount</span>,
<span style="color:#86efac;">'data_breaches'</span> => count(<span style="color:#f1fa8c;">$breaches</span>),
<span style="color:#86efac;">'high_risk_suppliers'</span> => <span style="color:#f1fa8c;">$highRiskSuppliers</span>,
<span style="color:#86efac;">'period'</span> => date(<span style="color:#86efac;">'Y-m'</span>),
]);</pre>
</div>
<div class="section">
<h2>Widget NIS2 per Report ESG SustainAI</h2>
<pre>&lt;!-- SustainAI: sezione Governance → Cybersecurity KPIs --&gt;
&lt;div id="nis2-esg-widget" style="padding:20px; border:1px solid #e2e8f0; border-radius:8px; background:#f0fdf4;"&gt;&lt;/div&gt;
&lt;script&gt;
fetch('https://nis2.certisource.it/api/services/compliance-summary', {
headers: { 'X-API-Key': '<span style="color:#86efac;">nis2_YOUR_KEY</span>', 'X-Organization-Id': '<span style="color:#86efac;">ORG_ID</span>' }
}).then(r => r.json()).then(({ data }) => {
document.getElementById('nis2-esg-widget').innerHTML = `
&lt;h4 style="font-size:.875rem; font-weight:700; color:#065f46; margin-bottom:16px;"&gt;
🔒 Governance Cybersecurity — NIS2 Compliance
&lt;/h4&gt;
&lt;div style="display:grid; grid-template-columns:repeat(4,1fr); gap:12px; text-align:center;"&gt;
&lt;div&gt;&lt;div style="font-size:1.5rem; font-weight:800; color:#06b6d4;"&gt;${data.overall_score}%&lt;/div&gt;
&lt;div style="font-size:.7rem; color:#64748b;"&gt;NIS2 Score&lt;/div&gt;&lt;/div&gt;
&lt;div&gt;&lt;div style="font-size:1.5rem; font-weight:800; color:#10b981;"&gt;${data.policies.approved}&lt;/div&gt;
&lt;div style="font-size:.7rem; color:#64748b;"&gt;Policy Approvate&lt;/div&gt;&lt;/div&gt;
&lt;div&gt;&lt;div style="font-size:1.5rem; font-weight:800; color:#f59e0b;"&gt;${data.risks.high}&lt;/div&gt;
&lt;div style="font-size:.7rem; color:#64748b;"&gt;Rischi HIGH&lt;/div&gt;&lt;/div&gt;
&lt;div&gt;&lt;div style="font-size:1.5rem; font-weight:800; color:#ef4444;"&gt;${data.incidents.significant}&lt;/div&gt;
&lt;div style="font-size:.7rem; color:#64748b;"&gt;Incidenti Art.23&lt;/div&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p style="font-size:.7rem; color:#94a3b8; margin-top:12px; text-align:right;"&gt;
Fonte: NIS2 Agile — nis2.certisource.it — Aggiornato: ${new Date().toLocaleDateString('it')}
&lt;/p&gt;`;
});
&lt;/script&gt;</pre>
</div>
<div class="info-box info-green">
<strong>CSRD / ESRS E5:</strong> 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.
</div>
</div>
</body>
</html>

View File

@ -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: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>` },
{ name: 'Normative', href: 'normative.html', icon: `<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>` },
]
},
{
@ -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' },
]
},
{

256
public/normative.html Normal file
View File

@ -0,0 +1,256 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aggiornamenti Normativi - NIS2 Agile</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.impact-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
.impact-critical { background: var(--danger-bg); color: var(--danger); }
.impact-high { background: #fff7ed; color: #c2410c; }
.impact-medium { background: var(--warning-bg); color: #a16207; }
.impact-low { background: var(--gray-100); color: var(--gray-600); }
.impact-informational { background: rgba(6,182,212,0.1); color: #0891b2; }
.ack-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; }
.ack-done { background: var(--success-bg); color: var(--success); }
.ack-pending { background: var(--warning-bg); color: #a16207; }
.source-chip { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 700; background: var(--gray-100); color: var(--gray-600); font-family: monospace; }
.update-card { border: 1px solid var(--gray-200); border-radius: var(--border-radius-lg); padding: 20px; margin-bottom: 16px; transition: all var(--transition-fast); }
.update-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(6,182,212,0.1); }
.update-card.action-required { border-left: 3px solid var(--warning); }
.update-card.acked { opacity: 0.7; }
.ack-progress { height: 6px; background: var(--gray-200); border-radius: 3px; overflow: hidden; }
.ack-progress-bar { height: 100%; background: var(--primary); border-radius: 3px; transition: width 0.5s ease; }
</style>
</head>
<body>
<div class="app-layout">
<div id="sidebar-container"></div>
<main class="main-content">
<div class="page-header">
<div class="page-header-content">
<h1 class="page-title">Aggiornamenti Normativi</h1>
<p class="page-subtitle">Feed NIS2 / ACN / DORA — Presa visione obbligatoria per audit compliance</p>
</div>
<div class="page-header-actions">
<button class="btn btn-secondary" onclick="loadPending()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
Solo Pendenti
</button>
</div>
</div>
<!-- Stats -->
<div class="stats-grid mb-24" id="stats-grid">
<div class="stat-card"><div class="spinner"></div></div>
</div>
<!-- Ack Progress bar -->
<div class="card mb-24">
<div class="card-body" style="padding:16px 20px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;">
<span style="font-size:0.875rem; font-weight:600; color:var(--gray-700);">Avanzamento presa visione</span>
<span id="ack-pct" style="font-size:0.875rem; font-weight:700; color:var(--primary);"></span>
</div>
<div class="ack-progress">
<div class="ack-progress-bar" id="ack-bar" style="width:0%"></div>
</div>
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:6px;">Documentare la presa visione degli aggiornamenti normativi è richiesto per la compliance NIS2 Art.21.</p>
</div>
</div>
<!-- Filters -->
<div class="card mb-16">
<div class="card-body" style="padding:14px 20px;">
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
<select id="filter-source" class="form-control" style="width:auto;" onchange="loadUpdates()">
<option value="">Tutte le fonti</option>
<option value="dlgs_138_2024">D.Lgs. 138/2024</option>
<option value="nis2_directive">Direttiva NIS2</option>
<option value="acn_guideline">ACN Guideline</option>
<option value="dora">DORA</option>
<option value="enisa">ENISA</option>
<option value="iso27001">ISO 27001</option>
</select>
<select id="filter-impact" class="form-control" style="width:auto;" onchange="loadUpdates()">
<option value="">Tutti i livelli</option>
<option value="critical">Critico</option>
<option value="high">Alto</option>
<option value="medium">Medio</option>
<option value="low">Basso</option>
<option value="informational">Informativo</option>
</select>
<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" id="filter-action" onchange="loadUpdates()" style="accent-color:var(--primary);">
Solo azione richiesta
</label>
</div>
</div>
</div>
<div id="updates-container"><div class="spinner" style="margin:60px auto;"></div></div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/common.js"></script>
<script src="/js/i18n.js"></script>
<script src="/js/help.js"></script>
<script>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
const sourceLabels = {
nis2_directive: 'NIS2 Directive', dlgs_138_2024: 'D.Lgs. 138/2024',
acn_guideline: 'ACN', dora: 'DORA', enisa: 'ENISA', iso27001: 'ISO 27001', other: 'Altro'
};
const impactLabels = {
critical: 'Critico', high: 'Alto', medium: 'Medio', low: 'Basso', informational: 'Informativo'
};
async function loadStats() {
try {
const result = await api.request('GET', '/normative/stats');
if (result.success) renderStats(result.data);
} catch (e) {}
}
function renderStats(d) {
document.getElementById('stats-grid').innerHTML = `
<div class="stat-card">
<div class="stat-label">Aggiornamenti Totali</div>
<div class="stat-value">${d.total_updates || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Prese Visione</div>
<div class="stat-value" style="color:var(--success);">${d.acknowledged || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Pendenti</div>
<div class="stat-value" style="color:var(--warning);">${d.pending || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Compliance ACK</div>
<div class="stat-value" style="color:var(--primary);">${d.ack_rate || 0}%</div>
</div>`;
document.getElementById('ack-pct').textContent = (d.ack_rate || 0) + '%';
document.getElementById('ack-bar').style.width = (d.ack_rate || 0) + '%';
}
async function loadUpdates() {
const container = document.getElementById('updates-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
try {
const params = new URLSearchParams();
const source = document.getElementById('filter-source').value;
const impact = document.getElementById('filter-impact').value;
const action = document.getElementById('filter-action').checked;
if (source) params.append('source', source);
if (impact) params.append('impact', impact);
if (action) params.append('action_required', '1');
const result = await api.request('GET', '/normative/list?' + params);
if (result.success) renderUpdates(result.data.updates || []);
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore caricamento aggiornamenti</h4></div>';
}
}
async function loadPending() {
const container = document.getElementById('updates-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
try {
const result = await api.request('GET', '/normative/pending');
if (result.success) renderUpdates(result.data.pending || [], true);
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore.</h4></div>'; }
}
function renderUpdates(updates, pendingOnly) {
const container = document.getElementById('updates-container');
if (!updates.length) {
container.innerHTML = `<div class="empty-state">
<svg viewBox="0 0 20 20" fill="currentColor" width="40" height="40" style="color:var(--gray-300)"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm2 10a1 1 0 10-2 0v3a1 1 0 102 0v-3zm2-3a1 1 0 011 1v5a1 1 0 11-2 0v-5a1 1 0 011-1zm4-1a1 1 0 10-2 0v6a1 1 0 102 0V8z" clip-rule="evenodd"/></svg>
<h4>${pendingOnly ? 'Nessun aggiornamento pendente — tutto preso in visione!' : 'Nessun aggiornamento trovato'}</h4>
</div>`;
return;
}
let html = '';
updates.forEach(u => {
const acked = u.is_acknowledged || !!(u.acknowledged_at);
const actionClass = u.action_required ? 'action-required' : '';
const ackedClass = acked ? 'acked' : '';
const domains = (u.affected_domains || []).map(d =>
`<span class="source-chip">${escapeHtml(d)}</span>`
).join(' ');
html += `<div class="update-card ${actionClass} ${ackedClass}" id="update-${u.id}">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div style="flex:1;">
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px;">
<span class="source-chip">${escapeHtml(sourceLabels[u.source] || u.source)}</span>
<span class="impact-badge impact-${u.impact_level}">${escapeHtml(impactLabels[u.impact_level] || u.impact_level)}</span>
${u.action_required ? '<span style="font-size:0.7rem; font-weight:700; color:var(--warning); background:var(--warning-bg); padding:2px 8px; border-radius:4px;">Azione richiesta</span>' : ''}
${acked
? `<span class="ack-badge ack-done"><svg viewBox="0 0 20 20" fill="currentColor" width="11" height="11"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>Preso in visione ${u.ack_by_name ? 'da ' + escapeHtml(u.ack_by_name) : ''}</span>`
: '<span class="ack-badge ack-pending">Da prendere in visione</span>'
}
</div>
<h3 style="font-size:1rem; font-weight:700; color:var(--gray-900); margin-bottom:6px;">${escapeHtml(u.title)}</h3>
${u.reference ? `<p style="font-size:0.75rem; color:var(--gray-500); margin-bottom:8px;"><code>${escapeHtml(u.reference)}</code></p>` : ''}
<p style="font-size:0.8125rem; color:var(--gray-600); line-height:1.6; margin-bottom:10px;">${escapeHtml(u.summary)}</p>
${domains ? `<div style="display:flex; gap:6px; flex-wrap:wrap;">${domains}</div>` : ''}
</div>
<div style="display:flex; flex-direction:column; gap:8px; align-items:flex-end; flex-shrink:0;">
${u.effective_date ? `<p style="font-size:0.75rem; color:var(--gray-500);">In vigore: <strong>${formatDate(u.effective_date)}</strong></p>` : ''}
${u.url ? `<a href="${escapeHtml(u.url)}" target="_blank" rel="noopener" class="btn btn-sm btn-secondary" style="font-size:0.75rem;">
<svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"/><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"/></svg>
Fonte
</a>` : ''}
${!acked ? `<button class="btn btn-sm btn-primary" onclick="acknowledgeUpdate(${u.id})">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
Prendi in visione
</button>` : ''}
</div>
</div>
</div>`;
});
container.innerHTML = html;
}
async function acknowledgeUpdate(id) {
showModal('Presa Visione', `
<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:16px;">
Documentare la presa visione dell'aggiornamento normativo. Opzionalmente aggiungere note sull'impatto per la tua organizzazione.
</p>
<div class="form-group">
<label class="form-label">Note (opzionale)</label>
<textarea id="ack-notes" class="form-control" rows="3" placeholder="Es. Analizzato con il team di compliance. Nessun impatto diretto rilevato..."></textarea>
</div>
`, `<button class="btn btn-primary" onclick="confirmAck(${id})">Conferma presa visione</button>
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>`);
}
async function confirmAck(id) {
const notes = document.getElementById('ack-notes').value;
try {
const result = await api.request('POST', `/normative/${id}/ack`, { notes });
if (result.success) {
closeModal();
showNotification('Presa visione registrata e documentata.', 'success');
loadUpdates();
loadStats();
} else showNotification(result.message || 'Errore.', 'error');
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
// Init
loadStats();
loadUpdates();
</script>
</body>
</html>

View File

@ -212,6 +212,8 @@
<button class="settings-tab" onclick="switchTab('profile')">Profilo</button>
<button class="settings-tab" onclick="switchTab('members')">Membri</button>
<button class="settings-tab" onclick="switchTab('security')">Sicurezza</button>
<button class="settings-tab" onclick="switchTab('apikeys')">API Keys</button>
<button class="settings-tab" onclick="switchTab('webhooks')">Webhook</button>
</div>
<!-- ══════════════ TAB: Organizzazione ══════════════ -->
@ -479,6 +481,64 @@
</div>
</div>
</div>
<!-- ══════════════ TAB: API Keys ══════════════ -->
<div class="tab-panel" id="tab-apikeys">
<div class="card mb-24">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<h3>API Keys</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">
Chiavi per accesso esterno alle API NIS2 Agile (SIEM, GRC, dashboard esterne).
</p>
</div>
<button class="btn btn-primary" onclick="showCreateApiKeyModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuova API Key
</button>
</div>
<div class="card-body" style="padding:0;">
<div id="apikeys-container"><div class="spinner" style="margin:40px auto;"></div></div>
</div>
</div>
<div class="card">
<div class="card-header"><h3>Scope Disponibili</h3></div>
<div class="card-body">
<div id="available-scopes-container"></div>
</div>
</div>
</div>
<!-- ══════════════ TAB: Webhook ══════════════ -->
<div class="tab-panel" id="tab-webhooks">
<div class="card mb-24">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<div>
<h3>Webhook Subscriptions</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">
Notifiche push verso sistemi esterni (SIEM, 231 Agile, SustainAI) su eventi NIS2.
</p>
</div>
<button class="btn btn-primary" onclick="showCreateWebhookModal()">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuovo Webhook
</button>
</div>
<div class="card-body" style="padding:0;">
<div id="webhooks-container"><div class="spinner" style="margin:40px auto;"></div></div>
</div>
</div>
<div class="card">
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3>Delivery Log</h3>
<button class="btn btn-sm btn-secondary" onclick="loadDeliveries()">Aggiorna</button>
</div>
<div class="card-body" style="padding:0;">
<div id="deliveries-container"><div class="empty-state" style="padding:32px;"><p>Seleziona un webhook per vedere i delivery.</p></div></div>
</div>
</div>
</div>
</div>
</main>
</div>
@ -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 = '<div class="spinner" style="margin:40px auto;"></div>';
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 = '<div class="empty-state"><h4>Errore caricamento API Keys</h4></div>'; }
}
function renderApiKeys(keys) {
const container = document.getElementById('apikeys-container');
if (!keys.length) {
container.innerHTML = `<div class="empty-state" style="padding:40px;"><svg viewBox="0 0 20 20" fill="currentColor" width="40" height="40" style="color:var(--gray-300)"><path fill-rule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clip-rule="evenodd"/></svg><h4>Nessuna API Key</h4><p>Crea una chiave per integrare sistemi esterni.</p></div>`;
return;
}
let html = `<div class="table-container"><table><thead><tr><th>Nome</th><th>Prefisso</th><th>Scopes</th><th>Ultimo Uso</th><th>Scadenza</th><th>Stato</th><th>Azioni</th></tr></thead><tbody>`;
keys.forEach(k => {
const active = k.is_active ? '<span class="badge badge-success">Attiva</span>' : '<span class="badge badge-danger">Revocata</span>';
const scopes = (k.scopes || []).map(s => `<span class="badge badge-neutral" style="font-size:0.65rem;">${escapeHtml(s)}</span>`).join(' ');
html += `<tr>
<td><strong>${escapeHtml(k.name)}</strong><br><small style="color:var(--gray-400);">Creata da ${escapeHtml(k.created_by_name || '-')}</small></td>
<td><code style="font-size:0.8rem;">${escapeHtml(k.key_prefix)}...</code></td>
<td>${scopes}</td>
<td>${k.last_used_at ? formatDateTime(k.last_used_at) : '<span style="color:var(--gray-400);">Mai</span>'}</td>
<td>${k.expires_at ? formatDate(k.expires_at) : '<span style="color:var(--gray-400);">Nessuna</span>'}</td>
<td>${active}</td>
<td>${k.is_active ? `<button class="btn-icon-action btn-danger-hover" onclick="revokeApiKey(${k.id}, '${escapeHtml(k.name)}')" title="Revoca"><svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9z" clip-rule="evenodd"/></svg></button>` : ''}</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function renderAvailableScopes(scopes) {
const container = document.getElementById('available-scopes-container');
let html = '<div style="display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px;">';
Object.entries(scopes).forEach(([key, desc]) => {
html += `<div style="padding:12px; background:var(--gray-50); border-radius:var(--border-radius); border:1px solid var(--gray-200);">
<code style="font-size:0.8rem; color:var(--primary);">${escapeHtml(key)}</code>
<p style="font-size:0.8rem; color:var(--gray-600); margin-top:4px;">${escapeHtml(desc)}</p>
</div>`;
});
html += '</div>';
container.innerHTML = html;
}
function showCreateApiKeyModal() {
showModal('Crea API Key', `
<div class="form-group">
<label class="form-label">Nome *</label>
<input type="text" id="apikey-name" class="form-control" placeholder="Es. SIEM Integration, Dashboard ESG..." required>
</div>
<div class="form-group">
<label class="form-label">Scopes *</label>
<div id="apikey-scopes-checkboxes" style="display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:8px;">
${['read:all','read:compliance','read:risks','read:incidents','read:assets','read:supply_chain','read:policies'].map(s =>
`<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" name="scope" value="${s}" style="accent-color:var(--primary);">
<code style="font-size:0.75rem;">${s}</code>
</label>`
).join('')}
</div>
</div>
<div class="form-group">
<label class="form-label">Scadenza (opzionale)</label>
<input type="date" id="apikey-expires" class="form-control">
</div>
`, '<button class="btn btn-primary" onclick="createApiKey()">Crea API Key</button>');
}
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', `
<div style="padding:16px; background:var(--warning-bg); border:1px solid var(--warning); border-radius:var(--border-radius); margin-bottom:16px;">
<strong style="color:#a16207;">⚠ Salva questa chiave ora. Non sarà più visibile.</strong>
</div>
<div style="padding:12px; background:var(--gray-900); border-radius:var(--border-radius); font-family:monospace; font-size:0.875rem; color:#34d399; word-break:break-all; user-select:all;">
${escapeHtml(result.data.key)}
</div>
<p style="font-size:0.8rem; color:var(--gray-500); margin-top:8px;">Usa come header: <code>X-API-Key: ${escapeHtml(result.data.key)}</code></p>
`, '<button class="btn btn-primary" onclick="closeModal(); loadApiKeys();">Ho salvato la chiave</button>');
} 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 = '<div class="spinner" style="margin:40px auto;"></div>';
try {
const result = await api.request('GET', '/webhooks/subscriptions');
if (result.success) renderWebhooks(result.data.subscriptions || []);
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore caricamento webhook</h4></div>'; }
}
function renderWebhooks(subs) {
const container = document.getElementById('webhooks-container');
if (!subs.length) {
container.innerHTML = `<div class="empty-state" style="padding:40px;"><svg viewBox="0 0 20 20" fill="currentColor" width="40" height="40" style="color:var(--gray-300)"><path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z"/><path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z"/></svg><h4>Nessun Webhook</h4><p>Configura webhook per notifiche push verso SIEM e sistemi esterni.</p></div>`;
return;
}
let html = `<div class="table-container"><table><thead><tr><th>Nome</th><th>URL</th><th>Eventi</th><th>Delivery</th><th>Stato</th><th>Azioni</th></tr></thead><tbody>`;
subs.forEach(s => {
const evts = (s.events || []).map(e => `<span class="badge badge-neutral" style="font-size:0.65rem;">${escapeHtml(e)}</span>`).join(' ');
const active = s.is_active ? '<span class="badge badge-success">Attivo</span>' : '<span class="badge badge-warning">Pausa</span>';
const deliveryInfo = `<small style="color:var(--gray-500);">${s.success_deliveries||0}✓ ${s.failed_deliveries||0}✗</small>`;
html += `<tr>
<td><strong>${escapeHtml(s.name)}</strong>${s.failure_count >= 5 ? '<br><span style="color:var(--danger);font-size:0.75rem;">⚠ '+s.failure_count+' errori</span>' : ''}</td>
<td><code style="font-size:0.75rem; word-break:break-all;">${escapeHtml(s.url)}</code></td>
<td>${evts}</td>
<td>${deliveryInfo}</td>
<td>${active}</td>
<td style="white-space:nowrap;">
<button class="btn-icon-action" onclick="testWebhook(${s.id})" title="Test Ping">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/></svg>
</button>
<button class="btn-icon-action" onclick="toggleWebhook(${s.id}, ${s.is_active})" title="${s.is_active ? 'Disabilita' : 'Abilita'}">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/></svg>
</button>
<button class="btn-icon-action btn-danger-hover" onclick="deleteWebhook(${s.id}, '${escapeHtml(s.name)}')" title="Elimina">
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9z" clip-rule="evenodd"/></svg>
</button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
function showCreateWebhookModal() {
const evtCheckboxes = Object.entries(availableEvents).map(([key, label]) =>
`<label style="display:flex; align-items:center; gap:8px; font-size:0.8rem; cursor:pointer; padding:4px 0;">
<input type="checkbox" name="wh-event" value="${key}" style="accent-color:var(--primary);">
<span><code style="font-size:0.7rem;">${key}</code> — ${escapeHtml(label)}</span>
</label>`
).join('');
showModal('Crea Webhook', `
<div class="form-group">
<label class="form-label">Nome *</label>
<input type="text" id="wh-name" class="form-control" placeholder="Es. SIEM Integration, 231 Agile Notify...">
</div>
<div class="form-group">
<label class="form-label">URL Endpoint * (https://...)</label>
<input type="url" id="wh-url" class="form-control" placeholder="https://your-siem.example.com/webhooks/nis2">
</div>
<div class="form-group">
<label class="form-label">Eventi da ascoltare *</label>
<div style="max-height:200px; overflow-y:auto; padding:8px; border:1px solid var(--gray-200); border-radius:var(--border-radius); margin-top:8px;">${evtCheckboxes}</div>
</div>
`, '<button class="btn btn-primary" onclick="createWebhook()">Crea Webhook</button>');
}
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', `
<div style="padding:16px; background:var(--warning-bg); border:1px solid var(--warning); border-radius:var(--border-radius); margin-bottom:16px;">
<strong style="color:#a16207;">⚠ Salva il secret per verificare la firma HMAC. Non sarà più visibile.</strong>
</div>
<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:8px;">Usa questo secret per verificare l'header <code>X-NIS2-Signature</code>:</p>
<div style="padding:12px; background:var(--gray-900); border-radius:var(--border-radius); font-family:monospace; font-size:0.875rem; color:#34d399; word-break:break-all; user-select:all;">
${escapeHtml(result.data.secret)}
</div>
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:8px;">Firma: <code>sha256=HMAC_SHA256(body, secret)</code></p>
`, '<button class="btn btn-primary" onclick="closeModal(); loadWebhooks();">Ho salvato il secret</button>');
} 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 = '<div class="spinner" style="margin:40px auto;"></div>';
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 = '<div class="empty-state" style="padding:32px;"><h4>Nessun delivery registrato</h4></div>';
return;
}
let html = `<div class="table-container"><table><thead><tr><th>Evento</th><th>Webhook</th><th>Stato</th><th>HTTP</th><th>Tentativo</th><th>Data</th></tr></thead><tbody>`;
deliveries.forEach(d => {
const statusClass = d.status === 'delivered' ? 'success' : d.status === 'retrying' ? 'warning' : 'danger';
html += `<tr>
<td><code style="font-size:0.75rem;">${escapeHtml(d.event_type)}</code></td>
<td style="font-size:0.8rem;">${escapeHtml(d.subscription_name || '-')}</td>
<td><span class="badge badge-${statusClass}">${escapeHtml(d.status)}</span></td>
<td>${d.http_status ? `<code>${d.http_status}</code>` : '-'}</td>
<td>${d.attempt}/3</td>
<td style="font-size:0.8rem;">${formatDateTime(d.created_at)}</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
} catch (e) { container.innerHTML = '<div class="empty-state"><h4>Errore caricamento delivery</h4></div>'; }
}
// ── Audit Log ────────────────────────────────────────────
async function loadAuditLog() {
const container = document.getElementById('audit-log-container');

436
public/whistleblowing.html Normal file
View File

@ -0,0 +1,436 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Segnalazioni - NIS2 Agile</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.priority-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
.priority-critical { background: var(--danger-bg); color: var(--danger); }
.priority-high { background: #fff7ed; color: #c2410c; }
.priority-medium { background: var(--warning-bg); color: #a16207; }
.priority-low { background: var(--gray-100); color: var(--gray-600); }
.status-badge { display: inline-flex; align-items: center; padding: 2px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; }
.status-received { background: rgba(6,182,212,0.1); color: #0891b2; }
.status-under_review { background: var(--warning-bg); color: #a16207; }
.status-investigating { background: #f3e8ff; color: #7c3aed; }
.status-resolved, .status-closed { background: var(--success-bg); color: var(--success); }
.status-rejected { background: var(--danger-bg); color: var(--danger); }
.anonymous-shield { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; background: rgba(16,185,129,0.1); border-radius: 12px; color: var(--success); font-size: 0.75rem; font-weight: 600; }
.art32-banner { background: linear-gradient(135deg, rgba(6,182,212,0.1), rgba(16,185,129,0.1)); border: 1px solid rgba(6,182,212,0.2); border-radius: var(--border-radius-lg); padding: 20px 24px; margin-bottom: 28px; }
.art32-banner h3 { font-size: 0.9375rem; font-weight: 700; color: var(--gray-800); margin-bottom: 6px; }
.art32-banner p { font-size: 0.8125rem; color: var(--gray-600); }
.track-form { background: var(--gray-50); border: 1px solid var(--gray-200); border-radius: var(--border-radius); padding: 20px; margin-bottom: 24px; }
</style>
</head>
<body>
<div class="app-layout">
<div id="sidebar-container"></div>
<main class="main-content">
<div class="page-header">
<div class="page-header-content">
<h1 class="page-title">Segnalazioni Sicurezza</h1>
<p class="page-subtitle">Canale interno Art.32 NIS2 — Segnala violazioni e anomalie di sicurezza</p>
</div>
<div class="page-header-actions">
<button class="btn btn-secondary" onclick="switchView('track')">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd"/></svg>
Traccia Segnalazione Anonima
</button>
<button class="btn btn-primary" onclick="switchView('submit')">
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>
Nuova Segnalazione
</button>
</div>
</div>
<!-- Art.32 Banner -->
<div class="art32-banner">
<h3>Art. 32 D.Lgs. 138/2024 — Canale Segnalazioni Interno</h3>
<p>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à.</p>
</div>
<!-- Stats Cards -->
<div class="stats-grid mb-24" id="stats-grid">
<div class="stat-card"><div class="spinner"></div></div>
</div>
<!-- Views -->
<div id="view-list">
<!-- Filters -->
<div class="card mb-16">
<div class="card-body" style="padding:16px 20px;">
<div style="display:flex; gap:12px; flex-wrap:wrap; align-items:center;">
<select id="filter-status" class="form-control" style="width:auto;" onchange="loadReports()">
<option value="">Tutti gli stati</option>
<option value="received">Ricevute</option>
<option value="under_review">In revisione</option>
<option value="investigating">In indagine</option>
<option value="resolved">Risolte</option>
<option value="closed">Chiuse</option>
</select>
<select id="filter-priority" class="form-control" style="width:auto;" onchange="loadReports()">
<option value="">Tutte le priorità</option>
<option value="critical">Critica</option>
<option value="high">Alta</option>
<option value="medium">Media</option>
<option value="low">Bassa</option>
</select>
<select id="filter-category" class="form-control" style="width:auto;" onchange="loadReports()">
<option value="">Tutte le categorie</option>
<option value="security_incident">Incidente sicurezza</option>
<option value="data_breach">Data breach</option>
<option value="unauthorized_access">Accesso non autorizzato</option>
<option value="policy_violation">Violazione policy</option>
<option value="supply_chain_risk">Rischio supply chain</option>
<option value="nis2_non_compliance">Non conformità NIS2</option>
<option value="other">Altro</option>
</select>
</div>
</div>
</div>
<div id="reports-container"><div class="spinner" style="margin:60px auto;"></div></div>
</div>
<!-- Submit Form -->
<div id="view-submit" style="display:none;">
<div class="card" style="max-width:720px;">
<div class="card-header">
<h3>Nuova Segnalazione</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Segnala anonimamente o con il tuo nome. L'anonimato è garantito dalla piattaforma.</p>
</div>
<div class="card-body">
<div style="margin-bottom:20px; padding:12px 16px; background:rgba(16,185,129,0.08); border:1px solid rgba(16,185,129,0.2); border-radius:var(--border-radius); display:flex; align-items:center; gap:10px;">
<svg viewBox="0 0 20 20" fill="currentColor" width="18" height="18" style="color:var(--success); flex-shrink:0;"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>
<label style="display:flex; align-items:center; gap:8px; font-size:0.875rem; cursor:pointer;">
<input type="checkbox" id="is-anonymous" checked style="accent-color:var(--success);">
<span><strong>Segnalazione anonima</strong> — La tua identità non verrà registrata</span>
</label>
</div>
<form id="submit-form" onsubmit="submitReport(event)">
<div class="form-group">
<label class="form-label">Categoria *</label>
<select id="rep-category" class="form-control" required>
<option value="">Seleziona categoria...</option>
<option value="security_incident">Incidente di sicurezza</option>
<option value="data_breach">Data breach / Violazione dati</option>
<option value="unauthorized_access">Accesso non autorizzato</option>
<option value="policy_violation">Violazione policy interna</option>
<option value="supply_chain_risk">Rischio supply chain</option>
<option value="nis2_non_compliance">Non conformità NIS2</option>
<option value="corruption">Corruzione / Frode</option>
<option value="other">Altro</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Titolo *</label>
<input type="text" id="rep-title" class="form-control" placeholder="Breve descrizione dell'anomalia..." required>
</div>
<div class="form-group">
<label class="form-label">Descrizione dettagliata *</label>
<textarea id="rep-description" class="form-control" rows="6" placeholder="Descrivi cosa hai osservato, quando è successo, chi è coinvolto (se noto), e qualsiasi altra informazione utile..." required></textarea>
</div>
<div class="form-group">
<label class="form-label">Priorità stimata</label>
<select id="rep-priority" class="form-control">
<option value="medium">Media</option>
<option value="high">Alta</option>
<option value="critical">Critica — Azione immediata richiesta</option>
<option value="low">Bassa</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Articolo NIS2 violato (opzionale)</label>
<input type="text" id="rep-article" class="form-control" placeholder="Es. Art.21, Art.23...">
</div>
<div class="form-group" id="contact-email-group">
<label class="form-label">Email per follow-up (opzionale)</label>
<input type="email" id="rep-email" class="form-control" placeholder="Email solo per ricevere aggiornamenti">
<p style="font-size:0.75rem; color:var(--gray-500); margin-top:4px;">Lascia vuoto per mantenere l'anonimato completo. Oppure fornisci un'email temporanea.</p>
</div>
<div style="display:flex; gap:12px; margin-top:24px;">
<button type="submit" class="btn btn-primary">Invia Segnalazione</button>
<button type="button" class="btn btn-secondary" onclick="switchView('list')">Annulla</button>
</div>
</form>
</div>
</div>
</div>
<!-- Track Anonymous -->
<div id="view-track" style="display:none;">
<div class="card" style="max-width:600px;">
<div class="card-header">
<h3>Traccia Segnalazione Anonima</h3>
<p style="font-size:0.8125rem; color:var(--gray-500); margin-top:4px;">Inserisci il token ricevuto al momento della segnalazione per verificarne lo stato.</p>
</div>
<div class="card-body">
<div class="form-group">
<label class="form-label">Token Segnalazione</label>
<input type="text" id="track-token" class="form-control" placeholder="Inserisci il token...">
</div>
<div style="display:flex; gap:12px;">
<button class="btn btn-primary" onclick="trackReport()">Verifica Stato</button>
<button class="btn btn-secondary" onclick="switchView('list')">Annulla</button>
</div>
<div id="track-result" style="margin-top:24px;"></div>
</div>
</div>
</div>
</main>
</div>
<!-- Report Detail Modal handled by common.js showModal -->
<script src="/js/api.js"></script>
<script src="/js/common.js"></script>
<script src="/js/i18n.js"></script>
<script src="/js/help.js"></script>
<script>
if (!checkAuth()) throw new Error('Not authenticated');
loadSidebar();
I18n.init();
let currentView = 'list';
const categoryLabels = {
security_incident: 'Incidente sicurezza', data_breach: 'Data breach',
unauthorized_access: 'Accesso non autorizzato', policy_violation: 'Violazione policy',
supply_chain_risk: 'Rischio supply chain', nis2_non_compliance: 'Non conformità NIS2',
corruption: 'Corruzione/Frode', fraud: 'Frode', other: 'Altro'
};
const priorityLabels = { critical: 'Critica', high: 'Alta', medium: 'Media', low: 'Bassa' };
const statusLabels = {
received: 'Ricevuta', under_review: 'In revisione', investigating: 'In indagine',
resolved: 'Risolta', closed: 'Chiusa', rejected: 'Respinta'
};
function switchView(view) {
currentView = view;
['list','submit','track'].forEach(v => {
document.getElementById('view-' + v).style.display = v === view ? 'block' : 'none';
});
if (view === 'list') { loadStats(); loadReports(); }
}
async function loadStats() {
try {
const result = await api.request('GET', '/whistleblowing/stats');
if (result.success) renderStats(result.data.stats);
} catch (e) {}
}
function renderStats(s) {
const grid = document.getElementById('stats-grid');
grid.innerHTML = `
<div class="stat-card">
<div class="stat-label">Totale Segnalazioni</div>
<div class="stat-value">${s.total || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Da Gestire</div>
<div class="stat-value" style="color:var(--warning);">${(s.received || 0) + (s.under_review || 0)}</div>
</div>
<div class="stat-card">
<div class="stat-label">In Indagine</div>
<div class="stat-value" style="color:var(--primary);">${s.investigating || 0}</div>
</div>
<div class="stat-card">
<div class="stat-label">Critiche/Alte</div>
<div class="stat-value" style="color:var(--danger);">${(s.critical || 0) + (s.high || 0)}</div>
</div>`;
}
async function loadReports() {
const container = document.getElementById('reports-container');
container.innerHTML = '<div class="spinner" style="margin:60px auto;"></div>';
try {
const params = new URLSearchParams();
const status = document.getElementById('filter-status').value;
const priority = document.getElementById('filter-priority').value;
const category = document.getElementById('filter-category').value;
if (status) params.append('status', status);
if (priority) params.append('priority', priority);
if (category) params.append('category', category);
const result = await api.request('GET', '/whistleblowing/list?' + params);
if (result.success) renderReports(result.data.reports || []);
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore caricamento segnalazioni</h4></div>';
}
}
function renderReports(reports) {
const container = document.getElementById('reports-container');
if (!reports.length) {
container.innerHTML = `
<div class="empty-state">
<svg viewBox="0 0 20 20" fill="currentColor" width="40" height="40" style="color:var(--gray-300)"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>
<h4>Nessuna segnalazione trovata</h4>
<p>Il canale di segnalazione è attivo. Le segnalazioni appariranno qui.</p>
</div>`;
return;
}
let html = `<div class="table-container"><table>
<thead><tr><th>Codice</th><th>Categoria</th><th>Titolo</th><th>Priorità</th><th>Stato</th><th>Anonima</th><th>Assegnata</th><th>Data</th><th></th></tr></thead><tbody>`;
reports.forEach(r => {
html += `<tr>
<td><code style="font-size:0.8rem;">${escapeHtml(r.report_code)}</code></td>
<td><span class="badge badge-neutral">${escapeHtml(categoryLabels[r.category] || r.category)}</span></td>
<td><strong style="font-size:0.875rem;">${escapeHtml(r.title)}</strong></td>
<td><span class="priority-badge priority-${r.priority}">${escapeHtml(priorityLabels[r.priority] || r.priority)}</span></td>
<td><span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span></td>
<td>${r.is_anonymous ? '<span class="anonymous-shield"><svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>Anonima</span>' : '<span style="color:var(--gray-400); font-size:0.75rem;">Firmata</span>'}</td>
<td>${r.assigned_to_name ? escapeHtml(r.assigned_to_name) : '<span style="color:var(--gray-400);"></span>'}</td>
<td style="font-size:0.8rem;">${formatDate(r.created_at)}</td>
<td><button class="btn btn-sm btn-secondary" onclick="viewReport(${r.id})">Gestisci</button></td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
}
async function viewReport(id) {
try {
const result = await api.request('GET', `/whistleblowing/${id}`);
if (!result.success) { showNotification('Errore caricamento.', 'error'); return; }
const r = result.data;
const timelineHtml = (r.timeline || []).map(t => `
<div style="padding:10px 0; border-bottom:1px solid var(--gray-100); display:flex; gap:12px;">
<div style="width:8px; height:8px; border-radius:50%; background:var(--primary); margin-top:6px; flex-shrink:0;"></div>
<div>
<p style="font-size:0.8125rem; color:var(--gray-700);">${escapeHtml(t.description)}</p>
<p style="font-size:0.75rem; color:var(--gray-400);">${formatDateTime(t.created_at)} ${t.created_by_name ? '— ' + escapeHtml(t.created_by_name) : ''}</p>
</div>
</div>`).join('');
showModal(`${escapeHtml(r.report_code)} — ${escapeHtml(r.title)}`, `
<div style="display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px;">
<span class="priority-badge priority-${r.priority}">${escapeHtml(priorityLabels[r.priority] || r.priority)}</span>
<span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span>
${r.is_anonymous ? '<span class="anonymous-shield"><svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/></svg>Anonima</span>' : ''}
</div>
<div style="padding:12px 16px; background:var(--gray-50); border-radius:var(--border-radius); margin-bottom:16px; font-size:0.875rem; line-height:1.6;">
${escapeHtml(r.description)}
</div>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px; font-size:0.8125rem;">
<div><strong>Categoria:</strong> ${escapeHtml(categoryLabels[r.category] || r.category)}</div>
<div><strong>Art. NIS2:</strong> ${r.nis2_article ? escapeHtml(r.nis2_article) : '—'}</div>
<div><strong>Assegnata a:</strong> ${r.assigned_to_name ? escapeHtml(r.assigned_to_name) : '—'}</div>
<div><strong>Data:</strong> ${formatDateTime(r.created_at)}</div>
</div>
<h4 style="font-size:0.8125rem; font-weight:700; margin-bottom:8px;">Timeline</h4>
<div>${timelineHtml || '<p style="color:var(--gray-400); font-size:0.8rem;">Nessun evento in timeline.</p>'}</div>
<div style="margin-top:20px; display:flex; flex-direction:column; gap:10px;">
<select id="modal-status" class="form-control">
<option value="">Cambia stato...</option>
${['under_review','investigating','resolved','closed','rejected'].map(s =>
`<option value="${s}" ${r.status === s ? 'selected' : ''}>${escapeHtml(statusLabels[s])}</option>`
).join('')}
</select>
<textarea id="modal-notes" class="form-control" rows="3" placeholder="Note di risoluzione...">${escapeHtml(r.resolution_notes || '')}</textarea>
</div>
`, `<button class="btn btn-primary" onclick="updateReport(${id})">Salva</button>
<button class="btn btn-secondary" onclick="closeModal()">Chiudi</button>`);
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function updateReport(id) {
const status = document.getElementById('modal-status').value;
const notes = document.getElementById('modal-notes').value;
const body = {};
if (status) body.status = status;
if (notes) body.resolution_notes = notes;
if (!Object.keys(body).length) { closeModal(); return; }
try {
const result = await api.request('PUT', `/whistleblowing/${id}`, body);
if (result.success) {
closeModal();
showNotification('Segnalazione aggiornata.', 'success');
loadReports(); loadStats();
} else showNotification(result.message || 'Errore.', 'error');
} catch (e) { showNotification('Errore di connessione.', 'error'); }
}
async function submitReport(e) {
e.preventDefault();
const btn = e.target.querySelector('[type=submit]');
btn.disabled = true; btn.textContent = 'Invio...';
try {
const body = {
category: document.getElementById('rep-category').value,
title: document.getElementById('rep-title').value,
description: document.getElementById('rep-description').value,
priority: document.getElementById('rep-priority').value,
nis2_article: document.getElementById('rep-article').value,
is_anonymous: document.getElementById('is-anonymous').checked ? 1 : 0,
contact_email: document.getElementById('rep-email').value,
organization_id: localStorage.getItem('nis2_org_id'),
};
const result = await api.request('POST', '/whistleblowing/submit', body);
if (result.success) {
const token = result.data.anonymous_token;
showModal('Segnalazione Inviata', `
<div style="padding:16px; background:rgba(16,185,129,0.1); border-radius:var(--border-radius); margin-bottom:16px;">
<p style="font-weight:700; color:var(--success);">Segnalazione ricevuta con codice: <code>${escapeHtml(result.data.report_code)}</code></p>
</div>
${token ? `<p style="font-size:0.875rem; color:var(--gray-600); margin-bottom:8px;">Token per tracking anonimo (conservalo):</p>
<div style="padding:12px; background:var(--gray-100); border-radius:var(--border-radius); font-family:monospace; font-size:0.8rem; word-break:break-all; user-select:all;">${escapeHtml(token)}</div>` : ''}
`, `<button class="btn btn-primary" onclick="closeModal(); switchView('list');">OK</button>`);
e.target.reset();
} else {
showNotification(result.message || 'Errore.', 'error');
}
} catch (err) { showNotification('Errore di connessione.', 'error'); }
finally { btn.disabled = false; btn.textContent = 'Invia Segnalazione'; }
}
async function trackReport() {
const token = document.getElementById('track-token').value.trim();
if (!token) { showNotification('Inserisci il token.', 'error'); return; }
const container = document.getElementById('track-result');
container.innerHTML = '<div class="spinner"></div>';
try {
const result = await api.request('GET', '/whistleblowing/track-anonymous?token=' + encodeURIComponent(token));
if (result.success) {
const r = result.data;
const tl = (r.timeline || []).map(t => `
<div style="display:flex; gap:10px; padding:8px 0; border-bottom:1px solid var(--gray-100);">
<div style="width:6px; height:6px; border-radius:50%; background:var(--primary); margin-top:7px; flex-shrink:0;"></div>
<div><p style="font-size:0.8125rem;">${escapeHtml(t.description)}</p>
<p style="font-size:0.75rem; color:var(--gray-400);">${formatDateTime(t.created_at)}</p></div>
</div>`).join('');
container.innerHTML = `
<div style="padding:16px; border:1px solid var(--gray-200); border-radius:var(--border-radius);">
<div style="display:flex; gap:10px; margin-bottom:12px;">
<code style="font-size:0.875rem;">${escapeHtml(r.report_code)}</code>
<span class="status-badge status-${r.status}">${escapeHtml(statusLabels[r.status] || r.status)}</span>
</div>
<p style="font-size:0.8125rem; color:var(--gray-600); margin-bottom:12px;">Categoria: ${escapeHtml(categoryLabels[r.category] || r.category)}</p>
<div>${tl}</div>
</div>`;
} else {
container.innerHTML = '<div class="empty-state"><h4>Token non valido o segnalazione non trovata</h4></div>';
}
} catch (e) {
container.innerHTML = '<div class="empty-state"><h4>Errore di connessione</h4></div>';
}
}
// Init
loadStats();
loadReports();
</script>
</body>
</html>