[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 <noreply@anthropic.com>
This commit is contained in:
commit
ae78a2f7f4
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@ -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/
|
||||||
279
CLAUDE.md
Normal file
279
CLAUDE.md
Normal file
@ -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*
|
||||||
81
application/config/config.php
Normal file
81
application/config/config.php
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Configurazione Principale
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/env.php';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// APPLICAZIONE
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
define('APP_ENV', Env::get('APP_ENV', 'development'));
|
||||||
|
define('APP_DEBUG', APP_ENV === 'development');
|
||||||
|
define('APP_NAME', Env::get('APP_NAME', 'NIS2 Agile'));
|
||||||
|
define('APP_VERSION', Env::get('APP_VERSION', '1.0.0'));
|
||||||
|
define('APP_URL', Env::get('APP_URL', 'http://localhost:8080'));
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// PERCORSI
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
define('BASE_PATH', dirname(dirname(__DIR__)));
|
||||||
|
define('APP_PATH', BASE_PATH . '/application');
|
||||||
|
define('PUBLIC_PATH', BASE_PATH . '/public');
|
||||||
|
define('UPLOAD_PATH', PUBLIC_PATH . '/uploads');
|
||||||
|
define('DATA_PATH', APP_PATH . '/data');
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// AUTENTICAZIONE JWT
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
define('JWT_SECRET', APP_ENV === 'production'
|
||||||
|
? Env::getRequired('JWT_SECRET')
|
||||||
|
: Env::get('JWT_SECRET', 'nis2_dev_jwt_secret'));
|
||||||
|
define('JWT_ALGORITHM', 'HS256');
|
||||||
|
define('JWT_EXPIRES_IN', Env::int('JWT_EXPIRES_IN', 7200));
|
||||||
|
define('JWT_REFRESH_EXPIRES_IN', Env::int('JWT_REFRESH_EXPIRES_IN', 604800));
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// PASSWORD POLICY
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
define('PASSWORD_MIN_LENGTH', Env::int('PASSWORD_MIN_LENGTH', 8));
|
||||||
|
define('PASSWORD_REQUIRE_UPPERCASE', Env::bool('PASSWORD_REQUIRE_UPPERCASE', true));
|
||||||
|
define('PASSWORD_REQUIRE_NUMBER', Env::bool('PASSWORD_REQUIRE_NUMBER', true));
|
||||||
|
define('PASSWORD_REQUIRE_SPECIAL', Env::bool('PASSWORD_REQUIRE_SPECIAL', true));
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// CORS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
define('CORS_ALLOWED_ORIGINS', array_filter([
|
||||||
|
APP_URL,
|
||||||
|
APP_ENV !== 'production' ? 'http://localhost:8080' : null,
|
||||||
|
APP_ENV !== 'production' ? 'http://localhost:3000' : null,
|
||||||
|
]));
|
||||||
|
define('CORS_ALLOWED_METHODS', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
define('CORS_ALLOWED_HEADERS', 'Content-Type, Authorization, X-Organization-Id, X-Requested-With');
|
||||||
|
define('CORS_MAX_AGE', '86400');
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// RATE LIMITING
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
define('RATE_LIMIT_AUTH_LOGIN', [
|
||||||
|
['max' => 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');
|
||||||
168
application/config/database.php
Normal file
168
application/config/database.php
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Configurazione Database
|
||||||
|
*
|
||||||
|
* Database dedicato: nis2_agile_db
|
||||||
|
* Utente dedicato: nis2_user
|
||||||
|
* COMPLETAMENTE ISOLATO dagli altri applicativi
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/env.php';
|
||||||
|
|
||||||
|
define('DB_HOST', Env::get('DB_HOST', 'localhost'));
|
||||||
|
define('DB_PORT', Env::get('DB_PORT', '3306'));
|
||||||
|
define('DB_NAME', Env::get('DB_NAME', 'nis2_agile_db'));
|
||||||
|
define('DB_USER', Env::get('DB_USER', 'nis2_user'));
|
||||||
|
define('DB_PASS', Env::getRequired('DB_PASS'));
|
||||||
|
define('DB_CHARSET', 'utf8mb4');
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottiene l'istanza PDO (singleton)
|
||||||
|
*/
|
||||||
|
public static function getInstance(): PDO
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%s;dbname=%s;charset=%s',
|
||||||
|
DB_HOST,
|
||||||
|
DB_PORT,
|
||||||
|
DB_NAME,
|
||||||
|
DB_CHARSET
|
||||||
|
);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => 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() {}
|
||||||
|
}
|
||||||
126
application/config/env.php
Normal file
126
application/config/env.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Environment Loader
|
||||||
|
*
|
||||||
|
* Carica variabili d'ambiente dal file .env
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Env
|
||||||
|
{
|
||||||
|
private static bool $loaded = false;
|
||||||
|
private static array $vars = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carica il file .env
|
||||||
|
*/
|
||||||
|
public static function load(?string $path = null): void
|
||||||
|
{
|
||||||
|
if (self::$loaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $path ?? dirname(__DIR__, 2) . '/.env';
|
||||||
|
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
self::$loaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (empty($line) || str_starts_with($line, '#')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strpos($line, '=') !== false) {
|
||||||
|
[$key, $value] = explode('=', $line, 2);
|
||||||
|
$key = trim($key);
|
||||||
|
$value = trim($value);
|
||||||
|
$value = trim($value, '"\'');
|
||||||
|
|
||||||
|
self::$vars[$key] = $value;
|
||||||
|
putenv("{$key}={$value}");
|
||||||
|
$_ENV[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottiene una variabile d'ambiente
|
||||||
|
*/
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
self::load();
|
||||||
|
|
||||||
|
if (isset(self::$vars[$key]) && self::$vars[$key] !== '') {
|
||||||
|
return self::$vars[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = getenv($key);
|
||||||
|
if ($value !== false && $value !== '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_ENV[$key]) && $_ENV[$key] !== '') {
|
||||||
|
return $_ENV[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se una variabile esiste e non e' vuota
|
||||||
|
*/
|
||||||
|
public static function has(string $key): bool
|
||||||
|
{
|
||||||
|
$value = self::get($key);
|
||||||
|
return $value !== null && $value !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottiene una variabile OBBLIGATORIA
|
||||||
|
*/
|
||||||
|
public static function getRequired(string $key): string
|
||||||
|
{
|
||||||
|
$value = self::get($key);
|
||||||
|
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Variabile d'ambiente obbligatoria '{$key}' non configurata. " .
|
||||||
|
"Aggiungerla al file .env"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottiene come booleano
|
||||||
|
*/
|
||||||
|
public static function bool(string $key, bool $default = false): bool
|
||||||
|
{
|
||||||
|
$value = self::get($key);
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
return $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array(strtolower($value), ['true', '1', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottiene come intero
|
||||||
|
*/
|
||||||
|
public static function int(string $key, int $default = 0): int
|
||||||
|
{
|
||||||
|
$value = self::get($key);
|
||||||
|
return $value !== null ? (int)$value : $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-carica all'inclusione
|
||||||
|
Env::load();
|
||||||
71
application/controllers/AdminController.php
Normal file
71
application/controllers/AdminController.php
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Admin Controller
|
||||||
|
*
|
||||||
|
* Gestione piattaforma (solo super_admin).
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class AdminController extends BaseController
|
||||||
|
{
|
||||||
|
public function listOrganizations(): void
|
||||||
|
{
|
||||||
|
$this->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'
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
448
application/controllers/AssessmentController.php
Normal file
448
application/controllers/AssessmentController.php
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Assessment Controller
|
||||||
|
*
|
||||||
|
* Gap Analysis wizard: questionario NIS2, scoring, AI analysis.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
require_once APP_PATH . '/services/AIService.php';
|
||||||
|
|
||||||
|
class AssessmentController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /api/assessments/list
|
||||||
|
*/
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
160
application/controllers/AssetController.php
Normal file
160
application/controllers/AssetController.php
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Asset Controller
|
||||||
|
*
|
||||||
|
* Inventario asset IT/OT, classificazione, dipendenze.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class AssetController extends BaseController
|
||||||
|
{
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
application/controllers/AuditController.php
Normal file
196
application/controllers/AuditController.php
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Audit Controller
|
||||||
|
*
|
||||||
|
* Controlli compliance, evidenze, audit logs, mapping ISO 27001.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class AuditController extends BaseController
|
||||||
|
{
|
||||||
|
public function listControls(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
280
application/controllers/AuthController.php
Normal file
280
application/controllers/AuthController.php
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Auth Controller
|
||||||
|
*
|
||||||
|
* Gestisce registrazione, login, JWT tokens, profilo utente.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class AuthController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* POST /api/auth/register
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
576
application/controllers/BaseController.php
Normal file
576
application/controllers/BaseController.php
Normal file
@ -0,0 +1,576 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Base Controller
|
||||||
|
*
|
||||||
|
* Classe base per tutti i controller.
|
||||||
|
* Gestisce autenticazione, multi-tenancy, risposte JSON, validazione.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once APP_PATH . '/config/database.php';
|
||||||
|
|
||||||
|
class BaseController
|
||||||
|
{
|
||||||
|
protected ?array $currentUser = null;
|
||||||
|
protected ?int $currentOrgId = null;
|
||||||
|
protected ?string $currentOrgRole = null;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// RISPOSTE JSON
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invia risposta JSON di successo
|
||||||
|
*/
|
||||||
|
protected function jsonSuccess($data = null, string $message = 'OK', int $statusCode = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
348
application/controllers/DashboardController.php
Normal file
348
application/controllers/DashboardController.php
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Dashboard Controller
|
||||||
|
*
|
||||||
|
* Overview di compliance, score, scadenze, attività recenti.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class DashboardController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /api/dashboard/overview
|
||||||
|
*/
|
||||||
|
public function overview(): void
|
||||||
|
{
|
||||||
|
$this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
325
application/controllers/IncidentController.php
Normal file
325
application/controllers/IncidentController.php
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Incident Controller
|
||||||
|
*
|
||||||
|
* Gestione incidenti con workflow NIS2 Art. 23 (24h/72h/30d).
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
require_once APP_PATH . '/services/AIService.php';
|
||||||
|
|
||||||
|
class IncidentController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /api/incidents/list
|
||||||
|
*/
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->requireOrgAccess();
|
||||||
|
$pagination = $this->getPagination();
|
||||||
|
|
||||||
|
$where = 'organization_id = ?';
|
||||||
|
$params = [$this->getCurrentOrgId()];
|
||||||
|
|
||||||
|
if ($this->hasParam('status')) {
|
||||||
|
$where .= ' AND status = ?';
|
||||||
|
$params[] = $this->getParam('status');
|
||||||
|
}
|
||||||
|
if ($this->hasParam('severity')) {
|
||||||
|
$where .= ' AND severity = ?';
|
||||||
|
$params[] = $this->getParam('severity');
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = Database::count('incidents', $where, $params);
|
||||||
|
|
||||||
|
$incidents = Database::fetchAll(
|
||||||
|
"SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name
|
||||||
|
FROM incidents i
|
||||||
|
LEFT JOIN users u1 ON u1.id = i.reported_by
|
||||||
|
LEFT JOIN users u2 ON u2.id = i.assigned_to
|
||||||
|
WHERE i.{$where}
|
||||||
|
ORDER BY i.detected_at DESC
|
||||||
|
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}",
|
||||||
|
$params
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->jsonPaginated($incidents, $total, $pagination['page'], $pagination['per_page']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/incidents/create
|
||||||
|
*/
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']);
|
||||||
|
$this->validateRequired(['title', 'classification', 'severity', 'detected_at']);
|
||||||
|
|
||||||
|
$detectedAt = $this->getParam('detected_at');
|
||||||
|
$isSignificant = (bool) $this->getParam('is_significant', false);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'organization_id' => $this->getCurrentOrgId(),
|
||||||
|
'incident_code' => $this->generateCode('INC'),
|
||||||
|
'title' => trim($this->getParam('title')),
|
||||||
|
'description' => $this->getParam('description'),
|
||||||
|
'classification' => $this->getParam('classification'),
|
||||||
|
'severity' => $this->getParam('severity'),
|
||||||
|
'is_significant' => $isSignificant ? 1 : 0,
|
||||||
|
'detected_at' => $detectedAt,
|
||||||
|
'affected_services' => $this->getParam('affected_services'),
|
||||||
|
'affected_users_count' => $this->getParam('affected_users_count'),
|
||||||
|
'cross_border_impact' => $this->getParam('cross_border_impact', 0),
|
||||||
|
'malicious_action' => $this->getParam('malicious_action', 0),
|
||||||
|
'reported_by' => $this->getCurrentUserId(),
|
||||||
|
'assigned_to' => $this->getParam('assigned_to'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calcola scadenze NIS2 Art. 23 se significativo
|
||||||
|
if ($isSignificant) {
|
||||||
|
$detectedTime = strtotime($detectedAt);
|
||||||
|
$data['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600); // +24h
|
||||||
|
$data['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600); // +72h
|
||||||
|
$data['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400); // +30 giorni
|
||||||
|
}
|
||||||
|
|
||||||
|
$incidentId = Database::insert('incidents', $data);
|
||||||
|
|
||||||
|
// Aggiungi evento timeline
|
||||||
|
Database::insert('incident_timeline', [
|
||||||
|
'incident_id' => $incidentId,
|
||||||
|
'event_type' => 'detection',
|
||||||
|
'description' => "Incidente rilevato: {$data['title']}",
|
||||||
|
'created_by' => $this->getCurrentUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->logAudit('incident_created', 'incident', $incidentId, [
|
||||||
|
'severity' => $data['severity'], 'is_significant' => $isSignificant
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
395
application/controllers/OrganizationController.php
Normal file
395
application/controllers/OrganizationController.php
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Organization Controller
|
||||||
|
*
|
||||||
|
* Gestione organizzazioni multi-tenant, membri, classificazione NIS2.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class OrganizationController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* POST /api/organizations/create
|
||||||
|
*/
|
||||||
|
public function create(): void
|
||||||
|
{
|
||||||
|
$this->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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
application/controllers/PolicyController.php
Normal file
170
application/controllers/PolicyController.php
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Policy Controller
|
||||||
|
*
|
||||||
|
* Gestione policy e procedure di sicurezza, con AI generation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
require_once APP_PATH . '/services/AIService.php';
|
||||||
|
|
||||||
|
class PolicyController extends BaseController
|
||||||
|
{
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
302
application/controllers/RiskController.php
Normal file
302
application/controllers/RiskController.php
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Risk Controller
|
||||||
|
*
|
||||||
|
* Gestione rischi cyber, matrice rischi, trattamenti.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
require_once APP_PATH . '/services/AIService.php';
|
||||||
|
|
||||||
|
class RiskController extends BaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /api/risks/list
|
||||||
|
*/
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
168
application/controllers/SupplyChainController.php
Normal file
168
application/controllers/SupplyChainController.php
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Supply Chain Controller
|
||||||
|
*
|
||||||
|
* Gestione fornitori, assessment cybersecurity, risk scoring.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class SupplyChainController extends BaseController
|
||||||
|
{
|
||||||
|
public function list(): void
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
150
application/controllers/TrainingController.php
Normal file
150
application/controllers/TrainingController.php
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Training Controller
|
||||||
|
*
|
||||||
|
* Gestione formazione cybersecurity (Art. 20 NIS2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/BaseController.php';
|
||||||
|
|
||||||
|
class TrainingController extends BaseController
|
||||||
|
{
|
||||||
|
public function listCourses(): void
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
898
application/data/nis2_questionnaire.json
Normal file
898
application/data/nis2_questionnaire.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
315
application/services/AIService.php
Normal file
315
application/services/AIService.php
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - AI Service
|
||||||
|
*
|
||||||
|
* Integrazione con Anthropic Claude API per:
|
||||||
|
* - Analisi gap analysis
|
||||||
|
* - Suggerimenti rischi
|
||||||
|
* - Generazione policy
|
||||||
|
* - Classificazione incidenti
|
||||||
|
* - Q&A NIS2
|
||||||
|
*/
|
||||||
|
|
||||||
|
class AIService
|
||||||
|
{
|
||||||
|
private string $apiKey;
|
||||||
|
private string $model;
|
||||||
|
private int $maxTokens;
|
||||||
|
private string $baseUrl = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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 = <<<PROMPT
|
||||||
|
Sei un esperto consulente di cybersecurity specializzato nella Direttiva NIS2 (EU 2022/2555) e nel D.Lgs. 138/2024 italiano.
|
||||||
|
|
||||||
|
Analizza i risultati della gap analysis per l'organizzazione seguente e fornisci raccomandazioni dettagliate.
|
||||||
|
|
||||||
|
## Organizzazione
|
||||||
|
- Nome: {$organization['name']}
|
||||||
|
- Settore: {$organization['sector']}
|
||||||
|
- Tipo entità NIS2: {$organization['entity_type']}
|
||||||
|
- Dipendenti: {$organization['employee_count']}
|
||||||
|
- Fatturato annuo: EUR {$organization['annual_turnover_eur']}
|
||||||
|
|
||||||
|
## Risultati Assessment (Score: {$overallScore}%)
|
||||||
|
|
||||||
|
{$responseSummary}
|
||||||
|
|
||||||
|
## Istruzioni
|
||||||
|
Fornisci la tua analisi in formato JSON con questa struttura:
|
||||||
|
{
|
||||||
|
"executive_summary": "Riepilogo esecutivo (3-4 frasi)",
|
||||||
|
"risk_level": "low|medium|high|critical",
|
||||||
|
"top_priorities": [
|
||||||
|
{
|
||||||
|
"area": "Nome area",
|
||||||
|
"nis2_article": "21.2.x",
|
||||||
|
"current_status": "Breve descrizione stato attuale",
|
||||||
|
"recommendation": "Azione raccomandata specifica",
|
||||||
|
"effort": "low|medium|high",
|
||||||
|
"timeline": "Tempistica suggerita"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"strengths": ["Punto di forza 1", "Punto di forza 2"],
|
||||||
|
"quick_wins": ["Azione rapida 1", "Azione rapida 2"],
|
||||||
|
"compliance_roadmap": [
|
||||||
|
{"phase": 1, "title": "Titolo fase", "actions": ["Azione 1"], "duration": "X mesi"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rispondi SOLO con il JSON, senza testo aggiuntivo.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$response = $this->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 = <<<PROMPT
|
||||||
|
Sei un esperto di cybersecurity risk assessment. Genera una lista di rischi cyber per questa organizzazione.
|
||||||
|
|
||||||
|
## Organizzazione
|
||||||
|
- Settore: {$organization['sector']}
|
||||||
|
- Tipo entità NIS2: {$organization['entity_type']}
|
||||||
|
- Dipendenti: {$organization['employee_count']}
|
||||||
|
|
||||||
|
## Asset IT/OT
|
||||||
|
{$assetList}
|
||||||
|
|
||||||
|
Fornisci 10 rischi in formato JSON:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Titolo rischio",
|
||||||
|
"description": "Descrizione dettagliata",
|
||||||
|
"category": "cyber|operational|compliance|supply_chain|physical|human",
|
||||||
|
"threat_source": "Fonte della minaccia",
|
||||||
|
"vulnerability": "Vulnerabilità sfruttata",
|
||||||
|
"likelihood": 1-5,
|
||||||
|
"impact": 1-5,
|
||||||
|
"nis2_article": "21.2.x",
|
||||||
|
"suggested_treatment": "mitigate|accept|transfer|avoid",
|
||||||
|
"mitigation_actions": ["Azione 1", "Azione 2"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Rispondi SOLO con il JSON array.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$response = $this->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 = <<<PROMPT
|
||||||
|
Sei un esperto di information security policy writing. Genera una policy aziendale per la categoria "{$category}" conforme alla Direttiva NIS2.
|
||||||
|
|
||||||
|
## Organizzazione
|
||||||
|
- Nome: {$organization['name']}
|
||||||
|
- Settore: {$organization['sector']}
|
||||||
|
- Tipo entità NIS2: {$organization['entity_type']}
|
||||||
|
|
||||||
|
## Contesto Assessment
|
||||||
|
{$context}
|
||||||
|
|
||||||
|
Genera la policy in formato JSON:
|
||||||
|
{
|
||||||
|
"title": "Titolo completo della policy",
|
||||||
|
"version": "1.0",
|
||||||
|
"category": "{$category}",
|
||||||
|
"nis2_article": "21.2.x",
|
||||||
|
"content": "Contenuto completo della policy in formato Markdown con sezioni: 1. Scopo, 2. Ambito di applicazione, 3. Responsabilità, 4. Definizioni, 5. Policy (regole specifiche), 6. Procedure operative, 7. Controlli, 8. Non conformità, 9. Revisione",
|
||||||
|
"review_period_months": 12
|
||||||
|
}
|
||||||
|
|
||||||
|
La policy deve essere in italiano, professionale, specifica per il settore dell'organizzazione, e conforme ai requisiti NIS2.
|
||||||
|
Rispondi SOLO con il JSON.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$response = $this->callAPI($prompt);
|
||||||
|
return $this->parseJsonResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifica un incidente e suggerisce severity
|
||||||
|
*/
|
||||||
|
public function classifyIncident(string $title, string $description, array $organization): array
|
||||||
|
{
|
||||||
|
$prompt = <<<PROMPT
|
||||||
|
Sei un analista di incident response. Classifica il seguente incidente di sicurezza secondo i criteri NIS2.
|
||||||
|
|
||||||
|
## Incidente
|
||||||
|
- Titolo: {$title}
|
||||||
|
- Descrizione: {$description}
|
||||||
|
|
||||||
|
## Organizzazione
|
||||||
|
- Settore: {$organization['sector']}
|
||||||
|
- Tipo entità: {$organization['entity_type']}
|
||||||
|
|
||||||
|
Rispondi in formato JSON:
|
||||||
|
{
|
||||||
|
"classification": "cyber_attack|data_breach|system_failure|human_error|natural_disaster|supply_chain|other",
|
||||||
|
"severity": "low|medium|high|critical",
|
||||||
|
"is_significant": true/false,
|
||||||
|
"significance_reason": "Motivo se significativo secondo NIS2",
|
||||||
|
"requires_csirt_notification": true/false,
|
||||||
|
"suggested_actions": ["Azione immediata 1", "Azione immediata 2"],
|
||||||
|
"potential_impact": "Descrizione impatto potenziale",
|
||||||
|
"iocs_to_check": ["Indicatore 1", "Indicatore 2"]
|
||||||
|
}
|
||||||
|
|
||||||
|
Rispondi SOLO con il JSON.
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$response = $this->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
docker/Dockerfile
Normal file
18
docker/Dockerfile
Normal file
@ -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
|
||||||
77
docker/docker-compose.yml
Normal file
77
docker/docker-compose.yml
Normal file
@ -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
|
||||||
62
docker/nginx.conf
Normal file
62
docker/nginx.conf
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
490
docs/sql/001_initial_schema.sql
Normal file
490
docs/sql/001_initial_schema.sql
Normal file
@ -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;
|
||||||
12
public/.htaccess
Normal file
12
public/.htaccess
Normal file
@ -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]
|
||||||
29
public/api-status.php
Normal file
29
public/api-status.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Health Check
|
||||||
|
*/
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$status = [
|
||||||
|
'status' => '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);
|
||||||
585
public/assessment.html
Normal file
585
public/assessment.html
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Gap Analysis - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Gap Analysis</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<button class="btn btn-outline btn-sm" id="btn-ai-analyze" style="display:none;" onclick="aiAnalyze()">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
||||||
|
Analisi AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<!-- Assessment Selection / Create -->
|
||||||
|
<div id="assessment-start" class="card mb-24">
|
||||||
|
<div class="card-body text-center" style="padding:48px;">
|
||||||
|
<h3 style="font-size:1.25rem; color:var(--gray-900); margin-bottom:8px;">Assessment di Conformita' NIS2</h3>
|
||||||
|
<p class="text-muted mb-24">Valuta il livello di conformita' della tua organizzazione rispetto ai requisiti della direttiva NIS2.</p>
|
||||||
|
|
||||||
|
<div id="existing-assessments" class="mb-24" style="display:none;"></div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary btn-lg" id="btn-new-assessment" onclick="createNewAssessment()">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||||
|
Nuovo Assessment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wizard Container -->
|
||||||
|
<div id="assessment-wizard" style="display:none;">
|
||||||
|
<!-- Steps Indicator -->
|
||||||
|
<div class="steps" id="steps-indicator"></div>
|
||||||
|
|
||||||
|
<!-- Question Card -->
|
||||||
|
<div class="card mb-24">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 id="category-title">Caricamento...</h3>
|
||||||
|
<span class="badge badge-neutral" id="question-counter">0/0</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="question-area">
|
||||||
|
<div class="spinner-lg" style="margin:40px auto;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<button class="btn btn-secondary" id="btn-prev" onclick="prevQuestion()" disabled>
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||||
|
Precedente
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="saveCurrentResponse()">
|
||||||
|
Salva Progresso
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" id="btn-next" onclick="nextQuestion()">
|
||||||
|
Successiva
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Container -->
|
||||||
|
<div id="assessment-results" style="display:none;">
|
||||||
|
<div class="card mb-24">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Risultati Assessment</h3>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="aiAnalyze()">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
||||||
|
Analisi AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Overall Score -->
|
||||||
|
<div class="text-center mb-32" id="results-gauge"></div>
|
||||||
|
|
||||||
|
<!-- Category Scores -->
|
||||||
|
<h4 style="margin-bottom:16px; color:var(--gray-900);">Punteggio per Categoria</h4>
|
||||||
|
<div id="category-scores"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Analysis result -->
|
||||||
|
<div class="card mb-24" id="ai-analysis-card" style="display:none;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Analisi AI</h3>
|
||||||
|
<span class="badge badge-info">Intelligenza Artificiale</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="ai-analysis-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth check ───────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────
|
||||||
|
let currentAssessmentId = null;
|
||||||
|
let questions = [];
|
||||||
|
let categories = [];
|
||||||
|
let currentCategoryIdx = 0;
|
||||||
|
let currentQuestionIdx = 0;
|
||||||
|
let responses = {}; // questionId -> { answer, maturity, notes, evidence }
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────
|
||||||
|
loadAssessments();
|
||||||
|
|
||||||
|
async function loadAssessments() {
|
||||||
|
try {
|
||||||
|
const result = await api.listAssessments();
|
||||||
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
|
const container = document.getElementById('existing-assessments');
|
||||||
|
container.style.display = 'block';
|
||||||
|
|
||||||
|
let html = '<h4 style="margin-bottom:12px; color:var(--gray-700);">Assessment Esistenti</h4>';
|
||||||
|
html += '<div class="table-container"><table><thead><tr><th>Data</th><th>Stato</th><th>Punteggio</th><th>Azioni</th></tr></thead><tbody>';
|
||||||
|
|
||||||
|
result.data.forEach(a => {
|
||||||
|
const statusBadge = a.status === 'completed'
|
||||||
|
? '<span class="badge badge-success">Completato</span>'
|
||||||
|
: '<span class="badge badge-warning">In Corso</span>';
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td>${formatDate(a.created_at)}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>${a.overall_score != null ? Math.round(a.overall_score) + '%' : '-'}</td>
|
||||||
|
<td>
|
||||||
|
${a.status === 'completed'
|
||||||
|
? `<button class="btn btn-sm btn-ghost" onclick="viewResults('${a.id}')">Risultati</button>`
|
||||||
|
: `<button class="btn btn-sm btn-primary" onclick="resumeAssessment('${a.id}')">Riprendi</button>`
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silenzioso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewAssessment() {
|
||||||
|
const btn = document.getElementById('btn-new-assessment');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Creazione in corso...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.createAssessment({ title: 'Assessment NIS2 - ' + formatDate(new Date()) });
|
||||||
|
if (result.success && result.data) {
|
||||||
|
currentAssessmentId = result.data.id;
|
||||||
|
await loadQuestions();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nella creazione.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = `<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg> Nuovo Assessment`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeAssessment(id) {
|
||||||
|
currentAssessmentId = id;
|
||||||
|
await loadQuestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewResults(id) {
|
||||||
|
currentAssessmentId = id;
|
||||||
|
await showResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQuestions() {
|
||||||
|
try {
|
||||||
|
const result = await api.getAssessmentQuestions(currentAssessmentId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
questions = result.data.questions || result.data;
|
||||||
|
organizeByCategory();
|
||||||
|
showWizard();
|
||||||
|
renderCurrentQuestion();
|
||||||
|
} else {
|
||||||
|
showNotification('Errore nel caricamento delle domande.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function organizeByCategory() {
|
||||||
|
const catMap = {};
|
||||||
|
questions.forEach(q => {
|
||||||
|
const cat = q.category || 'Generale';
|
||||||
|
if (!catMap[cat]) catMap[cat] = [];
|
||||||
|
catMap[cat].push(q);
|
||||||
|
});
|
||||||
|
categories = Object.keys(catMap).map(name => ({
|
||||||
|
name,
|
||||||
|
questions: catMap[name]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Ripristina risposte precedenti
|
||||||
|
questions.forEach(q => {
|
||||||
|
if (q.response) {
|
||||||
|
responses[q.id] = {
|
||||||
|
answer: q.response.answer || q.response.compliance_level,
|
||||||
|
maturity: q.response.maturity_level,
|
||||||
|
notes: q.response.notes || '',
|
||||||
|
evidence: q.response.evidence_description || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showWizard() {
|
||||||
|
document.getElementById('assessment-start').style.display = 'none';
|
||||||
|
document.getElementById('assessment-wizard').style.display = 'block';
|
||||||
|
document.getElementById('assessment-results').style.display = 'none';
|
||||||
|
renderSteps();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSteps() {
|
||||||
|
const container = document.getElementById('steps-indicator');
|
||||||
|
let html = '';
|
||||||
|
categories.forEach((cat, i) => {
|
||||||
|
const cls = i === currentCategoryIdx ? 'active' : i < currentCategoryIdx ? 'completed' : '';
|
||||||
|
html += `<div class="step ${cls}" onclick="goToCategory(${i})">
|
||||||
|
<span class="step-number">${i + 1}</span>
|
||||||
|
<span class="step-label">${escapeHtml(cat.name)}</span>
|
||||||
|
</div>`;
|
||||||
|
if (i < categories.length - 1) {
|
||||||
|
html += `<div class="step-connector"></div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCategory(idx) {
|
||||||
|
if (idx <= currentCategoryIdx || idx === currentCategoryIdx + 1) {
|
||||||
|
saveCurrentResponseSilent();
|
||||||
|
currentCategoryIdx = idx;
|
||||||
|
currentQuestionIdx = 0;
|
||||||
|
renderSteps();
|
||||||
|
renderCurrentQuestion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentQuestion() {
|
||||||
|
if (categories.length === 0) return;
|
||||||
|
|
||||||
|
const cat = categories[currentCategoryIdx];
|
||||||
|
const q = cat.questions[currentQuestionIdx];
|
||||||
|
const globalIdx = getGlobalQuestionIndex();
|
||||||
|
const totalQuestions = questions.length;
|
||||||
|
|
||||||
|
document.getElementById('category-title').textContent = cat.name;
|
||||||
|
document.getElementById('question-counter').textContent = `${globalIdx + 1}/${totalQuestions}`;
|
||||||
|
|
||||||
|
const r = responses[q.id] || {};
|
||||||
|
|
||||||
|
const answers = [
|
||||||
|
{ value: 'non_implementato', label: 'Non Implementato', cls: 'danger' },
|
||||||
|
{ value: 'parziale', label: 'Parziale', cls: 'warning' },
|
||||||
|
{ value: 'implementato', label: 'Implementato', cls: 'success' },
|
||||||
|
{ value: 'non_applicabile', label: 'Non Applicabile', cls: 'neutral' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<p style="font-size:1rem; font-weight:600; color:var(--gray-900); margin-bottom:4px;">
|
||||||
|
${escapeHtml(q.text || q.question || q.title || '')}
|
||||||
|
</p>
|
||||||
|
${q.description ? `<p class="text-muted" style="font-size:0.8125rem;">${escapeHtml(q.description)}</p>` : ''}
|
||||||
|
${q.reference ? `<span class="tag mt-8">Rif: ${escapeHtml(q.reference)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Livello di Conformita'</label>
|
||||||
|
<div class="form-radio-group">
|
||||||
|
${answers.map(a => `
|
||||||
|
<label class="form-radio ${r.answer === a.value ? 'selected' : ''}">
|
||||||
|
<input type="radio" name="answer" value="${a.value}" ${r.answer === a.value ? 'checked' : ''}
|
||||||
|
onchange="onAnswerChange(this)">
|
||||||
|
${a.label}
|
||||||
|
</label>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Livello di Maturita' (0-5)</label>
|
||||||
|
<input type="range" class="form-range" min="0" max="5" step="1"
|
||||||
|
value="${r.maturity != null ? r.maturity : 0}" id="maturity-slider"
|
||||||
|
oninput="document.getElementById('maturity-value').textContent = this.value">
|
||||||
|
<div class="form-range-labels">
|
||||||
|
<span>0 - Inesistente</span>
|
||||||
|
<span id="maturity-value">${r.maturity != null ? r.maturity : 0}</span>
|
||||||
|
<span>5 - Ottimizzato</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Note</label>
|
||||||
|
<textarea class="form-textarea" id="question-notes" rows="3"
|
||||||
|
placeholder="Aggiungi eventuali note o osservazioni...">${escapeHtml(r.notes || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Descrizione Evidenza</label>
|
||||||
|
<input type="text" class="form-input" id="question-evidence"
|
||||||
|
placeholder="Documenti, procedure, strumenti a supporto..."
|
||||||
|
value="${escapeHtml(r.evidence || '')}">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('question-area').innerHTML = html;
|
||||||
|
|
||||||
|
// Navigation buttons
|
||||||
|
document.getElementById('btn-prev').disabled = (currentCategoryIdx === 0 && currentQuestionIdx === 0);
|
||||||
|
|
||||||
|
const isLast = currentCategoryIdx === categories.length - 1 && currentQuestionIdx === cat.questions.length - 1;
|
||||||
|
const nextBtn = document.getElementById('btn-next');
|
||||||
|
if (isLast) {
|
||||||
|
nextBtn.innerHTML = 'Completa Assessment';
|
||||||
|
nextBtn.className = 'btn btn-success';
|
||||||
|
} else {
|
||||||
|
nextBtn.innerHTML = 'Successiva <svg viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>';
|
||||||
|
nextBtn.className = 'btn btn-primary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAnswerChange(radio) {
|
||||||
|
// Highlight selected radio
|
||||||
|
document.querySelectorAll('.form-radio').forEach(el => el.classList.remove('selected'));
|
||||||
|
radio.closest('.form-radio').classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGlobalQuestionIndex() {
|
||||||
|
let idx = 0;
|
||||||
|
for (let i = 0; i < currentCategoryIdx; i++) {
|
||||||
|
idx += categories[i].questions.length;
|
||||||
|
}
|
||||||
|
return idx + currentQuestionIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCurrentResponseSilent() {
|
||||||
|
if (categories.length === 0) return;
|
||||||
|
|
||||||
|
const cat = categories[currentCategoryIdx];
|
||||||
|
const q = cat.questions[currentQuestionIdx];
|
||||||
|
const selectedRadio = document.querySelector('input[name="answer"]:checked');
|
||||||
|
const maturity = document.getElementById('maturity-slider');
|
||||||
|
const notes = document.getElementById('question-notes');
|
||||||
|
const evidence = document.getElementById('question-evidence');
|
||||||
|
|
||||||
|
responses[q.id] = {
|
||||||
|
answer: selectedRadio ? selectedRadio.value : null,
|
||||||
|
maturity: maturity ? parseInt(maturity.value) : 0,
|
||||||
|
notes: notes ? notes.value : '',
|
||||||
|
evidence: evidence ? evidence.value : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCurrentResponse() {
|
||||||
|
saveCurrentResponseSilent();
|
||||||
|
const cat = categories[currentCategoryIdx];
|
||||||
|
const q = cat.questions[currentQuestionIdx];
|
||||||
|
const r = responses[q.id];
|
||||||
|
|
||||||
|
if (!r || !r.answer) {
|
||||||
|
showNotification('Seleziona un livello di conformita\' prima di salvare.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.saveAssessmentResponse(currentAssessmentId, {
|
||||||
|
question_id: q.id,
|
||||||
|
compliance_level: r.answer,
|
||||||
|
maturity_level: r.maturity,
|
||||||
|
notes: r.notes,
|
||||||
|
evidence_description: r.evidence
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Risposta salvata.', 'success', 2000);
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nel salvataggio.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextQuestion() {
|
||||||
|
saveCurrentResponseSilent();
|
||||||
|
|
||||||
|
// Salva la risposta corrente
|
||||||
|
const cat = categories[currentCategoryIdx];
|
||||||
|
const q = cat.questions[currentQuestionIdx];
|
||||||
|
const r = responses[q.id];
|
||||||
|
|
||||||
|
if (r && r.answer) {
|
||||||
|
// Salva in background
|
||||||
|
api.saveAssessmentResponse(currentAssessmentId, {
|
||||||
|
question_id: q.id,
|
||||||
|
compliance_level: r.answer,
|
||||||
|
maturity_level: r.maturity,
|
||||||
|
notes: r.notes,
|
||||||
|
evidence_description: r.evidence
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naviga avanti
|
||||||
|
const isLastInCategory = currentQuestionIdx >= cat.questions.length - 1;
|
||||||
|
const isLastCategory = currentCategoryIdx >= categories.length - 1;
|
||||||
|
|
||||||
|
if (isLastInCategory && isLastCategory) {
|
||||||
|
// Completa l'assessment
|
||||||
|
await completeAssessment();
|
||||||
|
} else if (isLastInCategory) {
|
||||||
|
currentCategoryIdx++;
|
||||||
|
currentQuestionIdx = 0;
|
||||||
|
renderSteps();
|
||||||
|
renderCurrentQuestion();
|
||||||
|
} else {
|
||||||
|
currentQuestionIdx++;
|
||||||
|
renderCurrentQuestion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevQuestion() {
|
||||||
|
saveCurrentResponseSilent();
|
||||||
|
|
||||||
|
if (currentQuestionIdx > 0) {
|
||||||
|
currentQuestionIdx--;
|
||||||
|
} else if (currentCategoryIdx > 0) {
|
||||||
|
currentCategoryIdx--;
|
||||||
|
currentQuestionIdx = categories[currentCategoryIdx].questions.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSteps();
|
||||||
|
renderCurrentQuestion();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeAssessment() {
|
||||||
|
try {
|
||||||
|
const result = await api.completeAssessment(currentAssessmentId);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Assessment completato!', 'success');
|
||||||
|
await showResults();
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nel completamento.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showResults() {
|
||||||
|
document.getElementById('assessment-start').style.display = 'none';
|
||||||
|
document.getElementById('assessment-wizard').style.display = 'none';
|
||||||
|
document.getElementById('assessment-results').style.display = 'block';
|
||||||
|
document.getElementById('btn-ai-analyze').style.display = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.getAssessmentReport(currentAssessmentId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const report = result.data;
|
||||||
|
|
||||||
|
// Overall score gauge
|
||||||
|
const score = report.overall_score || 0;
|
||||||
|
document.getElementById('results-gauge').innerHTML = renderScoreGauge(score, 200);
|
||||||
|
|
||||||
|
// Category scores
|
||||||
|
const catScores = report.category_scores || report.categories || {};
|
||||||
|
let scoresHtml = '';
|
||||||
|
|
||||||
|
const entries = Array.isArray(catScores) ? catScores : Object.entries(catScores).map(([k, v]) => ({
|
||||||
|
name: k,
|
||||||
|
score: typeof v === 'object' ? v.score : v
|
||||||
|
}));
|
||||||
|
|
||||||
|
entries.forEach(cat => {
|
||||||
|
const catName = cat.name || cat.category;
|
||||||
|
const catScore = Math.round(cat.score || 0);
|
||||||
|
const color = getScoreColor(catScore);
|
||||||
|
const barClass = catScore >= 60 ? 'success' : catScore >= 40 ? 'warning' : 'danger';
|
||||||
|
|
||||||
|
scoresHtml += `
|
||||||
|
<div class="category-score">
|
||||||
|
<div class="category-score-header">
|
||||||
|
<span class="category-score-name">${escapeHtml(catName)}</span>
|
||||||
|
<span class="category-score-value" style="color:${color}">${catScore}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar ${barClass}" style="width:${catScore}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('category-scores').innerHTML = scoresHtml || '<p class="text-muted">Nessun dato disponibile per le categorie.</p>';
|
||||||
|
} else {
|
||||||
|
document.getElementById('results-gauge').innerHTML = renderScoreGauge(0, 200);
|
||||||
|
document.getElementById('category-scores').innerHTML = '<p class="text-muted">Report non ancora disponibile.</p>';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore nel caricamento dei risultati.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function aiAnalyze() {
|
||||||
|
if (!currentAssessmentId) {
|
||||||
|
showNotification('Nessun assessment selezionato.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification('Analisi AI in corso... Potrebbe richiedere qualche secondo.', 'info', 6000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.aiAnalyzeAssessment(currentAssessmentId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const card = document.getElementById('ai-analysis-card');
|
||||||
|
const content = document.getElementById('ai-analysis-content');
|
||||||
|
card.style.display = 'block';
|
||||||
|
|
||||||
|
const analysis = result.data;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (analysis.summary) {
|
||||||
|
html += `<div style="margin-bottom:20px;"><h4 style="margin-bottom:8px;">Riepilogo</h4><p style="color:var(--gray-600); line-height:1.7;">${escapeHtml(analysis.summary)}</p></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analysis.strengths && analysis.strengths.length > 0) {
|
||||||
|
html += `<div style="margin-bottom:20px;"><h4 style="margin-bottom:8px; color:var(--secondary);">Punti di Forza</h4><ul style="padding-left:20px; color:var(--gray-600);">`;
|
||||||
|
analysis.strengths.forEach(s => { html += `<li style="margin-bottom:4px;">${escapeHtml(s)}</li>`; });
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analysis.weaknesses && analysis.weaknesses.length > 0) {
|
||||||
|
html += `<div style="margin-bottom:20px;"><h4 style="margin-bottom:8px; color:var(--danger);">Aree di Miglioramento</h4><ul style="padding-left:20px; color:var(--gray-600);">`;
|
||||||
|
analysis.weaknesses.forEach(w => { html += `<li style="margin-bottom:4px;">${escapeHtml(w)}</li>`; });
|
||||||
|
html += '</ul></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analysis.recommendations && analysis.recommendations.length > 0) {
|
||||||
|
html += `<div><h4 style="margin-bottom:8px; color:var(--primary);">Raccomandazioni</h4><ol style="padding-left:20px; color:var(--gray-600);">`;
|
||||||
|
analysis.recommendations.forEach(r => { html += `<li style="margin-bottom:8px;">${escapeHtml(r)}</li>`; });
|
||||||
|
html += '</ol></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
html = `<p style="color:var(--gray-600); line-height:1.7;">${escapeHtml(analysis.text || analysis.message || JSON.stringify(analysis))}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = html;
|
||||||
|
showNotification('Analisi AI completata.', 'success');
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nell\'analisi AI.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1641
public/css/style.css
Normal file
1641
public/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
324
public/dashboard.html
Normal file
324
public/dashboard.html
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dashboard - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<div class="content-header-actions">
|
||||||
|
<span class="text-muted" id="header-date"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<!-- Compliance Score + Stats -->
|
||||||
|
<div class="grid-3 mb-24" style="grid-template-columns: 1fr 2fr;">
|
||||||
|
<!-- Gauge -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div id="compliance-gauge">
|
||||||
|
<div class="spinner-lg" style="margin:40px auto;"></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-8" style="font-size:0.8rem;">Punteggio complessivo di conformita' NIS2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div>
|
||||||
|
<div class="stats-grid" id="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-icon danger">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<h4>Rischi Aperti</h4>
|
||||||
|
<div class="stat-card-value" id="stat-risks">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-icon warning">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<h4>Incidenti Attivi</h4>
|
||||||
|
<div class="stat-card-value" id="stat-incidents">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-icon success">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<h4>Policy Approvate</h4>
|
||||||
|
<div class="stat-card-value" id="stat-policies">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-card-icon primary">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card-content">
|
||||||
|
<h4>Formazione</h4>
|
||||||
|
<div class="stat-card-value" id="stat-training">--%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card mb-24">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Azioni Rapide</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button class="quick-action-btn" onclick="window.location.href='assessment.html'">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>
|
||||||
|
Nuovo Assessment
|
||||||
|
</button>
|
||||||
|
<button class="quick-action-btn" onclick="window.location.href='incidents.html'">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6z"/></svg>
|
||||||
|
Registra Incidente
|
||||||
|
</button>
|
||||||
|
<button class="quick-action-btn" onclick="generateAIPolicy()">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor" width="20" height="20"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>
|
||||||
|
Genera Policy AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deadlines + Activity -->
|
||||||
|
<div class="grid-2">
|
||||||
|
<!-- Scadenze -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Prossime Scadenze</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="deadlines-list">
|
||||||
|
<div class="spinner" style="margin:20px auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attivita' Recenti -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Attivita' Recenti</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="activity-list">
|
||||||
|
<div class="spinner" style="margin:20px auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth check ───────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
// Data corrente nell'header
|
||||||
|
document.getElementById('header-date').textContent = new Date().toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Caricamento dati ─────────────────────────────────────
|
||||||
|
loadDashboard();
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const result = await api.getDashboardOverview();
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const data = result.data;
|
||||||
|
|
||||||
|
// Compliance gauge
|
||||||
|
const score = data.compliance_score != null ? data.compliance_score : 0;
|
||||||
|
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(score, 180);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
document.getElementById('stat-risks').textContent = data.open_risks != null ? data.open_risks : 0;
|
||||||
|
document.getElementById('stat-incidents').textContent = data.active_incidents != null ? data.active_incidents : 0;
|
||||||
|
document.getElementById('stat-policies').textContent = data.approved_policies != null ? data.approved_policies : 0;
|
||||||
|
document.getElementById('stat-training').textContent = (data.training_completion != null ? data.training_completion : 0) + '%';
|
||||||
|
|
||||||
|
// Scadenze
|
||||||
|
renderDeadlines(data.upcoming_deadlines || []);
|
||||||
|
|
||||||
|
// Attivita'
|
||||||
|
renderActivity(data.recent_activity || []);
|
||||||
|
} else {
|
||||||
|
// Fallback: prova endpoint singoli
|
||||||
|
loadIndividualData();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Dashboard load error:', err);
|
||||||
|
loadIndividualData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIndividualData() {
|
||||||
|
// Compliance score
|
||||||
|
try {
|
||||||
|
const scoreRes = await api.getComplianceScore();
|
||||||
|
if (scoreRes.success && scoreRes.data) {
|
||||||
|
const score = scoreRes.data.score || scoreRes.data.compliance_score || 0;
|
||||||
|
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(score, 180);
|
||||||
|
} else {
|
||||||
|
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(0, 180);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('compliance-gauge').innerHTML = renderScoreGauge(0, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadlines
|
||||||
|
try {
|
||||||
|
const dlRes = await api.getUpcomingDeadlines();
|
||||||
|
renderDeadlines(dlRes.success ? (dlRes.data || []) : []);
|
||||||
|
} catch (e) {
|
||||||
|
renderDeadlines([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
try {
|
||||||
|
const actRes = await api.getRecentActivity();
|
||||||
|
renderActivity(actRes.success ? (actRes.data || []) : []);
|
||||||
|
} catch (e) {
|
||||||
|
renderActivity([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeadlines(deadlines) {
|
||||||
|
const container = document.getElementById('deadlines-list');
|
||||||
|
|
||||||
|
if (!deadlines || deadlines.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>
|
||||||
|
<h4>Nessuna scadenza imminente</h4>
|
||||||
|
<p>Le prossime scadenze appariranno qui.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = ['GEN','FEB','MAR','APR','MAG','GIU','LUG','AGO','SET','OTT','NOV','DIC'];
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
deadlines.slice(0, 5).forEach(dl => {
|
||||||
|
const d = new Date(dl.due_date || dl.date);
|
||||||
|
const now = new Date();
|
||||||
|
const diffDays = Math.ceil((d - now) / 86400000);
|
||||||
|
let urgency = 'normal';
|
||||||
|
if (diffDays <= 3) urgency = 'urgent';
|
||||||
|
else if (diffDays <= 7) urgency = 'soon';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="deadline-item">
|
||||||
|
<div class="deadline-date ${urgency}">
|
||||||
|
<span class="day">${d.getDate()}</span>
|
||||||
|
<span class="month">${months[d.getMonth()]}</span>
|
||||||
|
</div>
|
||||||
|
<div class="deadline-info">
|
||||||
|
<div class="deadline-title">${escapeHtml(dl.title || dl.description || 'Scadenza')}</div>
|
||||||
|
<div class="deadline-desc">${escapeHtml(dl.category || dl.type || '')}</div>
|
||||||
|
</div>
|
||||||
|
${diffDays <= 3 ? '<span class="badge badge-danger">Urgente</span>' : diffDays <= 7 ? '<span class="badge badge-warning">Prossima</span>' : ''}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivity(activities) {
|
||||||
|
const container = document.getElementById('activity-list');
|
||||||
|
|
||||||
|
if (!activities || activities.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
||||||
|
<h4>Nessuna attivita' recente</h4>
|
||||||
|
<p>Le attivita' appariranno qui quando inizierai ad usare la piattaforma.</p>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<ul class="activity-list">';
|
||||||
|
|
||||||
|
activities.slice(0, 8).forEach(act => {
|
||||||
|
html += `
|
||||||
|
<li class="activity-item">
|
||||||
|
<div class="activity-icon">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/></svg>
|
||||||
|
</div>
|
||||||
|
<div class="activity-content">
|
||||||
|
<div class="activity-text">${escapeHtml(act.description || act.message || act.action || '')}</div>
|
||||||
|
<div class="activity-time">${timeAgo(act.created_at || act.timestamp || act.date)}</div>
|
||||||
|
</div>
|
||||||
|
</li>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</ul>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateAIPolicy() {
|
||||||
|
showModal('Genera Policy con AI', `
|
||||||
|
<p style="margin-bottom:16px;">Seleziona la categoria per cui generare una policy automatica tramite intelligenza artificiale.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Categoria Policy</label>
|
||||||
|
<select class="form-select" id="ai-policy-category">
|
||||||
|
<option value="risk_management">Gestione del Rischio</option>
|
||||||
|
<option value="incident_response">Risposta agli Incidenti</option>
|
||||||
|
<option value="business_continuity">Continuita' Operativa</option>
|
||||||
|
<option value="supply_chain">Sicurezza Supply Chain</option>
|
||||||
|
<option value="access_control">Controllo degli Accessi</option>
|
||||||
|
<option value="encryption">Crittografia</option>
|
||||||
|
<option value="hr_security">Sicurezza del Personale</option>
|
||||||
|
<option value="asset_management">Gestione degli Asset</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`, {
|
||||||
|
footer: `
|
||||||
|
<button class="btn btn-secondary" onclick="closeModal()">Annulla</button>
|
||||||
|
<button class="btn btn-primary" onclick="doGeneratePolicy()">Genera</button>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doGeneratePolicy() {
|
||||||
|
const category = document.getElementById('ai-policy-category').value;
|
||||||
|
closeModal();
|
||||||
|
showNotification('Generazione policy in corso...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.aiGeneratePolicy(category);
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Policy generata con successo!', 'success');
|
||||||
|
setTimeout(() => window.location.href = 'policies.html', 1500);
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nella generazione.', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showNotification('Errore di connessione.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
384
public/index.php
Normal file
384
public/index.php
Normal file
@ -0,0 +1,384 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* NIS2 Agile - Front Controller / Router
|
||||||
|
*
|
||||||
|
* Tutte le richieste API passano da qui.
|
||||||
|
* URL Pattern: /nis2/api/{controller}/{action}/{id?}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// BOOTSTRAP
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
require_once __DIR__ . '/../application/config/config.php';
|
||||||
|
require_once __DIR__ . '/../application/config/database.php';
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// CORS HEADERS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
|
||||||
|
if (in_array($origin, CORS_ALLOWED_ORIGINS)) {
|
||||||
|
header("Access-Control-Allow-Origin: {$origin}");
|
||||||
|
} elseif (APP_DEBUG) {
|
||||||
|
header("Access-Control-Allow-Origin: *");
|
||||||
|
}
|
||||||
|
|
||||||
|
header("Access-Control-Allow-Methods: " . CORS_ALLOWED_METHODS);
|
||||||
|
header("Access-Control-Allow-Headers: " . CORS_ALLOWED_HEADERS);
|
||||||
|
header("Access-Control-Max-Age: " . CORS_MAX_AGE);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// ROUTING
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
$basePath = '/nis2';
|
||||||
|
|
||||||
|
$path = parse_url($requestUri, PHP_URL_PATH);
|
||||||
|
$path = preg_replace("#^{$basePath}#", '', $path);
|
||||||
|
$path = trim($path, '/');
|
||||||
|
|
||||||
|
// Se non è una richiesta API, servi file statici o index.html
|
||||||
|
if (!preg_match('#^api/#', $path)) {
|
||||||
|
$staticFile = __DIR__ . '/' . $path;
|
||||||
|
if ($path && file_exists($staticFile) && is_file($staticFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($path) || $path === 'index.html') {
|
||||||
|
include __DIR__ . '/index.html';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prova a servire come HTML page
|
||||||
|
$htmlFile = __DIR__ . '/' . $path;
|
||||||
|
if (!str_ends_with($path, '.html')) {
|
||||||
|
$htmlFile = __DIR__ . '/' . $path . '.html';
|
||||||
|
}
|
||||||
|
if (file_exists($htmlFile) && is_file($htmlFile)) {
|
||||||
|
include $htmlFile;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
244
public/js/api.js
Normal file
244
public/js/api.js
Normal file
@ -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();
|
||||||
463
public/js/common.js
Normal file
463
public/js/common.js
Normal file
@ -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: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>',
|
||||||
|
error: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>',
|
||||||
|
warning: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>',
|
||||||
|
info: '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `notification ${type}`;
|
||||||
|
notification.innerHTML = `
|
||||||
|
<span class="notification-icon">${icons[type] || icons.info}</span>
|
||||||
|
<span class="notification-message">${escapeHtml(message)}</span>
|
||||||
|
<button class="notification-close" onclick="this.parentElement.remove()">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="modal" ${sizeClass}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${escapeHtml(title)}</h3>
|
||||||
|
<button class="modal-close" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">${content}</div>
|
||||||
|
${options.footer ? `<div class="modal-footer">${options.footer}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 += `
|
||||||
|
<div class="sidebar-brand">
|
||||||
|
<div class="sidebar-brand-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/><path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>NIS2 Agile</h1>
|
||||||
|
<span>Compliance Platform</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Nav sections
|
||||||
|
navHTML += '<nav class="sidebar-nav">';
|
||||||
|
for (const section of navItems) {
|
||||||
|
navHTML += `<div class="sidebar-nav-label">${section.label}</div>`;
|
||||||
|
for (const item of section.items) {
|
||||||
|
const isActive = currentPage === item.href ? 'active' : '';
|
||||||
|
navHTML += `<a href="${item.href}" class="${isActive}">${item.icon}<span>${item.name}</span></a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
navHTML += '</nav>';
|
||||||
|
|
||||||
|
// Footer with user info
|
||||||
|
navHTML += `
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="sidebar-user-avatar" id="sidebar-user-avatar">--</div>
|
||||||
|
<div class="sidebar-user-info">
|
||||||
|
<div class="sidebar-user-name" id="sidebar-user-name">Caricamento...</div>
|
||||||
|
<div class="sidebar-user-role" id="sidebar-user-role">Utente</div>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-logout-btn" onclick="api.logout()" title="Esci">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 001 1h6a1 1 0 100-2H4V5h5a1 1 0 100-2H3zm11.707 3.293a1 1 0 010 1.414L12.414 10l2.293 2.293a1 1 0 01-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 0z" clip-rule="evenodd"/><path fill-rule="evenodd" d="M16 10a1 1 0 00-1-1H8a1 1 0 100 2h7a1 1 0 001-1z" clip-rule="evenodd"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = '<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"/></svg>';
|
||||||
|
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 `
|
||||||
|
<div class="score-gauge ${cls}" style="width:${size}px;height:${size}px">
|
||||||
|
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
<circle class="score-gauge-circle-bg" cx="${size/2}" cy="${size/2}" r="${radius}"/>
|
||||||
|
<circle class="score-gauge-circle" cx="${size/2}" cy="${size/2}" r="${radius}"
|
||||||
|
stroke="${color}"
|
||||||
|
stroke-dasharray="${circumference}"
|
||||||
|
stroke-dashoffset="${offset}"
|
||||||
|
style="--score-color: ${color}"/>
|
||||||
|
</svg>
|
||||||
|
<div class="score-gauge-value">
|
||||||
|
<div class="score-gauge-number">${Math.round(score)}</div>
|
||||||
|
<div class="score-gauge-label">Compliance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// SVG Icons (inline, nessuna dipendenza esterna)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function iconGrid() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zm0 8a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zm6-6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zm0 8a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconClipboardCheck() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/><path fill-rule="evenodd" d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm9.707 5.707a1 1 0 00-1.414-1.414L9 12.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconShieldExclamation() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clip-rule="evenodd"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconBell() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconDocumentText() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clip-rule="evenodd"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconLink() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clip-rule="evenodd"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconAcademicCap() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838L7.667 9.088l1.94.831a1 1 0 00.787 0l7-3a1 1 0 000-1.838l-7-3zM3.31 9.397L5 10.12v4.102a8.969 8.969 0 00-1.05-.174 1 1 0 01-.89-.89 11.115 11.115 0 01.25-3.762zM9.3 16.573A9.026 9.026 0 007 14.935v-3.957l1.818.78a3 3 0 002.364 0l5.508-2.361a11.026 11.026 0 01.25 3.762 1 1 0 01-.89.89 8.968 8.968 0 00-5.35 2.524 1 1 0 01-1.4 0z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconServer() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clip-rule="evenodd"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconChartBar() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zm6-4a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zm6-3a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconCog() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconPlus() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" clip-rule="evenodd"/></svg>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconSparkles() {
|
||||||
|
return '<svg viewBox="0 0 20 20" fill="currentColor"><path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 8.512a1 1 0 010 1.836l-3.354 1.311-1.18 4.456a1 1 0 01-1.932 0L9.854 11.66 6.5 10.348a1 1 0 010-1.836l3.354-1.311 1.18-4.456A1 1 0 0112 2z"/></svg>';
|
||||||
|
}
|
||||||
105
public/login.html
Normal file
105
public/login.html
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Accedi - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="auth-logo">
|
||||||
|
<div class="auth-logo-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/>
|
||||||
|
<path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="auth-logo-text">NIS2 <span>Agile</span></span>
|
||||||
|
</div>
|
||||||
|
<p class="auth-subtitle">Piattaforma di compliance NIS2</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-body">
|
||||||
|
<div class="auth-error" id="login-error"></div>
|
||||||
|
|
||||||
|
<form id="login-form" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="email">Indirizzo Email</label>
|
||||||
|
<input type="email" id="email" name="email" class="form-input"
|
||||||
|
placeholder="nome@azienda.it" autocomplete="email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" class="form-input"
|
||||||
|
placeholder="La tua password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-full" id="login-btn">
|
||||||
|
Accedi
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p>Non hai un account? <a href="register.html">Registrati</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// Se gia' autenticato, vai alla dashboard
|
||||||
|
if (api.isAuthenticated()) {
|
||||||
|
window.location.href = 'dashboard.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
const errorEl = document.getElementById('login-error');
|
||||||
|
const loginBtn = document.getElementById('login-btn');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorEl.classList.remove('visible');
|
||||||
|
|
||||||
|
const email = document.getElementById('email').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
errorEl.textContent = 'Inserisci email e password.';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
loginBtn.textContent = 'Accesso in corso...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.login(email, password);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Controlla se l'utente ha un'organizzazione
|
||||||
|
if (result.data.organizations && result.data.organizations.length > 0) {
|
||||||
|
window.location.href = 'dashboard.html';
|
||||||
|
} else {
|
||||||
|
window.location.href = 'setup-org.html';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = result.message || 'Credenziali non valide.';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Errore di connessione al server.';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
} finally {
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
loginBtn.textContent = 'Accedi';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
179
public/register.html
Normal file
179
public/register.html
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Registrazione - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-page">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<div class="auth-logo">
|
||||||
|
<div class="auth-logo-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 2.18l7 3.12v4.7c0 4.83-3.23 9.36-7 10.57-3.77-1.21-7-5.74-7-10.57V6.3l7-3.12z"/>
|
||||||
|
<path d="M10 12.5l-2-2-1.41 1.41L10 15.32l5.41-5.41L14 8.5l-4 4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="auth-logo-text">NIS2 <span>Agile</span></span>
|
||||||
|
</div>
|
||||||
|
<p class="auth-subtitle">Crea il tuo account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-body">
|
||||||
|
<div class="auth-error" id="register-error"></div>
|
||||||
|
|
||||||
|
<form id="register-form" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="fullname">Nome Completo <span class="required">*</span></label>
|
||||||
|
<input type="text" id="fullname" name="fullname" class="form-input"
|
||||||
|
placeholder="Mario Rossi" autocomplete="name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="email">Indirizzo Email <span class="required">*</span></label>
|
||||||
|
<input type="email" id="email" name="email" class="form-input"
|
||||||
|
placeholder="nome@azienda.it" autocomplete="email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="password">Password <span class="required">*</span></label>
|
||||||
|
<input type="password" id="password" name="password" class="form-input"
|
||||||
|
placeholder="Minimo 8 caratteri" autocomplete="new-password" required>
|
||||||
|
<div class="password-strength" id="password-strength">
|
||||||
|
<div class="password-strength-bar">
|
||||||
|
<div class="password-strength-segment" id="ps-1"></div>
|
||||||
|
<div class="password-strength-segment" id="ps-2"></div>
|
||||||
|
<div class="password-strength-segment" id="ps-3"></div>
|
||||||
|
<div class="password-strength-segment" id="ps-4"></div>
|
||||||
|
</div>
|
||||||
|
<div class="password-strength-text" id="ps-text"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="password-confirm">Conferma Password <span class="required">*</span></label>
|
||||||
|
<input type="password" id="password-confirm" name="password-confirm" class="form-input"
|
||||||
|
placeholder="Ripeti la password" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-full" id="register-btn">
|
||||||
|
Crea Account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p>Hai gia' un account? <a href="login.html">Accedi</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// Se gia' autenticato, vai alla dashboard
|
||||||
|
if (api.isAuthenticated()) {
|
||||||
|
window.location.href = 'dashboard.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.getElementById('register-form');
|
||||||
|
const errorEl = document.getElementById('register-error');
|
||||||
|
const registerBtn = document.getElementById('register-btn');
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
|
||||||
|
// ── Password Strength Indicator ──────────────────────────
|
||||||
|
passwordInput.addEventListener('input', () => {
|
||||||
|
const val = passwordInput.value;
|
||||||
|
const strength = calcPasswordStrength(val);
|
||||||
|
updateStrengthUI(strength);
|
||||||
|
});
|
||||||
|
|
||||||
|
function calcPasswordStrength(password) {
|
||||||
|
let score = 0;
|
||||||
|
if (password.length >= 8) score++;
|
||||||
|
if (password.length >= 12) score++;
|
||||||
|
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;
|
||||||
|
if (/\d/.test(password)) score++;
|
||||||
|
if (/[^a-zA-Z0-9]/.test(password)) score++;
|
||||||
|
// Normalize to 0-4
|
||||||
|
if (score <= 1) return 1;
|
||||||
|
if (score === 2) return 2;
|
||||||
|
if (score === 3) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStrengthUI(level) {
|
||||||
|
const labels = { 1: 'Debole', 2: 'Sufficiente', 3: 'Buona', 4: 'Forte' };
|
||||||
|
const classes = { 1: 'weak', 2: 'fair', 3: 'good', 4: 'strong' };
|
||||||
|
const textEl = document.getElementById('ps-text');
|
||||||
|
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
const seg = document.getElementById('ps-' + i);
|
||||||
|
seg.className = 'password-strength-segment';
|
||||||
|
if (i <= level && passwordInput.value.length > 0) {
|
||||||
|
seg.classList.add('active', classes[level]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textEl.textContent = passwordInput.value.length > 0 ? labels[level] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form Submit ──────────────────────────────────────────
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorEl.classList.remove('visible');
|
||||||
|
|
||||||
|
const fullname = document.getElementById('fullname').value.trim();
|
||||||
|
const email = document.getElementById('email').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const passwordConfirm = document.getElementById('password-confirm').value;
|
||||||
|
|
||||||
|
// Validazione
|
||||||
|
if (!fullname || !email || !password || !passwordConfirm) {
|
||||||
|
errorEl.textContent = 'Tutti i campi sono obbligatori.';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
errorEl.textContent = 'La password deve avere almeno 8 caratteri.';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== passwordConfirm) {
|
||||||
|
errorEl.textContent = 'Le password non coincidono.';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBtn.disabled = true;
|
||||||
|
registerBtn.textContent = 'Registrazione in corso...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.register(email, password, fullname);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Account creato con successo!', 'success');
|
||||||
|
// Dopo la registrazione, porta al setup organizzazione
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = 'setup-org.html';
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = result.message || 'Errore durante la registrazione.';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Errore di connessione al server.';
|
||||||
|
errorEl.classList.add('visible');
|
||||||
|
} finally {
|
||||||
|
registerBtn.disabled = false;
|
||||||
|
registerBtn.textContent = 'Crea Account';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
377
public/setup-org.html
Normal file
377
public/setup-org.html
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Configura Organizzazione - NIS2 Agile</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar"></aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="content-header">
|
||||||
|
<h2>Configurazione Organizzazione</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-body">
|
||||||
|
<div class="grid-2" style="max-width:960px;">
|
||||||
|
<!-- Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Dati Aziendali</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="org-form" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="company-name">Ragione Sociale <span class="required">*</span></label>
|
||||||
|
<input type="text" id="company-name" class="form-input"
|
||||||
|
placeholder="Es. Acme S.r.l." required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="vat-number">Partita IVA</label>
|
||||||
|
<input type="text" id="vat-number" class="form-input"
|
||||||
|
placeholder="IT12345678901" maxlength="16">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="sector">Settore <span class="required">*</span></label>
|
||||||
|
<select id="sector" class="form-select" required>
|
||||||
|
<option value="">-- Seleziona settore --</option>
|
||||||
|
<optgroup label="Settori ad Alta Criticita' (Allegato I)">
|
||||||
|
<option value="energy_electricity">Energia - Elettricita'</option>
|
||||||
|
<option value="energy_district_heating">Energia - Teleriscaldamento</option>
|
||||||
|
<option value="energy_oil">Energia - Petrolio</option>
|
||||||
|
<option value="energy_gas">Energia - Gas</option>
|
||||||
|
<option value="energy_hydrogen">Energia - Idrogeno</option>
|
||||||
|
<option value="transport_air">Trasporti - Aereo</option>
|
||||||
|
<option value="transport_rail">Trasporti - Ferroviario</option>
|
||||||
|
<option value="transport_water">Trasporti - Marittimo/Fluviale</option>
|
||||||
|
<option value="transport_road">Trasporti - Stradale</option>
|
||||||
|
<option value="banking">Banche</option>
|
||||||
|
<option value="financial_markets">Infrastrutture Mercati Finanziari</option>
|
||||||
|
<option value="health">Sanita'</option>
|
||||||
|
<option value="drinking_water">Acqua Potabile</option>
|
||||||
|
<option value="waste_water">Acque Reflue</option>
|
||||||
|
<option value="digital_infrastructure">Infrastruttura Digitale</option>
|
||||||
|
<option value="ict_service_management">Gestione Servizi ICT (B2B)</option>
|
||||||
|
<option value="public_administration">Pubblica Amministrazione</option>
|
||||||
|
<option value="space">Spazio</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Altri Settori Critici (Allegato II)">
|
||||||
|
<option value="postal_courier">Servizi Postali e Corrieri</option>
|
||||||
|
<option value="waste_management">Gestione Rifiuti</option>
|
||||||
|
<option value="chemicals">Fabbricazione Prodotti Chimici</option>
|
||||||
|
<option value="food">Produzione e Distribuzione Alimentare</option>
|
||||||
|
<option value="manufacturing_medical">Fabbricazione - Dispositivi Medici</option>
|
||||||
|
<option value="manufacturing_computers">Fabbricazione - Computer/Elettronica</option>
|
||||||
|
<option value="manufacturing_electrical">Fabbricazione - Apparecchiature Elettriche</option>
|
||||||
|
<option value="manufacturing_machinery">Fabbricazione - Macchinari</option>
|
||||||
|
<option value="manufacturing_vehicles">Fabbricazione - Autoveicoli</option>
|
||||||
|
<option value="manufacturing_transport">Fabbricazione - Altri Mezzi di Trasporto</option>
|
||||||
|
<option value="digital_providers">Fornitori Servizi Digitali</option>
|
||||||
|
<option value="research">Organizzazioni di Ricerca</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Altro">
|
||||||
|
<option value="other">Altro Settore</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="employee-count">Numero Dipendenti <span class="required">*</span></label>
|
||||||
|
<input type="number" id="employee-count" class="form-input"
|
||||||
|
placeholder="Es. 150" min="1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="annual-turnover">Fatturato Annuo (EUR) <span class="required">*</span></label>
|
||||||
|
<input type="number" id="annual-turnover" class="form-input"
|
||||||
|
placeholder="Es. 15000000" min="0" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-full mt-16" id="save-btn">
|
||||||
|
Salva e Continua
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Classification Preview -->
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Classificazione NIS2</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="classification-preview not-applicable" id="classification-preview">
|
||||||
|
<div class="classification-label">In Attesa</div>
|
||||||
|
<p class="classification-desc">
|
||||||
|
Compila i dati aziendali per ottenere la classificazione automatica
|
||||||
|
secondo i criteri della Direttiva NIS2 (UE) 2022/2555.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-24" id="classification-details" style="display:none;">
|
||||||
|
<h4 style="font-size:0.875rem; margin-bottom:12px; color:var(--gray-700);">Dettagli Classificazione</h4>
|
||||||
|
<div id="classification-info" style="font-size:0.8125rem; color:var(--gray-600); line-height:1.7;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-24">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>Criteri di Classificazione</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" style="font-size:0.8125rem; color:var(--gray-600); line-height:1.7;">
|
||||||
|
<p><strong>Soggetti Essenziali:</strong></p>
|
||||||
|
<ul style="padding-left:20px; margin-bottom:12px;">
|
||||||
|
<li>Settori Allegato I con ≥ 250 dipendenti o fatturato ≥ 50M EUR</li>
|
||||||
|
<li>Alcuni soggetti designati indipendentemente dalle dimensioni</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Soggetti Importanti:</strong></p>
|
||||||
|
<ul style="padding-left:20px; margin-bottom:12px;">
|
||||||
|
<li>Settori Allegato I/II con ≥ 50 dipendenti o fatturato ≥ 10M EUR</li>
|
||||||
|
<li>Non qualificati come essenziali</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Non Applicabile:</strong></p>
|
||||||
|
<ul style="padding-left:20px;">
|
||||||
|
<li>Organizzazioni sotto le soglie minime</li>
|
||||||
|
<li>Settori non coperti dalla direttiva</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/common.js"></script>
|
||||||
|
<script>
|
||||||
|
// ── Auth check ───────────────────────────────────────────
|
||||||
|
if (!checkAuth()) throw new Error('Not authenticated');
|
||||||
|
loadSidebar();
|
||||||
|
|
||||||
|
// ── Auto-classify on input change ────────────────────────
|
||||||
|
const sectorSelect = document.getElementById('sector');
|
||||||
|
const employeeInput = document.getElementById('employee-count');
|
||||||
|
const turnoverInput = document.getElementById('annual-turnover');
|
||||||
|
|
||||||
|
const debouncedClassify = debounce(autoClassify, 500);
|
||||||
|
|
||||||
|
sectorSelect.addEventListener('change', debouncedClassify);
|
||||||
|
employeeInput.addEventListener('input', debouncedClassify);
|
||||||
|
turnoverInput.addEventListener('input', debouncedClassify);
|
||||||
|
|
||||||
|
async function autoClassify() {
|
||||||
|
const sector = sectorSelect.value;
|
||||||
|
const employees = parseInt(employeeInput.value) || 0;
|
||||||
|
const turnover = parseInt(turnoverInput.value) || 0;
|
||||||
|
|
||||||
|
if (!sector || employees <= 0) {
|
||||||
|
resetClassification();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.classifyEntity({
|
||||||
|
sector: sector,
|
||||||
|
employee_count: employees,
|
||||||
|
annual_turnover: turnover
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
updateClassificationUI(result.data);
|
||||||
|
} else {
|
||||||
|
// Fallback: classificazione locale
|
||||||
|
const localResult = classifyLocally(sector, employees, turnover);
|
||||||
|
updateClassificationUI(localResult);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: classificazione locale
|
||||||
|
const localResult = classifyLocally(sector, employees, turnover);
|
||||||
|
updateClassificationUI(localResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyLocally(sector, employees, turnover) {
|
||||||
|
// Settori Allegato I (alta criticita')
|
||||||
|
const annexI = [
|
||||||
|
'energy_electricity', 'energy_district_heating', 'energy_oil', 'energy_gas',
|
||||||
|
'energy_hydrogen', 'transport_air', 'transport_rail', 'transport_water',
|
||||||
|
'transport_road', 'banking', 'financial_markets', 'health', 'drinking_water',
|
||||||
|
'waste_water', 'digital_infrastructure', 'ict_service_management',
|
||||||
|
'public_administration', 'space'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Settori Allegato II
|
||||||
|
const annexII = [
|
||||||
|
'postal_courier', 'waste_management', 'chemicals', 'food',
|
||||||
|
'manufacturing_medical', 'manufacturing_computers', 'manufacturing_electrical',
|
||||||
|
'manufacturing_machinery', 'manufacturing_vehicles', 'manufacturing_transport',
|
||||||
|
'digital_providers', 'research'
|
||||||
|
];
|
||||||
|
|
||||||
|
const isAnnexI = annexI.includes(sector);
|
||||||
|
const isAnnexII = annexII.includes(sector);
|
||||||
|
const isLarge = employees >= 250 || turnover >= 50000000;
|
||||||
|
const isMedium = employees >= 50 || turnover >= 10000000;
|
||||||
|
|
||||||
|
if (isAnnexI && isLarge) {
|
||||||
|
return {
|
||||||
|
classification: 'essential',
|
||||||
|
label: 'Soggetto Essenziale',
|
||||||
|
description: 'La vostra organizzazione rientra tra i soggetti essenziali ai sensi della Direttiva NIS2, in quanto opera in un settore ad alta criticita\' (Allegato I) e supera le soglie dimensionali per la classificazione come grande impresa.'
|
||||||
|
};
|
||||||
|
} else if ((isAnnexI || isAnnexII) && isMedium) {
|
||||||
|
return {
|
||||||
|
classification: 'important',
|
||||||
|
label: 'Soggetto Importante',
|
||||||
|
description: 'La vostra organizzazione rientra tra i soggetti importanti ai sensi della Direttiva NIS2. Siete tenuti a rispettare gli obblighi di sicurezza e di notifica degli incidenti, con un regime di vigilanza ex post.'
|
||||||
|
};
|
||||||
|
} else if (sector === 'other' || (!isAnnexI && !isAnnexII)) {
|
||||||
|
return {
|
||||||
|
classification: 'not_applicable',
|
||||||
|
label: 'Non Applicabile',
|
||||||
|
description: 'In base ai dati forniti, la vostra organizzazione non sembra rientrare nell\'ambito di applicazione della Direttiva NIS2. Consigliamo comunque di verificare con le autorita\' competenti.'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
classification: 'not_applicable',
|
||||||
|
label: 'Sotto le Soglie',
|
||||||
|
description: 'La vostra organizzazione opera in un settore coperto dalla NIS2 ma non raggiunge le soglie dimensionali minime (50 dipendenti o 10M EUR di fatturato). Potreste comunque essere designati dalle autorita\' nazionali.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClassificationUI(data) {
|
||||||
|
const preview = document.getElementById('classification-preview');
|
||||||
|
const details = document.getElementById('classification-details');
|
||||||
|
const info = document.getElementById('classification-info');
|
||||||
|
|
||||||
|
const classification = data.classification || data.type || 'not_applicable';
|
||||||
|
const label = data.label || classification;
|
||||||
|
const description = data.description || data.explanation || '';
|
||||||
|
|
||||||
|
// Reset classes
|
||||||
|
preview.className = 'classification-preview';
|
||||||
|
|
||||||
|
if (classification === 'essential' || classification === 'essenziale') {
|
||||||
|
preview.classList.add('essential');
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div class="classification-label">Soggetto Essenziale</div>
|
||||||
|
<p class="classification-desc">${escapeHtml(description) || 'Obblighi completi NIS2 - Vigilanza ex ante.'}</p>
|
||||||
|
`;
|
||||||
|
} else if (classification === 'important' || classification === 'importante') {
|
||||||
|
preview.classList.add('important');
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div class="classification-label">Soggetto Importante</div>
|
||||||
|
<p class="classification-desc">${escapeHtml(description) || 'Obblighi NIS2 - Vigilanza ex post.'}</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
preview.classList.add('not-applicable');
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div class="classification-label">${escapeHtml(label)}</div>
|
||||||
|
<p class="classification-desc">${escapeHtml(description) || 'Non rientra nell\'ambito NIS2.'}</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.details || data.obligations) {
|
||||||
|
details.style.display = 'block';
|
||||||
|
let detailsHtml = '';
|
||||||
|
if (data.obligations && data.obligations.length > 0) {
|
||||||
|
detailsHtml += '<p><strong>Obblighi principali:</strong></p><ul style="padding-left:20px;">';
|
||||||
|
data.obligations.forEach(o => {
|
||||||
|
detailsHtml += `<li>${escapeHtml(o)}</li>`;
|
||||||
|
});
|
||||||
|
detailsHtml += '</ul>';
|
||||||
|
}
|
||||||
|
if (data.details) {
|
||||||
|
detailsHtml += `<p>${escapeHtml(data.details)}</p>`;
|
||||||
|
}
|
||||||
|
info.innerHTML = detailsHtml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetClassification() {
|
||||||
|
const preview = document.getElementById('classification-preview');
|
||||||
|
preview.className = 'classification-preview not-applicable';
|
||||||
|
preview.innerHTML = `
|
||||||
|
<div class="classification-label">In Attesa</div>
|
||||||
|
<p class="classification-desc">
|
||||||
|
Compila i dati aziendali per ottenere la classificazione automatica
|
||||||
|
secondo i criteri della Direttiva NIS2 (UE) 2022/2555.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
document.getElementById('classification-details').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form Submit ──────────────────────────────────────────
|
||||||
|
document.getElementById('org-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const companyName = document.getElementById('company-name').value.trim();
|
||||||
|
const vatNumber = document.getElementById('vat-number').value.trim();
|
||||||
|
const sector = sectorSelect.value;
|
||||||
|
const employees = parseInt(employeeInput.value) || 0;
|
||||||
|
const turnover = parseInt(turnoverInput.value) || 0;
|
||||||
|
|
||||||
|
// Validazione
|
||||||
|
if (!companyName) {
|
||||||
|
showNotification('Inserisci la ragione sociale.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sector) {
|
||||||
|
showNotification('Seleziona un settore.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (employees <= 0) {
|
||||||
|
showNotification('Inserisci il numero di dipendenti.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (turnover <= 0) {
|
||||||
|
showNotification('Inserisci il fatturato annuo.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('save-btn');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Salvataggio in corso...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.createOrganization({
|
||||||
|
name: companyName,
|
||||||
|
vat_number: vatNumber,
|
||||||
|
sector: sector,
|
||||||
|
employee_count: employees,
|
||||||
|
annual_turnover: turnover
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
// Imposta l'organizzazione come attiva
|
||||||
|
api.setOrganization(result.data.id || result.data.organization_id);
|
||||||
|
showNotification('Organizzazione creata con successo!', 'success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = 'dashboard.html';
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
showNotification(result.message || 'Errore nella creazione.', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotification('Errore di connessione al server.', 'error');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Salva e Continua';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user