nis2-agile/application/controllers/WhistleblowingController.php
DevEnv nis2-agile 86e9bdded2 [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>
2026-03-07 13:20:24 +01:00

387 lines
15 KiB
PHP

<?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,
]);
}
}