nis2-agile/application/controllers/IncidentController.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

396 lines
15 KiB
PHP

<?php
/**
* NIS2 Agile - Incident Controller
*
* Gestione incidenti con workflow NIS2 Art. 23 (24h/72h/30d).
*/
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
{
/**
* GET /api/incidents/list
*/
public function list(): void
{
$this->requireOrgAccess();
$pagination = $this->getPagination();
$where = 'organization_id = ?';
$params = [$this->getCurrentOrgId()];
if ($this->hasParam('status')) {
$where .= ' AND status = ?';
$params[] = $this->getParam('status');
}
if ($this->hasParam('severity')) {
$where .= ' AND severity = ?';
$params[] = $this->getParam('severity');
}
$total = Database::count('incidents', $where, $params);
$incidents = Database::fetchAll(
"SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name
FROM incidents i
LEFT JOIN users u1 ON u1.id = i.reported_by
LEFT JOIN users u2 ON u2.id = i.assigned_to
WHERE i.{$where}
ORDER BY i.detected_at DESC
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}",
$params
);
$this->jsonPaginated($incidents, $total, $pagination['page'], $pagination['per_page']);
}
/**
* POST /api/incidents/create
*/
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']);
$this->validateRequired(['title', 'classification', 'severity', 'detected_at']);
$detectedAt = $this->getParam('detected_at');
$isSignificant = (bool) $this->getParam('is_significant', false);
$data = [
'organization_id' => $this->getCurrentOrgId(),
'incident_code' => $this->generateCode('INC'),
'title' => trim($this->getParam('title')),
'description' => $this->getParam('description'),
'classification' => $this->getParam('classification'),
'severity' => $this->getParam('severity'),
'is_significant' => $isSignificant ? 1 : 0,
'detected_at' => $detectedAt,
'affected_services' => $this->getParam('affected_services'),
'affected_users_count' => $this->getParam('affected_users_count'),
'cross_border_impact' => $this->getParam('cross_border_impact', 0),
'malicious_action' => $this->getParam('malicious_action', 0),
'reported_by' => $this->getCurrentUserId(),
'assigned_to' => $this->getParam('assigned_to'),
];
// Calcola scadenze NIS2 Art. 23 se significativo
if ($isSignificant) {
$detectedTime = strtotime($detectedAt);
$data['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600); // +24h
$data['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600); // +72h
$data['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400); // +30 giorni
}
$incidentId = Database::insert('incidents', $data);
// Aggiungi evento timeline
Database::insert('incident_timeline', [
'incident_id' => $incidentId,
'event_type' => 'detection',
'description' => "Incidente rilevato: {$data['title']}",
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('incident_created', 'incident', $incidentId, [
'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'],
'is_significant' => $isSignificant,
'deadlines' => $isSignificant ? [
'early_warning' => $data['early_warning_due'],
'notification' => $data['notification_due'],
'final_report' => $data['final_report_due'],
] : null,
], 'Incidente registrato', 201);
}
/**
* GET /api/incidents/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$incident = Database::fetchOne(
'SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name
FROM incidents i
LEFT JOIN users u1 ON u1.id = i.reported_by
LEFT JOIN users u2 ON u2.id = i.assigned_to
WHERE i.id = ? AND i.organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$incident) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$incident['timeline'] = Database::fetchAll(
'SELECT it.*, u.full_name as created_by_name
FROM incident_timeline it
LEFT JOIN users u ON u.id = it.created_by
WHERE it.incident_id = ?
ORDER BY it.created_at',
[$id]
);
$this->jsonSuccess($incident);
}
/**
* PUT /api/incidents/{id}
*/
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$incident = Database::fetchOne(
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$incident) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$updates = [];
$allowedFields = [
'title', 'description', 'classification', 'severity', 'is_significant',
'status', 'affected_services', 'affected_users_count', 'cross_border_impact',
'malicious_action', 'root_cause', 'remediation_actions', 'lessons_learned',
'assigned_to',
];
foreach ($allowedFields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
// Se chiuso, registra data
if (isset($updates['status']) && $updates['status'] === 'closed') {
$updates['closed_at'] = date('Y-m-d H:i:s');
}
// Se diventa significativo, calcola scadenze
if (isset($updates['is_significant']) && $updates['is_significant'] && !$incident['is_significant']) {
$detectedTime = strtotime($incident['detected_at']);
$updates['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600);
$updates['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600);
$updates['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400);
}
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');
}
/**
* POST /api/incidents/{id}/timeline
*/
public function addTimelineEvent(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']);
$this->validateRequired(['event_type', 'description']);
$incident = Database::fetchOne(
'SELECT id FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$incident) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$eventId = Database::insert('incident_timeline', [
'incident_id' => $id,
'event_type' => $this->getParam('event_type'),
'description' => $this->getParam('description'),
'created_by' => $this->getCurrentUserId(),
]);
$this->jsonSuccess(['id' => $eventId], 'Evento aggiunto alla timeline', 201);
}
/**
* POST /api/incidents/{id}/early-warning
* Registra invio early warning (24h) al CSIRT
*/
public function sendEarlyWarning(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
Database::update('incidents', [
'early_warning_sent_at' => date('Y-m-d H:i:s'),
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
Database::insert('incident_timeline', [
'incident_id' => $id,
'event_type' => 'notification',
'description' => 'Early warning (24h) inviato al CSIRT nazionale (ACN)',
'created_by' => $this->getCurrentUserId(),
]);
// Invia notifica email ai responsabili
$this->notifyIncidentStakeholders($id, 'early_warning');
$this->logAudit('early_warning_sent', 'incident', $id);
$this->jsonSuccess(null, 'Early warning registrato');
}
/**
* POST /api/incidents/{id}/notification
* Registra invio notifica (72h) al CSIRT
*/
public function sendNotification(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
Database::update('incidents', [
'notification_sent_at' => date('Y-m-d H:i:s'),
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
Database::insert('incident_timeline', [
'incident_id' => $id,
'event_type' => 'notification',
'description' => 'Notifica incidente (72h) inviata al CSIRT nazionale (ACN)',
'created_by' => $this->getCurrentUserId(),
]);
// Invia notifica email ai responsabili
$this->notifyIncidentStakeholders($id, 'notification');
$this->logAudit('notification_sent', 'incident', $id);
$this->jsonSuccess(null, 'Notifica CSIRT registrata');
}
/**
* POST /api/incidents/{id}/final-report
* Registra invio report finale (30 giorni)
*/
public function sendFinalReport(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
Database::update('incidents', [
'final_report_sent_at' => date('Y-m-d H:i:s'),
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
Database::insert('incident_timeline', [
'incident_id' => $id,
'event_type' => 'notification',
'description' => 'Report finale (30 giorni) inviato al CSIRT nazionale (ACN)',
'created_by' => $this->getCurrentUserId(),
]);
// Invia notifica email ai responsabili
$this->notifyIncidentStakeholders($id, 'final_report');
$this->logAudit('final_report_sent', 'incident', $id);
$this->jsonSuccess(null, 'Report finale registrato');
}
/**
* Notifica stakeholder via email per milestone incidente
*/
private function notifyIncidentStakeholders(int $incidentId, string $type): void
{
try {
$incident = Database::fetchOne('SELECT * FROM incidents WHERE id = ?', [$incidentId]);
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
if (!$incident || !$org) return;
// Trova org_admin e compliance_manager
$recipients = Database::fetchAll(
'SELECT u.email, u.full_name FROM users u
JOIN user_organizations uo ON uo.user_id = u.id
WHERE uo.organization_id = ? AND uo.role IN ("org_admin", "compliance_manager") AND u.is_active = 1',
[$this->getCurrentOrgId()]
);
if (empty($recipients)) return;
$emailService = new EmailService();
$emails = array_column($recipients, 'email');
match ($type) {
'early_warning' => $emailService->sendIncidentEarlyWarning($incident, $org, $emails),
'notification' => $emailService->sendIncidentNotification($incident, $org, $emails),
'final_report' => $emailService->sendIncidentFinalReport($incident, $org, $emails),
};
} catch (Throwable $e) {
error_log('[EMAIL_ERROR] ' . $e->getMessage());
// Non bloccare il flusso principale per errori email
}
}
/**
* POST /api/incidents/{id}/ai-classify
*/
public function aiClassify(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$incident = Database::fetchOne(
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$incident) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
try {
$aiService = new AIService();
$classification = $aiService->classifyIncident($incident['title'], $incident['description'] ?? '', $org);
$aiService->logInteraction(
$this->getCurrentOrgId(),
$this->getCurrentUserId(),
'incident_classification',
"Classify incident #{$id}: {$incident['title']}",
substr(json_encode($classification), 0, 500)
);
$this->jsonSuccess($classification, 'Classificazione AI completata');
} catch (Throwable $e) {
$this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR');
}
}
}