commit ae78a2f7f49591f1026d8f53343c80b81d5c96df Author: Cristiano Benassati Date: Tue Feb 17 17:50:18 2026 +0100 [CORE] Initial project scaffold - NIS2 Agile Compliance Platform Complete MVP implementation including: - PHP 8.4 backend with Front Controller pattern (80+ API endpoints) - Multi-tenant architecture with organization_id isolation - JWT authentication (HS256, 2h access + 7d refresh tokens) - 14 controllers: Auth, Organization, Assessment, Dashboard, Risk, Incident, Policy, SupplyChain, Training, Asset, Audit, Admin - AI Service integration (Anthropic Claude API) for gap analysis, risk suggestions, policy generation, incident classification - NIS2 gap analysis questionnaire (~80 questions, 10 categories) - MySQL schema (20 tables) with NIS2 Art. 21 compliance controls - NIS2 Art. 23 incident reporting workflow (24h/72h/30d) - Frontend: login, register, dashboard, assessment wizard, org setup - Docker configuration (PHP-FPM + Nginx + MySQL) Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbc1d3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Environment +.env + +# Credentials +docs/credentials/ +*.key +*.pem + +# Uploads +public/uploads/ + +# Logs +logs/ +*.log + +# Dependencies +vendor/ +node_modules/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Build +docker/data/ + +# Claude +.claude/plans/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..415e176 --- /dev/null +++ b/.htaccess @@ -0,0 +1,2 @@ +RewriteEngine On +RewriteRule ^(.*)$ public/$1 [L] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..87472e8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,279 @@ +# NIS2 Agile - Documentazione Progetto + +## PRIMA DI INIZIARE +- Leggi sempre questo file prima di iniziare qualsiasi lavoro +- File specializzati per area di lavoro: + - Assessment/Gap Analysis: docs/prompts/PROMPT_ASSESSMENT.md + - Risk Management: docs/prompts/PROMPT_RISK.md + - Incident Management: docs/prompts/PROMPT_INCIDENTS.md + +## Panoramica +NIS2 Agile è una piattaforma SaaS multi-tenant per supportare le aziende nella compliance alla Direttiva NIS2 (EU 2022/2555) e al D.Lgs. 138/2024 italiano. Include AI integration (Claude API) per gap analysis, generazione policy e suggerimenti. + +Target: PMI, Enterprise, Consulenti/CISO. + +## Stack Tecnologico +- Backend: PHP 8.4 vanilla (no framework) +- Database: MySQL 8.x (nis2_agile_db) +- Frontend: HTML5/CSS3/JavaScript vanilla +- Auth: JWT HS256 (2h access + 7d refresh) +- AI: Anthropic Claude API (Sonnet 4.5) +- Server: Hetzner CPX31 (135.181.149.254) +- VCS: Gitea (git.certisource.it) +- Routing: Front Controller pattern (public/index.php) + +## Regola Fondamentale +Il progetto NIS2 Agile è COMPLETAMENTE ISOLATO dagli altri applicativi (CertiSource, AGILE_DFM). Database dedicato, utente dedicato, path dedicati. Non condividere MAI credenziali tra applicativi. + +## Struttura Progetto +``` +nis2.agile/ +├── CLAUDE.md # Questo file - documentazione progetto +├── application/ +│ ├── config/ +│ │ ├── config.php # Costanti app, CORS, JWT, password policy +│ │ ├── database.php # Classe Database (PDO singleton) +│ │ └── env.php # Caricamento variabili ambiente da .env +│ ├── controllers/ +│ │ ├── BaseController.php # Classe base: auth JWT, multi-tenancy, JSON responses +│ │ ├── AdminController.php # Gestione piattaforma (super_admin) +│ │ ├── AssessmentController.php # Gap analysis e questionari NIS2 +│ │ ├── AssetController.php # Inventario asset e dipendenze +│ │ ├── AuditController.php # Controlli compliance, evidenze, report +│ │ ├── AuthController.php # Login, register, JWT, refresh token +│ │ ├── DashboardController.php # Overview, score, deadlines, heatmap +│ │ ├── IncidentController.php # Gestione incidenti (24h/72h/30d) +│ │ ├── OrganizationController.php # CRUD organizzazioni, membri, classificazione +│ │ ├── PolicyController.php # Gestione policy, approvazione, AI generation +│ │ ├── RiskController.php # Risk register, trattamenti, matrice rischi +│ │ ├── SupplyChainController.php # Fornitori, valutazione, risk overview +│ │ └── TrainingController.php # Corsi, assegnazioni, compliance formativa +│ ├── services/ +│ │ └── AIService.php # Integrazione Anthropic Claude API +│ ├── models/ # (riservato per modelli futuri) +│ └── data/ +│ ├── nis2_questionnaire.json # Domande questionario gap analysis +│ └── policy_templates/ # Template policy NIS2 +├── public/ +│ ├── index.php # Front Controller / Router +│ ├── api-status.php # Health check endpoint +│ ├── css/ # Fogli di stile +│ ├── js/ +│ │ └── api.js # Client API JavaScript +│ └── admin/ # Pannello admin frontend +├── docker/ +│ ├── Dockerfile # Build PHP-FPM +│ ├── docker-compose.yml # Orchestrazione servizi +│ ├── nginx.conf # Configurazione Nginx +│ └── php.ini # Configurazione PHP custom +├── docs/ +│ ├── sql/ +│ │ └── 001_initial_schema.sql # Schema database completo +│ ├── context/ +│ │ └── CONTEXT_SCHEMA_DB.md # Schema documentato +│ ├── prompts/ # Prompt specializzati per AI +│ └── credentials/ +│ ├── credentials.md # Note credenziali +│ └── hetzner_key # Chiave SSH Hetzner +├── .env # Variabili ambiente (NON committare) +└── .gitignore +``` + +## Multi-Tenancy +- Ogni tabella dati ha `organization_id` +- Header `X-Organization-Id` per selezionare org attiva +- Ruoli: `super_admin`, `org_admin`, `compliance_manager`, `board_member`, `auditor`, `employee`, `consultant` +- `requireOrgAccess()` in BaseController verifica membership +- Super admin bypassa tutti i controlli di membership + +## API Endpoints + +Tutti gli endpoint seguono il pattern: `/nis2/api/{controller}/{action}/{id?}` + +### AuthController (`/api/auth/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|-------------------------|------------------|----------------------------------| +| POST | /api/auth/register | register | Registrazione nuovo utente | +| POST | /api/auth/login | login | Login con email/password | +| POST | /api/auth/logout | logout | Logout e invalidazione token | +| POST | /api/auth/refresh | refresh | Refresh JWT token | +| GET | /api/auth/me | me | Profilo utente corrente | +| PUT | /api/auth/profile | updateProfile | Aggiorna profilo | +| POST | /api/auth/change-password | changePassword | Cambio password | + +### OrganizationController (`/api/organizations/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|---------------------------------------|----------------|------------------------------------| +| POST | /api/organizations/create | create | Crea organizzazione | +| GET | /api/organizations/current | getCurrent | Org corrente dell'utente | +| GET | /api/organizations/list | list | Lista organizzazioni accessibili | +| PUT | /api/organizations/{id} | update | Aggiorna organizzazione | +| GET | /api/organizations/{id}/members | listMembers | Lista membri organizzazione | +| POST | /api/organizations/{id}/invite | inviteMember | Invita membro | +| DELETE | /api/organizations/{id}/members/{sid} | removeMember | Rimuovi membro | +| POST | /api/organizations/classify | classifyEntity | Classifica entità NIS2 | + +### AssessmentController (`/api/assessments/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|----------------------------------|---------------|------------------------------------| +| GET | /api/assessments/list | list | Lista assessment | +| POST | /api/assessments/create | create | Crea nuovo assessment | +| GET | /api/assessments/{id} | get | Dettaglio assessment | +| PUT | /api/assessments/{id} | update | Aggiorna assessment | +| GET | /api/assessments/{id}/questions | getQuestions | Domande questionario | +| POST | /api/assessments/{id}/respond | saveResponse | Salva risposta | +| POST | /api/assessments/{id}/complete | complete | Completa assessment | +| GET | /api/assessments/{id}/report | getReport | Report assessment | +| POST | /api/assessments/{id}/ai-analyze | aiAnalyze | Analisi AI del gap | + +### DashboardController (`/api/dashboard/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|-----------------------------------|-----------------|----------------------------------| +| GET | /api/dashboard/overview | overview | Overview compliance | +| GET | /api/dashboard/compliance-score | complianceScore | Score compliance | +| GET | /api/dashboard/upcoming-deadlines | deadlines | Scadenze imminenti | +| GET | /api/dashboard/recent-activity | recentActivity | Attività recenti | +| GET | /api/dashboard/risk-heatmap | riskHeatmap | Heatmap rischi | + +### RiskController (`/api/risks/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|--------------------------------|------------------|----------------------------------| +| GET | /api/risks/list | list | Lista rischi | +| POST | /api/risks/create | create | Crea rischio | +| GET | /api/risks/{id} | get | Dettaglio rischio | +| PUT | /api/risks/{id} | update | Aggiorna rischio | +| DELETE | /api/risks/{id} | delete | Elimina rischio | +| POST | /api/risks/{id}/treatments | addTreatment | Aggiungi trattamento | +| PUT | /api/risks/treatments/{sid} | updateTreatment | Aggiorna trattamento | +| GET | /api/risks/matrix | getRiskMatrix | Matrice dei rischi | +| POST | /api/risks/ai-suggest | aiSuggestRisks | Suggerimenti AI rischi | + +### IncidentController (`/api/incidents/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|-------------------------------------|-------------------|----------------------------------| +| GET | /api/incidents/list | list | Lista incidenti | +| POST | /api/incidents/create | create | Crea incidente | +| GET | /api/incidents/{id} | get | Dettaglio incidente | +| PUT | /api/incidents/{id} | update | Aggiorna incidente | +| POST | /api/incidents/{id}/timeline | addTimelineEvent | Aggiungi evento timeline | +| POST | /api/incidents/{id}/early-warning | sendEarlyWarning | Early warning (24h) | +| POST | /api/incidents/{id}/notification | sendNotification | Notifica (72h) | +| POST | /api/incidents/{id}/final-report | sendFinalReport | Report finale (30d) | +| POST | /api/incidents/{id}/ai-classify | aiClassify | Classificazione AI incidente | + +### PolicyController (`/api/policies/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|------------------------------|------------------|----------------------------------| +| GET | /api/policies/list | list | Lista policy | +| POST | /api/policies/create | create | Crea policy | +| GET | /api/policies/{id} | get | Dettaglio policy | +| PUT | /api/policies/{id} | update | Aggiorna policy | +| DELETE | /api/policies/{id} | delete | Elimina policy | +| POST | /api/policies/{id}/approve | approve | Approva policy | +| POST | /api/policies/ai-generate | aiGeneratePolicy | Genera policy con AI | +| GET | /api/policies/templates | getTemplates | Lista template policy | + +### SupplyChainController (`/api/supply-chain/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|-----------------------------------|-----------------|----------------------------------| +| GET | /api/supply-chain/list | list | Lista fornitori | +| POST | /api/supply-chain/create | create | Aggiungi fornitore | +| GET | /api/supply-chain/{id} | get | Dettaglio fornitore | +| PUT | /api/supply-chain/{id} | update | Aggiorna fornitore | +| DELETE | /api/supply-chain/{id} | delete | Elimina fornitore | +| POST | /api/supply-chain/{id}/assess | assessSupplier | Valuta fornitore | +| GET | /api/supply-chain/risk-overview | riskOverview | Overview rischio supply chain | + +### TrainingController (`/api/training/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|-----------------------------------|-------------------|----------------------------------| +| GET | /api/training/courses | listCourses | Lista corsi | +| POST | /api/training/courses | createCourse | Crea corso | +| GET | /api/training/assignments | myAssignments | Le mie assegnazioni | +| POST | /api/training/assign | assignCourse | Assegna corso | +| PUT | /api/training/assignments/{sid} | updateAssignment | Aggiorna assegnazione | +| GET | /api/training/compliance-status | complianceStatus | Status compliance formativa | + +### AssetController (`/api/assets/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|------------------------------|----------------|----------------------------------| +| GET | /api/assets/list | list | Lista asset | +| POST | /api/assets/create | create | Crea asset | +| GET | /api/assets/{id} | get | Dettaglio asset | +| PUT | /api/assets/{id} | update | Aggiorna asset | +| DELETE | /api/assets/{id} | delete | Elimina asset | +| GET | /api/assets/dependency-map | dependencyMap | Mappa dipendenze | + +### AuditController (`/api/audit/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|---------------------------------|-----------------|----------------------------------| +| GET | /api/audit/controls | listControls | Lista controlli compliance | +| PUT | /api/audit/controls/{sid} | updateControl | Aggiorna controllo | +| POST | /api/audit/evidence/upload | uploadEvidence | Carica evidenza | +| GET | /api/audit/evidence/list | listEvidence | Lista evidenze | +| GET | /api/audit/report | generateReport | Genera report audit | +| GET | /api/audit/logs | getAuditLogs | Log audit | +| GET | /api/audit/iso27001-mapping | getIsoMapping | Mapping ISO 27001 | + +### AdminController (`/api/admin/`) +| Metodo | Endpoint | Azione | Descrizione | +|--------|---------------------------|--------------------|----------------------------------| +| GET | /api/admin/organizations | listOrganizations | Lista tutte le organizzazioni | +| GET | /api/admin/users | listUsers | Lista tutti gli utenti | +| GET | /api/admin/stats | platformStats | Statistiche piattaforma | + +## Database Schema +- **Tabelle**: organizations, users, user_organizations, refresh_tokens, assessments, assessment_responses, risks, risk_treatments, incidents, incident_timeline, policies, suppliers, training_courses, training_assignments, assets, compliance_controls, evidence_files, audit_logs, ai_interactions, rate_limits +- **Schema completo**: docs/sql/001_initial_schema.sql +- **Schema documentato**: docs/context/CONTEXT_SCHEMA_DB.md + +## AI Integration +- **Servizio**: application/services/AIService.php +- **Funzionalità**: gap analysis, risk suggestions, policy generation, incident classification +- **Modello**: claude-sonnet-4-5-20250929 +- **API Key**: da .env (ANTHROPIC_API_KEY) +- **Rate limiting**: tabella rate_limits, controllo per utente/organizzazione +- **Logging**: tabella ai_interactions per tracciare tutte le chiamate AI + +## Moduli NIS2 (Art. 21) +1. **Gap Analysis & Assessment** (Art. 21) - Questionario strutturato con analisi AI +2. **Risk Management** (Art. 21.2.a) - Risk register, matrice, trattamenti +3. **Incident Management** (Art. 23) - Timeline 24h/72h/30d, early warning, notifiche +4. **Policy Management** (Art. 21) - CRUD policy, approvazione, generazione AI +5. **Supply Chain Security** (Art. 21.2.d) - Valutazione fornitori, risk overview +6. **Training & Awareness** (Art. 20) - Corsi, assegnazioni, compliance formativa +7. **Asset Management** (Art. 21.2.i) - Inventario, classificazione, mappa dipendenze +8. **Audit & Compliance** (Art. 21.2.f) - Controlli, evidenze, report, mapping ISO 27001 + +## Deploy +- **SSH**: `ssh -i docs/credentials/hetzner_key root@135.181.149.254` +- **Path server**: `/var/www/nis2-agile/` +- **Deploy**: scp via SSH (manuale) +- **Docker**: `docker-compose up -d` + +## Git +- **Repository**: https://git.certisource.it/AdminGit2026/nis2-agile +- **Branch**: main +- **Commit format**: `[AREA] Descrizione` +- **Aree**: `[CORE]`, `[AUTH]`, `[ASSESSMENT]`, `[RISK]`, `[INCIDENT]`, `[POLICY]`, `[SUPPLY]`, `[TRAINING]`, `[ASSET]`, `[AUDIT]`, `[FRONTEND]`, `[AI]`, `[DOCS]`, `[DOCKER]` + +## Comandi Utili +```bash +# Sviluppo locale +php -S localhost:8080 -t public/ + +# Applicare schema database +mysql -u nis2_user -p nis2_agile_db < docs/sql/001_initial_schema.sql + +# Docker +cd docker && docker-compose up -d + +# SSH al server +ssh -i docs/credentials/hetzner_key root@135.181.149.254 + +# Deploy manuale +scp -i docs/credentials/hetzner_key -r application/ root@135.181.149.254:/var/www/nis2-agile/ +scp -i docs/credentials/hetzner_key -r public/ root@135.181.149.254:/var/www/nis2-agile/ +``` + +*Ultimo aggiornamento: 2026-02-17* diff --git a/application/config/config.php b/application/config/config.php new file mode 100644 index 0000000..804bfa8 --- /dev/null +++ b/application/config/config.php @@ -0,0 +1,81 @@ + 5, 'window_seconds' => 60], + ['max' => 20, 'window_seconds' => 3600], +]); +define('RATE_LIMIT_AUTH_REGISTER', [ + ['max' => 3, 'window_seconds' => 600], +]); +define('RATE_LIMIT_AI', [ + ['max' => 10, 'window_seconds' => 60], + ['max' => 100, 'window_seconds' => 3600], +]); + +// ═══════════════════════════════════════════════════════════════════════════ +// AI (ANTHROPIC) +// ═══════════════════════════════════════════════════════════════════════════ +define('ANTHROPIC_API_KEY', Env::get('ANTHROPIC_API_KEY', '')); +define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929')); +define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096)); + +// ═══════════════════════════════════════════════════════════════════════════ +// TIMEZONE +// ═══════════════════════════════════════════════════════════════════════════ +date_default_timezone_set('Europe/Rome'); diff --git a/application/config/database.php b/application/config/database.php new file mode 100644 index 0000000..3e3026a --- /dev/null +++ b/application/config/database.php @@ -0,0 +1,168 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", + ]; + + try { + self::$instance = new PDO($dsn, DB_USER, DB_PASS, $options); + } catch (PDOException $e) { + if (defined('APP_DEBUG') && APP_DEBUG) { + throw new Exception('Database connection failed: ' . $e->getMessage()); + } else { + throw new Exception('Database connection failed'); + } + } + } + + return self::$instance; + } + + /** + * Esegue una query con parametri + */ + public static function query(string $sql, array $params = []): PDOStatement + { + $stmt = self::getInstance()->prepare($sql); + $stmt->execute($params); + return $stmt; + } + + /** + * Ottiene una singola riga + */ + public static function fetchOne(string $sql, array $params = []): ?array + { + $result = self::query($sql, $params)->fetch(); + return $result ?: null; + } + + /** + * Ottiene tutte le righe + */ + public static function fetchAll(string $sql, array $params = []): array + { + return self::query($sql, $params)->fetchAll(); + } + + /** + * Inserisce e restituisce l'ID + */ + public static function insert(string $table, array $data): int + { + $columns = implode(', ', array_keys($data)); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + + $sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})"; + self::query($sql, array_values($data)); + + return (int) self::getInstance()->lastInsertId(); + } + + /** + * Aggiorna righe + */ + public static function update(string $table, array $data, string $where, array $whereParams = []): int + { + $setParts = []; + foreach (array_keys($data) as $column) { + $setParts[] = "{$column} = ?"; + } + $setClause = implode(', ', $setParts); + + $sql = "UPDATE {$table} SET {$setClause} WHERE {$where}"; + $params = array_merge(array_values($data), $whereParams); + + return self::query($sql, $params)->rowCount(); + } + + /** + * Elimina righe + */ + public static function delete(string $table, string $where, array $params = []): int + { + $sql = "DELETE FROM {$table} WHERE {$where}"; + return self::query($sql, $params)->rowCount(); + } + + /** + * Conta righe + */ + public static function count(string $table, string $where = '1=1', array $params = []): int + { + $sql = "SELECT COUNT(*) as cnt FROM {$table} WHERE {$where}"; + $result = self::fetchOne($sql, $params); + return (int) ($result['cnt'] ?? 0); + } + + /** + * Inizia una transazione + */ + public static function beginTransaction(): bool + { + return self::getInstance()->beginTransaction(); + } + + /** + * Commit transazione + */ + public static function commit(): bool + { + return self::getInstance()->commit(); + } + + /** + * Rollback transazione + */ + public static function rollback(): bool + { + return self::getInstance()->rollBack(); + } + + /** + * Restituisce l'ultimo ID inserito + */ + public static function lastInsertId(): int + { + return (int) self::getInstance()->lastInsertId(); + } + + private function __clone() {} +} diff --git a/application/config/env.php b/application/config/env.php new file mode 100644 index 0000000..9d96173 --- /dev/null +++ b/application/config/env.php @@ -0,0 +1,126 @@ +requireSuperAdmin(); + $pagination = $this->getPagination(); + + $total = Database::count('organizations', '1=1'); + $orgs = Database::fetchAll( + "SELECT o.*, + (SELECT COUNT(*) FROM user_organizations WHERE organization_id = o.id) as member_count, + (SELECT overall_score FROM assessments WHERE organization_id = o.id AND status = 'completed' ORDER BY completed_at DESC LIMIT 1) as last_score + FROM organizations o + ORDER BY o.created_at DESC + LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}" + ); + + $this->jsonPaginated($orgs, $total, $pagination['page'], $pagination['per_page']); + } + + public function listUsers(): void + { + $this->requireSuperAdmin(); + $pagination = $this->getPagination(); + + $total = Database::count('users', '1=1'); + $users = Database::fetchAll( + "SELECT u.id, u.email, u.full_name, u.role, u.is_active, u.last_login_at, u.created_at, + GROUP_CONCAT(o.name) as organizations + FROM users u + LEFT JOIN user_organizations uo ON uo.user_id = u.id + LEFT JOIN organizations o ON o.id = uo.organization_id + GROUP BY u.id + ORDER BY u.created_at DESC + LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}" + ); + + $this->jsonPaginated($users, $total, $pagination['page'], $pagination['per_page']); + } + + public function platformStats(): void + { + $this->requireSuperAdmin(); + + $this->jsonSuccess([ + 'total_organizations' => Database::count('organizations', '1=1'), + 'active_organizations' => Database::count('organizations', 'is_active = 1'), + 'total_users' => Database::count('users', '1=1'), + 'active_users' => Database::count('users', 'is_active = 1'), + 'total_assessments' => Database::count('assessments', '1=1'), + 'completed_assessments' => Database::count('assessments', 'status = "completed"'), + 'total_incidents' => Database::count('incidents', '1=1'), + 'open_incidents' => Database::count('incidents', 'status NOT IN ("closed", "post_mortem")'), + 'total_risks' => Database::count('risks', '1=1'), + 'total_policies' => Database::count('policies', '1=1'), + 'ai_interactions' => Database::count('ai_interactions', '1=1'), + 'plans_distribution' => Database::fetchAll( + 'SELECT subscription_plan, COUNT(*) as count FROM organizations GROUP BY subscription_plan' + ), + ]); + } +} diff --git a/application/controllers/AssessmentController.php b/application/controllers/AssessmentController.php new file mode 100644 index 0000000..6aa5918 --- /dev/null +++ b/application/controllers/AssessmentController.php @@ -0,0 +1,448 @@ +requireOrgAccess(); + + $assessments = Database::fetchAll( + 'SELECT a.*, u.full_name as completed_by_name + FROM assessments a + LEFT JOIN users u ON u.id = a.completed_by + WHERE a.organization_id = ? + ORDER BY a.created_at DESC', + [$this->getCurrentOrgId()] + ); + + $this->jsonSuccess($assessments); + } + + /** + * POST /api/assessments/create + */ + public function create(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $title = $this->getParam('title', 'Assessment NIS2 - ' . date('d/m/Y')); + $type = $this->getParam('assessment_type', 'initial'); + + $assessmentId = Database::insert('assessments', [ + 'organization_id' => $this->getCurrentOrgId(), + 'title' => $title, + 'assessment_type' => $type, + 'status' => 'draft', + ]); + + // Pre-popola le risposte con le domande dal questionario + $questionnaire = $this->loadQuestionnaire(); + + foreach ($questionnaire['categories'] as $category) { + foreach ($category['questions'] as $question) { + Database::insert('assessment_responses', [ + 'assessment_id' => $assessmentId, + 'question_code' => $question['code'], + 'nis2_article' => $question['nis2_article'], + 'iso27001_control' => $question['iso27001_control'], + 'category' => $category['id'], + 'question_text' => $question['text_it'], + ]); + } + } + + $this->logAudit('assessment_created', 'assessment', $assessmentId); + + $this->jsonSuccess([ + 'id' => $assessmentId, + 'title' => $title, + 'status' => 'draft', + ], 'Assessment creato', 201); + } + + /** + * GET /api/assessments/{id} + */ + public function get(int $id): void + { + $this->requireOrgAccess(); + + $assessment = $this->getAssessment($id); + + // Conta risposte per stato + $stats = Database::fetchAll( + 'SELECT response_value, COUNT(*) as count + FROM assessment_responses + WHERE assessment_id = ? AND response_value IS NOT NULL + GROUP BY response_value', + [$id] + ); + + $totalQuestions = Database::count('assessment_responses', 'assessment_id = ?', [$id]); + $answeredQuestions = Database::count( + 'assessment_responses', + 'assessment_id = ? AND response_value IS NOT NULL', + [$id] + ); + + $assessment['stats'] = $stats; + $assessment['total_questions'] = $totalQuestions; + $assessment['answered_questions'] = $answeredQuestions; + $assessment['progress_percentage'] = $totalQuestions > 0 + ? round($answeredQuestions / $totalQuestions * 100) + : 0; + + $this->jsonSuccess($assessment); + } + + /** + * GET /api/assessments/{id}/questions + * Restituisce domande con risposte correnti, organizzate per categoria + */ + public function getQuestions(int $id): void + { + $this->requireOrgAccess(); + $this->getAssessment($id); + + $responses = Database::fetchAll( + 'SELECT * FROM assessment_responses WHERE assessment_id = ? ORDER BY question_code', + [$id] + ); + + // Carica questionario per i metadati + $questionnaire = $this->loadQuestionnaire(); + $questionMeta = []; + foreach ($questionnaire['categories'] as $cat) { + foreach ($cat['questions'] as $q) { + $questionMeta[$q['code']] = [ + 'text_en' => $q['text_en'], + 'guidance_it' => $q['guidance_it'], + 'evidence_examples' => $q['evidence_examples'], + 'weight' => $q['weight'], + ]; + } + } + + // Organizza per categoria + $byCategory = []; + foreach ($responses as $r) { + $cat = $r['category']; + if (!isset($byCategory[$cat])) { + // Trova titolo categoria + $catTitle = $cat; + foreach ($questionnaire['categories'] as $c) { + if ($c['id'] === $cat) { + $catTitle = $c['title_it']; + break; + } + } + $byCategory[$cat] = [ + 'category_id' => $cat, + 'category_title' => $catTitle, + 'questions' => [], + ]; + } + + $meta = $questionMeta[$r['question_code']] ?? []; + $r['text_en'] = $meta['text_en'] ?? null; + $r['guidance_it'] = $meta['guidance_it'] ?? null; + $r['evidence_examples'] = $meta['evidence_examples'] ?? []; + $r['weight'] = $meta['weight'] ?? 1; + + $byCategory[$cat]['questions'][] = $r; + } + + $this->jsonSuccess(array_values($byCategory)); + } + + /** + * POST /api/assessments/{id}/respond + * Salva una o più risposte + */ + public function saveResponse(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); + + $assessment = $this->getAssessment($id); + + if ($assessment['status'] === 'completed') { + $this->jsonError('Assessment già completato', 400, 'ALREADY_COMPLETED'); + } + + // Accetta singola risposta o array di risposte + $responses = $this->getParam('responses'); + if (!$responses) { + // Singola risposta + $this->validateRequired(['question_code', 'response_value']); + $responses = [[ + 'question_code' => $this->getParam('question_code'), + 'response_value' => $this->getParam('response_value'), + 'maturity_level' => $this->getParam('maturity_level'), + 'evidence_description' => $this->getParam('evidence_description'), + 'notes' => $this->getParam('notes'), + ]]; + } + + $savedCount = 0; + foreach ($responses as $resp) { + $code = $resp['question_code'] ?? null; + $value = $resp['response_value'] ?? null; + + if (!$code || !$value) continue; + + Database::update('assessment_responses', [ + 'response_value' => $value, + 'maturity_level' => $resp['maturity_level'] ?? null, + 'evidence_description' => $resp['evidence_description'] ?? null, + 'notes' => $resp['notes'] ?? null, + 'answered_by' => $this->getCurrentUserId(), + 'answered_at' => date('Y-m-d H:i:s'), + ], 'assessment_id = ? AND question_code = ?', [$id, $code]); + + $savedCount++; + } + + // Aggiorna status a in_progress se era draft + if ($assessment['status'] === 'draft') { + Database::update('assessments', ['status' => 'in_progress'], 'id = ?', [$id]); + } + + $this->jsonSuccess(['saved' => $savedCount], "{$savedCount} risposte salvate"); + } + + /** + * POST /api/assessments/{id}/complete + */ + public function complete(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $assessment = $this->getAssessment($id); + + if ($assessment['status'] === 'completed') { + $this->jsonError('Assessment già completato', 400, 'ALREADY_COMPLETED'); + } + + // Calcola score + $responses = Database::fetchAll( + 'SELECT * FROM assessment_responses WHERE assessment_id = ?', + [$id] + ); + + $scores = $this->calculateScores($responses); + + Database::update('assessments', [ + 'status' => 'completed', + 'overall_score' => $scores['overall'], + 'category_scores' => json_encode($scores['by_category']), + 'completed_by' => $this->getCurrentUserId(), + 'completed_at' => date('Y-m-d H:i:s'), + ], 'id = ?', [$id]); + + $this->logAudit('assessment_completed', 'assessment', $id, [ + 'overall_score' => $scores['overall'] + ]); + + $this->jsonSuccess([ + 'overall_score' => $scores['overall'], + 'category_scores' => $scores['by_category'], + ], 'Assessment completato'); + } + + /** + * GET /api/assessments/{id}/report + */ + public function getReport(int $id): void + { + $this->requireOrgAccess(); + + $assessment = $this->getAssessment($id); + + if ($assessment['status'] !== 'completed') { + $this->jsonError('L\'assessment deve essere completato prima di generare il report', 400, 'NOT_COMPLETED'); + } + + $responses = Database::fetchAll( + 'SELECT ar.*, u.full_name as answered_by_name + FROM assessment_responses ar + LEFT JOIN users u ON u.id = ar.answered_by + WHERE ar.assessment_id = ? + ORDER BY ar.category, ar.question_code', + [$id] + ); + + $categoryScores = json_decode($assessment['category_scores'], true) ?? []; + + $this->jsonSuccess([ + 'assessment' => $assessment, + 'category_scores' => $categoryScores, + 'responses' => $responses, + 'ai_summary' => $assessment['ai_summary'], + 'ai_recommendations' => json_decode($assessment['ai_recommendations'], true), + ]); + } + + /** + * POST /api/assessments/{id}/ai-analyze + * Richiede analisi AI dell'assessment + */ + public function aiAnalyze(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $assessment = $this->getAssessment($id); + + if ($assessment['status'] !== 'completed') { + $this->jsonError('Completare l\'assessment prima dell\'analisi AI', 400, 'NOT_COMPLETED'); + } + + // Carica organizzazione + $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); + + // Carica risposte + $responses = Database::fetchAll( + 'SELECT * FROM assessment_responses WHERE assessment_id = ?', + [$id] + ); + + try { + $aiService = new AIService(); + $analysis = $aiService->analyzeGapAssessment($org, $responses, (float) $assessment['overall_score']); + + // Salva risultati AI + Database::update('assessments', [ + 'ai_summary' => $analysis['executive_summary'] ?? json_encode($analysis), + 'ai_recommendations' => json_encode($analysis), + ], 'id = ?', [$id]); + + // Log interazione AI + $aiService->logInteraction( + $this->getCurrentOrgId(), + $this->getCurrentUserId(), + 'gap_analysis', + "Gap analysis assessment #{$id}", + substr(json_encode($analysis), 0, 500) + ); + + $this->logAudit('ai_analysis_requested', 'assessment', $id); + + $this->jsonSuccess($analysis, 'Analisi AI completata'); + + } catch (Throwable $e) { + error_log('[AI_ERROR] ' . $e->getMessage()); + $this->jsonError('Errore durante l\'analisi AI: ' . $e->getMessage(), 500, 'AI_ERROR'); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // METODI PRIVATI + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Carica assessment verificando ownership + */ + private function getAssessment(int $id): array + { + $assessment = Database::fetchOne( + 'SELECT * FROM assessments WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$assessment) { + $this->jsonError('Assessment non trovato', 404, 'ASSESSMENT_NOT_FOUND'); + } + + return $assessment; + } + + /** + * Carica questionario da file JSON + */ + private function loadQuestionnaire(): array + { + $file = DATA_PATH . '/nis2_questionnaire.json'; + + if (!file_exists($file)) { + $this->jsonError('Questionario NIS2 non disponibile', 500, 'QUESTIONNAIRE_MISSING'); + } + + $data = json_decode(file_get_contents($file), true); + + if (!$data) { + $this->jsonError('Errore caricamento questionario', 500, 'QUESTIONNAIRE_ERROR'); + } + + return $data; + } + + /** + * Calcola score dell'assessment + */ + private function calculateScores(array $responses): array + { + $byCategory = []; + $totalWeightedScore = 0; + $totalWeight = 0; + + // Carica pesi dalle domande + $questionnaire = $this->loadQuestionnaire(); + $weights = []; + foreach ($questionnaire['categories'] as $cat) { + foreach ($cat['questions'] as $q) { + $weights[$q['code']] = $q['weight'] ?? 1; + } + } + + foreach ($responses as $r) { + $cat = $r['category'] ?? 'other'; + $value = $r['response_value']; + $weight = $weights[$r['question_code']] ?? 1; + + if (!isset($byCategory[$cat])) { + $byCategory[$cat] = ['score' => 0, 'max' => 0, 'count' => 0]; + } + + if ($value === 'not_applicable') continue; + + $score = match ($value) { + 'implemented' => 100, + 'partial' => 50, + default => 0, + }; + + $byCategory[$cat]['score'] += $score * $weight; + $byCategory[$cat]['max'] += 100 * $weight; + $byCategory[$cat]['count']++; + + $totalWeightedScore += $score * $weight; + $totalWeight += 100 * $weight; + } + + // Calcola percentuali per categoria + $categoryScores = []; + foreach ($byCategory as $cat => $data) { + $categoryScores[$cat] = [ + 'score' => $data['max'] > 0 ? round($data['score'] / $data['max'] * 100, 1) : 0, + 'count' => $data['count'], + ]; + } + + $overallScore = $totalWeight > 0 ? round($totalWeightedScore / $totalWeight * 100, 1) : 0; + + return [ + 'overall' => $overallScore, + 'by_category' => $categoryScores, + ]; + } +} diff --git a/application/controllers/AssetController.php b/application/controllers/AssetController.php new file mode 100644 index 0000000..2186ea5 --- /dev/null +++ b/application/controllers/AssetController.php @@ -0,0 +1,160 @@ +requireOrgAccess(); + $pagination = $this->getPagination(); + + $where = 'organization_id = ?'; + $params = [$this->getCurrentOrgId()]; + + if ($this->hasParam('asset_type')) { + $where .= ' AND asset_type = ?'; + $params[] = $this->getParam('asset_type'); + } + if ($this->hasParam('criticality')) { + $where .= ' AND criticality = ?'; + $params[] = $this->getParam('criticality'); + } + if ($this->hasParam('status')) { + $where .= ' AND status = ?'; + $params[] = $this->getParam('status'); + } + + $total = Database::count('assets', $where, $params); + $assets = Database::fetchAll( + "SELECT a.*, u.full_name as owner_name + FROM assets a + LEFT JOIN users u ON u.id = a.owner_user_id + WHERE a.{$where} + ORDER BY a.criticality DESC, a.name + LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}", + $params + ); + + $this->jsonPaginated($assets, $total, $pagination['page'], $pagination['per_page']); + } + + public function create(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['name', 'asset_type']); + + $assetId = Database::insert('assets', [ + 'organization_id' => $this->getCurrentOrgId(), + 'name' => trim($this->getParam('name')), + 'asset_type' => $this->getParam('asset_type'), + 'category' => $this->getParam('category'), + 'description' => $this->getParam('description'), + 'criticality' => $this->getParam('criticality', 'medium'), + 'owner_user_id' => $this->getParam('owner_user_id'), + 'location' => $this->getParam('location'), + 'ip_address' => $this->getParam('ip_address'), + 'vendor' => $this->getParam('vendor'), + 'version' => $this->getParam('version'), + 'serial_number' => $this->getParam('serial_number'), + 'purchase_date' => $this->getParam('purchase_date'), + 'warranty_expiry' => $this->getParam('warranty_expiry'), + 'dependencies' => $this->getParam('dependencies') ? json_encode($this->getParam('dependencies')) : null, + ]); + + $this->logAudit('asset_created', 'asset', $assetId); + $this->jsonSuccess(['id' => $assetId], 'Asset registrato', 201); + } + + public function get(int $id): void + { + $this->requireOrgAccess(); + + $asset = Database::fetchOne( + 'SELECT a.*, u.full_name as owner_name + FROM assets a LEFT JOIN users u ON u.id = a.owner_user_id + WHERE a.id = ? AND a.organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$asset) { + $this->jsonError('Asset non trovato', 404, 'ASSET_NOT_FOUND'); + } + + $this->jsonSuccess($asset); + } + + public function update(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $updates = []; + $fields = ['name', 'asset_type', 'category', 'description', 'criticality', + 'owner_user_id', 'location', 'ip_address', 'vendor', 'version', + 'serial_number', 'purchase_date', 'warranty_expiry', 'status']; + + foreach ($fields as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if ($this->hasParam('dependencies')) { + $updates['dependencies'] = json_encode($this->getParam('dependencies')); + } + + if (!empty($updates)) { + Database::update('assets', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + $this->logAudit('asset_updated', 'asset', $id, $updates); + } + + $this->jsonSuccess($updates, 'Asset aggiornato'); + } + + public function delete(int $id): void + { + $this->requireOrgRole(['org_admin']); + $deleted = Database::delete('assets', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + if ($deleted === 0) { + $this->jsonError('Asset non trovato', 404, 'ASSET_NOT_FOUND'); + } + $this->logAudit('asset_deleted', 'asset', $id); + $this->jsonSuccess(null, 'Asset eliminato'); + } + + public function dependencyMap(): void + { + $this->requireOrgAccess(); + + $assets = Database::fetchAll( + 'SELECT id, name, asset_type, criticality, dependencies + FROM assets + WHERE organization_id = ? AND status = "active"', + [$this->getCurrentOrgId()] + ); + + $nodes = []; + $edges = []; + + foreach ($assets as $asset) { + $nodes[] = [ + 'id' => $asset['id'], + 'label' => $asset['name'], + 'type' => $asset['asset_type'], + 'criticality' => $asset['criticality'], + ]; + + $deps = json_decode($asset['dependencies'] ?? '[]', true) ?: []; + foreach ($deps as $depId) { + $edges[] = ['from' => $asset['id'], 'to' => (int) $depId]; + } + } + + $this->jsonSuccess(['nodes' => $nodes, 'edges' => $edges]); + } +} diff --git a/application/controllers/AuditController.php b/application/controllers/AuditController.php new file mode 100644 index 0000000..2e4dcb5 --- /dev/null +++ b/application/controllers/AuditController.php @@ -0,0 +1,196 @@ +requireOrgAccess(); + + $controls = Database::fetchAll( + 'SELECT cc.*, u.full_name as responsible_name + FROM compliance_controls cc + LEFT JOIN users u ON u.id = cc.responsible_user_id + WHERE cc.organization_id = ? + ORDER BY cc.control_code', + [$this->getCurrentOrgId()] + ); + + $this->jsonSuccess($controls); + } + + public function updateControl(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); + + $updates = []; + foreach (['status', 'implementation_percentage', 'evidence_description', 'responsible_user_id', 'next_review_date'] as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if (isset($updates['status']) && $updates['status'] === 'verified') { + $updates['last_verified_at'] = date('Y-m-d H:i:s'); + } + + if (!empty($updates)) { + Database::update('compliance_controls', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + $this->logAudit('control_updated', 'compliance_control', $id, $updates); + } + + $this->jsonSuccess($updates, 'Controllo aggiornato'); + } + + public function uploadEvidence(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']); + + if (!isset($_FILES['file'])) { + $this->jsonError('File non fornito', 400, 'NO_FILE'); + } + + $file = $_FILES['file']; + $maxSize = 10 * 1024 * 1024; // 10MB + + if ($file['size'] > $maxSize) { + $this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE'); + } + + $orgId = $this->getCurrentOrgId(); + $uploadDir = UPLOAD_PATH . "/evidence/{$orgId}"; + + if (!is_dir($uploadDir)) { + mkdir($uploadDir, 0755, true); + } + + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $filename = uniqid('ev_') . '.' . $ext; + $filePath = $uploadDir . '/' . $filename; + + if (!move_uploaded_file($file['tmp_name'], $filePath)) { + $this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR'); + } + + $evidenceId = Database::insert('evidence_files', [ + 'organization_id' => $orgId, + 'control_id' => $this->getParam('control_id'), + 'entity_type' => $this->getParam('entity_type'), + 'entity_id' => $this->getParam('entity_id'), + 'file_name' => $file['name'], + 'file_path' => "evidence/{$orgId}/{$filename}", + 'file_size' => $file['size'], + 'mime_type' => $file['type'], + 'uploaded_by' => $this->getCurrentUserId(), + ]); + + $this->logAudit('evidence_uploaded', 'evidence', $evidenceId); + $this->jsonSuccess(['id' => $evidenceId], 'Evidenza caricata', 201); + } + + public function listEvidence(): void + { + $this->requireOrgAccess(); + + $where = 'organization_id = ?'; + $params = [$this->getCurrentOrgId()]; + + if ($this->hasParam('control_id')) { + $where .= ' AND control_id = ?'; + $params[] = $this->getParam('control_id'); + } + if ($this->hasParam('entity_type') && $this->hasParam('entity_id')) { + $where .= ' AND entity_type = ? AND entity_id = ?'; + $params[] = $this->getParam('entity_type'); + $params[] = $this->getParam('entity_id'); + } + + $evidence = Database::fetchAll( + "SELECT ef.*, u.full_name as uploaded_by_name + FROM evidence_files ef + LEFT JOIN users u ON u.id = ef.uploaded_by + WHERE ef.{$where} + ORDER BY ef.created_at DESC", + $params + ); + + $this->jsonSuccess($evidence); + } + + public function generateReport(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$orgId]); + $controls = Database::fetchAll('SELECT * FROM compliance_controls WHERE organization_id = ? ORDER BY control_code', [$orgId]); + $lastAssessment = Database::fetchOne('SELECT * FROM assessments WHERE organization_id = ? AND status = "completed" ORDER BY completed_at DESC LIMIT 1', [$orgId]); + $riskCount = Database::count('risks', 'organization_id = ? AND status != "closed"', [$orgId]); + $incidentCount = Database::count('incidents', 'organization_id = ?', [$orgId]); + $policyCount = Database::count('policies', 'organization_id = ? AND status IN ("approved","published")', [$orgId]); + + $totalControls = count($controls); + $implemented = count(array_filter($controls, fn($c) => in_array($c['status'], ['implemented', 'verified']))); + + $this->jsonSuccess([ + 'organization' => $org, + 'report_date' => date('Y-m-d H:i:s'), + 'compliance_summary' => [ + 'total_controls' => $totalControls, + 'implemented_controls' => $implemented, + 'compliance_percentage' => $totalControls > 0 ? round($implemented / $totalControls * 100) : 0, + ], + 'controls' => $controls, + 'last_assessment' => $lastAssessment, + 'risk_count' => $riskCount, + 'incident_count' => $incidentCount, + 'policy_count' => $policyCount, + ]); + } + + public function getAuditLogs(): void + { + $this->requireOrgRole(['org_admin', 'auditor']); + $pagination = $this->getPagination(50); + + $total = Database::count('audit_logs', 'organization_id = ?', [$this->getCurrentOrgId()]); + + $logs = Database::fetchAll( + "SELECT al.*, u.full_name + FROM audit_logs al + LEFT JOIN users u ON u.id = al.user_id + WHERE al.organization_id = ? + ORDER BY al.created_at DESC + LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}", + [$this->getCurrentOrgId()] + ); + + $this->jsonPaginated($logs, $total, $pagination['page'], $pagination['per_page']); + } + + public function getIsoMapping(): void + { + $this->requireOrgAccess(); + + $mapping = [ + ['nis2' => '21.2.a', 'iso27001' => 'A.5.1, A.5.2, A.8.1, A.8.2', 'title' => 'Risk analysis and security policies'], + ['nis2' => '21.2.b', 'iso27001' => 'A.5.24, A.5.25, A.5.26, A.6.8', 'title' => 'Incident handling'], + ['nis2' => '21.2.c', 'iso27001' => 'A.5.29, A.5.30', 'title' => 'Business continuity'], + ['nis2' => '21.2.d', 'iso27001' => 'A.5.19, A.5.20, A.5.21, A.5.22', 'title' => 'Supply chain security'], + ['nis2' => '21.2.e', 'iso27001' => 'A.8.25, A.8.26, A.8.27, A.8.28', 'title' => 'System acquisition and development'], + ['nis2' => '21.2.f', 'iso27001' => 'A.5.35, A.5.36', 'title' => 'Effectiveness assessment'], + ['nis2' => '21.2.g', 'iso27001' => 'A.6.3, A.6.6', 'title' => 'Cyber hygiene and training'], + ['nis2' => '21.2.h', 'iso27001' => 'A.8.24', 'title' => 'Cryptography and encryption'], + ['nis2' => '21.2.i', 'iso27001' => 'A.5.15, A.5.16, A.5.17, A.5.18, A.6.1, A.6.2', 'title' => 'HR security, access control, asset management'], + ['nis2' => '21.2.j', 'iso27001' => 'A.8.5', 'title' => 'Multi-factor authentication'], + ]; + + $this->jsonSuccess($mapping); + } +} diff --git a/application/controllers/AuthController.php b/application/controllers/AuthController.php new file mode 100644 index 0000000..8371199 --- /dev/null +++ b/application/controllers/AuthController.php @@ -0,0 +1,280 @@ +validateRequired(['email', 'password', 'full_name']); + + $email = strtolower(trim($this->getParam('email'))); + $password = $this->getParam('password'); + $fullName = trim($this->getParam('full_name')); + $phone = $this->getParam('phone'); + + // Validazione email + if (!$this->validateEmail($email)) { + $this->jsonError('Formato email non valido', 400, 'INVALID_EMAIL'); + } + + // Validazione password + $passwordErrors = $this->validatePassword($password); + if (!empty($passwordErrors)) { + $this->jsonError( + implode('. ', $passwordErrors), + 400, + 'WEAK_PASSWORD' + ); + } + + // Verifica email duplicata + $existing = Database::fetchOne('SELECT id FROM users WHERE email = ?', [$email]); + if ($existing) { + $this->jsonError('Email già registrata', 409, 'EMAIL_EXISTS'); + } + + // Crea utente + $userId = Database::insert('users', [ + 'email' => $email, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + 'full_name' => $fullName, + 'phone' => $phone, + 'role' => 'employee', + 'is_active' => 1, + ]); + + // Genera tokens + $accessToken = $this->generateJWT($userId); + $refreshToken = $this->generateRefreshToken($userId); + + // Audit log + $this->currentUser = ['id' => $userId]; + $this->logAudit('user_registered', 'user', $userId); + + $this->jsonSuccess([ + 'user' => [ + 'id' => $userId, + 'email' => $email, + 'full_name' => $fullName, + 'role' => 'employee', + ], + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'expires_in' => JWT_EXPIRES_IN, + ], 'Registrazione completata', 201); + } + + /** + * POST /api/auth/login + */ + public function login(): void + { + $this->validateRequired(['email', 'password']); + + $email = strtolower(trim($this->getParam('email'))); + $password = $this->getParam('password'); + + // Trova utente + $user = Database::fetchOne( + 'SELECT * FROM users WHERE email = ? AND is_active = 1', + [$email] + ); + + if (!$user || !password_verify($password, $user['password_hash'])) { + $this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS'); + } + + // Aggiorna ultimo login + Database::update('users', [ + 'last_login_at' => date('Y-m-d H:i:s'), + ], 'id = ?', [$user['id']]); + + // Genera tokens + $accessToken = $this->generateJWT((int) $user['id']); + $refreshToken = $this->generateRefreshToken((int) $user['id']); + + // Carica organizzazioni + $organizations = Database::fetchAll( + 'SELECT uo.organization_id, uo.role, uo.is_primary, o.name, o.sector, o.entity_type + FROM user_organizations uo + JOIN organizations o ON o.id = uo.organization_id + WHERE uo.user_id = ? AND o.is_active = 1', + [$user['id']] + ); + + $this->currentUser = $user; + $this->logAudit('user_login', 'user', (int) $user['id']); + + $this->jsonSuccess([ + 'user' => [ + 'id' => (int) $user['id'], + 'email' => $user['email'], + 'full_name' => $user['full_name'], + 'role' => $user['role'], + 'preferred_language' => $user['preferred_language'], + ], + 'organizations' => $organizations, + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'expires_in' => JWT_EXPIRES_IN, + ], 'Login effettuato'); + } + + /** + * POST /api/auth/logout + */ + public function logout(): void + { + $this->requireAuth(); + + // Invalida tutti i refresh token dell'utente + Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]); + + $this->logAudit('user_logout', 'user', $this->getCurrentUserId()); + + $this->jsonSuccess(null, 'Logout effettuato'); + } + + /** + * POST /api/auth/refresh + */ + public function refresh(): void + { + $this->validateRequired(['refresh_token']); + + $refreshToken = $this->getParam('refresh_token'); + $hashedToken = hash('sha256', $refreshToken); + + // Verifica refresh token + $tokenRecord = Database::fetchOne( + 'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW()', + [$hashedToken] + ); + + if (!$tokenRecord) { + $this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN'); + } + + // Elimina vecchio token + Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]); + + // Genera nuovi tokens + $userId = (int) $tokenRecord['user_id']; + $accessToken = $this->generateJWT($userId); + $newRefreshToken = $this->generateRefreshToken($userId); + + $this->jsonSuccess([ + 'access_token' => $accessToken, + 'refresh_token' => $newRefreshToken, + 'expires_in' => JWT_EXPIRES_IN, + ], 'Token rinnovato'); + } + + /** + * GET /api/auth/me + */ + public function me(): void + { + $this->requireAuth(); + + $user = $this->getCurrentUser(); + + // Carica organizzazioni + $organizations = Database::fetchAll( + 'SELECT uo.organization_id, uo.role as org_role, uo.is_primary, + o.name, o.sector, o.entity_type, o.subscription_plan + FROM user_organizations uo + JOIN organizations o ON o.id = uo.organization_id + WHERE uo.user_id = ? AND o.is_active = 1', + [$user['id']] + ); + + $this->jsonSuccess([ + 'id' => (int) $user['id'], + 'email' => $user['email'], + 'full_name' => $user['full_name'], + 'phone' => $user['phone'], + 'role' => $user['role'], + 'preferred_language' => $user['preferred_language'], + 'last_login_at' => $user['last_login_at'], + 'created_at' => $user['created_at'], + 'organizations' => $organizations, + ]); + } + + /** + * PUT /api/auth/profile + */ + public function updateProfile(): void + { + $this->requireAuth(); + + $updates = []; + + if ($this->hasParam('full_name')) { + $updates['full_name'] = trim($this->getParam('full_name')); + } + if ($this->hasParam('phone')) { + $updates['phone'] = $this->getParam('phone'); + } + if ($this->hasParam('preferred_language')) { + $lang = $this->getParam('preferred_language'); + if (in_array($lang, ['it', 'en', 'fr', 'de'])) { + $updates['preferred_language'] = $lang; + } + } + + if (empty($updates)) { + $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); + } + + Database::update('users', $updates, 'id = ?', [$this->getCurrentUserId()]); + + $this->logAudit('profile_updated', 'user', $this->getCurrentUserId(), $updates); + + $this->jsonSuccess($updates, 'Profilo aggiornato'); + } + + /** + * POST /api/auth/change-password + */ + public function changePassword(): void + { + $this->requireAuth(); + $this->validateRequired(['current_password', 'new_password']); + + $currentPassword = $this->getParam('current_password'); + $newPassword = $this->getParam('new_password'); + + // Verifica password attuale + if (!password_verify($currentPassword, $this->currentUser['password_hash'])) { + $this->jsonError('Password attuale non corretta', 400, 'WRONG_PASSWORD'); + } + + // Validazione nuova password + $errors = $this->validatePassword($newPassword); + if (!empty($errors)) { + $this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD'); + } + + Database::update('users', [ + 'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), + ], 'id = ?', [$this->getCurrentUserId()]); + + // Invalida tutti i refresh token (force re-login) + Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]); + + $this->logAudit('password_changed', 'user', $this->getCurrentUserId()); + + $this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.'); + } +} diff --git a/application/controllers/BaseController.php b/application/controllers/BaseController.php new file mode 100644 index 0000000..be3d7a1 --- /dev/null +++ b/application/controllers/BaseController.php @@ -0,0 +1,576 @@ + true, + 'message' => $message, + 'data' => $data, + ], JSON_UNESCAPED_UNICODE); + + exit; + } + + /** + * Invia risposta JSON di errore + */ + protected function jsonError(string $message, int $statusCode = 400, ?string $errorCode = null, ?array $data = null): void + { + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + + $response = [ + 'success' => false, + 'message' => $message, + ]; + + if ($errorCode) { + $response['error_code'] = $errorCode; + } + + if ($data) { + $response['data'] = $data; + } + + echo json_encode($response, JSON_UNESCAPED_UNICODE); + exit; + } + + /** + * Invia risposta paginata + */ + protected function jsonPaginated(array $items, int $total, int $page, int $perPage): void + { + $this->jsonSuccess([ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'pages' => ceil($total / $perPage), + ]); + } + + // ═══════════════════════════════════════════════════════════════════════ + // PARAMETRI RICHIESTA + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Ottiene parametro dalla richiesta (GET, POST o JSON body) + */ + protected function getParam(string $key, $default = null) + { + if (isset($_REQUEST[$key])) { + return $_REQUEST[$key]; + } + + $jsonBody = $this->getJsonBody(); + if (isset($jsonBody[$key])) { + return $jsonBody[$key]; + } + + return $default; + } + + /** + * Verifica se un parametro esiste + */ + protected function hasParam(string $key): bool + { + if (isset($_REQUEST[$key])) { + return true; + } + + $jsonBody = $this->getJsonBody(); + return isset($jsonBody[$key]); + } + + /** + * Ottiene tutti i parametri dalla richiesta + */ + protected function getAllParams(): array + { + $params = $_REQUEST; + $jsonBody = $this->getJsonBody(); + return array_merge($params, $jsonBody); + } + + /** + * Ottiene il body JSON della richiesta + */ + protected function getJsonBody(): array + { + static $jsonBody = null; + + if ($jsonBody === null) { + $input = file_get_contents('php://input'); + $jsonBody = json_decode($input, true) ?? []; + } + + return $jsonBody; + } + + /** + * Ottiene parametri di paginazione + */ + protected function getPagination(int $defaultPerPage = 20): array + { + $page = max(1, (int) $this->getParam('page', 1)); + $perPage = min(100, max(1, (int) $this->getParam('per_page', $defaultPerPage))); + $offset = ($page - 1) * $perPage; + + return ['page' => $page, 'per_page' => $perPage, 'offset' => $offset]; + } + + // ═══════════════════════════════════════════════════════════════════════ + // VALIDAZIONE + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Valida parametri obbligatori + */ + protected function validateRequired(array $required): void + { + $missing = []; + + foreach ($required as $field) { + $value = $this->getParam($field); + if ($value === null || $value === '') { + $missing[] = $field; + } + } + + if (!empty($missing)) { + $this->jsonError( + 'Campi obbligatori mancanti: ' . implode(', ', $missing), + 400, + 'MISSING_REQUIRED_FIELDS' + ); + } + } + + /** + * Valida formato email + */ + protected function validateEmail(string $email): bool + { + return filter_var($email, FILTER_VALIDATE_EMAIL) !== false; + } + + /** + * Valida Partita IVA italiana + */ + protected function validateVAT(string $vat): bool + { + $vat = preg_replace('/\s+/', '', $vat); + $vat = preg_replace('/^IT/i', '', $vat); + + if (!preg_match('/^\d{11}$/', $vat)) { + return false; + } + + $sum = 0; + for ($i = 0; $i < 11; $i++) { + $digit = (int) $vat[$i]; + if ($i % 2 === 0) { + $sum += $digit; + } else { + $double = $digit * 2; + $sum += ($double > 9) ? $double - 9 : $double; + } + } + + return ($sum % 10) === 0; + } + + /** + * Valida Codice Fiscale italiano + */ + protected function validateFiscalCode(string $cf): bool + { + $cf = strtoupper(trim($cf)); + return (bool) preg_match('/^[A-Z0-9]{16}$/', $cf); + } + + /** + * Valida password secondo policy + */ + protected function validatePassword(string $password): array + { + $errors = []; + + if (strlen($password) < PASSWORD_MIN_LENGTH) { + $errors[] = 'La password deve essere di almeno ' . PASSWORD_MIN_LENGTH . ' caratteri'; + } + + if (PASSWORD_REQUIRE_UPPERCASE && !preg_match('/[A-Z]/', $password)) { + $errors[] = 'La password deve contenere almeno una lettera maiuscola'; + } + + if (PASSWORD_REQUIRE_NUMBER && !preg_match('/[0-9]/', $password)) { + $errors[] = 'La password deve contenere almeno un numero'; + } + + if (PASSWORD_REQUIRE_SPECIAL && !preg_match('/[!@#$%^&*(),.?":{}|<>]/', $password)) { + $errors[] = 'La password deve contenere almeno un carattere speciale'; + } + + return $errors; + } + + // ═══════════════════════════════════════════════════════════════════════ + // AUTENTICAZIONE JWT + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Richiede autenticazione JWT + */ + protected function requireAuth(): void + { + $token = $this->getBearerToken(); + + if (!$token) { + $this->jsonError('Token di autenticazione mancante', 401, 'MISSING_TOKEN'); + } + + $payload = $this->verifyJWT($token); + + if (!$payload) { + $this->jsonError('Token non valido o scaduto', 401, 'INVALID_TOKEN'); + } + + $user = Database::fetchOne( + 'SELECT * FROM users WHERE id = ? AND is_active = 1', + [$payload['user_id']] + ); + + if (!$user) { + $this->jsonError('Utente non trovato o disabilitato', 401, 'USER_NOT_FOUND'); + } + + $this->currentUser = $user; + } + + /** + * Richiede ruolo super_admin + */ + protected function requireSuperAdmin(): void + { + $this->requireAuth(); + + if ($this->currentUser['role'] !== 'super_admin') { + $this->jsonError('Accesso riservato ai super amministratori', 403, 'SUPER_ADMIN_REQUIRED'); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // MULTI-TENANCY + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Richiede accesso all'organizzazione corrente + */ + protected function requireOrgAccess(): void + { + $this->requireAuth(); + + $orgId = $this->resolveOrgId(); + + if (!$orgId) { + $this->jsonError('Organizzazione non selezionata', 403, 'NO_ORG'); + } + + // Super admin ha accesso a tutto + if ($this->currentUser['role'] === 'super_admin') { + $this->currentOrgId = $orgId; + $this->currentOrgRole = 'super_admin'; + return; + } + + // Verifica membership + $membership = Database::fetchOne( + 'SELECT role FROM user_organizations WHERE user_id = ? AND organization_id = ?', + [$this->getCurrentUserId(), $orgId] + ); + + if (!$membership) { + $this->jsonError('Accesso non autorizzato a questa organizzazione', 403, 'ORG_ACCESS_DENIED'); + } + + $this->currentOrgId = $orgId; + $this->currentOrgRole = $membership['role']; + } + + /** + * Richiede ruolo minimo nell'organizzazione + */ + protected function requireOrgRole(array $allowedRoles): void + { + $this->requireOrgAccess(); + + if ($this->currentOrgRole === 'super_admin') { + return; + } + + if (!in_array($this->currentOrgRole, $allowedRoles)) { + $this->jsonError( + 'Ruolo insufficiente. Richiesto: ' . implode(' o ', $allowedRoles), + 403, + 'INSUFFICIENT_ROLE' + ); + } + } + + /** + * Risolve l'ID organizzazione dalla richiesta + */ + protected function resolveOrgId(): ?int + { + // 1. Header X-Organization-Id + $orgId = $_SERVER['HTTP_X_ORGANIZATION_ID'] ?? null; + if ($orgId) { + return (int) $orgId; + } + + // 2. Query parameter org_id + $orgId = $this->getParam('org_id'); + if ($orgId) { + return (int) $orgId; + } + + // 3. Organizzazione primaria dell'utente + $primary = Database::fetchOne( + 'SELECT organization_id FROM user_organizations WHERE user_id = ? AND is_primary = 1', + [$this->getCurrentUserId()] + ); + + return $primary ? (int) $primary['organization_id'] : null; + } + + /** + * Ottiene utente corrente + */ + protected function getCurrentUser(): ?array + { + return $this->currentUser; + } + + /** + * Ottiene ID utente corrente + */ + protected function getCurrentUserId(): ?int + { + return $this->currentUser ? (int) $this->currentUser['id'] : null; + } + + /** + * Ottiene ID organizzazione corrente + */ + protected function getCurrentOrgId(): ?int + { + return $this->currentOrgId; + } + + // ═══════════════════════════════════════════════════════════════════════ + // JWT TOKEN MANAGEMENT + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Estrae Bearer token dall'header Authorization + */ + protected function getBearerToken(): ?string + { + $headers = $this->getAuthorizationHeader(); + + if ($headers && preg_match('/Bearer\s(\S+)/', $headers, $matches)) { + return $matches[1]; + } + + if (isset($_GET['token']) && !empty($_GET['token'])) { + return $_GET['token']; + } + + return null; + } + + /** + * Ottiene header Authorization + */ + private function getAuthorizationHeader(): ?string + { + if (isset($_SERVER['Authorization'])) { + return $_SERVER['Authorization']; + } + + if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + return $_SERVER['HTTP_AUTHORIZATION']; + } + + if (function_exists('apache_request_headers')) { + $headers = apache_request_headers(); + if (isset($headers['Authorization'])) { + return $headers['Authorization']; + } + } + + return null; + } + + /** + * Genera JWT token + */ + protected function generateJWT(int $userId, array $extraData = []): string + { + $header = json_encode([ + 'typ' => 'JWT', + 'alg' => JWT_ALGORITHM, + ]); + + $payload = json_encode(array_merge([ + 'user_id' => $userId, + 'iat' => time(), + 'exp' => time() + JWT_EXPIRES_IN, + ], $extraData)); + + $base64Header = $this->base64UrlEncode($header); + $base64Payload = $this->base64UrlEncode($payload); + + $signature = hash_hmac('sha256', "$base64Header.$base64Payload", JWT_SECRET, true); + $base64Signature = $this->base64UrlEncode($signature); + + return "$base64Header.$base64Payload.$base64Signature"; + } + + /** + * Verifica JWT token + */ + protected function verifyJWT(string $token): ?array + { + $parts = explode('.', $token); + + if (count($parts) !== 3) { + return null; + } + + [$base64Header, $base64Payload, $base64Signature] = $parts; + + $signature = $this->base64UrlDecode($base64Signature); + $expectedSignature = hash_hmac('sha256', "$base64Header.$base64Payload", JWT_SECRET, true); + + if (!hash_equals($signature, $expectedSignature)) { + return null; + } + + $payload = json_decode($this->base64UrlDecode($base64Payload), true); + + if (!$payload) { + return null; + } + + if (isset($payload['exp']) && $payload['exp'] < time()) { + return null; + } + + return $payload; + } + + /** + * Genera refresh token + */ + protected function generateRefreshToken(int $userId): string + { + $token = bin2hex(random_bytes(32)); + $expiresAt = date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN); + + Database::insert('refresh_tokens', [ + 'user_id' => $userId, + 'token' => hash('sha256', $token), + 'expires_at' => $expiresAt, + ]); + + return $token; + } + + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $data): string + { + return base64_decode(strtr($data, '-_', '+/')); + } + + // ═══════════════════════════════════════════════════════════════════════ + // AUDIT LOGGING + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Registra azione nell'audit log + */ + protected function logAudit(string $action, ?string $entityType = null, ?int $entityId = null, ?array $details = null): void + { + Database::insert('audit_logs', [ + 'user_id' => $this->getCurrentUserId(), + 'organization_id' => $this->currentOrgId, + 'action' => $action, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'details' => $details ? json_encode($details) : null, + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null, + ]); + } + + // ═══════════════════════════════════════════════════════════════════════ + // UTILITY + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Sanitizza stringa per output + */ + protected function sanitize(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } + + /** + * Ottiene metodo HTTP della richiesta + */ + protected function getMethod(): string + { + return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); + } + + /** + * Genera codice univoco + */ + protected function generateCode(string $prefix, int $length = 6): string + { + $number = str_pad(mt_rand(0, pow(10, $length) - 1), $length, '0', STR_PAD_LEFT); + return $prefix . '-' . $number; + } +} diff --git a/application/controllers/DashboardController.php b/application/controllers/DashboardController.php new file mode 100644 index 0000000..94c9165 --- /dev/null +++ b/application/controllers/DashboardController.php @@ -0,0 +1,348 @@ +requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + // Ultimo assessment + $lastAssessment = Database::fetchOne( + 'SELECT id, title, overall_score, status, completed_at + FROM assessments + WHERE organization_id = ? AND status = "completed" + ORDER BY completed_at DESC LIMIT 1', + [$orgId] + ); + + // Compliance controls status + $controlStats = Database::fetchAll( + 'SELECT status, COUNT(*) as count + FROM compliance_controls + WHERE organization_id = ? + GROUP BY status', + [$orgId] + ); + + // Rischi aperti + $openRisks = Database::fetchAll( + 'SELECT severity, COUNT(*) as count + FROM risks + WHERE organization_id = ? AND status NOT IN ("closed") + GROUP BY FIELD(severity, "critical", "high", "medium", "low") -- non supportato, usiamo ORDER', + [$orgId] + ); + + $riskCounts = Database::fetchOne( + 'SELECT + SUM(CASE WHEN inherent_risk_score >= 20 THEN 1 ELSE 0 END) as critical_high, + SUM(CASE WHEN inherent_risk_score BETWEEN 10 AND 19 THEN 1 ELSE 0 END) as medium, + SUM(CASE WHEN inherent_risk_score < 10 THEN 1 ELSE 0 END) as low, + COUNT(*) as total + FROM risks + WHERE organization_id = ? AND status != "closed"', + [$orgId] + ); + + // Incidenti attivi + $activeIncidents = Database::count( + 'incidents', + 'organization_id = ? AND status NOT IN ("closed", "post_mortem")', + [$orgId] + ); + + // Policy per stato + $policyStats = Database::fetchAll( + 'SELECT status, COUNT(*) as count + FROM policies + WHERE organization_id = ? + GROUP BY status', + [$orgId] + ); + + // Fornitori critici + $criticalSuppliers = Database::count( + 'suppliers', + 'organization_id = ? AND criticality IN ("high", "critical") AND security_requirements_met = 0', + [$orgId] + ); + + // Training completamento + $trainingStats = Database::fetchOne( + 'SELECT + COUNT(*) as total, + SUM(CASE WHEN status = "completed" THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = "overdue" THEN 1 ELSE 0 END) as overdue + FROM training_assignments + WHERE organization_id = ?', + [$orgId] + ); + + // Conteggi asset + $assetCount = Database::count('assets', 'organization_id = ? AND status = "active"', [$orgId]); + + // Organizzazione info + $org = Database::fetchOne('SELECT name, sector, entity_type, subscription_plan FROM organizations WHERE id = ?', [$orgId]); + + $this->jsonSuccess([ + 'organization' => $org, + 'last_assessment' => $lastAssessment, + 'compliance_score' => $lastAssessment ? (float) $lastAssessment['overall_score'] : null, + 'controls' => $controlStats, + 'risks' => $riskCounts, + 'active_incidents' => $activeIncidents, + 'policies' => $policyStats, + 'critical_suppliers' => $criticalSuppliers, + 'training' => $trainingStats, + 'asset_count' => $assetCount, + ]); + } + + /** + * GET /api/dashboard/compliance-score + */ + public function complianceScore(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + // Score dall'ultimo assessment + $assessments = Database::fetchAll( + 'SELECT id, title, overall_score, category_scores, completed_at + FROM assessments + WHERE organization_id = ? AND status = "completed" + ORDER BY completed_at DESC + LIMIT 5', + [$orgId] + ); + + // Score dei controlli + $controls = Database::fetchAll( + 'SELECT control_code, title, status, implementation_percentage + FROM compliance_controls + WHERE organization_id = ? + ORDER BY control_code', + [$orgId] + ); + + $totalControls = count($controls); + $implementedControls = 0; + $avgImplementation = 0; + + foreach ($controls as $c) { + if ($c['status'] === 'implemented' || $c['status'] === 'verified') { + $implementedControls++; + } + $avgImplementation += (int) $c['implementation_percentage']; + } + + $avgImplementation = $totalControls > 0 ? round($avgImplementation / $totalControls) : 0; + + $this->jsonSuccess([ + 'assessments' => $assessments, + 'controls' => $controls, + 'total_controls' => $totalControls, + 'implemented_controls' => $implementedControls, + 'avg_implementation' => $avgImplementation, + ]); + } + + /** + * GET /api/dashboard/upcoming-deadlines + */ + public function deadlines(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + + $deadlines = []; + + // Incidenti con scadenze notifica + $incidentDeadlines = Database::fetchAll( + 'SELECT id, incident_code, title, severity, + early_warning_due, early_warning_sent_at, + notification_due, notification_sent_at, + final_report_due, final_report_sent_at + FROM incidents + WHERE organization_id = ? AND is_significant = 1 AND status NOT IN ("closed", "post_mortem") + ORDER BY detected_at DESC', + [$orgId] + ); + + foreach ($incidentDeadlines as $inc) { + if ($inc['early_warning_due'] && !$inc['early_warning_sent_at']) { + $deadlines[] = [ + 'type' => 'incident_early_warning', + 'title' => "Early Warning: {$inc['title']}", + 'due_date' => $inc['early_warning_due'], + 'severity' => 'critical', + 'entity_type' => 'incident', + 'entity_id' => $inc['id'], + ]; + } + if ($inc['notification_due'] && !$inc['notification_sent_at']) { + $deadlines[] = [ + 'type' => 'incident_notification', + 'title' => "Notifica CSIRT: {$inc['title']}", + 'due_date' => $inc['notification_due'], + 'severity' => 'high', + 'entity_type' => 'incident', + 'entity_id' => $inc['id'], + ]; + } + if ($inc['final_report_due'] && !$inc['final_report_sent_at']) { + $deadlines[] = [ + 'type' => 'incident_final_report', + 'title' => "Report finale: {$inc['title']}", + 'due_date' => $inc['final_report_due'], + 'severity' => 'medium', + 'entity_type' => 'incident', + 'entity_id' => $inc['id'], + ]; + } + } + + // Policy in scadenza revisione + $policyDeadlines = Database::fetchAll( + 'SELECT id, title, next_review_date + FROM policies + WHERE organization_id = ? AND next_review_date IS NOT NULL + AND next_review_date <= DATE_ADD(NOW(), INTERVAL 30 DAY) + AND status NOT IN ("archived") + ORDER BY next_review_date', + [$orgId] + ); + + foreach ($policyDeadlines as $p) { + $deadlines[] = [ + 'type' => 'policy_review', + 'title' => "Revisione policy: {$p['title']}", + 'due_date' => $p['next_review_date'], + 'severity' => 'medium', + 'entity_type' => 'policy', + 'entity_id' => $p['id'], + ]; + } + + // Risk treatments in scadenza + $treatmentDeadlines = Database::fetchAll( + 'SELECT rt.id, rt.action_description, rt.due_date, r.title as risk_title + FROM risk_treatments rt + JOIN risks r ON r.id = rt.risk_id + WHERE r.organization_id = ? AND rt.status IN ("planned", "in_progress") + AND rt.due_date IS NOT NULL AND rt.due_date <= DATE_ADD(NOW(), INTERVAL 30 DAY) + ORDER BY rt.due_date', + [$orgId] + ); + + foreach ($treatmentDeadlines as $t) { + $deadlines[] = [ + 'type' => 'risk_treatment', + 'title' => "Trattamento rischio: {$t['risk_title']}", + 'due_date' => $t['due_date'], + 'severity' => 'medium', + 'entity_type' => 'risk_treatment', + 'entity_id' => $t['id'], + ]; + } + + // Training in scadenza + $trainingDeadlines = Database::fetchAll( + 'SELECT ta.id, tc.title, ta.due_date, u.full_name + FROM training_assignments ta + JOIN training_courses tc ON tc.id = ta.course_id + JOIN users u ON u.id = ta.user_id + WHERE ta.organization_id = ? AND ta.status IN ("assigned", "in_progress") + AND ta.due_date IS NOT NULL AND ta.due_date <= DATE_ADD(NOW(), INTERVAL 30 DAY) + ORDER BY ta.due_date', + [$orgId] + ); + + foreach ($trainingDeadlines as $t) { + $deadlines[] = [ + 'type' => 'training_due', + 'title' => "Formazione: {$t['title']} - {$t['full_name']}", + 'due_date' => $t['due_date'], + 'severity' => 'low', + 'entity_type' => 'training', + 'entity_id' => $t['id'], + ]; + } + + // Ordina per data + usort($deadlines, fn($a, $b) => strcmp($a['due_date'], $b['due_date'])); + + $this->jsonSuccess($deadlines); + } + + /** + * GET /api/dashboard/recent-activity + */ + public function recentActivity(): void + { + $this->requireOrgAccess(); + + $activities = Database::fetchAll( + 'SELECT al.*, u.full_name + FROM audit_logs al + LEFT JOIN users u ON u.id = al.user_id + WHERE al.organization_id = ? + ORDER BY al.created_at DESC + LIMIT 20', + [$this->getCurrentOrgId()] + ); + + $this->jsonSuccess($activities); + } + + /** + * GET /api/dashboard/risk-heatmap + */ + public function riskHeatmap(): void + { + $this->requireOrgAccess(); + + $risks = Database::fetchAll( + 'SELECT id, title, category, likelihood, impact, inherent_risk_score, status + FROM risks + WHERE organization_id = ? AND status != "closed" + ORDER BY inherent_risk_score DESC', + [$this->getCurrentOrgId()] + ); + + // Costruisci matrice 5x5 + $matrix = []; + for ($l = 1; $l <= 5; $l++) { + for ($i = 1; $i <= 5; $i++) { + $matrix["{$l}_{$i}"] = []; + } + } + + foreach ($risks as $risk) { + if ($risk['likelihood'] && $risk['impact']) { + $key = "{$risk['likelihood']}_{$risk['impact']}"; + $matrix[$key][] = [ + 'id' => $risk['id'], + 'title' => $risk['title'], + 'score' => $risk['inherent_risk_score'], + ]; + } + } + + $this->jsonSuccess([ + 'risks' => $risks, + 'matrix' => $matrix, + ]); + } +} diff --git a/application/controllers/IncidentController.php b/application/controllers/IncidentController.php new file mode 100644 index 0000000..7117817 --- /dev/null +++ b/application/controllers/IncidentController.php @@ -0,0 +1,325 @@ +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 + ]); + + $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); + } + + $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(), + ]); + + $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(), + ]); + + $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(), + ]); + + $this->logAudit('final_report_sent', 'incident', $id); + $this->jsonSuccess(null, 'Report finale registrato'); + } + + /** + * 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'); + } + } +} diff --git a/application/controllers/OrganizationController.php b/application/controllers/OrganizationController.php new file mode 100644 index 0000000..6b1ea89 --- /dev/null +++ b/application/controllers/OrganizationController.php @@ -0,0 +1,395 @@ +requireAuth(); + $this->validateRequired(['name', 'sector']); + + $name = trim($this->getParam('name')); + $sector = $this->getParam('sector'); + $vatNumber = $this->getParam('vat_number'); + $fiscalCode = $this->getParam('fiscal_code'); + + // Valida P.IVA se fornita + if ($vatNumber && !$this->validateVAT($vatNumber)) { + $this->jsonError('Partita IVA non valida', 400, 'INVALID_VAT'); + } + + Database::beginTransaction(); + try { + // Crea organizzazione + $orgId = Database::insert('organizations', [ + 'name' => $name, + 'vat_number' => $vatNumber, + 'fiscal_code' => $fiscalCode, + 'sector' => $sector, + 'employee_count' => $this->getParam('employee_count'), + 'annual_turnover_eur' => $this->getParam('annual_turnover_eur'), + 'country' => $this->getParam('country', 'IT'), + 'city' => $this->getParam('city'), + 'address' => $this->getParam('address'), + 'website' => $this->getParam('website'), + 'contact_email' => $this->getParam('contact_email'), + 'contact_phone' => $this->getParam('contact_phone'), + ]); + + // Auto-classifica entità NIS2 + $entityType = $this->classifyNis2Entity( + $sector, + (int) $this->getParam('employee_count', 0), + (float) $this->getParam('annual_turnover_eur', 0) + ); + + Database::update('organizations', [ + 'entity_type' => $entityType, + ], 'id = ?', [$orgId]); + + // Aggiungi creatore come org_admin + Database::insert('user_organizations', [ + 'user_id' => $this->getCurrentUserId(), + 'organization_id' => $orgId, + 'role' => 'org_admin', + 'is_primary' => 1, + ]); + + // Inizializza controlli di compliance NIS2 + $this->initializeComplianceControls($orgId); + + Database::commit(); + + $this->currentOrgId = $orgId; + $this->logAudit('organization_created', 'organization', $orgId, [ + 'name' => $name, 'sector' => $sector, 'entity_type' => $entityType + ]); + + $this->jsonSuccess([ + 'id' => $orgId, + 'name' => $name, + 'sector' => $sector, + 'entity_type' => $entityType, + ], 'Organizzazione creata', 201); + + } catch (Throwable $e) { + Database::rollback(); + throw $e; + } + } + + /** + * GET /api/organizations/current + */ + public function getCurrent(): void + { + $this->requireOrgAccess(); + + $org = Database::fetchOne( + 'SELECT * FROM organizations WHERE id = ?', + [$this->getCurrentOrgId()] + ); + + if (!$org) { + $this->jsonError('Organizzazione non trovata', 404, 'ORG_NOT_FOUND'); + } + + // Conta membri + $memberCount = Database::count( + 'user_organizations', + 'organization_id = ?', + [$this->getCurrentOrgId()] + ); + + $org['member_count'] = $memberCount; + $org['current_user_role'] = $this->currentOrgRole; + + $this->jsonSuccess($org); + } + + /** + * GET /api/organizations/list + */ + public function list(): void + { + $this->requireAuth(); + + if ($this->currentUser['role'] === 'super_admin') { + $orgs = Database::fetchAll('SELECT * FROM organizations WHERE is_active = 1 ORDER BY name'); + } else { + $orgs = Database::fetchAll( + 'SELECT o.*, uo.role as user_role, uo.is_primary + FROM organizations o + JOIN user_organizations uo ON uo.organization_id = o.id + WHERE uo.user_id = ? AND o.is_active = 1 + ORDER BY uo.is_primary DESC, o.name', + [$this->getCurrentUserId()] + ); + } + + $this->jsonSuccess($orgs); + } + + /** + * PUT /api/organizations/{id} + */ + public function update(int $id): void + { + $this->requireOrgRole(['org_admin']); + + if ($id !== $this->getCurrentOrgId()) { + $this->jsonError('ID organizzazione non corrisponde', 400, 'ORG_MISMATCH'); + } + + $updates = []; + $allowedFields = [ + 'name', 'vat_number', 'fiscal_code', 'sector', 'employee_count', + 'annual_turnover_eur', 'country', 'city', 'address', 'website', + 'contact_email', 'contact_phone', + ]; + + foreach ($allowedFields as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if (empty($updates)) { + $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); + } + + // Ri-classifica se cambiano settore, dipendenti o fatturato + if (isset($updates['sector']) || isset($updates['employee_count']) || isset($updates['annual_turnover_eur'])) { + $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$id]); + $updates['entity_type'] = $this->classifyNis2Entity( + $updates['sector'] ?? $org['sector'], + (int) ($updates['employee_count'] ?? $org['employee_count']), + (float) ($updates['annual_turnover_eur'] ?? $org['annual_turnover_eur']) + ); + } + + Database::update('organizations', $updates, 'id = ?', [$id]); + + $this->logAudit('organization_updated', 'organization', $id, $updates); + + $this->jsonSuccess($updates, 'Organizzazione aggiornata'); + } + + /** + * GET /api/organizations/{id}/members + */ + public function listMembers(int $id): void + { + $this->requireOrgAccess(); + + $members = Database::fetchAll( + 'SELECT u.id, u.email, u.full_name, u.phone, u.last_login_at, + uo.role as org_role, uo.joined_at + FROM user_organizations uo + JOIN users u ON u.id = uo.user_id + WHERE uo.organization_id = ? AND u.is_active = 1 + ORDER BY uo.role, u.full_name', + [$this->getCurrentOrgId()] + ); + + $this->jsonSuccess($members); + } + + /** + * POST /api/organizations/{id}/invite + */ + public function inviteMember(int $id): void + { + $this->requireOrgRole(['org_admin']); + $this->validateRequired(['email', 'role']); + + $email = strtolower(trim($this->getParam('email'))); + $role = $this->getParam('role'); + + $validRoles = ['org_admin', 'compliance_manager', 'board_member', 'auditor', 'employee']; + if (!in_array($role, $validRoles)) { + $this->jsonError('Ruolo non valido', 400, 'INVALID_ROLE'); + } + + // Trova utente per email + $user = Database::fetchOne('SELECT id FROM users WHERE email = ?', [$email]); + + if (!$user) { + $this->jsonError( + 'Utente non registrato. L\'utente deve prima registrarsi sulla piattaforma.', + 404, + 'USER_NOT_FOUND' + ); + } + + // Verifica se già membro + $existing = Database::fetchOne( + 'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ?', + [$user['id'], $this->getCurrentOrgId()] + ); + + if ($existing) { + $this->jsonError('L\'utente è già membro di questa organizzazione', 409, 'ALREADY_MEMBER'); + } + + Database::insert('user_organizations', [ + 'user_id' => $user['id'], + 'organization_id' => $this->getCurrentOrgId(), + 'role' => $role, + 'is_primary' => 0, + ]); + + $this->logAudit('member_invited', 'organization', $this->getCurrentOrgId(), [ + 'invited_user_id' => $user['id'], 'role' => $role + ]); + + $this->jsonSuccess([ + 'user_id' => $user['id'], + 'role' => $role, + ], 'Membro aggiunto'); + } + + /** + * DELETE /api/organizations/{id}/members/{userId} + */ + public function removeMember(int $orgId, int $userId): void + { + $this->requireOrgRole(['org_admin']); + + // Non puoi rimuovere te stesso + if ($userId === $this->getCurrentUserId()) { + $this->jsonError('Non puoi rimuovere te stesso dall\'organizzazione', 400, 'CANNOT_REMOVE_SELF'); + } + + $deleted = Database::delete( + 'user_organizations', + 'user_id = ? AND organization_id = ?', + [$userId, $this->getCurrentOrgId()] + ); + + if ($deleted === 0) { + $this->jsonError('Membro non trovato', 404, 'MEMBER_NOT_FOUND'); + } + + $this->logAudit('member_removed', 'organization', $this->getCurrentOrgId(), [ + 'removed_user_id' => $userId + ]); + + $this->jsonSuccess(null, 'Membro rimosso'); + } + + /** + * POST /api/organizations/classify + * Classifica se l'organizzazione è Essential o Important secondo NIS2 + */ + public function classifyEntity(): void + { + $this->validateRequired(['sector', 'employee_count', 'annual_turnover_eur']); + + $sector = $this->getParam('sector'); + $employees = (int) $this->getParam('employee_count'); + $turnover = (float) $this->getParam('annual_turnover_eur'); + + $entityType = $this->classifyNis2Entity($sector, $employees, $turnover); + + $this->jsonSuccess([ + 'entity_type' => $entityType, + 'sector' => $sector, + 'employee_count' => $employees, + 'annual_turnover_eur' => $turnover, + 'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover), + ]); + } + + // ═══════════════════════════════════════════════════════════════════════ + // METODI PRIVATI + // ═══════════════════════════════════════════════════════════════════════ + + /** + * Classifica entità NIS2 in base a settore, dipendenti e fatturato + */ + private function classifyNis2Entity(string $sector, int $employees, float $turnover): string + { + // Settori Essenziali (Allegato I) + $essentialSectors = [ + 'energy', 'transport', 'banking', 'health', 'water', + 'digital_infra', 'public_admin', 'space', + ]; + + // Settori Importanti (Allegato II) + $importantSectors = [ + 'manufacturing', 'postal', 'chemical', 'food', 'waste', + 'ict_services', 'digital_providers', 'research', + ]; + + // Soglie dimensionali + $isLarge = $employees >= 250 || $turnover >= 50000000; + $isMedium = ($employees >= 50 || $turnover >= 10000000) && !$isLarge; + + if (in_array($sector, $essentialSectors)) { + if ($isLarge) return 'essential'; + if ($isMedium) return 'important'; + } + + if (in_array($sector, $importantSectors)) { + if ($isLarge || $isMedium) return 'important'; + } + + return 'not_applicable'; + } + + /** + * Genera spiegazione della classificazione + */ + private function getClassificationExplanation(string $type, string $sector, int $employees, float $turnover): string + { + $sizeLabel = $employees >= 250 ? 'grande impresa' : ($employees >= 50 ? 'media impresa' : 'piccola impresa'); + + return match ($type) { + 'essential' => "L'organizzazione opera nel settore '{$sector}' (Allegato I NIS2) ed è classificata come {$sizeLabel}. Rientra tra le entità ESSENZIALI soggette a supervisione proattiva. Sanzioni fino a EUR 10M o 2% del fatturato globale.", + 'important' => "L'organizzazione opera nel settore '{$sector}' ed è classificata come {$sizeLabel}. Rientra tra le entità IMPORTANTI soggette a supervisione reattiva. Sanzioni fino a EUR 7M o 1,4% del fatturato globale.", + default => "In base ai parametri forniti ({$sizeLabel}, settore '{$sector}'), l'organizzazione NON rientra attualmente nell'ambito di applicazione della NIS2. Si consiglia comunque di adottare le best practice di cybersecurity.", + }; + } + + /** + * Inizializza i controlli di compliance NIS2 per una nuova organizzazione + */ + private function initializeComplianceControls(int $orgId): void + { + $controls = [ + ['NIS2-21.2.a', 'nis2', 'Politiche di analisi dei rischi e sicurezza dei sistemi informatici'], + ['NIS2-21.2.b', 'nis2', 'Gestione degli incidenti'], + ['NIS2-21.2.c', 'nis2', 'Continuità operativa e gestione delle crisi'], + ['NIS2-21.2.d', 'nis2', 'Sicurezza della catena di approvvigionamento'], + ['NIS2-21.2.e', 'nis2', 'Sicurezza acquisizione, sviluppo e manutenzione sistemi'], + ['NIS2-21.2.f', 'nis2', 'Politiche e procedure per valutare efficacia misure'], + ['NIS2-21.2.g', 'nis2', 'Pratiche di igiene informatica di base e formazione'], + ['NIS2-21.2.h', 'nis2', 'Politiche e procedure relative alla crittografia'], + ['NIS2-21.2.i', 'nis2', 'Sicurezza risorse umane, controllo accessi e gestione asset'], + ['NIS2-21.2.j', 'nis2', 'Autenticazione multi-fattore e comunicazioni sicure'], + ['NIS2-20.1', 'nis2', 'Governance: approvazione misure da parte degli organi di gestione'], + ['NIS2-20.2', 'nis2', 'Formazione obbligatoria per gli organi di gestione'], + ['NIS2-23.1', 'nis2', 'Notifica incidenti significativi al CSIRT (24h/72h/30gg)'], + ]; + + foreach ($controls as [$code, $framework, $title]) { + Database::insert('compliance_controls', [ + 'organization_id' => $orgId, + 'control_code' => $code, + 'framework' => $framework, + 'title' => $title, + 'status' => 'not_started', + ]); + } + } +} diff --git a/application/controllers/PolicyController.php b/application/controllers/PolicyController.php new file mode 100644 index 0000000..cd269f1 --- /dev/null +++ b/application/controllers/PolicyController.php @@ -0,0 +1,170 @@ +requireOrgAccess(); + + $where = 'organization_id = ?'; + $params = [$this->getCurrentOrgId()]; + + if ($this->hasParam('status')) { + $where .= ' AND status = ?'; + $params[] = $this->getParam('status'); + } + if ($this->hasParam('category')) { + $where .= ' AND category = ?'; + $params[] = $this->getParam('category'); + } + + $policies = Database::fetchAll( + "SELECT p.*, u.full_name as approved_by_name + FROM policies p + LEFT JOIN users u ON u.id = p.approved_by + WHERE p.{$where} + ORDER BY p.category, p.title", + $params + ); + + $this->jsonSuccess($policies); + } + + public function create(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['title', 'category']); + + $policyId = Database::insert('policies', [ + 'organization_id' => $this->getCurrentOrgId(), + 'title' => trim($this->getParam('title')), + 'category' => $this->getParam('category'), + 'nis2_article' => $this->getParam('nis2_article'), + 'content' => $this->getParam('content'), + 'next_review_date' => $this->getParam('next_review_date'), + 'ai_generated' => $this->getParam('ai_generated', 0), + ]); + + $this->logAudit('policy_created', 'policy', $policyId); + $this->jsonSuccess(['id' => $policyId], 'Policy creata', 201); + } + + public function get(int $id): void + { + $this->requireOrgAccess(); + + $policy = Database::fetchOne( + 'SELECT p.*, u.full_name as approved_by_name + FROM policies p + LEFT JOIN users u ON u.id = p.approved_by + WHERE p.id = ? AND p.organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$policy) { + $this->jsonError('Policy non trovata', 404, 'POLICY_NOT_FOUND'); + } + + $this->jsonSuccess($policy); + } + + public function update(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $updates = []; + foreach (['title', 'content', 'category', 'nis2_article', 'status', 'version', 'next_review_date'] as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if (!empty($updates)) { + Database::update('policies', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + $this->logAudit('policy_updated', 'policy', $id, $updates); + } + + $this->jsonSuccess($updates, 'Policy aggiornata'); + } + + public function delete(int $id): void + { + $this->requireOrgRole(['org_admin']); + + $deleted = Database::delete('policies', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + if ($deleted === 0) { + $this->jsonError('Policy non trovata', 404, 'POLICY_NOT_FOUND'); + } + + $this->logAudit('policy_deleted', 'policy', $id); + $this->jsonSuccess(null, 'Policy eliminata'); + } + + public function approve(int $id): void + { + $this->requireOrgRole(['org_admin']); + + Database::update('policies', [ + 'status' => 'approved', + 'approved_by' => $this->getCurrentUserId(), + 'approved_at' => date('Y-m-d H:i:s'), + ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + + $this->logAudit('policy_approved', 'policy', $id); + $this->jsonSuccess(null, 'Policy approvata'); + } + + public function aiGeneratePolicy(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['category']); + + $category = $this->getParam('category'); + $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); + + try { + $aiService = new AIService(); + $generated = $aiService->generatePolicy($category, $org); + + $aiService->logInteraction( + $this->getCurrentOrgId(), + $this->getCurrentUserId(), + 'policy_draft', + "Generate {$category} policy", + substr($generated['title'] ?? '', 0, 500) + ); + + $this->jsonSuccess($generated, 'Policy generata dall\'AI'); + } catch (Throwable $e) { + $this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR'); + } + } + + public function getTemplates(): void + { + $this->requireOrgAccess(); + + $templates = [ + ['category' => 'information_security', 'title' => 'Politica di Sicurezza delle Informazioni', 'nis2_article' => '21.2.a'], + ['category' => 'access_control', 'title' => 'Politica di Controllo degli Accessi', 'nis2_article' => '21.2.i'], + ['category' => 'incident_response', 'title' => 'Piano di Risposta agli Incidenti', 'nis2_article' => '21.2.b'], + ['category' => 'business_continuity', 'title' => 'Piano di Continuità Operativa', 'nis2_article' => '21.2.c'], + ['category' => 'supply_chain', 'title' => 'Politica di Sicurezza della Supply Chain', 'nis2_article' => '21.2.d'], + ['category' => 'encryption', 'title' => 'Politica sulla Crittografia', 'nis2_article' => '21.2.h'], + ['category' => 'hr_security', 'title' => 'Politica di Sicurezza delle Risorse Umane', 'nis2_article' => '21.2.i'], + ['category' => 'asset_management', 'title' => 'Politica di Gestione degli Asset', 'nis2_article' => '21.2.i'], + ['category' => 'network_security', 'title' => 'Politica di Sicurezza della Rete', 'nis2_article' => '21.2.e'], + ['category' => 'vulnerability_management', 'title' => 'Politica di Gestione delle Vulnerabilità', 'nis2_article' => '21.2.e'], + ]; + + $this->jsonSuccess($templates); + } +} diff --git a/application/controllers/RiskController.php b/application/controllers/RiskController.php new file mode 100644 index 0000000..12db623 --- /dev/null +++ b/application/controllers/RiskController.php @@ -0,0 +1,302 @@ +requireOrgAccess(); + $pagination = $this->getPagination(); + + $where = 'organization_id = ?'; + $params = [$this->getCurrentOrgId()]; + + // Filtri opzionali + if ($this->hasParam('status')) { + $where .= ' AND status = ?'; + $params[] = $this->getParam('status'); + } + if ($this->hasParam('category')) { + $where .= ' AND category = ?'; + $params[] = $this->getParam('category'); + } + + $total = Database::count('risks', $where, $params); + + $risks = Database::fetchAll( + "SELECT r.*, u.full_name as owner_name + FROM risks r + LEFT JOIN users u ON u.id = r.owner_user_id + WHERE r.{$where} + ORDER BY r.inherent_risk_score DESC + LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}", + $params + ); + + $this->jsonPaginated($risks, $total, $pagination['page'], $pagination['per_page']); + } + + /** + * POST /api/risks/create + */ + public function create(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['title', 'category']); + + $likelihood = (int) $this->getParam('likelihood', 0); + $impact = (int) $this->getParam('impact', 0); + + $riskId = Database::insert('risks', [ + 'organization_id' => $this->getCurrentOrgId(), + 'risk_code' => $this->generateCode('RSK'), + 'title' => trim($this->getParam('title')), + 'description' => $this->getParam('description'), + 'category' => $this->getParam('category'), + 'threat_source' => $this->getParam('threat_source'), + 'vulnerability' => $this->getParam('vulnerability'), + 'affected_assets' => $this->getParam('affected_assets') ? json_encode($this->getParam('affected_assets')) : null, + 'likelihood' => $likelihood, + 'impact' => $impact, + 'inherent_risk_score' => $likelihood * $impact, + 'treatment' => $this->getParam('treatment', 'mitigate'), + 'owner_user_id' => $this->getParam('owner_user_id'), + 'review_date' => $this->getParam('review_date'), + 'nis2_article' => $this->getParam('nis2_article'), + ]); + + $this->logAudit('risk_created', 'risk', $riskId); + + $this->jsonSuccess(['id' => $riskId], 'Rischio registrato', 201); + } + + /** + * GET /api/risks/{id} + */ + public function get(int $id): void + { + $this->requireOrgAccess(); + + $risk = Database::fetchOne( + 'SELECT r.*, u.full_name as owner_name + FROM risks r + LEFT JOIN users u ON u.id = r.owner_user_id + WHERE r.id = ? AND r.organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$risk) { + $this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND'); + } + + // Carica trattamenti + $risk['treatments'] = Database::fetchAll( + 'SELECT rt.*, u.full_name as responsible_name + FROM risk_treatments rt + LEFT JOIN users u ON u.id = rt.responsible_user_id + WHERE rt.risk_id = ? + ORDER BY rt.due_date', + [$id] + ); + + $this->jsonSuccess($risk); + } + + /** + * PUT /api/risks/{id} + */ + public function update(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $risk = Database::fetchOne( + 'SELECT * FROM risks WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$risk) { + $this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND'); + } + + $updates = []; + $allowedFields = [ + 'title', 'description', 'category', 'threat_source', 'vulnerability', + 'likelihood', 'impact', 'treatment', 'residual_likelihood', 'residual_impact', + 'status', 'owner_user_id', 'review_date', 'nis2_article', + ]; + + foreach ($allowedFields as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + // Ricalcola score se likelihood o impact cambiano + $likelihood = (int) ($updates['likelihood'] ?? $risk['likelihood']); + $impact = (int) ($updates['impact'] ?? $risk['impact']); + $updates['inherent_risk_score'] = $likelihood * $impact; + + if (isset($updates['residual_likelihood']) || isset($updates['residual_impact'])) { + $resLikelihood = (int) ($updates['residual_likelihood'] ?? $risk['residual_likelihood']); + $resImpact = (int) ($updates['residual_impact'] ?? $risk['residual_impact']); + $updates['residual_risk_score'] = $resLikelihood * $resImpact; + } + + if (!empty($updates)) { + Database::update('risks', $updates, 'id = ?', [$id]); + $this->logAudit('risk_updated', 'risk', $id, $updates); + } + + $this->jsonSuccess($updates, 'Rischio aggiornato'); + } + + /** + * DELETE /api/risks/{id} + */ + public function delete(int $id): void + { + $this->requireOrgRole(['org_admin']); + + $deleted = Database::delete('risks', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + + if ($deleted === 0) { + $this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND'); + } + + $this->logAudit('risk_deleted', 'risk', $id); + $this->jsonSuccess(null, 'Rischio eliminato'); + } + + /** + * POST /api/risks/{id}/treatments + */ + public function addTreatment(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['action_description']); + + // Verifica che il rischio esista per l'organizzazione + $risk = Database::fetchOne( + 'SELECT id FROM risks WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$risk) { + $this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND'); + } + + $treatmentId = Database::insert('risk_treatments', [ + 'risk_id' => $id, + 'action_description' => $this->getParam('action_description'), + 'responsible_user_id' => $this->getParam('responsible_user_id'), + 'due_date' => $this->getParam('due_date'), + 'status' => 'planned', + 'notes' => $this->getParam('notes'), + ]); + + $this->logAudit('treatment_added', 'risk', $id, ['treatment_id' => $treatmentId]); + + $this->jsonSuccess(['id' => $treatmentId], 'Trattamento aggiunto', 201); + } + + /** + * PUT /api/risks/treatments/{id} + */ + public function updateTreatment(int $treatmentId): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $treatment = Database::fetchOne( + 'SELECT rt.* FROM risk_treatments rt + JOIN risks r ON r.id = rt.risk_id + WHERE rt.id = ? AND r.organization_id = ?', + [$treatmentId, $this->getCurrentOrgId()] + ); + + if (!$treatment) { + $this->jsonError('Trattamento non trovato', 404, 'TREATMENT_NOT_FOUND'); + } + + $updates = []; + foreach (['action_description', 'responsible_user_id', 'due_date', 'status', 'completion_date', 'notes'] as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if (!empty($updates)) { + Database::update('risk_treatments', $updates, 'id = ?', [$treatmentId]); + $this->logAudit('treatment_updated', 'risk_treatment', $treatmentId, $updates); + } + + $this->jsonSuccess($updates, 'Trattamento aggiornato'); + } + + /** + * GET /api/risks/matrix + */ + public function getRiskMatrix(): void + { + $this->requireOrgAccess(); + + $risks = Database::fetchAll( + 'SELECT id, title, category, likelihood, impact, inherent_risk_score, + residual_likelihood, residual_impact, residual_risk_score, status + FROM risks + WHERE organization_id = ? AND status != "closed"', + [$this->getCurrentOrgId()] + ); + + $this->jsonSuccess([ + 'risks' => $risks, + 'summary' => [ + 'total' => count($risks), + 'critical' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 20)), + 'high' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 12 && ($r['inherent_risk_score'] ?? 0) < 20)), + 'medium' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 6 && ($r['inherent_risk_score'] ?? 0) < 12)), + 'low' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) < 6)), + ], + ]); + } + + /** + * POST /api/risks/ai-suggest + */ + public function aiSuggestRisks(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); + $assets = Database::fetchAll( + 'SELECT name, asset_type, criticality FROM assets WHERE organization_id = ? AND status = "active"', + [$this->getCurrentOrgId()] + ); + + try { + $aiService = new AIService(); + $suggestions = $aiService->suggestRisks($org, $assets); + + $aiService->logInteraction( + $this->getCurrentOrgId(), + $this->getCurrentUserId(), + 'risk_suggestion', + 'Risk suggestions for ' . $org['sector'], + substr(json_encode($suggestions), 0, 500) + ); + + $this->jsonSuccess($suggestions, 'Suggerimenti rischi generati'); + } catch (Throwable $e) { + $this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR'); + } + } +} diff --git a/application/controllers/SupplyChainController.php b/application/controllers/SupplyChainController.php new file mode 100644 index 0000000..6ae7e11 --- /dev/null +++ b/application/controllers/SupplyChainController.php @@ -0,0 +1,168 @@ +requireOrgAccess(); + + $suppliers = Database::fetchAll( + 'SELECT * FROM suppliers WHERE organization_id = ? ORDER BY criticality DESC, name', + [$this->getCurrentOrgId()] + ); + + $this->jsonSuccess($suppliers); + } + + public function create(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['name', 'service_type']); + + $supplierId = Database::insert('suppliers', [ + 'organization_id' => $this->getCurrentOrgId(), + 'name' => trim($this->getParam('name')), + 'vat_number' => $this->getParam('vat_number'), + 'contact_email' => $this->getParam('contact_email'), + 'contact_name' => $this->getParam('contact_name'), + 'service_type' => $this->getParam('service_type'), + 'service_description' => $this->getParam('service_description'), + 'criticality' => $this->getParam('criticality', 'medium'), + 'contract_start_date' => $this->getParam('contract_start_date'), + 'contract_expiry_date' => $this->getParam('contract_expiry_date'), + 'notes' => $this->getParam('notes'), + ]); + + $this->logAudit('supplier_created', 'supplier', $supplierId); + $this->jsonSuccess(['id' => $supplierId], 'Fornitore aggiunto', 201); + } + + public function get(int $id): void + { + $this->requireOrgAccess(); + + $supplier = Database::fetchOne( + 'SELECT * FROM suppliers WHERE id = ? AND organization_id = ?', + [$id, $this->getCurrentOrgId()] + ); + + if (!$supplier) { + $this->jsonError('Fornitore non trovato', 404, 'SUPPLIER_NOT_FOUND'); + } + + $this->jsonSuccess($supplier); + } + + public function update(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + + $updates = []; + $fields = ['name', 'vat_number', 'contact_email', 'contact_name', 'service_type', + 'service_description', 'criticality', 'contract_start_date', 'contract_expiry_date', + 'security_requirements_met', 'notes', 'status']; + + foreach ($fields as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if (!empty($updates)) { + Database::update('suppliers', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + $this->logAudit('supplier_updated', 'supplier', $id, $updates); + } + + $this->jsonSuccess($updates, 'Fornitore aggiornato'); + } + + public function delete(int $id): void + { + $this->requireOrgRole(['org_admin']); + $deleted = Database::delete('suppliers', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + if ($deleted === 0) { + $this->jsonError('Fornitore non trovato', 404, 'SUPPLIER_NOT_FOUND'); + } + $this->logAudit('supplier_deleted', 'supplier', $id); + $this->jsonSuccess(null, 'Fornitore eliminato'); + } + + public function assessSupplier(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['assessment_responses']); + + $responses = $this->getParam('assessment_responses'); + $riskScore = $this->calculateSupplierRiskScore($responses); + + Database::update('suppliers', [ + 'assessment_responses' => json_encode($responses), + 'risk_score' => $riskScore, + 'last_assessment_date' => date('Y-m-d'), + 'next_assessment_date' => date('Y-m-d', strtotime('+6 months')), + 'security_requirements_met' => $riskScore >= 70 ? 1 : 0, + ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + + $this->logAudit('supplier_assessed', 'supplier', $id, ['risk_score' => $riskScore]); + $this->jsonSuccess(['risk_score' => $riskScore], 'Assessment fornitore completato'); + } + + public function riskOverview(): void + { + $this->requireOrgAccess(); + + $overview = Database::fetchAll( + 'SELECT criticality, status, + COUNT(*) as count, + AVG(risk_score) as avg_risk_score, + SUM(CASE WHEN security_requirements_met = 0 THEN 1 ELSE 0 END) as non_compliant + FROM suppliers + WHERE organization_id = ? + GROUP BY criticality, status', + [$this->getCurrentOrgId()] + ); + + $expiring = Database::fetchAll( + 'SELECT id, name, contract_expiry_date, criticality + FROM suppliers + WHERE organization_id = ? AND contract_expiry_date IS NOT NULL + AND contract_expiry_date <= DATE_ADD(NOW(), INTERVAL 90 DAY) + AND status = "active" + ORDER BY contract_expiry_date', + [$this->getCurrentOrgId()] + ); + + $this->jsonSuccess([ + 'overview' => $overview, + 'expiring_contracts' => $expiring, + ]); + } + + private function calculateSupplierRiskScore(array $responses): int + { + if (empty($responses)) return 0; + + $totalScore = 0; + $totalWeight = 0; + + foreach ($responses as $resp) { + $weight = $resp['weight'] ?? 1; + $value = match ($resp['value'] ?? '') { + 'yes', 'implemented' => 100, + 'partial' => 50, + default => 0, + }; + $totalScore += $value * $weight; + $totalWeight += 100 * $weight; + } + + return $totalWeight > 0 ? (int) round($totalScore / $totalWeight * 100) : 0; + } +} diff --git a/application/controllers/TrainingController.php b/application/controllers/TrainingController.php new file mode 100644 index 0000000..52e86cb --- /dev/null +++ b/application/controllers/TrainingController.php @@ -0,0 +1,150 @@ +requireOrgAccess(); + + $courses = Database::fetchAll( + 'SELECT * FROM training_courses + WHERE (organization_id = ? OR organization_id IS NULL) AND is_active = 1 + ORDER BY is_mandatory DESC, title', + [$this->getCurrentOrgId()] + ); + + $this->jsonSuccess($courses); + } + + public function createCourse(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['title']); + + $courseId = Database::insert('training_courses', [ + 'organization_id' => $this->getCurrentOrgId(), + 'title' => trim($this->getParam('title')), + 'description' => $this->getParam('description'), + 'target_role' => $this->getParam('target_role', 'all'), + 'nis2_article' => $this->getParam('nis2_article'), + 'is_mandatory' => $this->getParam('is_mandatory', 0), + 'duration_minutes' => $this->getParam('duration_minutes'), + 'content' => $this->getParam('content') ? json_encode($this->getParam('content')) : null, + 'quiz' => $this->getParam('quiz') ? json_encode($this->getParam('quiz')) : null, + 'passing_score' => $this->getParam('passing_score', 70), + ]); + + $this->logAudit('course_created', 'training_course', $courseId); + $this->jsonSuccess(['id' => $courseId], 'Corso creato', 201); + } + + public function myAssignments(): void + { + $this->requireAuth(); + + $assignments = Database::fetchAll( + 'SELECT ta.*, tc.title, tc.description, tc.duration_minutes, tc.is_mandatory + FROM training_assignments ta + JOIN training_courses tc ON tc.id = ta.course_id + WHERE ta.user_id = ? + ORDER BY ta.status, ta.due_date', + [$this->getCurrentUserId()] + ); + + $this->jsonSuccess($assignments); + } + + public function assignCourse(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $this->validateRequired(['course_id', 'user_ids']); + + $courseId = (int) $this->getParam('course_id'); + $userIds = $this->getParam('user_ids'); + $dueDate = $this->getParam('due_date'); + + $assigned = 0; + foreach ($userIds as $userId) { + $existing = Database::fetchOne( + 'SELECT id FROM training_assignments WHERE course_id = ? AND user_id = ?', + [$courseId, $userId] + ); + if ($existing) continue; + + Database::insert('training_assignments', [ + 'course_id' => $courseId, + 'user_id' => (int) $userId, + 'organization_id' => $this->getCurrentOrgId(), + 'due_date' => $dueDate, + ]); + $assigned++; + } + + $this->logAudit('training_assigned', 'training_course', $courseId, ['assigned_count' => $assigned]); + $this->jsonSuccess(['assigned' => $assigned], "{$assigned} assegnazioni create"); + } + + public function updateAssignment(int $id): void + { + $this->requireAuth(); + + $updates = []; + foreach (['status', 'quiz_score'] as $field) { + if ($this->hasParam($field)) { + $updates[$field] = $this->getParam($field); + } + } + + if (isset($updates['status']) && $updates['status'] === 'in_progress' && !isset($updates['started_at'])) { + $updates['started_at'] = date('Y-m-d H:i:s'); + } + + if (isset($updates['status']) && $updates['status'] === 'completed') { + $updates['completed_at'] = date('Y-m-d H:i:s'); + } + + if (!empty($updates)) { + Database::update('training_assignments', $updates, 'id = ? AND user_id = ?', [$id, $this->getCurrentUserId()]); + } + + $this->jsonSuccess($updates, 'Assegnazione aggiornata'); + } + + public function complianceStatus(): void + { + $this->requireOrgAccess(); + + $members = Database::fetchAll( + 'SELECT u.id, u.full_name, u.email, uo.role + FROM user_organizations uo + JOIN users u ON u.id = uo.user_id + WHERE uo.organization_id = ? AND u.is_active = 1', + [$this->getCurrentOrgId()] + ); + + foreach ($members as &$member) { + $member['assignments'] = Database::fetchAll( + 'SELECT ta.status, ta.completed_at, ta.quiz_score, tc.title, tc.is_mandatory + FROM training_assignments ta + JOIN training_courses tc ON tc.id = ta.course_id + WHERE ta.user_id = ? AND ta.organization_id = ?', + [$member['id'], $this->getCurrentOrgId()] + ); + + $mandatory = array_filter($member['assignments'], fn($a) => $a['is_mandatory']); + $completedMandatory = array_filter($mandatory, fn($a) => $a['status'] === 'completed'); + $member['mandatory_compliance'] = count($mandatory) > 0 + ? round(count($completedMandatory) / count($mandatory) * 100) + : 100; + } + + $this->jsonSuccess($members); + } +} diff --git a/application/data/nis2_questionnaire.json b/application/data/nis2_questionnaire.json new file mode 100644 index 0000000..1e3933b --- /dev/null +++ b/application/data/nis2_questionnaire.json @@ -0,0 +1,898 @@ +{ + "version": "1.0.0", + "title": "NIS2 Directive - Compliance Gap Analysis Questionnaire", + "description": "Questionario di analisi del divario di conformita alla Direttiva NIS2 (EU 2022/2555) - Articolo 21(2)", + "total_questions": 80, + "categories": [ + { + "id": "risk_assessment", + "title_it": "Analisi dei rischi e sicurezza dei sistemi informatici", + "title_en": "Risk analysis and information system security", + "nis2_article": "21.2.a", + "iso27001_controls": ["A.5.1", "A.5.2", "A.8.1", "A.8.2", "A.8.8"], + "questions": [ + { + "code": "ART21A_001", + "text_it": "L'organizzazione ha implementato un processo formale e documentato di analisi dei rischi informatici che copra tutti i sistemi informativi critici?", + "text_en": "Has the organization implemented a formal and documented IT risk analysis process covering all critical information systems?", + "guidance_it": "Il processo dovrebbe includere identificazione, valutazione e trattamento dei rischi cyber secondo una metodologia riconosciuta (es. ISO 27005, NIST SP 800-30). Deve essere approvato dalla direzione e comunicato a tutte le parti interessate.", + "evidence_examples": ["Documento di metodologia di analisi dei rischi", "Policy di gestione del rischio approvata dalla direzione", "Verbale di approvazione del framework di risk management"], + "nis2_article": "21.2.a", + "iso27001_control": "A.5.1", + "weight": 3 + }, + { + "code": "ART21A_002", + "text_it": "Esiste un registro dei rischi (risk register) aggiornato che documenti tutti i rischi cyber identificati, le relative valutazioni e i piani di trattamento?", + "text_en": "Is there an up-to-date risk register documenting all identified cyber risks, their assessments, and treatment plans?", + "guidance_it": "Il registro deve contenere per ogni rischio: descrizione, probabilita, impatto, livello di rischio residuo, piano di trattamento, responsabile e scadenze. Deve essere aggiornato almeno trimestralmente.", + "evidence_examples": ["Risk register aggiornato", "Report di valutazione dei rischi", "Piano di trattamento dei rischi con stato di avanzamento"], + "nis2_article": "21.2.a", + "iso27001_control": "A.5.1", + "weight": 3 + }, + { + "code": "ART21A_003", + "text_it": "Viene effettuata una revisione periodica dell'analisi dei rischi almeno annualmente o in occasione di cambiamenti significativi nell'infrastruttura o nel contesto delle minacce?", + "text_en": "Is the risk analysis reviewed periodically at least annually or upon significant changes in infrastructure or threat landscape?", + "guidance_it": "La revisione deve essere pianificata e documentata. I cambiamenti significativi includono: nuovi sistemi, modifiche architetturali, nuove minacce rilevanti, incidenti di sicurezza, cambiamenti normativi o organizzativi.", + "evidence_examples": ["Piano di revisione periodica dei rischi", "Verbali delle revisioni effettuate", "Report di aggiornamento del risk assessment"], + "nis2_article": "21.2.a", + "iso27001_control": "A.5.1", + "weight": 2 + }, + { + "code": "ART21A_004", + "text_it": "L'organizzazione ha definito formalmente la propria propensione al rischio (risk appetite) e le soglie di accettazione del rischio residuo, approvate dall'organo di gestione?", + "text_en": "Has the organization formally defined its risk appetite and residual risk acceptance thresholds, approved by the management body?", + "guidance_it": "Il risk appetite deve essere definito in termini quantitativi o qualitativi, approvato dal consiglio di amministrazione o dall'organo direttivo, e comunicato ai responsabili della gestione dei rischi.", + "evidence_examples": ["Documento di definizione del risk appetite", "Delibera di approvazione dell'organo di gestione", "Matrice delle soglie di accettazione del rischio"], + "nis2_article": "21.2.a", + "iso27001_control": "A.5.2", + "weight": 2 + }, + { + "code": "ART21A_005", + "text_it": "Sono stati designati formalmente i responsabili (risk owner) per ciascun rischio identificato nel registro dei rischi?", + "text_en": "Have risk owners been formally designated for each risk identified in the risk register?", + "guidance_it": "Ogni rischio deve avere un responsabile chiaramente identificato con l'autorita e le risorse necessarie per gestire il rischio. I risk owner devono essere consapevoli delle proprie responsabilita e rendere conto periodicamente dello stato dei rischi assegnati.", + "evidence_examples": ["Matrice RACI della gestione dei rischi", "Nomine formali dei risk owner", "Report periodici dei risk owner alla direzione"], + "nis2_article": "21.2.a", + "iso27001_control": "A.5.2", + "weight": 2 + }, + { + "code": "ART21A_006", + "text_it": "E' stata definita una classificazione degli impatti (finanziario, operativo, reputazionale, legale) con scale e criteri chiari per la valutazione dei rischi?", + "text_en": "Has an impact classification (financial, operational, reputational, legal) been defined with clear scales and criteria for risk assessment?", + "guidance_it": "La classificazione deve prevedere almeno 3-5 livelli di impatto per ciascuna dimensione, con descrizioni concrete e soglie quantitative dove possibile. Deve essere coerente con il framework di risk management aziendale.", + "evidence_examples": ["Tabella di classificazione degli impatti", "Criteri di valutazione della probabilita e dell'impatto", "Matrice probabilita-impatto documentata"], + "nis2_article": "21.2.a", + "iso27001_control": "A.8.1", + "weight": 2 + }, + { + "code": "ART21A_007", + "text_it": "L'organizzazione utilizza fonti di threat intelligence per aggiornare la propria analisi dei rischi e identificare nuove minacce rilevanti per il proprio settore?", + "text_en": "Does the organization use threat intelligence sources to update its risk analysis and identify new threats relevant to its sector?", + "guidance_it": "Le fonti possono includere CERT nazionali ed europei (CSIRT Italia, ENISA), feed commerciali di threat intelligence, ISAC settoriali, bollettini di sicurezza dei vendor. Le informazioni raccolte devono essere integrate nel processo di risk assessment.", + "evidence_examples": ["Elenco delle fonti di threat intelligence sottoscritte", "Report periodici di threat intelligence", "Evidenza di aggiornamento del risk register basato su nuove minacce"], + "nis2_article": "21.2.a", + "iso27001_control": "A.5.1", + "weight": 2 + }, + { + "code": "ART21A_008", + "text_it": "I risultati dell'analisi dei rischi e lo stato dei piani di trattamento vengono regolarmente comunicati all'organo di gestione (Art. 20 NIS2) attraverso report strutturati?", + "text_en": "Are risk analysis results and treatment plan status regularly reported to the management body (Art. 20 NIS2) through structured reports?", + "guidance_it": "L'Art. 20 della Direttiva NIS2 richiede che gli organi di gestione approvino le misure di gestione dei rischi e supervisionino la loro attuazione. Il reporting deve essere almeno semestrale e includere: stato dei rischi critici, avanzamento delle misure di mitigazione, nuovi rischi emergenti.", + "evidence_examples": ["Report periodico sui rischi presentato al CdA", "Verbali del comitato rischi", "Dashboard di sintesi del rischio per la direzione"], + "nis2_article": "21.2.a", + "iso27001_control": "A.5.1", + "weight": 3 + } + ] + }, + { + "id": "incident_handling", + "title_it": "Gestione degli incidenti", + "title_en": "Incident handling", + "nis2_article": "21.2.b", + "iso27001_controls": ["A.5.24", "A.5.25", "A.5.26", "A.5.27", "A.6.8", "A.8.16"], + "questions": [ + { + "code": "ART21B_001", + "text_it": "L'organizzazione dispone di un piano di risposta agli incidenti di sicurezza informatica formalizzato, approvato dalla direzione e testato almeno annualmente?", + "text_en": "Does the organization have a formalized cybersecurity incident response plan, approved by management and tested at least annually?", + "guidance_it": "Il piano deve definire ruoli e responsabilita, procedure di attivazione, fasi di risposta (identificazione, contenimento, eradicazione, ripristino, lessons learned), canali di comunicazione e criteri di escalation.", + "evidence_examples": ["Piano di risposta agli incidenti approvato", "Report dei test del piano (tabletop exercise o simulazione)", "Registro delle revisioni del piano"], + "nis2_article": "21.2.b", + "iso27001_control": "A.5.24", + "weight": 3 + }, + { + "code": "ART21B_002", + "text_it": "Sono implementati strumenti e processi di rilevamento degli incidenti di sicurezza (SIEM, SOC, IDS/IPS, EDR) con copertura adeguata dell'infrastruttura critica?", + "text_en": "Are incident detection tools and processes (SIEM, SOC, IDS/IPS, EDR) implemented with adequate coverage of critical infrastructure?", + "guidance_it": "Le capacita di rilevamento devono coprire rete, endpoint, applicazioni e cloud. Devono essere definiti use case di rilevamento basati sulle minacce rilevanti. Il monitoraggio deve essere attivo 24/7 per i sistemi critici.", + "evidence_examples": ["Architettura del sistema di monitoraggio", "Elenco degli use case di detection attivi", "Report di copertura del monitoraggio sull'infrastruttura"], + "nis2_article": "21.2.b", + "iso27001_control": "A.8.16", + "weight": 3 + }, + { + "code": "ART21B_003", + "text_it": "Esiste un sistema di classificazione degli incidenti di sicurezza con livelli di severita definiti e criteri oggettivi per determinare se un incidente e' significativo ai sensi dell'Art. 23 NIS2?", + "text_en": "Is there an incident classification system with defined severity levels and objective criteria for determining whether an incident is significant under Art. 23 NIS2?", + "guidance_it": "La classificazione deve distinguere tra eventi, incidenti minori e incidenti significativi. I criteri per l'incidente significativo devono considerare: numero di utenti impattati, durata, estensione geografica, impatto sui servizi essenziali/importanti.", + "evidence_examples": ["Tabella di classificazione degli incidenti", "Criteri di significativita ex Art. 23 NIS2", "Procedura di triage e classificazione"], + "nis2_article": "21.2.b", + "iso27001_control": "A.5.25", + "weight": 3 + }, + { + "code": "ART21B_004", + "text_it": "Sono definite procedure di escalation chiare con tempi di risposta, ruoli decisionali e criteri di attivazione del team di crisi per incidenti di elevata severita?", + "text_en": "Are clear escalation procedures defined with response times, decision-making roles, and crisis team activation criteria for high-severity incidents?", + "guidance_it": "Le procedure devono specificare chi contattare a ciascun livello, i tempi massimi di escalation, le autorita decisionali per le azioni di contenimento e le condizioni per l'attivazione del crisis management team.", + "evidence_examples": ["Matrice di escalation con contatti e tempi", "Procedura di attivazione del team di crisi", "Registro degli incidenti con tracciamento delle escalation"], + "nis2_article": "21.2.b", + "iso27001_control": "A.5.26", + "weight": 2 + }, + { + "code": "ART21B_005", + "text_it": "E' stato predisposto un piano di comunicazione per gli incidenti che includa comunicazione interna, verso clienti/utenti, autorita di controllo, media e altre parti interessate?", + "text_en": "Has a communication plan for incidents been prepared including internal communication, to customers/users, supervisory authorities, media, and other stakeholders?", + "guidance_it": "Il piano deve prevedere template pre-approvati, portavoce designati, canali di comunicazione alternativi e procedure specifiche per la notifica alle autorita competenti prevista dalla NIS2.", + "evidence_examples": ["Piano di comunicazione per incidenti di sicurezza", "Template di notifica pre-approvati", "Lista dei contatti per le comunicazioni di crisi"], + "nis2_article": "21.2.b", + "iso27001_control": "A.5.26", + "weight": 2 + }, + { + "code": "ART21B_006", + "text_it": "L'organizzazione e' in grado di rispettare gli obblighi di notifica NIS2: preallarme entro 24 ore, notifica completa entro 72 ore e relazione finale entro 30 giorni dalla notifica dell'incidente significativo?", + "text_en": "Is the organization able to meet NIS2 notification obligations: early warning within 24 hours, full notification within 72 hours, and final report within 30 days of the significant incident notification?", + "guidance_it": "Ai sensi dell'Art. 23 NIS2, i soggetti essenziali e importanti devono notificare al CSIRT e all'autorita competente: (1) preallarme entro 24h, (2) notifica dell'incidente entro 72h, (3) relazione finale entro un mese. Devono essere predisposte procedure e template per ciascuna fase.", + "evidence_examples": ["Procedura di notifica al CSIRT nazionale", "Template di preallarme 24h e notifica 72h", "Evidenza di test della procedura di notifica"], + "nis2_article": "21.2.b", + "iso27001_control": "A.5.26", + "weight": 3 + }, + { + "code": "ART21B_007", + "text_it": "L'organizzazione dispone di capacita di analisi forense digitale, interne o tramite accordi con fornitori esterni, per investigare gli incidenti di sicurezza e preservare le evidenze?", + "text_en": "Does the organization have digital forensic analysis capabilities, internal or through agreements with external providers, to investigate security incidents and preserve evidence?", + "guidance_it": "Le capacita forensi devono includere: raccolta e preservazione delle evidenze digitali, analisi dei log, analisi del malware, ricostruzione della timeline dell'incidente. Se esternalizzate, devono essere previsti SLA adeguati.", + "evidence_examples": ["Procedura di digital forensics e raccolta evidenze", "Contratto con provider di incident response/forensics", "Kit e strumenti forensi disponibili"], + "nis2_article": "21.2.b", + "iso27001_control": "A.5.27", + "weight": 2 + }, + { + "code": "ART21B_008", + "text_it": "Viene condotta sistematicamente un'analisi post-incidente (lessons learned) per ogni incidente significativo, con identificazione di azioni correttive e aggiornamento delle misure di sicurezza?", + "text_en": "Is a post-incident analysis (lessons learned) systematically conducted for every significant incident, with identification of corrective actions and update of security measures?", + "guidance_it": "L'analisi post-incidente deve produrre un report che includa: causa radice, timeline dell'incidente, efficacia della risposta, azioni correttive con responsabili e scadenze. I risultati devono alimentare l'aggiornamento del risk register e delle misure di sicurezza.", + "evidence_examples": ["Report di lessons learned degli incidenti", "Piano di azioni correttive post-incidente", "Evidenza di aggiornamento delle misure di sicurezza a seguito di incidenti"], + "nis2_article": "21.2.b", + "iso27001_control": "A.5.27", + "weight": 2 + } + ] + }, + { + "id": "business_continuity", + "title_it": "Continuita operativa e gestione delle crisi", + "title_en": "Business continuity and crisis management", + "nis2_article": "21.2.c", + "iso27001_controls": ["A.5.29", "A.5.30", "A.8.13", "A.8.14"], + "questions": [ + { + "code": "ART21C_001", + "text_it": "L'organizzazione ha adottato un piano di continuita operativa (BCP) formalizzato che copra i servizi essenziali/importanti erogati e sia stato approvato dalla direzione?", + "text_en": "Has the organization adopted a formalized business continuity plan (BCP) covering the essential/important services provided and approved by management?", + "guidance_it": "Il BCP deve identificare i processi critici, le risorse necessarie, le strategie di continuita e i ruoli/responsabilita. Deve essere allineato alla Business Impact Analysis (BIA) e aggiornato almeno annualmente.", + "evidence_examples": ["Piano di continuita operativa approvato", "Business Impact Analysis (BIA)", "Organigramma del team di continuita operativa"], + "nis2_article": "21.2.c", + "iso27001_control": "A.5.29", + "weight": 3 + }, + { + "code": "ART21C_002", + "text_it": "Il piano di disaster recovery (DRP) viene testato regolarmente con esercitazioni che simulino scenari realistici di disastro, inclusi scenari di attacco cyber?", + "text_en": "Is the disaster recovery plan (DRP) regularly tested with exercises simulating realistic disaster scenarios, including cyber attack scenarios?", + "guidance_it": "I test devono essere condotti almeno annualmente e includere diversi scenari: guasto data center, ransomware, compromissione dell'infrastruttura. I risultati devono essere documentati con gap identificati e azioni correttive.", + "evidence_examples": ["Report dei test del DRP con risultati", "Piano di test annuale del disaster recovery", "Azioni correttive derivanti dai test"], + "nis2_article": "21.2.c", + "iso27001_control": "A.5.30", + "weight": 3 + }, + { + "code": "ART21C_003", + "text_it": "Sono stati definiti e documentati gli obiettivi di tempo di ripristino (RTO) e di punto di ripristino (RPO) per tutti i sistemi e servizi critici, validati dalla direzione aziendale?", + "text_en": "Have recovery time objectives (RTO) and recovery point objectives (RPO) been defined and documented for all critical systems and services, validated by business management?", + "guidance_it": "RTO e RPO devono essere definiti sulla base della Business Impact Analysis, concordati con i responsabili di business e tecnicamente realizzabili con le infrastrutture disponibili. Devono essere verificati attraverso i test di DR.", + "evidence_examples": ["Tabella RTO/RPO per sistemi critici", "Business Impact Analysis con requisiti di ripristino", "Verbale di approvazione RTO/RPO da parte della direzione"], + "nis2_article": "21.2.c", + "iso27001_control": "A.5.30", + "weight": 3 + }, + { + "code": "ART21C_004", + "text_it": "E' stato costituito un team di gestione delle crisi con ruoli, responsabilita e autorita decisionali chiaramente definiti, e il team viene formato e addestrato regolarmente?", + "text_en": "Has a crisis management team been established with clearly defined roles, responsibilities, and decision-making authority, and is the team regularly trained?", + "guidance_it": "Il team deve includere rappresentanti del management, IT, sicurezza, comunicazione, legale e risorse umane. Devono essere previsti sostituti per ciascun ruolo e il team deve partecipare a esercitazioni almeno semestrali.", + "evidence_examples": ["Composizione del team di crisi con ruoli e contatti", "Piano di formazione del team di crisi", "Report delle esercitazioni di crisis management"], + "nis2_article": "21.2.c", + "iso27001_control": "A.5.29", + "weight": 2 + }, + { + "code": "ART21C_005", + "text_it": "E' stata implementata una strategia di backup che preveda copie multiple, almeno una offline o immutabile, con test di ripristino regolari e protezione contro il ransomware?", + "text_en": "Has a backup strategy been implemented with multiple copies, at least one offline or immutable, with regular restore tests and ransomware protection?", + "guidance_it": "La strategia deve seguire almeno la regola 3-2-1 (3 copie, 2 supporti diversi, 1 offsite). I backup devono essere protetti da crittografia, segregati dalla rete di produzione e testati per il ripristino almeno trimestralmente.", + "evidence_examples": ["Policy di backup e retention", "Report dei test di ripristino dei backup", "Architettura della soluzione di backup con dettaglio della segregazione"], + "nis2_article": "21.2.c", + "iso27001_control": "A.8.13", + "weight": 3 + }, + { + "code": "ART21C_006", + "text_it": "Esiste un piano di comunicazione di crisi che definisca le modalita di comunicazione verso dipendenti, clienti, autorita, media e pubblico durante un evento di crisi cyber?", + "text_en": "Is there a crisis communication plan defining communication methods towards employees, customers, authorities, media, and the public during a cyber crisis event?", + "guidance_it": "Il piano deve prevedere canali di comunicazione alternativi (nel caso in cui i sistemi principali siano compromessi), template pre-approvati, portavoce designati e procedure di coordinamento con le autorita competenti.", + "evidence_examples": ["Piano di comunicazione di crisi", "Template di comunicazione per diversi scenari", "Lista dei canali di comunicazione alternativi"], + "nis2_article": "21.2.c", + "iso27001_control": "A.5.29", + "weight": 2 + }, + { + "code": "ART21C_007", + "text_it": "L'organizzazione dispone di siti o infrastrutture alternative per garantire la continuita dei servizi critici in caso di indisponibilita del sito primario?", + "text_en": "Does the organization have alternative sites or infrastructure to ensure continuity of critical services in case of primary site unavailability?", + "guidance_it": "Le soluzioni possono includere data center secondari, infrastrutture cloud, accordi di mutuo soccorso. La distanza geografica deve essere adeguata a coprire scenari regionali. La capacita del sito alternativo deve essere sufficiente per i servizi critici.", + "evidence_examples": ["Documentazione del sito di disaster recovery", "Contratti per infrastrutture alternative", "Test di failover sul sito secondario"], + "nis2_article": "21.2.c", + "iso27001_control": "A.8.14", + "weight": 2 + }, + { + "code": "ART21C_008", + "text_it": "La strategia di continuita operativa considera le dipendenze dalla supply chain e prevede piani di contingenza per l'indisponibilita di fornitori critici di servizi ICT?", + "text_en": "Does the business continuity strategy consider supply chain dependencies and include contingency plans for the unavailability of critical ICT service providers?", + "guidance_it": "Devono essere identificati i fornitori critici per la continuita operativa, valutata la loro capacita di continuita, e previsti piani alternativi (fornitori sostitutivi, soluzioni interne temporanee) per ciascuna dipendenza critica.", + "evidence_examples": ["Analisi delle dipendenze dalla supply chain", "Piani di contingenza per fornitori critici", "Clausole di continuita nei contratti con fornitori chiave"], + "nis2_article": "21.2.c", + "iso27001_control": "A.5.29", + "weight": 2 + } + ] + }, + { + "id": "supply_chain", + "title_it": "Sicurezza della catena di approvvigionamento", + "title_en": "Supply chain security", + "nis2_article": "21.2.d", + "iso27001_controls": ["A.5.19", "A.5.20", "A.5.21", "A.5.22", "A.5.23"], + "questions": [ + { + "code": "ART21D_001", + "text_it": "L'organizzazione mantiene un inventario aggiornato di tutti i fornitori e prestatori di servizi ICT, classificati in base alla criticita per i servizi essenziali/importanti erogati?", + "text_en": "Does the organization maintain an up-to-date inventory of all ICT suppliers and service providers, classified based on criticality for the essential/important services provided?", + "guidance_it": "L'inventario deve includere: nome del fornitore, servizi erogati, dati trattati, livello di accesso ai sistemi, classificazione di criticita, contratto di riferimento, contatti per la sicurezza. Deve essere aggiornato almeno semestralmente.", + "evidence_examples": ["Registro dei fornitori ICT con classificazione di criticita", "Matrice dei servizi forniti e delle dipendenze", "Procedura di censimento e aggiornamento dell'inventario fornitori"], + "nis2_article": "21.2.d", + "iso27001_control": "A.5.19", + "weight": 3 + }, + { + "code": "ART21D_002", + "text_it": "I contratti con i fornitori ICT includono requisiti specifici di sicurezza informatica, clausole di audit, obblighi di notifica degli incidenti e requisiti di conformita NIS2?", + "text_en": "Do contracts with ICT suppliers include specific cybersecurity requirements, audit clauses, incident notification obligations, and NIS2 compliance requirements?", + "guidance_it": "I contratti devono contenere: requisiti minimi di sicurezza, diritto di audit, obbligo di notifica incidenti entro tempi definiti, clausole di riservatezza, requisiti di conformita normativa, penali per inadempimento e clausole di uscita.", + "evidence_examples": ["Template contrattuale con clausole di sicurezza", "Allegato di sicurezza tipo per i contratti ICT", "Checklist di verifica delle clausole contrattuali di sicurezza"], + "nis2_article": "21.2.d", + "iso27001_control": "A.5.20", + "weight": 3 + }, + { + "code": "ART21D_003", + "text_it": "Viene condotta una valutazione del rischio di sicurezza (vendor risk assessment) per tutti i fornitori ICT critici, sia in fase di selezione che periodicamente durante il rapporto contrattuale?", + "text_en": "Is a security risk assessment (vendor risk assessment) conducted for all critical ICT suppliers, both during selection and periodically during the contractual relationship?", + "guidance_it": "La valutazione deve coprire: postura di sicurezza del fornitore, certificazioni (ISO 27001, SOC 2), capacita di risposta agli incidenti, solidita finanziaria, localizzazione dei dati, subappaltatori. Deve essere ripetuta almeno annualmente per i fornitori critici.", + "evidence_examples": ["Questionario di valutazione della sicurezza dei fornitori", "Report di vendor risk assessment", "Matrice di rischio fornitori con punteggi"], + "nis2_article": "21.2.d", + "iso27001_control": "A.5.21", + "weight": 3 + }, + { + "code": "ART21D_004", + "text_it": "L'accesso dei fornitori ai sistemi e alle reti dell'organizzazione viene monitorato, limitato al necessario e revocato al termine delle attivita o del contratto?", + "text_en": "Is supplier access to the organization's systems and networks monitored, limited to what is necessary, and revoked upon completion of activities or contract termination?", + "guidance_it": "Devono essere implementati: accesso basato sul principio del minimo privilegio, autenticazione forte, monitoraggio delle sessioni, registrazione delle attivita, revisione periodica degli accessi attivi, procedura di revoca immediata.", + "evidence_examples": ["Policy di gestione degli accessi dei fornitori", "Log di monitoraggio degli accessi dei fornitori", "Registro degli accessi attivi con date di revisione"], + "nis2_article": "21.2.d", + "iso27001_control": "A.5.22", + "weight": 2 + }, + { + "code": "ART21D_005", + "text_it": "Vengono imposti requisiti di sviluppo sicuro ai fornitori di software e vengono verificati attraverso revisioni del codice, test di sicurezza o certificazioni?", + "text_en": "Are secure development requirements imposed on software suppliers and verified through code reviews, security testing, or certifications?", + "guidance_it": "I requisiti devono includere: adozione di pratiche SDLC sicuro, gestione delle vulnerabilita, test di sicurezza del codice (SAST/DAST), gestione delle dipendenze, fornitura di SBOM (Software Bill of Materials) ove applicabile.", + "evidence_examples": ["Requisiti di sviluppo sicuro per i fornitori", "Report di test di sicurezza del software fornito", "SBOM ricevuti dai fornitori di software"], + "nis2_article": "21.2.d", + "iso27001_control": "A.5.21", + "weight": 2 + }, + { + "code": "ART21D_006", + "text_it": "L'organizzazione ha visibilita e controllo sull'utilizzo di subappaltatori da parte dei propri fornitori ICT critici, con diritto di approvazione e requisiti di sicurezza estesi?", + "text_en": "Does the organization have visibility and control over the use of subcontractors by its critical ICT suppliers, with approval rights and extended security requirements?", + "guidance_it": "I contratti devono prevedere: obbligo di comunicazione preventiva dei subappalti, diritto di approvazione, estensione dei requisiti di sicurezza ai subappaltatori, diritto di audit sulla catena di subappalto.", + "evidence_examples": ["Clausole contrattuali sul subappalto", "Registro dei subappaltatori dei fornitori critici", "Evidenza di approvazione dei subappaltatori"], + "nis2_article": "21.2.d", + "iso27001_control": "A.5.21", + "weight": 2 + }, + { + "code": "ART21D_007", + "text_it": "I Service Level Agreement (SLA) con i fornitori ICT critici includono metriche di sicurezza, tempi di risposta per vulnerabilita e incidenti, e penali per il mancato rispetto?", + "text_en": "Do Service Level Agreements (SLAs) with critical ICT suppliers include security metrics, response times for vulnerabilities and incidents, and penalties for non-compliance?", + "guidance_it": "Gli SLA devono definire: tempi di patching delle vulnerabilita critiche, tempi di notifica degli incidenti, disponibilita dei servizi, tempi di risposta del supporto di sicurezza, metriche di performance della sicurezza misurabili.", + "evidence_examples": ["SLA con metriche di sicurezza", "Report di monitoraggio degli SLA di sicurezza", "Registro delle violazioni degli SLA e azioni intraprese"], + "nis2_article": "21.2.d", + "iso27001_control": "A.5.23", + "weight": 2 + }, + { + "code": "ART21D_008", + "text_it": "Viene condotta una revisione periodica complessiva della sicurezza della supply chain, con aggiornamento della valutazione dei rischi e verifica dell'adeguatezza delle misure in essere?", + "text_en": "Is a comprehensive periodic review of supply chain security conducted, with an update of risk assessment and verification of the adequacy of existing measures?", + "guidance_it": "La revisione deve essere almeno annuale e includere: aggiornamento dell'inventario fornitori, rivalutazione della criticita, verifica delle certificazioni, analisi degli incidenti relativi ai fornitori, aggiornamento dei piani di contingenza.", + "evidence_examples": ["Report annuale di revisione della supply chain security", "Verbale della riunione di revisione con la direzione", "Piano di miglioramento della sicurezza della supply chain"], + "nis2_article": "21.2.d", + "iso27001_control": "A.5.22", + "weight": 2 + } + ] + }, + { + "id": "system_security", + "title_it": "Sicurezza nell'acquisizione, sviluppo e manutenzione dei sistemi informatici e di rete", + "title_en": "Security in network and information systems acquisition, development, and maintenance", + "nis2_article": "21.2.e", + "iso27001_controls": ["A.8.25", "A.8.26", "A.8.27", "A.8.28", "A.8.29", "A.8.31", "A.8.32"], + "questions": [ + { + "code": "ART21E_001", + "text_it": "L'organizzazione ha integrato la sicurezza nel ciclo di vita dello sviluppo software (SDLC), con attivita di sicurezza definite per ogni fase (requisiti, progettazione, sviluppo, test, rilascio)?", + "text_en": "Has the organization integrated security into the software development lifecycle (SDLC), with security activities defined for each phase (requirements, design, development, testing, release)?", + "guidance_it": "Le attivita devono includere: analisi dei requisiti di sicurezza, threat modeling in fase di design, coding guidelines sicuro, revisione del codice, test di sicurezza automatizzati (SAST/DAST), security gate prima del rilascio in produzione.", + "evidence_examples": ["Documento di Secure SDLC con attivita per fase", "Checklist di security gate per i rilasci", "Evidenza di threat modeling per progetti recenti"], + "nis2_article": "21.2.e", + "iso27001_control": "A.8.25", + "weight": 3 + }, + { + "code": "ART21E_002", + "text_it": "Esiste un processo strutturato di gestione delle vulnerabilita che includa scansione regolare, classificazione, prioritizzazione e remediation con tempi definiti in base alla severita?", + "text_en": "Is there a structured vulnerability management process including regular scanning, classification, prioritization, and remediation with defined timelines based on severity?", + "guidance_it": "Il processo deve prevedere: scansione almeno mensile (settimanale per sistemi critici), classificazione CVSS, tempi massimi di remediation (es. 24-48h per critiche, 7gg per alte, 30gg per medie), tracking delle vulnerabilita aperte, reporting alla direzione.", + "evidence_examples": ["Policy di gestione delle vulnerabilita con SLA di remediation", "Report delle scansioni di vulnerabilita", "Dashboard di tracciamento delle vulnerabilita aperte"], + "nis2_article": "21.2.e", + "iso27001_control": "A.8.28", + "weight": 3 + }, + { + "code": "ART21E_003", + "text_it": "E' implementato un processo di patch management che garantisca l'applicazione tempestiva degli aggiornamenti di sicurezza su tutti i sistemi, con particolare attenzione ai sistemi esposti a Internet?", + "text_en": "Is a patch management process implemented that ensures timely application of security updates on all systems, with particular attention to Internet-facing systems?", + "guidance_it": "Il processo deve prevedere: monitoraggio dei bollettini di sicurezza, valutazione dell'applicabilita, test prima del deployment, finestre di manutenzione pianificate, procedure di rollback, gestione delle eccezioni documentate.", + "evidence_examples": ["Policy e procedura di patch management", "Report mensile sullo stato di patching", "Registro delle eccezioni di patching con compensating controls"], + "nis2_article": "21.2.e", + "iso27001_control": "A.8.31", + "weight": 3 + }, + { + "code": "ART21E_004", + "text_it": "Esiste un processo formale di gestione dei cambiamenti (change management) che includa valutazione dell'impatto sulla sicurezza per ogni modifica ai sistemi informativi?", + "text_en": "Is there a formal change management process that includes security impact assessment for every modification to information systems?", + "guidance_it": "Il processo deve prevedere: richiesta formale di cambiamento, valutazione dell'impatto sulla sicurezza, approvazione da parte del security team per cambiamenti significativi, test pre-deployment, piano di rollback, documentazione post-implementazione.", + "evidence_examples": ["Procedura di change management", "Template di valutazione dell'impatto sulla sicurezza", "Registro dei change con evidenza di approvazione security"], + "nis2_article": "21.2.e", + "iso27001_control": "A.8.32", + "weight": 2 + }, + { + "code": "ART21E_005", + "text_it": "Sono definiti e applicati standard di configurazione sicura (hardening) per tutti i sistemi operativi, database, applicazioni, apparati di rete e dispositivi, basati su benchmark riconosciuti?", + "text_en": "Are secure configuration standards (hardening) defined and applied for all operating systems, databases, applications, network devices, and equipment, based on recognized benchmarks?", + "guidance_it": "Gli standard devono essere basati su benchmark riconosciuti (CIS Benchmarks, guide NIST). Devono coprire: sistemi operativi, web server, database, apparati di rete, container, servizi cloud. La conformita deve essere verificata periodicamente.", + "evidence_examples": ["Standard di hardening per ciascuna tecnologia", "Report di conformita agli standard di hardening", "Procedura di verifica periodica delle configurazioni"], + "nis2_article": "21.2.e", + "iso27001_control": "A.8.27", + "weight": 2 + }, + { + "code": "ART21E_006", + "text_it": "Vengono eseguiti penetration test almeno annualmente sui sistemi e le applicazioni critiche, condotti da personale qualificato indipendente, con remediation tracciata delle vulnerabilita identificate?", + "text_en": "Are penetration tests performed at least annually on critical systems and applications, conducted by qualified independent personnel, with tracked remediation of identified vulnerabilities?", + "guidance_it": "I penetration test devono coprire: perimetro esterno, rete interna, applicazioni web/mobile, social engineering. Devono essere condotti da tester certificati (OSCP, CREST). I risultati devono alimentare il processo di gestione delle vulnerabilita.", + "evidence_examples": ["Report dei penetration test con classificazione delle vulnerabilita", "Piano di remediation delle vulnerabilita identificate", "Evidenza di ritest dopo la remediation"], + "nis2_article": "21.2.e", + "iso27001_control": "A.8.29", + "weight": 2 + }, + { + "code": "ART21E_007", + "text_it": "E' implementato un processo di revisione del codice sorgente che includa analisi statica automatizzata (SAST) e revisione manuale per le componenti critiche, integrato nella pipeline CI/CD?", + "text_en": "Is a source code review process implemented that includes automated static analysis (SAST) and manual review for critical components, integrated into the CI/CD pipeline?", + "guidance_it": "L'analisi statica deve essere integrata nella pipeline di build e bloccare il deployment in caso di vulnerabilita critiche. La revisione manuale deve essere effettuata per componenti ad alto rischio (autenticazione, autorizzazione, crittografia, input validation).", + "evidence_examples": ["Configurazione degli strumenti SAST nella pipeline CI/CD", "Report di analisi statica del codice", "Linee guida per la revisione manuale del codice sicuro"], + "nis2_article": "21.2.e", + "iso27001_control": "A.8.26", + "weight": 2 + }, + { + "code": "ART21E_008", + "text_it": "Esistono procedure formalizzate per la dismissione sicura (decommissioning) di sistemi, applicazioni e componenti hardware, che garantiscano la cancellazione sicura dei dati e la rimozione degli accessi?", + "text_en": "Are there formalized procedures for secure decommissioning of systems, applications, and hardware components, ensuring secure data erasure and access removal?", + "guidance_it": "Le procedure devono prevedere: inventario dei dati presenti, cancellazione sicura certificata, rimozione di tutti gli accessi e credenziali, aggiornamento dell'inventario asset, verifica della completezza della dismissione, conservazione della documentazione.", + "evidence_examples": ["Procedura di decommissioning sicuro", "Certificati di cancellazione sicura dei dati", "Checklist di decommissioning completate"], + "nis2_article": "21.2.e", + "iso27001_control": "A.8.27", + "weight": 1 + } + ] + }, + { + "id": "effectiveness_assessment", + "title_it": "Strategie e procedure per valutare l'efficacia delle misure di gestione dei rischi", + "title_en": "Policies and procedures to assess the effectiveness of cybersecurity risk management measures", + "nis2_article": "21.2.f", + "iso27001_controls": ["A.5.35", "A.5.36", "A.8.34"], + "questions": [ + { + "code": "ART21F_001", + "text_it": "L'organizzazione ha definito un set di indicatori chiave di performance (KPI) e indicatori chiave di rischio (KRI) per misurare l'efficacia delle misure di sicurezza informatica?", + "text_en": "Has the organization defined a set of key performance indicators (KPIs) and key risk indicators (KRIs) to measure the effectiveness of cybersecurity measures?", + "guidance_it": "I KPI/KRI devono coprire almeno: tempo medio di rilevamento e risposta agli incidenti, percentuale di sistemi patchati entro gli SLA, percentuale di dipendenti formati, esito dei test di phishing, numero di vulnerabilita critiche aperte, conformita alle policy.", + "evidence_examples": ["Catalogo dei KPI/KRI di sicurezza informatica", "Dashboard dei KPI di sicurezza", "Report periodico dei KPI presentato alla direzione"], + "nis2_article": "21.2.f", + "iso27001_control": "A.5.36", + "weight": 3 + }, + { + "code": "ART21F_002", + "text_it": "Vengono condotti audit interni di sicurezza informatica su base regolare, con un piano di audit basato sul rischio e condotti da personale con adeguate competenze e indipendenza?", + "text_en": "Are internal cybersecurity audits conducted on a regular basis, with a risk-based audit plan and performed by personnel with adequate skills and independence?", + "guidance_it": "Gli audit interni devono coprire tutti i controlli di sicurezza rilevanti su un ciclo pluriennale. Gli auditor devono essere indipendenti dai processi auditati. Il piano deve essere basato sulla valutazione dei rischi e aggiornato annualmente.", + "evidence_examples": ["Piano di audit interno di sicurezza informatica", "Report degli audit interni con finding e raccomandazioni", "Qualifiche e certificazioni degli auditor interni"], + "nis2_article": "21.2.f", + "iso27001_control": "A.5.35", + "weight": 2 + }, + { + "code": "ART21F_003", + "text_it": "L'organizzazione si sottopone a verifiche di sicurezza da parte di enti esterni indipendenti (audit di terza parte, certificazioni) con cadenza almeno annuale?", + "text_en": "Does the organization undergo security assessments by independent external bodies (third-party audits, certifications) at least annually?", + "guidance_it": "Le verifiche esterne possono includere: audit ISO 27001, SOC 2 Type II, penetration test da parte di terzi, assessment di conformita normativa. I risultati devono essere condivisi con la direzione e i finding gestiti con piani di remediation tracciati.", + "evidence_examples": ["Certificato ISO 27001 o report SOC 2", "Report di audit esterno di sicurezza", "Piano di remediation dei finding dell'audit esterno"], + "nis2_article": "21.2.f", + "iso27001_control": "A.5.35", + "weight": 2 + }, + { + "code": "ART21F_004", + "text_it": "Esiste un programma continuativo di penetration testing e red teaming che copra i sistemi critici e simuli scenari di attacco realistici e aggiornati?", + "text_en": "Is there a continuous penetration testing and red teaming program covering critical systems and simulating realistic, up-to-date attack scenarios?", + "guidance_it": "Il programma deve prevedere: test almeno annuali del perimetro esterno, test interni, test applicativi, social engineering, e per le organizzazioni piu mature, esercitazioni di red team. Gli scenari devono essere basati sulle minacce reali per il settore.", + "evidence_examples": ["Piano annuale di penetration testing e red teaming", "Report dei penetration test con remediation", "Scenari di attacco utilizzati per il red teaming"], + "nis2_article": "21.2.f", + "iso27001_control": "A.8.34", + "weight": 2 + }, + { + "code": "ART21F_005", + "text_it": "Sono implementate soluzioni di monitoraggio continuo della sicurezza (continuous security monitoring) che forniscano visibilita in tempo reale sulla postura di sicurezza dell'organizzazione?", + "text_en": "Are continuous security monitoring solutions implemented that provide real-time visibility into the organization's security posture?", + "guidance_it": "Il monitoraggio continuo deve includere: scansione automatica delle vulnerabilita, verifica della conformita delle configurazioni, monitoraggio degli eventi di sicurezza, anomaly detection, monitoraggio dell'esposizione esterna (attack surface management).", + "evidence_examples": ["Architettura della soluzione di continuous monitoring", "Dashboard di monitoraggio continuo della sicurezza", "Report automatici di postura di sicurezza"], + "nis2_article": "21.2.f", + "iso27001_control": "A.8.34", + "weight": 2 + }, + { + "code": "ART21F_006", + "text_it": "Sono disponibili dashboard di conformita che forniscano alla direzione una visione sintetica e aggiornata dello stato di compliance alle normative applicabili, inclusa la NIS2?", + "text_en": "Are compliance dashboards available that provide management with a concise and up-to-date view of compliance status with applicable regulations, including NIS2?", + "guidance_it": "Le dashboard devono mostrare: stato di implementazione dei controlli, gap identificati, avanzamento dei piani di remediation, indicatori di rischio, stato delle certificazioni, scadenze normative. Devono essere aggiornate almeno mensilmente.", + "evidence_examples": ["Screenshot delle dashboard di compliance", "Report di compliance presentato alla direzione", "Mapping dei controlli implementati rispetto ai requisiti NIS2"], + "nis2_article": "21.2.f", + "iso27001_control": "A.5.36", + "weight": 2 + }, + { + "code": "ART21F_007", + "text_it": "L'organo di gestione effettua un riesame periodico (management review) del sistema di gestione della sicurezza informatica, valutando l'adeguatezza e l'efficacia delle misure adottate?", + "text_en": "Does the management body perform a periodic management review of the cybersecurity management system, assessing the adequacy and effectiveness of the measures adopted?", + "guidance_it": "Il riesame della direzione deve essere almeno annuale e considerare: risultati degli audit, stato dei rischi, incidenti occorsi, efficacia delle misure, cambiamenti nel contesto, risorse necessarie, opportunita di miglioramento. Deve produrre decisioni e azioni documentate.", + "evidence_examples": ["Verbale del riesame della direzione", "Presentazione di sintesi per il management review", "Piano di azioni derivante dal riesame della direzione"], + "nis2_article": "21.2.f", + "iso27001_control": "A.5.35", + "weight": 3 + }, + { + "code": "ART21F_008", + "text_it": "Esiste un processo formalizzato di miglioramento continuo che raccolga i risultati di audit, incidenti, test e monitoraggio per aggiornare e rafforzare sistematicamente le misure di sicurezza?", + "text_en": "Is there a formalized continuous improvement process that collects results from audits, incidents, tests, and monitoring to systematically update and strengthen security measures?", + "guidance_it": "Il processo deve seguire il ciclo PDCA (Plan-Do-Check-Act) e prevedere: raccolta sistematica dei finding da tutte le fonti, analisi delle cause radice, definizione di azioni correttive e preventive, tracciamento dell'implementazione, verifica dell'efficacia.", + "evidence_examples": ["Procedura di miglioramento continuo della sicurezza", "Registro delle azioni correttive e preventive", "Evidenza di miglioramenti implementati a seguito di finding"], + "nis2_article": "21.2.f", + "iso27001_control": "A.5.36", + "weight": 2 + } + ] + }, + { + "id": "cyber_hygiene", + "title_it": "Pratiche di igiene informatica di base e formazione in materia di sicurezza informatica", + "title_en": "Basic cyber hygiene practices and cybersecurity training", + "nis2_article": "21.2.g", + "iso27001_controls": ["A.6.3", "A.6.6", "A.7.2", "A.7.3"], + "questions": [ + { + "code": "ART21G_001", + "text_it": "L'organizzazione ha implementato un programma strutturato di sensibilizzazione sulla sicurezza informatica (security awareness) per tutti i dipendenti, con contenuti aggiornati regolarmente?", + "text_en": "Has the organization implemented a structured cybersecurity awareness program for all employees, with regularly updated content?", + "guidance_it": "Il programma deve essere continuo (non una tantum), coprire i rischi cyber rilevanti per l'organizzazione, utilizzare formati diversificati (e-learning, video, newsletter, poster) e adattare i contenuti ai diversi ruoli aziendali. La partecipazione deve essere obbligatoria e tracciata.", + "evidence_examples": ["Piano annuale del programma di awareness", "Materiali formativi e comunicazioni inviate", "Report di partecipazione e completamento del programma"], + "nis2_article": "21.2.g", + "iso27001_control": "A.6.3", + "weight": 3 + }, + { + "code": "ART21G_002", + "text_it": "Vengono condotte campagne di simulazione di phishing con cadenza regolare, con analisi dei risultati, feedback personalizzato e formazione mirata per i dipendenti che cadono nelle simulazioni?", + "text_en": "Are phishing simulation campaigns conducted regularly, with analysis of results, personalized feedback, and targeted training for employees who fall for the simulations?", + "guidance_it": "Le simulazioni devono essere condotte almeno trimestralmente, con scenari realistici e diversificati. I risultati devono essere analizzati per identificare trend e aree di debolezza. I dipendenti che cliccano devono ricevere formazione immediata e follow-up.", + "evidence_examples": ["Report delle campagne di phishing simulation", "Trend dei risultati nel tempo", "Evidenza di formazione per i dipendenti che hanno fallito le simulazioni"], + "nis2_article": "21.2.g", + "iso27001_control": "A.6.3", + "weight": 2 + }, + { + "code": "ART21G_003", + "text_it": "Tutti i dipendenti ricevono una formazione di base sulla sicurezza informatica al momento dell'assunzione e poi con aggiornamenti almeno annuali, coprendo le minacce principali e le policy aziendali?", + "text_en": "Do all employees receive basic cybersecurity training at onboarding and then with at least annual updates, covering main threats and company policies?", + "guidance_it": "La formazione deve coprire almeno: phishing e social engineering, gestione delle password, uso sicuro di email e Internet, protezione dei dati, segnalazione degli incidenti, policy di sicurezza aziendali, uso sicuro dei dispositivi mobili.", + "evidence_examples": ["Contenuti della formazione di base sulla sicurezza", "Registro delle presenze/completamenti della formazione", "Test di verifica dell'apprendimento con risultati"], + "nis2_article": "21.2.g", + "iso27001_control": "A.6.3", + "weight": 3 + }, + { + "code": "ART21G_004", + "text_it": "Sono previsti percorsi di formazione specialistica sulla sicurezza informatica per il personale con ruoli tecnici (IT, sviluppo, operations, sicurezza) con contenuti adeguati al loro ruolo?", + "text_en": "Are specialized cybersecurity training paths provided for personnel in technical roles (IT, development, operations, security) with content appropriate to their role?", + "guidance_it": "La formazione specialistica deve coprire: secure coding per gli sviluppatori, gestione delle vulnerabilita per gli operatori IT, risposta agli incidenti per il team di sicurezza, architettura sicura per gli architetti. Deve includere certificazioni professionali ove appropriato.", + "evidence_examples": ["Piano di formazione specialistica per ruoli tecnici", "Registro delle certificazioni professionali del personale", "Programma di sviluppo delle competenze di cybersecurity"], + "nis2_article": "21.2.g", + "iso27001_control": "A.6.3", + "weight": 2 + }, + { + "code": "ART21G_005", + "text_it": "I membri dell'organo di gestione (consiglio di amministrazione, direzione) ricevono formazione specifica sulla sicurezza informatica come previsto dall'Art. 20 della Direttiva NIS2?", + "text_en": "Do members of the management body (board of directors, executive management) receive specific cybersecurity training as required by Art. 20 of the NIS2 Directive?", + "guidance_it": "L'Art. 20 NIS2 richiede che i membri degli organi di gestione seguano una formazione per acquisire conoscenze e competenze sufficienti per individuare i rischi e valutare le pratiche di gestione dei rischi di cibersicurezza. La formazione deve essere documentata.", + "evidence_examples": ["Programma di formazione per l'organo di gestione", "Attestati di partecipazione dei membri del CdA", "Materiali formativi specifici per la direzione"], + "nis2_article": "21.2.g", + "iso27001_control": "A.6.3", + "weight": 3 + }, + { + "code": "ART21G_006", + "text_it": "L'organizzazione mantiene registrazioni complete di tutte le attivita formative sulla sicurezza informatica, inclusi partecipanti, contenuti, date e risultati delle verifiche di apprendimento?", + "text_en": "Does the organization maintain complete records of all cybersecurity training activities, including participants, content, dates, and learning assessment results?", + "guidance_it": "Le registrazioni devono permettere di dimostrare la conformita ai requisiti normativi e devono essere conservate per almeno tre anni. Devono includere: elenco dei partecipanti, contenuti erogati, data e durata, esito dei test di apprendimento.", + "evidence_examples": ["Database o registro delle attivita formative", "Report di completamento della formazione per reparto", "Risultati aggregati dei test di apprendimento"], + "nis2_article": "21.2.g", + "iso27001_control": "A.6.3", + "weight": 1 + }, + { + "code": "ART21G_007", + "text_it": "Viene effettuata una valutazione periodica della cultura della sicurezza informatica nell'organizzazione, con misurazione del livello di consapevolezza e identificazione delle aree di miglioramento?", + "text_en": "Is a periodic assessment of the cybersecurity culture in the organization conducted, measuring the level of awareness and identifying areas for improvement?", + "guidance_it": "La valutazione puo includere: survey sulla percezione della sicurezza, analisi dei risultati delle simulazioni di phishing, analisi degli incidenti causati da errore umano, focus group, interviste. I risultati devono guidare l'evoluzione del programma di awareness.", + "evidence_examples": ["Survey sulla cultura della sicurezza con risultati", "Analisi dei trend di consapevolezza nel tempo", "Piano di miglioramento della cultura della sicurezza"], + "nis2_article": "21.2.g", + "iso27001_control": "A.6.3", + "weight": 1 + }, + { + "code": "ART21G_008", + "text_it": "Esiste un processo strutturato di onboarding sulla sicurezza informatica per i nuovi dipendenti e collaboratori che includa formazione, consegna delle policy e firma delle responsabilita?", + "text_en": "Is there a structured cybersecurity onboarding process for new employees and contractors that includes training, policy delivery, and responsibility acknowledgment?", + "guidance_it": "L'onboarding deve includere: formazione introduttiva sulla sicurezza, consegna e firma della policy di sicurezza e della policy di uso accettabile, configurazione sicura della postazione, attivazione dell'MFA, briefing sulle procedure di segnalazione degli incidenti.", + "evidence_examples": ["Checklist di onboarding sulla sicurezza informatica", "Moduli di accettazione delle policy firmati", "Materiale formativo per i nuovi assunti"], + "nis2_article": "21.2.g", + "iso27001_control": "A.6.6", + "weight": 2 + } + ] + }, + { + "id": "cryptography", + "title_it": "Politiche e procedure relative all'uso della crittografia e della cifratura", + "title_en": "Policies and procedures regarding the use of cryptography and encryption", + "nis2_article": "21.2.h", + "iso27001_controls": ["A.8.24"], + "questions": [ + { + "code": "ART21H_001", + "text_it": "L'organizzazione ha adottato una policy formale sull'uso della crittografia che definisca standard, algoritmi approvati, lunghezze minime delle chiavi e casi d'uso obbligatori?", + "text_en": "Has the organization adopted a formal cryptography policy defining standards, approved algorithms, minimum key lengths, and mandatory use cases?", + "guidance_it": "La policy deve specificare: algoritmi approvati (es. AES-256, RSA-2048+, SHA-256+), algoritmi vietati (es. DES, MD5, SHA-1), lunghezze minime delle chiavi per ogni uso, requisiti per TLS (versione minima 1.2), scenari in cui la crittografia e' obbligatoria.", + "evidence_examples": ["Policy sulla crittografia approvata dalla direzione", "Standard tecnici crittografici", "Linee guida per l'implementazione della crittografia"], + "nis2_article": "21.2.h", + "iso27001_control": "A.8.24", + "weight": 3 + }, + { + "code": "ART21H_002", + "text_it": "I dati sensibili e critici sono cifrati quando memorizzati (data at rest), con particolare attenzione a database, storage, backup e dispositivi mobili/rimovibili?", + "text_en": "Are sensitive and critical data encrypted when stored (data at rest), with particular attention to databases, storage, backups, and mobile/removable devices?", + "guidance_it": "La cifratura at rest deve coprire: database contenenti dati sensibili (full disk o column-level), storage condiviso, backup (prima della trasmissione offsite), laptop e dispositivi mobili (full disk encryption), supporti rimovibili. Devono essere usati algoritmi conformi alla policy.", + "evidence_examples": ["Inventario dei sistemi con indicazione dello stato di cifratura", "Configurazione della cifratura at rest per i sistemi critici", "Report di verifica della cifratura sui dispositivi mobili"], + "nis2_article": "21.2.h", + "iso27001_control": "A.8.24", + "weight": 3 + }, + { + "code": "ART21H_003", + "text_it": "Tutte le comunicazioni di rete che trasportano dati sensibili o credenziali sono protette da cifratura in transito (TLS 1.2+, IPsec, SSH) con configurazioni aggiornate e sicure?", + "text_en": "Are all network communications carrying sensitive data or credentials protected by encryption in transit (TLS 1.2+, IPsec, SSH) with up-to-date and secure configurations?", + "guidance_it": "La cifratura in transito deve coprire: tutte le connessioni esterne (HTTPS, SFTP, VPN), comunicazioni tra sistemi interni per dati sensibili, connessioni ai database, API. Le configurazioni TLS devono disabilitare protocolli e cipher suite obsoleti.", + "evidence_examples": ["Report di scansione TLS/SSL dei servizi esposti", "Standard di configurazione TLS dell'organizzazione", "Inventario dei protocolli di comunicazione utilizzati con stato della cifratura"], + "nis2_article": "21.2.h", + "iso27001_control": "A.8.24", + "weight": 3 + }, + { + "code": "ART21H_004", + "text_it": "Esiste un processo strutturato di gestione delle chiavi crittografiche che copra l'intero ciclo di vita: generazione, distribuzione, archiviazione, rotazione, revoca e distruzione?", + "text_en": "Is there a structured cryptographic key management process covering the entire lifecycle: generation, distribution, storage, rotation, revocation, and destruction?", + "guidance_it": "Il processo deve prevedere: generazione delle chiavi con entropia adeguata, distribuzione sicura, archiviazione in HSM o key vault, rotazione periodica (almeno annuale per chiavi simmetriche), procedure di revoca di emergenza, distruzione sicura. Deve essere documentato chi ha accesso alle chiavi.", + "evidence_examples": ["Procedura di gestione delle chiavi crittografiche", "Inventario delle chiavi con date di scadenza e rotazione", "Configurazione del sistema di key management (HSM/Key Vault)"], + "nis2_article": "21.2.h", + "iso27001_control": "A.8.24", + "weight": 3 + }, + { + "code": "ART21H_005", + "text_it": "I certificati digitali (TLS/SSL, firma digitale, autenticazione) sono gestiti centralmente con monitoraggio delle scadenze, procedure di rinnovo e un'autorita di certificazione affidabile?", + "text_en": "Are digital certificates (TLS/SSL, digital signature, authentication) centrally managed with expiration monitoring, renewal procedures, and a trusted certificate authority?", + "guidance_it": "La gestione deve prevedere: inventario completo dei certificati, monitoraggio automatico delle scadenze con alert, procedure di rinnovo tempestivo, utilizzo di CA riconosciute, revoca immediata in caso di compromissione, gestione dei certificati interni (PKI).", + "evidence_examples": ["Inventario centralizzato dei certificati digitali", "Tool di monitoraggio delle scadenze dei certificati", "Procedura di rinnovo e revoca dei certificati"], + "nis2_article": "21.2.h", + "iso27001_control": "A.8.24", + "weight": 2 + }, + { + "code": "ART21H_006", + "text_it": "Gli standard crittografici adottati sono conformi alle raccomandazioni delle autorita competenti (ENISA, ANSSI, ACN) e vengono aggiornati per riflettere l'evoluzione delle minacce?", + "text_en": "Are the adopted cryptographic standards compliant with recommendations from competent authorities (ENISA, ANSSI, ACN) and updated to reflect evolving threats?", + "guidance_it": "L'organizzazione deve monitorare le raccomandazioni delle autorita (es. ENISA Technical Guidelines on Cryptography) e aggiornare i propri standard di conseguenza. Deve prevedere un piano di transizione per l'eliminazione degli algoritmi obsoleti e la preparazione alla crittografia post-quantistica.", + "evidence_examples": ["Mapping degli standard crittografici adottati rispetto alle raccomandazioni", "Piano di aggiornamento degli standard crittografici", "Valutazione della preparazione alla crittografia post-quantistica"], + "nis2_article": "21.2.h", + "iso27001_control": "A.8.24", + "weight": 2 + }, + { + "code": "ART21H_007", + "text_it": "Sono implementate soluzioni di cifratura per le comunicazioni email che trasportano informazioni sensibili, con possibilita di cifratura end-to-end per le comunicazioni piu critiche?", + "text_en": "Are encryption solutions implemented for email communications carrying sensitive information, with end-to-end encryption capability for the most critical communications?", + "guidance_it": "Le soluzioni possono includere: TLS obbligatorio per le connessioni SMTP tra mail server, S/MIME o PGP per la cifratura dei messaggi, soluzioni di email gateway encryption, DLP con cifratura automatica per email contenenti dati sensibili.", + "evidence_examples": ["Configurazione della cifratura email (TLS, S/MIME)", "Policy sull'uso della cifratura nelle comunicazioni email", "Report di conformita della configurazione dei mail server"], + "nis2_article": "21.2.h", + "iso27001_control": "A.8.24", + "weight": 1 + }, + { + "code": "ART21H_008", + "text_it": "E' stata condotta una valutazione complessiva della postura crittografica dell'organizzazione per identificare l'uso di algoritmi deboli, configurazioni inadeguate e gap rispetto alle best practice?", + "text_en": "Has a comprehensive assessment of the organization's cryptographic posture been conducted to identify use of weak algorithms, inadequate configurations, and gaps against best practices?", + "guidance_it": "La valutazione deve includere: scansione di tutti i servizi per identificare protocolli e cipher suite, verifica delle configurazioni TLS, revisione della gestione delle chiavi, identificazione degli algoritmi obsoleti in uso, piano di remediation con priorita.", + "evidence_examples": ["Report di assessment della postura crittografica", "Inventario degli algoritmi e protocolli in uso", "Piano di remediation per le debolezze crittografiche identificate"], + "nis2_article": "21.2.h", + "iso27001_control": "A.8.24", + "weight": 2 + } + ] + }, + { + "id": "hr_access_assets", + "title_it": "Sicurezza delle risorse umane, politiche di controllo degli accessi e gestione degli asset", + "title_en": "Human resources security, access control policies, and asset management", + "nis2_article": "21.2.i", + "iso27001_controls": ["A.5.15", "A.5.16", "A.5.17", "A.5.18", "A.6.1", "A.6.2", "A.6.5", "A.8.1", "A.8.2"], + "questions": [ + { + "code": "ART21I_001", + "text_it": "Vengono effettuati controlli di background (verifica delle referenze, dei precedenti, delle qualifiche) per il personale che avra accesso a sistemi e dati critici, proporzionati al livello di rischio?", + "text_en": "Are background checks (reference verification, background screening, qualification verification) performed for personnel who will have access to critical systems and data, proportionate to the risk level?", + "guidance_it": "I controlli devono essere proporzionati alla criticita del ruolo e conformi alla normativa sulla privacy. Devono coprire almeno: verifica dell'identita, referenze professionali, qualifiche dichiarate. Per ruoli critici possono includere controllo casellario giudiziale ove legalmente consentito.", + "evidence_examples": ["Policy di screening pre-assunzione", "Checklist dei controlli di background per ruolo", "Evidenza di completamento dei controlli (anonimizzata)"], + "nis2_article": "21.2.i", + "iso27001_control": "A.6.1", + "weight": 2 + }, + { + "code": "ART21I_002", + "text_it": "L'organizzazione ha definito e implementato una policy formale di controllo degli accessi che stabilisca i principi, i ruoli e le responsabilita per la gestione degli accessi logici e fisici?", + "text_en": "Has the organization defined and implemented a formal access control policy establishing principles, roles, and responsibilities for logical and physical access management?", + "guidance_it": "La policy deve definire: principi di accesso (need-to-know, minimo privilegio), processo di richiesta e approvazione degli accessi, ruoli di autorizzazione, requisiti di autenticazione, regole per accessi remoti, gestione degli accessi privilegiati.", + "evidence_examples": ["Policy di controllo degli accessi approvata", "Procedura di richiesta e approvazione degli accessi", "Matrice dei ruoli e delle responsabilita per la gestione degli accessi"], + "nis2_article": "21.2.i", + "iso27001_control": "A.5.15", + "weight": 3 + }, + { + "code": "ART21I_003", + "text_it": "Il principio del minimo privilegio (least privilege) viene applicato sistematicamente, garantendo che gli utenti abbiano accesso solo alle risorse strettamente necessarie per le proprie mansioni?", + "text_en": "Is the principle of least privilege systematically applied, ensuring that users have access only to the resources strictly necessary for their duties?", + "guidance_it": "L'implementazione deve prevedere: profili di accesso basati sui ruoli (RBAC), separazione dei compiti per funzioni critiche, account di servizio con privilegi minimi, eliminazione degli accessi amministrativi non necessari, segregazione degli ambienti.", + "evidence_examples": ["Matrice dei profili di accesso basati sui ruoli", "Evidenza di implementazione RBAC nei sistemi critici", "Report di verifica della conformita al principio del minimo privilegio"], + "nis2_article": "21.2.i", + "iso27001_control": "A.5.18", + "weight": 3 + }, + { + "code": "ART21I_004", + "text_it": "Viene effettuata una revisione periodica degli accessi (access review) almeno semestrale per gli accessi privilegiati e annuale per tutti gli altri, con revoca degli accessi non piu necessari?", + "text_en": "Is a periodic access review performed at least semi-annually for privileged access and annually for all others, with revocation of access no longer needed?", + "guidance_it": "La revisione deve coinvolgere i responsabili delle risorse e dei reparti. Deve verificare: validita degli account attivi, appropriatezza dei privilegi assegnati, account orfani o inattivi, account di servizio. Le azioni correttive devono essere tracciate e completate.", + "evidence_examples": ["Report delle access review effettuate", "Evidenza di revoca degli accessi non necessari", "Piano di access review con responsabili e tempistiche"], + "nis2_article": "21.2.i", + "iso27001_control": "A.5.18", + "weight": 2 + }, + { + "code": "ART21I_005", + "text_it": "L'organizzazione mantiene un inventario completo e aggiornato di tutti gli asset informatici (hardware, software, dati, servizi cloud) con identificazione del responsabile per ciascun asset?", + "text_en": "Does the organization maintain a complete and up-to-date inventory of all IT assets (hardware, software, data, cloud services) with identification of the owner for each asset?", + "guidance_it": "L'inventario deve includere: dispositivi hardware (server, workstation, dispositivi di rete, IoT), software (licenze, versioni), servizi cloud, database e archivi di dati. Ogni asset deve avere un owner responsabile. L'inventario deve essere aggiornato automaticamente ove possibile.", + "evidence_examples": ["Inventario degli asset informatici", "Tool di discovery e gestione degli asset (CMDB)", "Procedura di aggiornamento dell'inventario"], + "nis2_article": "21.2.i", + "iso27001_control": "A.8.1", + "weight": 3 + }, + { + "code": "ART21I_006", + "text_it": "Gli asset informatici sono classificati in base alla criticita e alla sensibilita delle informazioni trattate, con misure di protezione differenziate per ciascun livello di classificazione?", + "text_en": "Are IT assets classified based on the criticality and sensitivity of the information processed, with differentiated protection measures for each classification level?", + "guidance_it": "La classificazione deve prevedere almeno 3-4 livelli (es. pubblico, interno, riservato, strettamente riservato). Per ogni livello devono essere definite le misure di protezione richieste: cifratura, controllo accessi, backup, monitoraggio, regole di condivisione.", + "evidence_examples": ["Schema di classificazione degli asset e delle informazioni", "Linee guida per la gestione di ciascun livello di classificazione", "Inventario degli asset con classificazione assegnata"], + "nis2_article": "21.2.i", + "iso27001_control": "A.8.2", + "weight": 2 + }, + { + "code": "ART21I_007", + "text_it": "Esiste una policy che disciplini l'uso dei dispositivi personali (BYOD) per accedere ai sistemi e ai dati aziendali, con requisiti minimi di sicurezza e misure di controllo?", + "text_en": "Is there a policy governing the use of personal devices (BYOD) to access corporate systems and data, with minimum security requirements and control measures?", + "guidance_it": "La policy deve definire: dispositivi e sistemi operativi ammessi, requisiti minimi di sicurezza (cifratura, antivirus, aggiornamenti), separazione dei dati aziendali da quelli personali (containerizzazione), gestione remota (MDM), procedura di wiping in caso di smarrimento/furto.", + "evidence_examples": ["Policy BYOD approvata", "Configurazione della soluzione MDM", "Modulo di accettazione della policy BYOD firmato dai dipendenti"], + "nis2_article": "21.2.i", + "iso27001_control": "A.6.2", + "weight": 1 + }, + { + "code": "ART21I_008", + "text_it": "Sono definite e implementate procedure di cessazione e cambio ruolo che garantiscano la tempestiva revoca o modifica degli accessi quando un dipendente lascia l'organizzazione o cambia funzione?", + "text_en": "Are termination and role change procedures defined and implemented to ensure timely revocation or modification of access when an employee leaves the organization or changes role?", + "guidance_it": "Le procedure devono prevedere: notifica tempestiva dall'HR all'IT, disabilitazione degli account entro il giorno di uscita (immediatamente per cessazioni non volontarie), restituzione degli asset, revoca degli accessi fisici, revoca delle credenziali VPN e remote, revisione degli accessi in caso di cambio ruolo.", + "evidence_examples": ["Procedura di offboarding con checklist IT", "SLA per la disabilitazione degli account in uscita", "Report di verifica della tempestivita delle revoche degli accessi"], + "nis2_article": "21.2.i", + "iso27001_control": "A.6.5", + "weight": 2 + } + ] + }, + { + "id": "mfa_secure_comms", + "title_it": "Uso di soluzioni di autenticazione a piu fattori, comunicazioni sicure e sistemi di comunicazione di emergenza", + "title_en": "Use of multi-factor authentication, secured communications, and emergency communication systems", + "nis2_article": "21.2.j", + "iso27001_controls": ["A.5.14", "A.5.16", "A.5.17", "A.8.3", "A.8.5", "A.8.20", "A.8.21"], + "questions": [ + { + "code": "ART21J_001", + "text_it": "L'organizzazione ha implementato l'autenticazione a piu fattori (MFA) per l'accesso a tutti i sistemi critici, ai servizi cloud e alle applicazioni che trattano dati sensibili?", + "text_en": "Has the organization implemented multi-factor authentication (MFA) for access to all critical systems, cloud services, and applications processing sensitive data?", + "guidance_it": "L'MFA deve essere implementata con fattori di autenticazione di categorie diverse (qualcosa che sai, che hai, che sei). Le soluzioni basate solo su SMS sono da considerare come misura minima ma non ottimale. Preferire app di autenticazione, token hardware o biometria.", + "evidence_examples": ["Inventario dei sistemi con stato di implementazione MFA", "Configurazione della soluzione MFA", "Policy sull'autenticazione a piu fattori"], + "nis2_article": "21.2.j", + "iso27001_control": "A.8.5", + "weight": 3 + }, + { + "code": "ART21J_002", + "text_it": "L'MFA e' obbligatoria per tutti gli accessi remoti (VPN, desktop remoto, accesso cloud da rete esterna) senza eccezioni per ruoli o dispositivi?", + "text_en": "Is MFA mandatory for all remote access (VPN, remote desktop, cloud access from external network) without exceptions for roles or devices?", + "guidance_it": "L'accesso remoto senza MFA rappresenta un rischio critico. L'obbligo deve coprire: connessioni VPN, accesso a desktop remoto (RDP/Citrix), portali cloud, webmail, applicazioni SaaS. Eventuali eccezioni devono essere documentate con compensating controls.", + "evidence_examples": ["Configurazione MFA per VPN e accesso remoto", "Policy di accesso remoto con obbligo MFA", "Registro delle eventuali eccezioni con compensating controls"], + "nis2_article": "21.2.j", + "iso27001_control": "A.8.5", + "weight": 3 + }, + { + "code": "ART21J_003", + "text_it": "Gli account con privilegi amministrativi sono protetti con MFA rafforzata e sono soggetti a misure aggiuntive di sicurezza (PAM, sessioni registrate, password vault)?", + "text_en": "Are accounts with administrative privileges protected with enhanced MFA and subject to additional security measures (PAM, recorded sessions, password vault)?", + "guidance_it": "Per gli account privilegiati devono essere previsti: MFA con token hardware o biometria, gestione tramite soluzione PAM (Privileged Access Management), sessioni registrate e monitorate, password gestite in vault con rotazione automatica, just-in-time access ove possibile.", + "evidence_examples": ["Configurazione della soluzione PAM", "Policy di gestione degli accessi privilegiati", "Report di utilizzo della soluzione PAM con audit trail"], + "nis2_article": "21.2.j", + "iso27001_control": "A.8.3", + "weight": 3 + }, + { + "code": "ART21J_004", + "text_it": "L'organizzazione utilizza canali di comunicazione sicuri e cifrati per lo scambio di informazioni sensibili, con soluzioni validate e approvate per le comunicazioni interne ed esterne?", + "text_en": "Does the organization use secure and encrypted communication channels for exchanging sensitive information, with validated and approved solutions for internal and external communications?", + "guidance_it": "I canali sicuri devono includere: piattaforme di messaggistica cifrate end-to-end per comunicazioni sensibili, email cifrata per documenti riservati, file sharing sicuro con cifratura, videoconferenze sicure per riunioni riservate. Devono essere definiti i canali approvati per ogni livello di classificazione.", + "evidence_examples": ["Elenco dei canali di comunicazione approvati per livello di classificazione", "Configurazione delle piattaforme di comunicazione sicura", "Linee guida per la scelta del canale di comunicazione appropriato"], + "nis2_article": "21.2.j", + "iso27001_control": "A.5.14", + "weight": 2 + }, + { + "code": "ART21J_005", + "text_it": "L'accesso remoto alla rete aziendale avviene esclusivamente tramite VPN o soluzioni equivalenti con cifratura forte, autenticazione robusta e monitoraggio delle connessioni?", + "text_en": "Does remote access to the corporate network occur exclusively through VPN or equivalent solutions with strong encryption, robust authentication, and connection monitoring?", + "guidance_it": "Le soluzioni di accesso remoto devono prevedere: cifratura forte (AES-256), autenticazione MFA, split tunneling disabilitato per i profili ad alto rischio, controllo della postura del dispositivo (patch, antivirus), timeout delle sessioni inattive, logging completo.", + "evidence_examples": ["Configurazione della VPN con standard di sicurezza", "Policy di accesso remoto", "Report di monitoraggio delle connessioni VPN"], + "nis2_article": "21.2.j", + "iso27001_control": "A.8.20", + "weight": 2 + }, + { + "code": "ART21J_006", + "text_it": "L'organizzazione ha valutato e sta implementando un approccio zero trust per il controllo degli accessi, superando il modello tradizionale basato sul perimetro di rete?", + "text_en": "Has the organization evaluated and is implementing a zero trust approach to access control, moving beyond the traditional network perimeter-based model?", + "guidance_it": "L'approccio zero trust prevede: verifica continua dell'identita e del contesto, microsegmentazione della rete, accesso condizionale basato su rischio, verifica della postura del dispositivo, cifratura di tutte le comunicazioni, monitoraggio continuo del comportamento degli utenti.", + "evidence_examples": ["Strategia e roadmap di adozione zero trust", "Architettura di sicurezza con elementi zero trust", "Stato di implementazione delle componenti zero trust"], + "nis2_article": "21.2.j", + "iso27001_control": "A.8.20", + "weight": 1 + }, + { + "code": "ART21J_007", + "text_it": "Sono definite procedure di accesso di emergenza (break-glass) per garantire l'accesso ai sistemi critici in situazioni di emergenza, con adeguati controlli compensativi e registrazione?", + "text_en": "Are emergency access (break-glass) procedures defined to ensure access to critical systems in emergency situations, with adequate compensating controls and logging?", + "guidance_it": "Le procedure devono prevedere: account di emergenza pre-configurati e sigillati, processo di autorizzazione per l'uso, registrazione completa di tutte le attivita svolte, revisione obbligatoria post-utilizzo, cambio delle credenziali dopo l'uso, notifica automatica ai responsabili.", + "evidence_examples": ["Procedura di break-glass documentata", "Registro degli utilizzi degli account di emergenza", "Evidenza di revisione post-utilizzo degli accessi di emergenza"], + "nis2_article": "21.2.j", + "iso27001_control": "A.5.16", + "weight": 2 + }, + { + "code": "ART21J_008", + "text_it": "Tutti i tentativi di autenticazione (riusciti e falliti) vengono registrati in un sistema centralizzato di logging con conservazione adeguata e monitoraggio per rilevare attivita sospette?", + "text_en": "Are all authentication attempts (successful and failed) logged in a centralized logging system with adequate retention and monitoring to detect suspicious activity?", + "guidance_it": "I log devono includere: timestamp, identita dell'utente, origine della richiesta (IP, dispositivo), esito, metodo di autenticazione. Devono essere definiti alert per: tentativi ripetuti falliti, accessi da localita insolite, accessi fuori orario, utilizzo di account di servizio anomalo. Conservazione minima raccomandata: 12 mesi.", + "evidence_examples": ["Configurazione del sistema centralizzato di logging dell'autenticazione", "Regole di alert per attivita sospette di autenticazione", "Report di monitoraggio dei tentativi di autenticazione"], + "nis2_article": "21.2.j", + "iso27001_control": "A.8.21", + "weight": 2 + } + ] + } + ] +} diff --git a/application/services/AIService.php b/application/services/AIService.php new file mode 100644 index 0000000..8af62cd --- /dev/null +++ b/application/services/AIService.php @@ -0,0 +1,315 @@ +apiKey = ANTHROPIC_API_KEY; + $this->model = ANTHROPIC_MODEL; + $this->maxTokens = ANTHROPIC_MAX_TOKENS; + + if (empty($this->apiKey) || $this->apiKey === 'sk-ant-xxxxx') { + throw new RuntimeException('ANTHROPIC_API_KEY non configurata'); + } + } + + /** + * Analizza risultati gap analysis e genera raccomandazioni + */ + public function analyzeGapAssessment(array $organization, array $responses, float $overallScore): array + { + $responseSummary = $this->summarizeResponses($responses); + + $prompt = <<callAPI($prompt); + return $this->parseJsonResponse($response); + } + + /** + * Suggerisce rischi basati su settore e asset + */ + public function suggestRisks(array $organization, array $assets = []): array + { + $assetList = empty($assets) ? 'Non disponibile' : json_encode($assets, JSON_UNESCAPED_UNICODE); + + $prompt = <<callAPI($prompt); + return $this->parseJsonResponse($response); + } + + /** + * Genera bozza di policy + */ + public function generatePolicy(string $category, array $organization, ?array $assessmentContext = null): array + { + $context = $assessmentContext ? json_encode($assessmentContext, JSON_UNESCAPED_UNICODE) : 'Non disponibile'; + + $prompt = <<callAPI($prompt); + return $this->parseJsonResponse($response); + } + + /** + * Classifica un incidente e suggerisce severity + */ + public function classifyIncident(string $title, string $description, array $organization): array + { + $prompt = <<callAPI($prompt); + return $this->parseJsonResponse($response); + } + + /** + * Chiama Anthropic API + */ + private function callAPI(string $prompt, ?string $systemPrompt = null): string + { + $system = $systemPrompt ?? 'Sei un esperto consulente di cybersecurity e compliance NIS2. Rispondi sempre in italiano in modo professionale e accurato.'; + + $body = [ + 'model' => $this->model, + 'max_tokens' => $this->maxTokens, + 'system' => $system, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + ]; + + $ch = curl_init($this->baseUrl); + curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'x-api-key: ' . $this->apiKey, + 'anthropic-version: 2023-06-01', + ], + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($body), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + throw new RuntimeException('Errore connessione AI: ' . $curlError); + } + + if ($httpCode !== 200) { + $errorData = json_decode($response, true); + $errorMessage = $errorData['error']['message'] ?? 'Errore API sconosciuto'; + throw new RuntimeException("Errore API AI ({$httpCode}): {$errorMessage}"); + } + + $data = json_decode($response, true); + + if (!isset($data['content'][0]['text'])) { + throw new RuntimeException('Risposta AI non valida'); + } + + return $data['content'][0]['text']; + } + + /** + * Riassume le risposte dell'assessment per il prompt AI + */ + private function summarizeResponses(array $responses): string + { + $byCategory = []; + foreach ($responses as $r) { + $cat = $r['category'] ?? 'other'; + if (!isset($byCategory[$cat])) { + $byCategory[$cat] = ['implemented' => 0, 'partial' => 0, 'not_implemented' => 0, 'na' => 0, 'total' => 0]; + } + $byCategory[$cat]['total']++; + match ($r['response_value']) { + 'implemented' => $byCategory[$cat]['implemented']++, + 'partial' => $byCategory[$cat]['partial']++, + 'not_implemented' => $byCategory[$cat]['not_implemented']++, + 'not_applicable' => $byCategory[$cat]['na']++, + default => null, + }; + } + + $summary = ''; + foreach ($byCategory as $cat => $counts) { + $pct = $counts['total'] > 0 + ? round(($counts['implemented'] * 100 + $counts['partial'] * 50) / (($counts['total'] - $counts['na']) * 100) * 100) + : 0; + $summary .= "- {$cat}: {$pct}% (implementati: {$counts['implemented']}, parziali: {$counts['partial']}, non implementati: {$counts['not_implemented']})\n"; + } + + return $summary; + } + + /** + * Parsing robusto della risposta JSON dall'AI + */ + private function parseJsonResponse(string $response): array + { + // Rimuovi eventuale markdown code blocks + $response = preg_replace('/^```(?:json)?\s*/m', '', $response); + $response = preg_replace('/\s*```\s*$/m', '', $response); + $response = trim($response); + + $data = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + error_log('[AI] JSON parse error: ' . json_last_error_msg() . ' | Response: ' . substr($response, 0, 500)); + return ['error' => 'Impossibile analizzare la risposta AI', 'raw' => substr($response, 0, 1000)]; + } + + return $data; + } + + /** + * Registra interazione AI nel database + */ + public function logInteraction(int $orgId, int $userId, string $type, string $promptSummary, string $responseSummary, int $tokensUsed = 0): void + { + Database::insert('ai_interactions', [ + 'organization_id' => $orgId, + 'user_id' => $userId, + 'interaction_type' => $type, + 'prompt_summary' => substr($promptSummary, 0, 500), + 'response_summary' => $responseSummary, + 'tokens_used' => $tokensUsed, + 'model_used' => $this->model, + ]); + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..7a219a0 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,18 @@ +FROM php:8.4-fpm-alpine + +# Extensions +RUN docker-php-ext-install pdo pdo_mysql + +# Curl extension +RUN apk add --no-cache curl-dev && docker-php-ext-install curl + +# Config +COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini + +WORKDIR /var/www/nis2-agile + +COPY . . + +RUN chown -R www-data:www-data /var/www/nis2-agile + +EXPOSE 9000 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..ab45412 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,77 @@ +version: '3.8' + +services: + # ── PHP-FPM Application ────────────────────────────────────────────────── + app: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: nis2-app + restart: unless-stopped + volumes: + - ../application:/var/www/nis2-agile/application + - ../public:/var/www/nis2-agile/public + environment: + - APP_ENV=${APP_ENV:-production} + - APP_DEBUG=${APP_DEBUG:-false} + - DB_HOST=db + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-nis2_agile_db} + - DB_USERNAME=${DB_USERNAME:-nis2_user} + - DB_PASSWORD=${DB_PASSWORD} + - JWT_SECRET=${JWT_SECRET} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + networks: + - nis2-network + depends_on: + db: + condition: service_healthy + + # ── Nginx Web Server ───────────────────────────────────────────────────── + web: + image: nginx:1.25-alpine + container_name: nis2-web + restart: unless-stopped + ports: + - "${WEB_PORT:-8080}:8080" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ../public:/var/www/nis2-agile/public:ro + networks: + - nis2-network + depends_on: + - app + + # ── MySQL Database ─────────────────────────────────────────────────────── + db: + image: mysql:8.0 + container_name: nis2-db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_DATABASE:-nis2_agile_db} + MYSQL_USER: ${DB_USERNAME:-nis2_user} + MYSQL_PASSWORD: ${DB_PASSWORD} + ports: + - "${DB_PORT:-3306}:3306" + volumes: + - nis2-db-data:/var/lib/mysql + - ../docs/sql/001_initial_schema.sql:/docker-entrypoint-initdb.d/001_initial_schema.sql:ro + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - nis2-network + +# ── Volumes ────────────────────────────────────────────────────────────── +volumes: + nis2-db-data: + driver: local + +# ── Networks ───────────────────────────────────────────────────────────── +networks: + nis2-network: + driver: bridge diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..2b718f7 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,62 @@ +server { + listen 8080; + server_name _; + + root /var/www/nis2-agile/public; + index index.php index.html; + + charset utf-8; + + # ── Security Headers ─────────────────────────────────────────────────── + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # ── Logging ──────────────────────────────────────────────────────────── + access_log /var/log/nginx/nis2-access.log; + error_log /var/log/nginx/nis2-error.log; + + # ── Max Upload Size ──────────────────────────────────────────────────── + client_max_body_size 20M; + + # ── Main Location ────────────────────────────────────────────────────── + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # ── PHP-FPM Processing ───────────────────────────────────────────────── + location ~ \.php$ { + fastcgi_pass app:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + fastcgi_param HTTP_PROXY ""; + fastcgi_buffer_size 128k; + fastcgi_buffers 4 256k; + fastcgi_busy_buffers_size 256k; + fastcgi_read_timeout 300; + } + + # ── Static Assets Caching ────────────────────────────────────────────── + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # ── Deny Hidden Files ────────────────────────────────────────────────── + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + # ── Deny access to sensitive files ───────────────────────────────────── + location ~* \.(env|sql|md|json|lock|yml|yaml)$ { + deny all; + access_log off; + log_not_found off; + } +} diff --git a/docs/sql/001_initial_schema.sql b/docs/sql/001_initial_schema.sql new file mode 100644 index 0000000..a94fd23 --- /dev/null +++ b/docs/sql/001_initial_schema.sql @@ -0,0 +1,490 @@ +-- ═══════════════════════════════════════════════════════════════════════════ +-- NIS2 Agile - Schema Database Iniziale +-- Database: nis2_agile_db +-- Versione: 1.0.0 +-- Data: 2026-02-17 +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE DATABASE IF NOT EXISTS nis2_agile_db + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE nis2_agile_db; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- CORE: Organizzazioni e Utenti +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE organizations ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + vat_number VARCHAR(20), + fiscal_code VARCHAR(16), + sector ENUM( + 'energy','transport','banking','health','water','digital_infra', + 'public_admin','manufacturing','postal','chemical','food', + 'waste','ict_services','digital_providers','space','research','other' + ) NOT NULL DEFAULT 'other', + entity_type ENUM('essential','important','not_applicable') DEFAULT 'not_applicable', + employee_count INT, + annual_turnover_eur DECIMAL(15,2), + country VARCHAR(2) DEFAULT 'IT', + city VARCHAR(100), + address VARCHAR(255), + website VARCHAR(255), + contact_email VARCHAR(255), + contact_phone VARCHAR(30), + subscription_plan ENUM('free','professional','enterprise') DEFAULT 'free', + is_active TINYINT(1) DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_sector (sector), + INDEX idx_entity_type (entity_type), + INDEX idx_subscription (subscription_plan) +) ENGINE=InnoDB; + +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(255) NOT NULL, + phone VARCHAR(30), + role ENUM( + 'super_admin','org_admin','compliance_manager', + 'board_member','auditor','employee','consultant' + ) NOT NULL DEFAULT 'employee', + preferred_language VARCHAR(5) DEFAULT 'it', + is_active TINYINT(1) DEFAULT 1, + email_verified_at DATETIME, + last_login_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_email (email), + INDEX idx_role (role), + INDEX idx_active (is_active) +) ENGINE=InnoDB; + +CREATE TABLE user_organizations ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + organization_id INT NOT NULL, + role ENUM('org_admin','compliance_manager','board_member','auditor','employee') NOT NULL DEFAULT 'employee', + is_primary TINYINT(1) DEFAULT 0, + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + UNIQUE KEY uk_user_org (user_id, organization_id), + INDEX idx_user (user_id), + INDEX idx_org (organization_id) +) ENGINE=InnoDB; + +CREATE TABLE refresh_tokens ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + token VARCHAR(255) NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_token (token), + INDEX idx_expires (expires_at) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- GAP ANALYSIS & ASSESSMENT +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE assessments ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + assessment_type ENUM('initial','periodic','post_incident') DEFAULT 'initial', + status ENUM('draft','in_progress','completed') DEFAULT 'draft', + overall_score DECIMAL(5,2), + category_scores JSON, + completed_by INT, + completed_at DATETIME, + ai_summary TEXT, + ai_recommendations JSON, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (completed_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_org (organization_id), + INDEX idx_status (status) +) ENGINE=InnoDB; + +CREATE TABLE assessment_responses ( + id INT AUTO_INCREMENT PRIMARY KEY, + assessment_id INT NOT NULL, + question_code VARCHAR(50) NOT NULL, + nis2_article VARCHAR(20), + iso27001_control VARCHAR(20), + category VARCHAR(100), + question_text TEXT NOT NULL, + response_value ENUM('not_implemented','partial','implemented','not_applicable'), + maturity_level TINYINT, + evidence_description TEXT, + notes TEXT, + answered_by INT, + answered_at DATETIME, + FOREIGN KEY (assessment_id) REFERENCES assessments(id) ON DELETE CASCADE, + FOREIGN KEY (answered_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_assessment (assessment_id), + INDEX idx_category (category), + UNIQUE KEY uk_assessment_question (assessment_id, question_code) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- RISK MANAGEMENT +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE risks ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + risk_code VARCHAR(20), + title VARCHAR(255) NOT NULL, + description TEXT, + category ENUM('cyber','operational','compliance','supply_chain','physical','human') NOT NULL, + threat_source VARCHAR(255), + vulnerability VARCHAR(255), + affected_assets JSON, + likelihood TINYINT, + impact TINYINT, + inherent_risk_score TINYINT, + treatment ENUM('mitigate','accept','transfer','avoid') DEFAULT 'mitigate', + residual_likelihood TINYINT, + residual_impact TINYINT, + residual_risk_score TINYINT, + status ENUM('identified','analyzing','treating','monitored','closed') DEFAULT 'identified', + owner_user_id INT, + review_date DATE, + nis2_article VARCHAR(20), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_org (organization_id), + INDEX idx_status (status), + INDEX idx_category (category), + UNIQUE KEY uk_org_risk_code (organization_id, risk_code) +) ENGINE=InnoDB; + +CREATE TABLE risk_treatments ( + id INT AUTO_INCREMENT PRIMARY KEY, + risk_id INT NOT NULL, + action_description TEXT NOT NULL, + responsible_user_id INT, + due_date DATE, + status ENUM('planned','in_progress','completed','overdue') DEFAULT 'planned', + completion_date DATE, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (risk_id) REFERENCES risks(id) ON DELETE CASCADE, + FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_risk (risk_id), + INDEX idx_status (status) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- INCIDENT MANAGEMENT (Art. 23) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE incidents ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + incident_code VARCHAR(20), + title VARCHAR(255) NOT NULL, + description TEXT, + classification ENUM( + 'cyber_attack','data_breach','system_failure', + 'human_error','natural_disaster','supply_chain','other' + ) NOT NULL, + severity ENUM('low','medium','high','critical') NOT NULL, + is_significant TINYINT(1) DEFAULT 0, + status ENUM( + 'detected','analyzing','containing','eradicating', + 'recovering','closed','post_mortem' + ) DEFAULT 'detected', + detected_at DATETIME NOT NULL, + -- NIS2 Art.23 milestones + early_warning_due DATETIME, + early_warning_sent_at DATETIME, + notification_due DATETIME, + notification_sent_at DATETIME, + final_report_due DATETIME, + final_report_sent_at DATETIME, + -- Impact + affected_services TEXT, + affected_users_count INT, + cross_border_impact TINYINT(1) DEFAULT 0, + malicious_action TINYINT(1) DEFAULT 0, + -- Resolution + root_cause TEXT, + remediation_actions TEXT, + lessons_learned TEXT, + reported_by INT, + assigned_to INT, + closed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (reported_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_org (organization_id), + INDEX idx_status (status), + INDEX idx_severity (severity), + UNIQUE KEY uk_org_incident_code (organization_id, incident_code) +) ENGINE=InnoDB; + +CREATE TABLE incident_timeline ( + id INT AUTO_INCREMENT PRIMARY KEY, + incident_id INT NOT NULL, + event_type ENUM('detection','escalation','notification','action','update','resolution') NOT NULL, + description TEXT NOT NULL, + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (incident_id) REFERENCES incidents(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_incident (incident_id) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- POLICY & PROCEDURE MANAGEMENT +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE policies ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + category ENUM( + 'information_security','access_control','incident_response', + 'business_continuity','supply_chain','encryption','hr_security', + 'asset_management','network_security','vulnerability_management' + ) NOT NULL, + nis2_article VARCHAR(20), + version VARCHAR(10) DEFAULT '1.0', + status ENUM('draft','review','approved','published','archived') DEFAULT 'draft', + content LONGTEXT, + approved_by INT, + approved_at DATETIME, + next_review_date DATE, + ai_generated TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_org (organization_id), + INDEX idx_status (status), + INDEX idx_category (category) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- SUPPLY CHAIN SECURITY (Art. 21.2.d) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE suppliers ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + vat_number VARCHAR(20), + contact_email VARCHAR(255), + contact_name VARCHAR(255), + service_type VARCHAR(255), + service_description TEXT, + criticality ENUM('low','medium','high','critical') DEFAULT 'medium', + risk_score TINYINT, + last_assessment_date DATE, + next_assessment_date DATE, + contract_start_date DATE, + contract_expiry_date DATE, + security_requirements_met TINYINT(1) DEFAULT 0, + assessment_responses JSON, + notes TEXT, + status ENUM('active','under_review','suspended','terminated') DEFAULT 'active', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + INDEX idx_org (organization_id), + INDEX idx_criticality (criticality), + INDEX idx_status (status) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- TRAINING & AWARENESS (Art. 20) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE training_courses ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT, + title VARCHAR(255) NOT NULL, + description TEXT, + target_role ENUM('all','board_member','compliance_manager','employee','technical') DEFAULT 'all', + nis2_article VARCHAR(20), + is_mandatory TINYINT(1) DEFAULT 0, + duration_minutes INT, + content JSON, + quiz JSON, + passing_score TINYINT DEFAULT 70, + is_active TINYINT(1) DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + INDEX idx_org (organization_id), + INDEX idx_role (target_role) +) ENGINE=InnoDB; + +CREATE TABLE training_assignments ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL, + user_id INT NOT NULL, + organization_id INT NOT NULL, + status ENUM('assigned','in_progress','completed','overdue') DEFAULT 'assigned', + due_date DATE, + started_at DATETIME, + completed_at DATETIME, + quiz_score TINYINT, + certificate_url VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES training_courses(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + INDEX idx_user (user_id), + INDEX idx_org (organization_id), + INDEX idx_status (status), + UNIQUE KEY uk_course_user (course_id, user_id) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- ASSET MANAGEMENT (Art. 21.2.i) +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE assets ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + asset_type ENUM('hardware','software','network','data','service','personnel','facility') NOT NULL, + category VARCHAR(100), + description TEXT, + criticality ENUM('low','medium','high','critical') DEFAULT 'medium', + owner_user_id INT, + location VARCHAR(255), + ip_address VARCHAR(45), + vendor VARCHAR(255), + version VARCHAR(50), + serial_number VARCHAR(100), + purchase_date DATE, + warranty_expiry DATE, + status ENUM('active','maintenance','decommissioned') DEFAULT 'active', + dependencies JSON, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_org (organization_id), + INDEX idx_type (asset_type), + INDEX idx_criticality (criticality), + INDEX idx_status (status) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- AUDIT & COMPLIANCE TRACKING +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE compliance_controls ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + control_code VARCHAR(50) NOT NULL, + framework ENUM('nis2','iso27001','both') DEFAULT 'nis2', + title VARCHAR(255) NOT NULL, + description TEXT, + status ENUM('not_started','in_progress','implemented','verified') DEFAULT 'not_started', + implementation_percentage TINYINT DEFAULT 0, + evidence_description TEXT, + responsible_user_id INT, + last_verified_at DATETIME, + next_review_date DATE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_org (organization_id), + INDEX idx_framework (framework), + INDEX idx_status (status), + UNIQUE KEY uk_org_control (organization_id, control_code) +) ENGINE=InnoDB; + +CREATE TABLE evidence_files ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + control_id INT, + entity_type VARCHAR(50), + entity_id INT, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size INT, + mime_type VARCHAR(100), + uploaded_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (control_id) REFERENCES compliance_controls(id) ON DELETE SET NULL, + FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_org (organization_id), + INDEX idx_entity (entity_type, entity_id) +) ENGINE=InnoDB; + +CREATE TABLE audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT, + organization_id INT, + action VARCHAR(255) NOT NULL, + entity_type VARCHAR(100), + entity_id INT, + details JSON, + ip_address VARCHAR(45), + user_agent VARCHAR(500), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_org (organization_id), + INDEX idx_action (action), + INDEX idx_entity (entity_type, entity_id), + INDEX idx_created (created_at) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- AI INTERACTIONS LOG +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE ai_interactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + user_id INT NOT NULL, + interaction_type ENUM( + 'gap_analysis','risk_suggestion','policy_draft', + 'incident_classification','qa','report_generation' + ) NOT NULL, + prompt_summary VARCHAR(500), + response_summary TEXT, + tokens_used INT, + model_used VARCHAR(50), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_org (organization_id), + INDEX idx_type (interaction_type) +) ENGINE=InnoDB; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- RATE LIMITING +-- ═══════════════════════════════════════════════════════════════════════════ + +CREATE TABLE rate_limits ( + id INT AUTO_INCREMENT PRIMARY KEY, + rate_key VARCHAR(255) NOT NULL, + ip_address VARCHAR(45) NOT NULL, + attempts INT DEFAULT 1, + window_start DATETIME NOT NULL, + INDEX idx_key_ip (rate_key, ip_address), + INDEX idx_window (window_start) +) ENGINE=InnoDB; diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..7f0483f --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,12 @@ +RewriteEngine On + +# Redirect HTTP to HTTPS (production) +# RewriteCond %{HTTPS} off +# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# If file or directory exists, serve directly +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d + +# Route everything through index.php +RewriteRule ^(.*)$ index.php [QSA,L] diff --git a/public/api-status.php b/public/api-status.php new file mode 100644 index 0000000..b40ed4d --- /dev/null +++ b/public/api-status.php @@ -0,0 +1,29 @@ + 'ok', + 'app' => 'NIS2 Agile', + 'version' => '1.0.0', + 'timestamp' => date('c'), + 'checks' => [], +]; + +// Database check +try { + require_once __DIR__ . '/../application/config/config.php'; + require_once __DIR__ . '/../application/config/database.php'; + + Database::fetchOne('SELECT 1'); + $status['checks']['database'] = 'ok'; +} catch (Throwable $e) { + $status['checks']['database'] = 'error'; + $status['status'] = 'degraded'; +} + +http_response_code($status['status'] === 'ok' ? 200 : 503); +echo json_encode($status, JSON_PRETTY_PRINT); diff --git a/public/assessment.html b/public/assessment.html new file mode 100644 index 0000000..f9f9f45 --- /dev/null +++ b/public/assessment.html @@ -0,0 +1,585 @@ + + + + + + Gap Analysis - NIS2 Agile + + + +
+ + + + +
+
+

Gap Analysis

+
+ +
+
+ +
+ +
+
+

Assessment di Conformita' NIS2

+

Valuta il livello di conformita' della tua organizzazione rispetto ai requisiti della direttiva NIS2.

+ + + + +
+
+ + + + + + +
+
+
+ + + + + + diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..1cacc53 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,1641 @@ +/* ═══════════════════════════════════════════════════════════════════ + NIS2 Agile - Main Stylesheet + Professional compliance management UI + ═══════════════════════════════════════════════════════════════════ */ + +/* ── CSS Variables ────────────────────────────────────────────────── */ +:root { + /* Primary palette */ + --primary: #1a73e8; + --primary-light: #4a9af5; + --primary-dark: #1557b0; + --primary-bg: #e8f0fe; + + /* Status colors */ + --secondary: #34a853; + --secondary-light: #5cb876; + --secondary-bg: #e6f4ea; + --warning: #fbbc04; + --warning-light: #fdd663; + --warning-bg: #fef7e0; + --danger: #ea4335; + --danger-light: #f28b82; + --danger-bg: #fce8e6; + --info: #4285f4; + --info-bg: #e8f0fe; + + /* Neutrals */ + --gray-50: #f8fafc; + --gray-100: #f1f5f9; + --gray-200: #e2e8f0; + --gray-300: #cbd5e1; + --gray-400: #94a3b8; + --gray-500: #64748b; + --gray-600: #475569; + --gray-700: #334155; + --gray-800: #1e293b; + --gray-900: #0f172a; + + /* Sidebar */ + --sidebar-bg: #1e293b; + --sidebar-hover: #334155; + --sidebar-active: #1a73e8; + --sidebar-text: #94a3b8; + --sidebar-text-active: #ffffff; + --sidebar-width: 260px; + + /* Layout */ + --content-bg: #f1f5f9; + --card-bg: #ffffff; + --card-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06); + --card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06); + --border-radius: 8px; + --border-radius-lg: 12px; + --border-radius-sm: 4px; + + /* Typography */ + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace; + + /* Transitions */ + --transition-fast: 150ms ease; + --transition: 250ms ease; +} + +/* ── Reset & Base ─────────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-family); + background: var(--content-bg); + color: var(--gray-700); + line-height: 1.6; + min-height: 100vh; +} + +a { + color: var(--primary); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--primary-dark); +} + +img { + max-width: 100%; +} + +/* ── Layout ───────────────────────────────────────────────────────── */ +.app-layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + background: var(--sidebar-bg); + color: var(--sidebar-text); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 100; + transition: transform var(--transition); + overflow-y: auto; +} + +.sidebar-brand { + padding: 24px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + gap: 12px; +} + +.sidebar-brand-icon { + width: 36px; + height: 36px; + background: var(--primary); + border-radius: var(--border-radius); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.sidebar-brand-icon svg { + width: 20px; + height: 20px; + fill: #fff; +} + +.sidebar-brand h1 { + font-size: 1.125rem; + font-weight: 700; + color: #fff; + letter-spacing: -0.01em; +} + +.sidebar-brand span { + font-size: 0.7rem; + color: var(--sidebar-text); + display: block; + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.sidebar-nav { + flex: 1; + padding: 12px 0; +} + +.sidebar-nav-label { + padding: 16px 20px 8px; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--gray-500); +} + +.sidebar-nav a { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; + color: var(--sidebar-text); + font-size: 0.875rem; + font-weight: 500; + border-radius: 0; + transition: all var(--transition-fast); + border-left: 3px solid transparent; +} + +.sidebar-nav a:hover { + background: var(--sidebar-hover); + color: var(--sidebar-text-active); +} + +.sidebar-nav a.active { + background: rgba(26, 115, 232, 0.12); + color: var(--primary-light); + border-left-color: var(--primary); +} + +.sidebar-nav a svg { + width: 20px; + height: 20px; + flex-shrink: 0; + opacity: 0.7; +} + +.sidebar-nav a.active svg, +.sidebar-nav a:hover svg { + opacity: 1; +} + +.sidebar-footer { + padding: 16px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.sidebar-user { + display: flex; + align-items: center; + gap: 10px; +} + +.sidebar-user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--primary); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 600; + font-size: 0.8rem; + flex-shrink: 0; +} + +.sidebar-user-info { + flex: 1; + min-width: 0; +} + +.sidebar-user-name { + font-size: 0.8rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-user-role { + font-size: 0.7rem; + color: var(--gray-400); +} + +.sidebar-logout-btn { + background: none; + border: none; + color: var(--gray-400); + cursor: pointer; + padding: 4px; + border-radius: var(--border-radius-sm); + transition: color var(--transition-fast); +} + +.sidebar-logout-btn:hover { + color: var(--danger); +} + +.main-content { + flex: 1; + margin-left: var(--sidebar-width); + min-height: 100vh; +} + +.content-header { + background: var(--card-bg); + padding: 20px 32px; + border-bottom: 1px solid var(--gray-200); + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 50; +} + +.content-header h2 { + font-size: 1.375rem; + font-weight: 700; + color: var(--gray-900); + letter-spacing: -0.01em; +} + +.content-header-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.content-body { + padding: 24px 32px; +} + +/* ── Mobile sidebar toggle ────────────────────────────────────────── */ +.sidebar-toggle { + display: none; + position: fixed; + top: 16px; + left: 16px; + z-index: 200; + background: var(--sidebar-bg); + border: none; + color: #fff; + width: 40px; + height: 40px; + border-radius: var(--border-radius); + cursor: pointer; + align-items: center; + justify-content: center; + box-shadow: var(--card-shadow); +} + +/* ── Cards ────────────────────────────────────────────────────────── */ +.card { + background: var(--card-bg); + border-radius: var(--border-radius-lg); + box-shadow: var(--card-shadow); + transition: box-shadow var(--transition); + overflow: hidden; +} + +.card:hover { + box-shadow: var(--card-shadow-hover); +} + +.card-header { + padding: 16px 24px; + border-bottom: 1px solid var(--gray-100); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-header h3 { + font-size: 1rem; + font-weight: 600; + color: var(--gray-900); +} + +.card-body { + padding: 24px; +} + +.card-footer { + padding: 16px 24px; + border-top: 1px solid var(--gray-100); + background: var(--gray-50); +} + +/* ── Stat Cards ───────────────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--card-bg); + border-radius: var(--border-radius-lg); + box-shadow: var(--card-shadow); + padding: 20px 24px; + display: flex; + align-items: flex-start; + gap: 16px; + transition: box-shadow var(--transition), transform var(--transition); +} + +.stat-card:hover { + box-shadow: var(--card-shadow-hover); + transform: translateY(-2px); +} + +.stat-card-icon { + width: 48px; + height: 48px; + border-radius: var(--border-radius); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.stat-card-icon svg { + width: 24px; + height: 24px; +} + +.stat-card-icon.primary { background: var(--primary-bg); color: var(--primary); } +.stat-card-icon.primary svg { fill: var(--primary); } +.stat-card-icon.success { background: var(--secondary-bg); color: var(--secondary); } +.stat-card-icon.success svg { fill: var(--secondary); } +.stat-card-icon.warning { background: var(--warning-bg); color: var(--warning); } +.stat-card-icon.warning svg { fill: var(--warning); } +.stat-card-icon.danger { background: var(--danger-bg); color: var(--danger); } +.stat-card-icon.danger svg { fill: var(--danger); } + +.stat-card-content h4 { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--gray-500); + margin-bottom: 4px; +} + +.stat-card-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--gray-900); + line-height: 1.2; +} + +.stat-card-change { + font-size: 0.75rem; + margin-top: 4px; +} + +.stat-card-change.up { color: var(--secondary); } +.stat-card-change.down { color: var(--danger); } + +/* ── Score Gauge ──────────────────────────────────────────────────── */ +.score-gauge { + position: relative; + width: 180px; + height: 180px; + margin: 0 auto; +} + +.score-gauge svg { + transform: rotate(-90deg); +} + +.score-gauge-circle-bg { + fill: none; + stroke: var(--gray-200); + stroke-width: 12; +} + +.score-gauge-circle { + fill: none; + stroke-width: 12; + stroke-linecap: round; + transition: stroke-dashoffset 1s ease, stroke 0.5s ease; +} + +.score-gauge-value { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +.score-gauge-number { + font-size: 2.5rem; + font-weight: 800; + color: var(--gray-900); + line-height: 1; +} + +.score-gauge-label { + font-size: 0.75rem; + color: var(--gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 4px; +} + +/* Score colors */ +.score-critical { --score-color: var(--danger); } +.score-low { --score-color: #f97316; } +.score-medium { --score-color: var(--warning); } +.score-good { --score-color: #84cc16; } +.score-excellent { --score-color: var(--secondary); } + +.score-gauge-circle { + stroke: var(--score-color, var(--primary)); +} + +/* ── Risk Matrix Colors ───────────────────────────────────────────── */ +.risk-critical { background: var(--danger); color: #fff; } +.risk-high { background: #f97316; color: #fff; } +.risk-medium { background: var(--warning); color: var(--gray-900); } +.risk-low { background: var(--secondary); color: #fff; } +.risk-info { background: var(--info); color: #fff; } + +.risk-text-critical { color: var(--danger); } +.risk-text-high { color: #f97316; } +.risk-text-medium { color: #d97706; } +.risk-text-low { color: var(--secondary); } + +/* ── Buttons ──────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + font-size: 0.875rem; + font-weight: 600; + font-family: inherit; + border: 1px solid transparent; + border-radius: var(--border-radius); + cursor: pointer; + transition: all var(--transition-fast); + white-space: nowrap; + text-decoration: none; + line-height: 1.4; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn svg { + width: 18px; + height: 18px; +} + +.btn-primary { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-dark); + border-color: var(--primary-dark); +} + +.btn-secondary { + background: var(--gray-100); + color: var(--gray-700); + border-color: var(--gray-300); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--gray-200); + border-color: var(--gray-400); +} + +.btn-success { + background: var(--secondary); + color: #fff; + border-color: var(--secondary); +} + +.btn-success:hover:not(:disabled) { + background: var(--secondary-light); +} + +.btn-danger { + background: var(--danger); + color: #fff; + border-color: var(--danger); +} + +.btn-danger:hover:not(:disabled) { + background: var(--danger-light); +} + +.btn-warning { + background: var(--warning); + color: var(--gray-900); + border-color: var(--warning); +} + +.btn-outline { + background: transparent; + color: var(--primary); + border-color: var(--primary); +} + +.btn-outline:hover:not(:disabled) { + background: var(--primary); + color: #fff; +} + +.btn-ghost { + background: transparent; + color: var(--gray-600); + border-color: transparent; +} + +.btn-ghost:hover:not(:disabled) { + background: var(--gray-100); + color: var(--gray-800); +} + +.btn-sm { + padding: 6px 14px; + font-size: 0.8rem; +} + +.btn-lg { + padding: 14px 28px; + font-size: 1rem; +} + +.btn-icon { + padding: 8px; + width: 36px; + height: 36px; +} + +.btn-group { + display: flex; + gap: 8px; +} + +/* ── Forms ────────────────────────────────────────────────────────── */ +.form-group { + margin-bottom: 20px; +} + +.form-label { + display: block; + font-size: 0.8125rem; + font-weight: 600; + color: var(--gray-700); + margin-bottom: 6px; +} + +.form-label .required { + color: var(--danger); + margin-left: 2px; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 10px 14px; + font-size: 0.875rem; + font-family: inherit; + color: var(--gray-700); + background: var(--card-bg); + border: 1px solid var(--gray-300); + border-radius: var(--border-radius); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + outline: none; +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.15); +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--gray-400); +} + +.form-input.error, +.form-select.error, +.form-textarea.error { + border-color: var(--danger); + box-shadow: 0 0 0 3px rgba(234, 67, 53, 0.1); +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2364748b' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; +} + +.form-help { + font-size: 0.75rem; + color: var(--gray-500); + margin-top: 4px; +} + +.form-error { + font-size: 0.75rem; + color: var(--danger); + margin-top: 4px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +} + +/* Radio & Checkbox */ +.form-radio-group, +.form-check-group { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.form-radio, +.form-check { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 8px 16px; + border: 1px solid var(--gray-200); + border-radius: var(--border-radius); + transition: all var(--transition-fast); + font-size: 0.875rem; +} + +.form-radio:hover, +.form-check:hover { + border-color: var(--primary); + background: var(--primary-bg); +} + +.form-radio input[type="radio"], +.form-check input[type="checkbox"] { + accent-color: var(--primary); +} + +.form-radio.selected, +.form-check.selected { + border-color: var(--primary); + background: var(--primary-bg); +} + +/* Range Slider */ +.form-range { + width: 100%; + accent-color: var(--primary); + margin: 8px 0; +} + +.form-range-labels { + display: flex; + justify-content: space-between; + font-size: 0.7rem; + color: var(--gray-500); +} + +/* ── Tables ───────────────────────────────────────────────────────── */ +.table-container { + overflow-x: auto; + border-radius: var(--border-radius-lg); +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +thead { + background: var(--gray-50); +} + +thead th { + padding: 12px 16px; + font-weight: 600; + color: var(--gray-600); + text-align: left; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 2px solid var(--gray-200); + white-space: nowrap; +} + +tbody td { + padding: 12px 16px; + border-bottom: 1px solid var(--gray-100); + color: var(--gray-700); +} + +tbody tr { + transition: background var(--transition-fast); +} + +tbody tr:hover { + background: var(--gray-50); +} + +tbody tr:last-child td { + border-bottom: none; +} + +/* ── Badges / Tags ────────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + font-size: 0.7rem; + font-weight: 600; + border-radius: 100px; + text-transform: uppercase; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.badge-primary { background: var(--primary-bg); color: var(--primary); } +.badge-success { background: var(--secondary-bg); color: #15803d; } +.badge-warning { background: var(--warning-bg); color: #a16207; } +.badge-danger { background: var(--danger-bg); color: var(--danger); } +.badge-info { background: var(--info-bg); color: var(--primary); } +.badge-neutral { background: var(--gray-100); color: var(--gray-600); } + +.badge-lg { + padding: 6px 16px; + font-size: 0.8rem; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + font-size: 0.75rem; + font-weight: 500; + background: var(--gray-100); + color: var(--gray-600); + border-radius: var(--border-radius-sm); + border: 1px solid var(--gray-200); +} + +/* ── Progress Bar ─────────────────────────────────────────────────── */ +.progress { + width: 100%; + height: 8px; + background: var(--gray-200); + border-radius: 100px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + border-radius: 100px; + transition: width 0.8s ease; + background: var(--primary); +} + +.progress-bar.success { background: var(--secondary); } +.progress-bar.warning { background: var(--warning); } +.progress-bar.danger { background: var(--danger); } + +.progress-lg { + height: 12px; +} + +.progress-label { + display: flex; + justify-content: space-between; + margin-bottom: 6px; + font-size: 0.8rem; +} + +.progress-label span:first-child { + font-weight: 600; + color: var(--gray-700); +} + +.progress-label span:last-child { + color: var(--gray-500); +} + +/* ── Modal ────────────────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(15, 23, 42, 0.6); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: all var(--transition); +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal { + background: var(--card-bg); + border-radius: var(--border-radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + width: 90%; + max-width: 560px; + max-height: 85vh; + overflow-y: auto; + transform: translateY(20px) scale(0.95); + transition: transform var(--transition); +} + +.modal-overlay.active .modal { + transform: translateY(0) scale(1); +} + +.modal-header { + padding: 20px 24px; + border-bottom: 1px solid var(--gray-100); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h3 { + font-size: 1.125rem; + font-weight: 700; + color: var(--gray-900); +} + +.modal-close { + background: none; + border: none; + color: var(--gray-400); + cursor: pointer; + padding: 4px; + border-radius: var(--border-radius-sm); + transition: color var(--transition-fast); + line-height: 1; +} + +.modal-close:hover { + color: var(--gray-700); +} + +.modal-body { + padding: 24px; +} + +.modal-footer { + padding: 16px 24px; + border-top: 1px solid var(--gray-100); + display: flex; + justify-content: flex-end; + gap: 8px; +} + +/* ── Notifications / Toasts ───────────────────────────────────────── */ +.notification-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.notification { + background: var(--card-bg); + border-radius: var(--border-radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + padding: 14px 20px; + display: flex; + align-items: center; + gap: 12px; + min-width: 320px; + max-width: 480px; + pointer-events: auto; + animation: slideIn 0.3s ease; + border-left: 4px solid var(--gray-400); +} + +.notification.success { border-left-color: var(--secondary); } +.notification.error { border-left-color: var(--danger); } +.notification.warning { border-left-color: var(--warning); } +.notification.info { border-left-color: var(--primary); } + +.notification-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.notification.success .notification-icon { color: var(--secondary); } +.notification.error .notification-icon { color: var(--danger); } +.notification.warning .notification-icon { color: var(--warning); } +.notification.info .notification-icon { color: var(--primary); } + +.notification-message { + flex: 1; + font-size: 0.875rem; + color: var(--gray-700); + font-weight: 500; +} + +.notification-close { + background: none; + border: none; + color: var(--gray-400); + cursor: pointer; + padding: 2px; + font-size: 1.1rem; + line-height: 1; +} + +.notification.fade-out { + animation: slideOut 0.3s ease forwards; +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +/* ── Steps / Wizard ───────────────────────────────────────────────── */ +.steps { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + margin-bottom: 32px; + padding: 0 16px; + overflow-x: auto; +} + +.step { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + font-size: 0.8rem; + font-weight: 500; + color: var(--gray-400); + white-space: nowrap; + position: relative; + cursor: pointer; + transition: color var(--transition-fast); +} + +.step-number { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--gray-200); + color: var(--gray-500); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + flex-shrink: 0; + transition: all var(--transition-fast); +} + +.step.active .step-number { + background: var(--primary); + color: #fff; +} + +.step.active { + color: var(--primary); +} + +.step.completed .step-number { + background: var(--secondary); + color: #fff; +} + +.step.completed { + color: var(--secondary); +} + +.step-connector { + width: 32px; + height: 2px; + background: var(--gray-200); + flex-shrink: 0; +} + +.step.completed + .step-connector, +.step.completed ~ .step-connector { + background: var(--secondary); +} + +/* ── Activity Feed ────────────────────────────────────────────────── */ +.activity-list { + list-style: none; +} + +.activity-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--gray-100); +} + +.activity-item:last-child { + border-bottom: none; +} + +.activity-icon { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--primary-bg); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.activity-icon svg { + width: 16px; + height: 16px; + fill: var(--primary); +} + +.activity-content { + flex: 1; +} + +.activity-text { + font-size: 0.875rem; + color: var(--gray-700); +} + +.activity-time { + font-size: 0.75rem; + color: var(--gray-400); + margin-top: 2px; +} + +/* ── Deadline Item ────────────────────────────────────────────────── */ +.deadline-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--gray-100); +} + +.deadline-item:last-child { + border-bottom: none; +} + +.deadline-date { + width: 48px; + height: 48px; + border-radius: var(--border-radius); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-weight: 700; +} + +.deadline-date .day { + font-size: 1.125rem; + line-height: 1; +} + +.deadline-date .month { + font-size: 0.6rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.deadline-date.urgent { + background: var(--danger-bg); + color: var(--danger); +} + +.deadline-date.soon { + background: var(--warning-bg); + color: #a16207; +} + +.deadline-date.normal { + background: var(--primary-bg); + color: var(--primary); +} + +.deadline-info { + flex: 1; +} + +.deadline-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--gray-700); +} + +.deadline-desc { + font-size: 0.75rem; + color: var(--gray-500); +} + +/* ── Quick Actions ────────────────────────────────────────────────── */ +.quick-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.quick-action-btn { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 20px; + background: var(--card-bg); + border: 2px dashed var(--gray-200); + border-radius: var(--border-radius-lg); + cursor: pointer; + transition: all var(--transition-fast); + font-family: inherit; + color: var(--gray-600); + font-size: 0.875rem; + font-weight: 600; +} + +.quick-action-btn:hover { + border-color: var(--primary); + color: var(--primary); + background: var(--primary-bg); +} + +.quick-action-btn svg { + width: 20px; + height: 20px; +} + +/* ── Auth Pages ───────────────────────────────────────────────────── */ +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--gray-800) 0%, var(--gray-900) 100%); + padding: 20px; +} + +.auth-card { + background: var(--card-bg); + border-radius: var(--border-radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + width: 100%; + max-width: 440px; + overflow: hidden; +} + +.auth-header { + padding: 32px 32px 0; + text-align: center; +} + +.auth-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 24px; +} + +.auth-logo-icon { + width: 44px; + height: 44px; + background: var(--primary); + border-radius: var(--border-radius); + display: flex; + align-items: center; + justify-content: center; +} + +.auth-logo-icon svg { + width: 24px; + height: 24px; + fill: #fff; +} + +.auth-logo-text { + font-size: 1.5rem; + font-weight: 800; + color: var(--gray-900); + letter-spacing: -0.02em; +} + +.auth-logo-text span { + color: var(--primary); +} + +.auth-subtitle { + font-size: 0.875rem; + color: var(--gray-500); + margin-bottom: 8px; +} + +.auth-body { + padding: 32px; +} + +.auth-footer { + padding: 0 32px 32px; + text-align: center; +} + +.auth-footer p { + font-size: 0.8125rem; + color: var(--gray-500); +} + +.auth-footer a { + font-weight: 600; +} + +.auth-error { + background: var(--danger-bg); + color: var(--danger); + padding: 10px 16px; + border-radius: var(--border-radius); + font-size: 0.8125rem; + font-weight: 500; + margin-bottom: 16px; + display: none; +} + +.auth-error.visible { + display: block; +} + +/* Password Strength */ +.password-strength { + margin-top: 8px; +} + +.password-strength-bar { + display: flex; + gap: 4px; + margin-bottom: 4px; +} + +.password-strength-segment { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--gray-200); + transition: background var(--transition-fast); +} + +.password-strength-segment.active.weak { background: var(--danger); } +.password-strength-segment.active.fair { background: #f97316; } +.password-strength-segment.active.good { background: var(--warning); } +.password-strength-segment.active.strong { background: var(--secondary); } + +.password-strength-text { + font-size: 0.7rem; + color: var(--gray-500); +} + +/* ── Classification Badge ─────────────────────────────────────────── */ +.classification-preview { + padding: 20px; + border-radius: var(--border-radius-lg); + border: 2px solid var(--gray-200); + text-align: center; + transition: all var(--transition); +} + +.classification-preview.essential { + border-color: var(--danger); + background: var(--danger-bg); +} + +.classification-preview.important { + border-color: var(--warning); + background: var(--warning-bg); +} + +.classification-preview.not-applicable { + border-color: var(--gray-300); + background: var(--gray-50); +} + +.classification-label { + font-size: 1.5rem; + font-weight: 800; +} + +.classification-preview.essential .classification-label { color: var(--danger); } +.classification-preview.important .classification-label { color: #a16207; } +.classification-preview.not-applicable .classification-label { color: var(--gray-500); } + +.classification-desc { + font-size: 0.8125rem; + color: var(--gray-600); + margin-top: 8px; + line-height: 1.5; +} + +/* ── Category Score Bars (Assessment) ─────────────────────────────── */ +.category-score { + margin-bottom: 16px; +} + +.category-score-header { + display: flex; + justify-content: space-between; + margin-bottom: 6px; +} + +.category-score-name { + font-size: 0.8125rem; + font-weight: 600; + color: var(--gray-700); +} + +.category-score-value { + font-size: 0.8125rem; + font-weight: 700; +} + +/* ── Empty State ──────────────────────────────────────────────────── */ +.empty-state { + text-align: center; + padding: 48px 24px; + color: var(--gray-400); +} + +.empty-state svg { + width: 64px; + height: 64px; + margin-bottom: 16px; + opacity: 0.4; +} + +.empty-state h4 { + font-size: 1rem; + color: var(--gray-600); + margin-bottom: 8px; +} + +.empty-state p { + font-size: 0.875rem; + max-width: 320px; + margin: 0 auto; +} + +/* ── Loading Spinner ──────────────────────────────────────────────── */ +.spinner { + width: 24px; + height: 24px; + border: 3px solid var(--gray-200); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + margin: 0 auto; +} + +.spinner-lg { + width: 40px; + height: 40px; + border-width: 4px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + border-radius: inherit; +} + +/* ── Grid Helpers ─────────────────────────────────────────────────── */ +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 24px; +} + +.grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 24px; +} + +/* ── Utility Classes ──────────────────────────────────────────────── */ +.text-center { text-align: center; } +.text-right { text-align: right; } +.text-muted { color: var(--gray-500); } +.text-primary { color: var(--primary); } +.text-success { color: var(--secondary); } +.text-danger { color: var(--danger); } +.text-warning { color: #a16207; } +.font-bold { font-weight: 700; } +.font-semibold { font-weight: 600; } +.mt-0 { margin-top: 0; } +.mt-8 { margin-top: 8px; } +.mt-16 { margin-top: 16px; } +.mt-24 { margin-top: 24px; } +.mt-32 { margin-top: 32px; } +.mb-0 { margin-bottom: 0; } +.mb-8 { margin-bottom: 8px; } +.mb-16 { margin-bottom: 16px; } +.mb-24 { margin-bottom: 24px; } +.mb-32 { margin-bottom: 32px; } +.p-16 { padding: 16px; } +.p-24 { padding: 24px; } +.gap-8 { gap: 8px; } +.gap-16 { gap: 16px; } +.gap-24 { gap: 24px; } +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.flex-1 { flex: 1; } +.hidden { display: none !important; } +.w-full { width: 100%; } + +/* ── Responsive ───────────────────────────────────────────────────── */ +@media (max-width: 1024px) { + .grid-3 { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-toggle { + display: flex; + } + + .main-content { + margin-left: 0; + } + + .content-header { + padding: 16px 20px; + padding-left: 60px; + } + + .content-body { + padding: 16px 20px; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .grid-2, + .grid-3 { + grid-template-columns: 1fr; + } + + .steps { + overflow-x: auto; + justify-content: flex-start; + -webkit-overflow-scrolling: touch; + } + + .form-radio-group { + flex-direction: column; + } + + .quick-actions { + flex-direction: column; + } + + .modal { + width: 95%; + margin: 16px; + } + + .auth-card { + max-width: 100%; + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .stat-card { + padding: 16px; + } +} diff --git a/public/dashboard.html b/public/dashboard.html new file mode 100644 index 0000000..928f1c8 --- /dev/null +++ b/public/dashboard.html @@ -0,0 +1,324 @@ + + + + + + Dashboard - NIS2 Agile + + + +
+ + + + +
+
+

Dashboard

+
+ +
+
+ +
+ +
+ +
+
+
+
+
+

Punteggio complessivo di conformita' NIS2

+
+
+ + +
+
+
+
+ +
+
+

Rischi Aperti

+
--
+
+
+
+
+ +
+
+

Incidenti Attivi

+
--
+
+
+
+
+ +
+
+

Policy Approvate

+
--
+
+
+
+
+ +
+
+

Formazione

+
--%
+
+
+
+
+
+ + +
+
+

Azioni Rapide

+
+
+
+ + + +
+
+
+ + +
+ +
+
+

Prossime Scadenze

+
+
+
+
+
+ + +
+
+

Attivita' Recenti

+
+
+
+
+
+
+
+
+
+ + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..6d3f70a --- /dev/null +++ b/public/index.php @@ -0,0 +1,384 @@ + false, 'message' => 'Not Found']); + exit; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// API ROUTING +// ═══════════════════════════════════════════════════════════════════════════ + +$parts = explode('/', $path); +array_shift($parts); // Rimuovi "api" + +$controllerName = $parts[0] ?? 'index'; +$actionName = $parts[1] ?? 'index'; +$resourceId = isset($parts[2]) ? $parts[2] : null; +$subAction = $parts[3] ?? null; +$subResourceId = isset($parts[4]) ? $parts[4] : null; + +// Mappa controller +$controllerMap = [ + 'auth' => 'AuthController', + 'organizations' => 'OrganizationController', + 'assessments' => 'AssessmentController', + 'dashboard' => 'DashboardController', + 'risks' => 'RiskController', + 'incidents' => 'IncidentController', + 'policies' => 'PolicyController', + 'supply-chain' => 'SupplyChainController', + 'training' => 'TrainingController', + 'assets' => 'AssetController', + 'audit' => 'AuditController', + 'admin' => 'AdminController', +]; + +if (!isset($controllerMap[$controllerName])) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => 'Endpoint non trovato', + 'error_code' => 'NOT_FOUND', + ]); + exit; +} + +$controllerClass = $controllerMap[$controllerName]; +$controllerFile = APP_PATH . "/controllers/{$controllerClass}.php"; + +if (!file_exists($controllerFile)) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => 'Controller non disponibile', + ]); + exit; +} + +require_once $controllerFile; + +// ═══════════════════════════════════════════════════════════════════════════ +// MAPPA AZIONI PER METODO HTTP +// ═══════════════════════════════════════════════════════════════════════════ +$method = $_SERVER['REQUEST_METHOD']; + +// Converti action name con trattini in camelCase +$actionName = str_replace('-', '', lcfirst(ucwords($actionName, '-'))); + +$actionMap = [ + // ── AuthController ────────────────────────────── + 'auth' => [ + 'POST:register' => 'register', + 'POST:login' => 'login', + 'POST:logout' => 'logout', + 'POST:refresh' => 'refresh', + 'GET:me' => 'me', + 'PUT:profile' => 'updateProfile', + 'POST:changePassword' => 'changePassword', + ], + + // ── OrganizationController ────────────────────── + 'organizations' => [ + 'POST:create' => 'create', + 'GET:current' => 'getCurrent', + 'GET:list' => 'list', + 'PUT:{id}' => 'update', + 'GET:{id}/members' => 'listMembers', + 'POST:{id}/invite' => 'inviteMember', + 'DELETE:{id}/members/{subId}' => 'removeMember', + 'POST:classify' => 'classifyEntity', + ], + + // ── AssessmentController ──────────────────────── + 'assessments' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'GET:{id}/questions' => 'getQuestions', + 'POST:{id}/respond' => 'saveResponse', + 'POST:{id}/complete' => 'complete', + 'GET:{id}/report' => 'getReport', + 'POST:{id}/aiAnalyze' => 'aiAnalyze', + ], + + // ── DashboardController ───────────────────────── + 'dashboard' => [ + 'GET:overview' => 'overview', + 'GET:complianceScore' => 'complianceScore', + 'GET:upcomingDeadlines' => 'deadlines', + 'GET:recentActivity' => 'recentActivity', + 'GET:riskHeatmap' => 'riskHeatmap', + ], + + // ── RiskController ────────────────────────────── + 'risks' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'POST:{id}/treatments' => 'addTreatment', + 'PUT:treatments/{subId}' => 'updateTreatment', + 'GET:matrix' => 'getRiskMatrix', + 'POST:aiSuggest' => 'aiSuggestRisks', + ], + + // ── IncidentController ────────────────────────── + 'incidents' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'POST:{id}/timeline' => 'addTimelineEvent', + 'POST:{id}/earlyWarning' => 'sendEarlyWarning', + 'POST:{id}/notification' => 'sendNotification', + 'POST:{id}/finalReport' => 'sendFinalReport', + 'POST:{id}/aiClassify' => 'aiClassify', + ], + + // ── PolicyController ──────────────────────────── + 'policies' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'POST:{id}/approve' => 'approve', + 'POST:aiGenerate' => 'aiGeneratePolicy', + 'GET:templates' => 'getTemplates', + ], + + // ── SupplyChainController ─────────────────────── + 'supply-chain' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'POST:{id}/assess' => 'assessSupplier', + 'GET:riskOverview' => 'riskOverview', + ], + + // ── TrainingController ────────────────────────── + 'training' => [ + 'GET:courses' => 'listCourses', + 'POST:courses' => 'createCourse', + 'GET:assignments' => 'myAssignments', + 'POST:assign' => 'assignCourse', + 'PUT:assignments/{subId}' => 'updateAssignment', + 'GET:complianceStatus' => 'complianceStatus', + ], + + // ── AssetController ───────────────────────────── + 'assets' => [ + 'GET:list' => 'list', + 'POST:create' => 'create', + 'GET:{id}' => 'get', + 'PUT:{id}' => 'update', + 'DELETE:{id}' => 'delete', + 'GET:dependencyMap' => 'dependencyMap', + ], + + // ── AuditController ───────────────────────────── + 'audit' => [ + 'GET:controls' => 'listControls', + 'PUT:controls/{subId}' => 'updateControl', + 'POST:evidence/upload' => 'uploadEvidence', + 'GET:evidence/list' => 'listEvidence', + 'GET:report' => 'generateReport', + 'GET:logs' => 'getAuditLogs', + 'GET:iso27001Mapping' => 'getIsoMapping', + ], + + // ── AdminController ───────────────────────────── + 'admin' => [ + 'GET:organizations' => 'listOrganizations', + 'GET:users' => 'listUsers', + 'GET:stats' => 'platformStats', + ], +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// RISOLUZIONE AZIONE +// ═══════════════════════════════════════════════════════════════════════════ + +$actions = $actionMap[$controllerName] ?? []; +$resolvedAction = null; + +// Costruisci combinazioni di pattern da verificare (ordine di specificità) +$patterns = []; + +if ($subResourceId !== null && $subAction !== null) { + // METHOD:action/{id}/subAction/{subId} + $patterns[] = "{$method}:{$actionName}/{$subAction}/{subId}"; + // METHOD:{id}/subAction/{subId} + $patterns[] = "{$method}:{id}/{$subAction}/{subId}"; +} + +if ($subAction !== null && $resourceId !== null) { + // METHOD:{id}/subAction + $patterns[] = "{$method}:{id}/{$subAction}"; + // METHOD:action/{subId} + $patterns[] = "{$method}:{$actionName}/{subId}"; +} + +if ($resourceId !== null && $subAction === null) { + // METHOD:action/{id} (actionName è in realtà l'ID numerico) + if (is_numeric($actionName)) { + $patterns[] = "{$method}:{id}"; + $resourceId = (int) $actionName; + } else { + // METHOD:{id} + $patterns[] = "{$method}:{id}"; + } +} + +// METHOD:action/subAction +if ($resourceId !== null && !is_numeric($actionName)) { + $patterns[] = "{$method}:{$actionName}/{$resourceId}"; +} + +// METHOD:action +$patterns[] = "{$method}:{$actionName}"; + +// Cerca match +foreach ($patterns as $pattern) { + if (isset($actions[$pattern])) { + $resolvedAction = $actions[$pattern]; + break; + } +} + +// Se il primo segmento è numerico, l'azione è basata sull'ID +if (!$resolvedAction && is_numeric($actionName)) { + $resourceId = (int) $actionName; + $pattern = "{$method}:{id}"; + if (isset($actions[$pattern])) { + $resolvedAction = $actions[$pattern]; + } +} + +if (!$resolvedAction) { + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => "Azione non trovata: {$method} /{$controllerName}/{$actionName}", + 'error_code' => 'ACTION_NOT_FOUND', + ]); + exit; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ESECUZIONE +// ═══════════════════════════════════════════════════════════════════════════ + +try { + $controller = new $controllerClass(); + + if (!method_exists($controller, $resolvedAction)) { + http_response_code(501); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => "Metodo '{$resolvedAction}' non implementato", + ]); + exit; + } + + // Chiama con gli argomenti appropriati + if ($subResourceId !== null) { + $controller->$resolvedAction((int) $resourceId, (int) $subResourceId); + } elseif ($resourceId !== null && !is_numeric($actionName)) { + $controller->$resolvedAction((int) $resourceId); + } elseif (is_numeric($actionName)) { + $controller->$resolvedAction((int) $resourceId); + } else { + $controller->$resolvedAction(); + } +} catch (PDOException $e) { + error_log('[DB_ERROR] ' . $e->getMessage()); + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => APP_DEBUG ? 'Errore database: ' . $e->getMessage() : 'Errore interno del server', + ]); +} catch (Throwable $e) { + error_log('[ERROR] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => false, + 'message' => APP_DEBUG ? $e->getMessage() : 'Errore interno del server', + ]); +} diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..0aaa4da --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,244 @@ +/** + * NIS2 Agile - API Client + * + * Client JavaScript per comunicare con il backend REST API. + */ + +class NIS2API { + constructor(baseUrl = '/nis2/api') { + this.baseUrl = baseUrl; + this.token = localStorage.getItem('nis2_access_token'); + this.refreshToken = localStorage.getItem('nis2_refresh_token'); + this.orgId = localStorage.getItem('nis2_org_id'); + } + + // ═══════════════════════════════════════════════════════════════════ + // HTTP Methods + // ═══════════════════════════════════════════════════════════════════ + + async request(method, endpoint, data = null, options = {}) { + const url = `${this.baseUrl}${endpoint}`; + const headers = { + 'Content-Type': 'application/json', + }; + + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + if (this.orgId) { + headers['X-Organization-Id'] = this.orgId; + } + + const config = { method, headers }; + + if (data && (method === 'POST' || method === 'PUT')) { + config.body = JSON.stringify(data); + } + + try { + const response = await fetch(url, config); + const json = await response.json(); + + // Token expired - try refresh + if (response.status === 401 && this.refreshToken && !options._isRetry) { + const refreshed = await this.doRefreshToken(); + if (refreshed) { + return this.request(method, endpoint, data, { ...options, _isRetry: true }); + } + } + + if (!json.success && !options.silent) { + console.error(`[API] ${method} ${endpoint}:`, json.message); + } + + return json; + } catch (error) { + console.error(`[API] Network error: ${method} ${endpoint}`, error); + return { success: false, message: 'Errore di connessione al server' }; + } + } + + get(endpoint) { return this.request('GET', endpoint); } + post(endpoint, data) { return this.request('POST', endpoint, data); } + put(endpoint, data) { return this.request('PUT', endpoint, data); } + del(endpoint) { return this.request('DELETE', endpoint); } + + // ═══════════════════════════════════════════════════════════════════ + // Auth + // ═══════════════════════════════════════════════════════════════════ + + async login(email, password) { + const result = await this.post('/auth/login', { email, password }); + if (result.success) { + this.setTokens(result.data.access_token, result.data.refresh_token); + if (result.data.organizations && result.data.organizations.length > 0) { + const primary = result.data.organizations.find(o => o.is_primary) || result.data.organizations[0]; + this.setOrganization(primary.organization_id); + } + } + return result; + } + + async register(email, password, fullName) { + const result = await this.post('/auth/register', { email, password, full_name: fullName }); + if (result.success) { + this.setTokens(result.data.access_token, result.data.refresh_token); + } + return result; + } + + async doRefreshToken() { + try { + const result = await this.post('/auth/refresh', { refresh_token: this.refreshToken }); + if (result.success) { + this.setTokens(result.data.access_token, result.data.refresh_token); + return true; + } + } catch (e) { /* ignore */ } + this.logout(); + return false; + } + + logout() { + this.post('/auth/logout', {}).catch(() => {}); + this.clearTokens(); + window.location.href = '/nis2/login.html'; + } + + getMe() { return this.get('/auth/me'); } + + setTokens(access, refresh) { + this.token = access; + this.refreshToken = refresh; + localStorage.setItem('nis2_access_token', access); + localStorage.setItem('nis2_refresh_token', refresh); + } + + clearTokens() { + this.token = null; + this.refreshToken = null; + this.orgId = null; + localStorage.removeItem('nis2_access_token'); + localStorage.removeItem('nis2_refresh_token'); + localStorage.removeItem('nis2_org_id'); + } + + setOrganization(orgId) { + this.orgId = orgId; + localStorage.setItem('nis2_org_id', orgId); + } + + isAuthenticated() { + return !!this.token; + } + + // ═══════════════════════════════════════════════════════════════════ + // Organizations + // ═══════════════════════════════════════════════════════════════════ + + createOrganization(data) { return this.post('/organizations/create', data); } + getCurrentOrg() { return this.get('/organizations/current'); } + listOrganizations() { return this.get('/organizations/list'); } + updateOrganization(id, data) { return this.put(`/organizations/${id}`, data); } + classifyEntity(data) { return this.post('/organizations/classify', data); } + + // ═══════════════════════════════════════════════════════════════════ + // Assessments + // ═══════════════════════════════════════════════════════════════════ + + listAssessments() { return this.get('/assessments/list'); } + createAssessment(data) { return this.post('/assessments/create', data); } + getAssessment(id) { return this.get(`/assessments/${id}`); } + getAssessmentQuestions(id) { return this.get(`/assessments/${id}/questions`); } + saveAssessmentResponse(id, data) { return this.post(`/assessments/${id}/respond`, data); } + completeAssessment(id) { return this.post(`/assessments/${id}/complete`, {}); } + getAssessmentReport(id) { return this.get(`/assessments/${id}/report`); } + aiAnalyzeAssessment(id) { return this.post(`/assessments/${id}/ai-analyze`, {}); } + + // ═══════════════════════════════════════════════════════════════════ + // Dashboard + // ═══════════════════════════════════════════════════════════════════ + + getDashboardOverview() { return this.get('/dashboard/overview'); } + getComplianceScore() { return this.get('/dashboard/compliance-score'); } + getUpcomingDeadlines() { return this.get('/dashboard/upcoming-deadlines'); } + getRecentActivity() { return this.get('/dashboard/recent-activity'); } + getRiskHeatmap() { return this.get('/dashboard/risk-heatmap'); } + + // ═══════════════════════════════════════════════════════════════════ + // Risks + // ═══════════════════════════════════════════════════════════════════ + + listRisks(params = {}) { return this.get('/risks/list?' + new URLSearchParams(params)); } + createRisk(data) { return this.post('/risks/create', data); } + getRisk(id) { return this.get(`/risks/${id}`); } + updateRisk(id, data) { return this.put(`/risks/${id}`, data); } + deleteRisk(id) { return this.del(`/risks/${id}`); } + getRiskMatrix() { return this.get('/risks/matrix'); } + aiSuggestRisks() { return this.post('/risks/ai-suggest', {}); } + + // ═══════════════════════════════════════════════════════════════════ + // Incidents + // ═══════════════════════════════════════════════════════════════════ + + listIncidents(params = {}) { return this.get('/incidents/list?' + new URLSearchParams(params)); } + createIncident(data) { return this.post('/incidents/create', data); } + getIncident(id) { return this.get(`/incidents/${id}`); } + updateIncident(id, data) { return this.put(`/incidents/${id}`, data); } + sendEarlyWarning(id) { return this.post(`/incidents/${id}/early-warning`, {}); } + sendNotification(id) { return this.post(`/incidents/${id}/notification`, {}); } + sendFinalReport(id) { return this.post(`/incidents/${id}/final-report`, {}); } + + // ═══════════════════════════════════════════════════════════════════ + // Policies + // ═══════════════════════════════════════════════════════════════════ + + listPolicies(params = {}) { return this.get('/policies/list?' + new URLSearchParams(params)); } + createPolicy(data) { return this.post('/policies/create', data); } + getPolicy(id) { return this.get(`/policies/${id}`); } + updatePolicy(id, data) { return this.put(`/policies/${id}`, data); } + approvePolicy(id) { return this.post(`/policies/${id}/approve`, {}); } + aiGeneratePolicy(category) { return this.post('/policies/ai-generate', { category }); } + getPolicyTemplates() { return this.get('/policies/templates'); } + + // ═══════════════════════════════════════════════════════════════════ + // Supply Chain + // ═══════════════════════════════════════════════════════════════════ + + listSuppliers() { return this.get('/supply-chain/list'); } + createSupplier(data) { return this.post('/supply-chain/create', data); } + getSupplier(id) { return this.get(`/supply-chain/${id}`); } + updateSupplier(id, data) { return this.put(`/supply-chain/${id}`, data); } + assessSupplier(id, data) { return this.post(`/supply-chain/${id}/assess`, data); } + + // ═══════════════════════════════════════════════════════════════════ + // Training + // ═══════════════════════════════════════════════════════════════════ + + listCourses() { return this.get('/training/courses'); } + getMyTraining() { return this.get('/training/assignments'); } + getTrainingCompliance() { return this.get('/training/compliance-status'); } + + // ═══════════════════════════════════════════════════════════════════ + // Assets + // ═══════════════════════════════════════════════════════════════════ + + listAssets(params = {}) { return this.get('/assets/list?' + new URLSearchParams(params)); } + createAsset(data) { return this.post('/assets/create', data); } + getAsset(id) { return this.get(`/assets/${id}`); } + updateAsset(id, data) { return this.put(`/assets/${id}`, data); } + + // ═══════════════════════════════════════════════════════════════════ + // Audit + // ═══════════════════════════════════════════════════════════════════ + + listControls() { return this.get('/audit/controls'); } + updateControl(id, data) { return this.put(`/audit/controls/${id}`, data); } + generateComplianceReport() { return this.get('/audit/report'); } + getAuditLogs(params = {}) { return this.get('/audit/logs?' + new URLSearchParams(params)); } + getIsoMapping() { return this.get('/audit/iso27001-mapping'); } +} + +// Singleton globale +const api = new NIS2API(); diff --git a/public/js/common.js b/public/js/common.js new file mode 100644 index 0000000..523033b --- /dev/null +++ b/public/js/common.js @@ -0,0 +1,463 @@ +/** + * NIS2 Agile - Common Utilities + * + * Funzioni condivise tra tutte le pagine del frontend. + */ + +// ═══════════════════════════════════════════════════════════════════ +// Notifications (Toast) +// ═══════════════════════════════════════════════════════════════════ + +/** + * Mostra una notifica toast. + * @param {string} message - Testo del messaggio + * @param {string} type - 'success' | 'error' | 'warning' | 'info' + * @param {number} duration - Durata in ms (default 4000) + */ +function showNotification(message, type = 'info', duration = 4000) { + let container = document.querySelector('.notification-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'notification-container'; + document.body.appendChild(container); + } + + const icons = { + success: '', + error: '', + warning: '', + info: '' + }; + + const notification = document.createElement('div'); + notification.className = `notification ${type}`; + notification.innerHTML = ` + ${icons[type] || icons.info} + ${escapeHtml(message)} + + `; + + container.appendChild(notification); + + setTimeout(() => { + notification.classList.add('fade-out'); + setTimeout(() => notification.remove(), 300); + }, duration); +} + + +// ═══════════════════════════════════════════════════════════════════ +// Modal +// ═══════════════════════════════════════════════════════════════════ + +/** + * Mostra un dialogo modale. + * @param {string} title - Titolo del modale + * @param {string} content - Contenuto HTML del body + * @param {object} options - { footer: HTML, size: 'sm'|'md'|'lg' } + */ +function showModal(title, content, options = {}) { + closeModal(); // Chiudi eventuale modale aperto + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.id = 'modal-overlay'; + + const sizeClass = options.size === 'lg' ? 'style="max-width:720px"' : options.size === 'sm' ? 'style="max-width:400px"' : ''; + + overlay.innerHTML = ` + + `; + + // Chiudi cliccando fuori dal modale + overlay.addEventListener('click', (e) => { + if (e.target === overlay) closeModal(); + }); + + document.body.appendChild(overlay); + + // Trigger animation + requestAnimationFrame(() => { + overlay.classList.add('active'); + }); + + // Chiudi con ESC + document.addEventListener('keydown', _modalEscHandler); +} + +function closeModal() { + const overlay = document.getElementById('modal-overlay'); + if (overlay) { + overlay.classList.remove('active'); + setTimeout(() => overlay.remove(), 250); + } + document.removeEventListener('keydown', _modalEscHandler); +} + +function _modalEscHandler(e) { + if (e.key === 'Escape') closeModal(); +} + + +// ═══════════════════════════════════════════════════════════════════ +// Date Formatting +// ═══════════════════════════════════════════════════════════════════ + +/** + * Formatta una data in formato italiano (dd/mm/yyyy). + * @param {string|Date} date + * @returns {string} + */ +function formatDate(date) { + if (!date) return '-'; + const d = new Date(date); + if (isNaN(d.getTime())) return '-'; + return d.toLocaleDateString('it-IT', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); +} + +/** + * Formatta data e ora in formato italiano. + * @param {string|Date} date + * @returns {string} + */ +function formatDateTime(date) { + if (!date) return '-'; + const d = new Date(date); + if (isNaN(d.getTime())) return '-'; + return d.toLocaleDateString('it-IT', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +/** + * Restituisce un'etichetta relativa (es. "2 ore fa", "ieri"). + * @param {string|Date} date + * @returns {string} + */ +function timeAgo(date) { + if (!date) return ''; + const d = new Date(date); + const now = new Date(); + const diffMs = now - d; + const diffMin = Math.floor(diffMs / 60000); + const diffHrs = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMin < 1) return 'Adesso'; + if (diffMin < 60) return `${diffMin} min fa`; + if (diffHrs < 24) return `${diffHrs} ore fa`; + if (diffDays === 1) return 'Ieri'; + if (diffDays < 7) return `${diffDays} giorni fa`; + return formatDate(date); +} + + +// ═══════════════════════════════════════════════════════════════════ +// Sidebar +// ═══════════════════════════════════════════════════════════════════ + +/** + * Inietta la sidebar di navigazione nell'elemento #sidebar. + */ +function loadSidebar() { + const container = document.getElementById('sidebar'); + if (!container) return; + + // Determina la pagina corrente + const currentPage = window.location.pathname.split('/').pop() || 'dashboard.html'; + + const navItems = [ + { + label: 'Principale', + items: [ + { name: 'Dashboard', href: 'dashboard.html', icon: iconGrid() }, + { name: 'Gap Analysis', href: 'assessment.html', icon: iconClipboardCheck() }, + ] + }, + { + label: 'Gestione', + items: [ + { name: 'Rischi', href: 'risks.html', icon: iconShieldExclamation() }, + { name: 'Incidenti', href: 'incidents.html', icon: iconBell() }, + { name: 'Policy', href: 'policies.html', icon: iconDocumentText() }, + { name: 'Supply Chain', href: 'supply-chain.html', icon: iconLink() }, + ] + }, + { + label: 'Operativo', + items: [ + { name: 'Formazione', href: 'training.html', icon: iconAcademicCap() }, + { name: 'Asset', href: 'assets.html', icon: iconServer() }, + { name: 'Audit & Report', href: 'audit.html', icon: iconChartBar() }, + ] + }, + { + label: 'Sistema', + items: [ + { name: 'Impostazioni', href: 'settings.html', icon: iconCog() }, + ] + } + ]; + + let navHTML = ''; + + // Brand + navHTML += ` + + `; + + // Nav sections + navHTML += ''; + + // Footer with user info + navHTML += ` + + `; + + container.innerHTML = navHTML; + + // Carica info utente + _loadUserInfo(); + + // Mobile toggle + _setupMobileToggle(); +} + +async function _loadUserInfo() { + try { + const result = await api.getMe(); + if (result.success && result.data) { + const user = result.data; + const nameEl = document.getElementById('sidebar-user-name'); + const avatarEl = document.getElementById('sidebar-user-avatar'); + const roleEl = document.getElementById('sidebar-user-role'); + if (nameEl) nameEl.textContent = user.full_name || user.email; + if (avatarEl) { + const initials = (user.full_name || user.email || '--') + .split(' ') + .map(w => w[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + avatarEl.textContent = initials; + } + if (roleEl) roleEl.textContent = user.role === 'admin' ? 'Amministratore' : 'Utente'; + } + } catch (e) { + // Silenzioso + } +} + +function _setupMobileToggle() { + // Crea pulsante toggle se non esiste + if (!document.querySelector('.sidebar-toggle')) { + const toggle = document.createElement('button'); + toggle.className = 'sidebar-toggle'; + toggle.innerHTML = ''; + toggle.addEventListener('click', () => { + const sidebar = document.getElementById('sidebar'); + if (sidebar) sidebar.classList.toggle('open'); + }); + document.body.appendChild(toggle); + } +} + + +// ═══════════════════════════════════════════════════════════════════ +// Auth Check +// ═══════════════════════════════════════════════════════════════════ + +/** + * Verifica che l'utente sia autenticato. Se non lo e', redirige al login. + * @returns {boolean} + */ +function checkAuth() { + if (!api.isAuthenticated()) { + window.location.href = 'login.html'; + return false; + } + return true; +} + + +// ═══════════════════════════════════════════════════════════════════ +// Utilities +// ═══════════════════════════════════════════════════════════════════ + +/** + * Escape HTML per prevenire XSS. + * @param {string} str + * @returns {string} + */ +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; +} + +/** + * Debounce: ritarda l'esecuzione di fn fino a delay ms dopo l'ultima chiamata. + * @param {Function} fn + * @param {number} delay + * @returns {Function} + */ +function debounce(fn, delay = 300) { + let timer; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; +} + +/** + * Restituisce la classe CSS per un punteggio di conformita'. + * @param {number} score - Valore 0-100 + * @returns {string} + */ +function getScoreClass(score) { + if (score >= 80) return 'score-excellent'; + if (score >= 60) return 'score-good'; + if (score >= 40) return 'score-medium'; + if (score >= 20) return 'score-low'; + return 'score-critical'; +} + +/** + * Restituisce il colore esadecimale per un punteggio. + * @param {number} score - Valore 0-100 + * @returns {string} + */ +function getScoreColor(score) { + if (score >= 80) return '#34a853'; + if (score >= 60) return '#84cc16'; + if (score >= 40) return '#fbbc04'; + if (score >= 20) return '#f97316'; + return '#ea4335'; +} + +/** + * Crea l'SVG per il gauge circolare del punteggio. + * @param {number} score - Valore 0-100 + * @param {number} size - Dimensione in px (default 180) + * @returns {string} HTML + */ +function renderScoreGauge(score, size = 180) { + const radius = (size / 2) - 14; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (score / 100) * circumference; + const color = getScoreColor(score); + const cls = getScoreClass(score); + + return ` +
+ + + + +
+
${Math.round(score)}
+
Compliance
+
+
+ `; +} + + +// ═══════════════════════════════════════════════════════════════════ +// SVG Icons (inline, nessuna dipendenza esterna) +// ═══════════════════════════════════════════════════════════════════ + +function iconGrid() { + return ''; +} + +function iconClipboardCheck() { + return ''; +} + +function iconShieldExclamation() { + return ''; +} + +function iconBell() { + return ''; +} + +function iconDocumentText() { + return ''; +} + +function iconLink() { + return ''; +} + +function iconAcademicCap() { + return ''; +} + +function iconServer() { + return ''; +} + +function iconChartBar() { + return ''; +} + +function iconCog() { + return ''; +} + +function iconPlus() { + return ''; +} + +function iconSparkles() { + return ''; +} diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..3cf9222 --- /dev/null +++ b/public/login.html @@ -0,0 +1,105 @@ + + + + + + Accedi - NIS2 Agile + + + +
+
+
+ +

Piattaforma di compliance NIS2

+
+ +
+
+ +
+
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+ + + + + + diff --git a/public/register.html b/public/register.html new file mode 100644 index 0000000..d02b551 --- /dev/null +++ b/public/register.html @@ -0,0 +1,179 @@ + + + + + + Registrazione - NIS2 Agile + + + +
+
+
+ +

Crea il tuo account

+
+ +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+
+
+
+
+ +
+ + +
+ + +
+
+ + +
+
+ + + + + + diff --git a/public/setup-org.html b/public/setup-org.html new file mode 100644 index 0000000..cac424c --- /dev/null +++ b/public/setup-org.html @@ -0,0 +1,377 @@ + + + + + + Configura Organizzazione - NIS2 Agile + + + +
+ + + + +
+
+

Configurazione Organizzazione

+
+ +
+
+ +
+
+

Dati Aziendali

+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+

Classificazione NIS2

+
+
+
+
In Attesa
+

+ Compila i dati aziendali per ottenere la classificazione automatica + secondo i criteri della Direttiva NIS2 (UE) 2022/2555. +

+
+ + +
+
+ +
+
+

Criteri di Classificazione

+
+
+

Soggetti Essenziali:

+
    +
  • Settori Allegato I con ≥ 250 dipendenti o fatturato ≥ 50M EUR
  • +
  • Alcuni soggetti designati indipendentemente dalle dimensioni
  • +
+

Soggetti Importanti:

+
    +
  • Settori Allegato I/II con ≥ 50 dipendenti o fatturato ≥ 10M EUR
  • +
  • Non qualificati come essenziali
  • +
+

Non Applicabile:

+
    +
  • Organizzazioni sotto le soglie minime
  • +
  • Settori non coperti dalla direttiva
  • +
+
+
+
+
+
+
+
+ + + + + +