[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
co-authored by Claude Sonnet 4.6
parent 3f4b451e2a
commit 86e9bdded2
22 changed files with 5080 additions and 10 deletions
@@ -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');
@@ -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);
}
}
@@ -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');
}
@@ -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);
}
@@ -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';
}
}
@@ -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)',
];
}
}
@@ -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,
]);
}
}