Nuovo modulo guidato in 6 step (cl. 4-10 + Statement of Applicability): - migration 037 (isms_models/roles/soa/documents) + 038 (dataset 111 controlli: 93 Annex A:2022 + 7 CLD/27017 + 11 PII/27018) + runner scripts/migrate-isms.php - IsmsModelController (16 endpoint) registrato in index.php - SoA pre-popolato dalle risposte Gap Analysis NIS2 (mapping iso27001_control) - estensioni cloud condizionali 27017/27018 via flag uses_public_cloud/ is_cloud_provider/processes_pii_in_cloud - AIService::generateIsmsDocument + fonti ISO in nis2_sources.php - frontend isms.html/isms.js + api client + sidebar + help + i18n IT/EN - ingest KB ISO (scope SYSTEM, solo titoli/sintesi: no testo coperto da copyright) - version.json 1.14.0; doc studio + deploy handoff Strumento di supporto/pre-audit (non certificazione). Migration DA APPLICARE su host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
653 lines
28 KiB
PHP
653 lines
28 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Modello Organizzativo SGSI (ISO/IEC 27001:2022)
|
|
* ----------------------------------------------------------------------------
|
|
* Procedura guidata per costruire un Sistema di Gestione della Sicurezza delle
|
|
* Informazioni (SGSI/ISMS) secondo ISO/IEC 27001:2022 (clausole 4-10) +
|
|
* Statement of Applicability (SoA) sui controlli Annex A:2022, estendibile con
|
|
* i controlli cloud ISO/IEC 27017:2015 e privacy-cloud ISO/IEC 27018:2019.
|
|
*
|
|
* Valore chiave: il SoA viene PRE-POPOLATO dalle risposte dell'assessment NIS2
|
|
* Art.21 (ogni domanda del questionario porta gia un iso27001_control), cosi un
|
|
* assessment NIS2 esistente produce una prima bozza di SoA.
|
|
*
|
|
* NON sostituisce la valutazione di un auditor: e uno strumento di supporto /
|
|
* pre-audit. Le bozze AI riportano sempre un disclaimer.
|
|
*
|
|
* Endpoint (base /api/isms):
|
|
* GET /model - SGSI dell'org (null se non iniziato)
|
|
* POST /model - crea/aggiorna SGSI (upsert) — clausole 4-6
|
|
* PUT /model - alias di POST (salva step)
|
|
* GET /annex-controls - dataset controlli applicabili (filtrato per flag cloud/PII)
|
|
* GET /soa - Statement of Applicability (auto-derivato al 1o accesso)
|
|
* POST /soa/derive - (ri)deriva lo stato iniziale dal NIS2
|
|
* PUT /soa - aggiorna un controllo del SoA (body: control_code, ...)
|
|
* GET /roles - ruoli/RACI del SGSI
|
|
* POST /roles - crea ruolo
|
|
* DELETE /roles/{id} - elimina ruolo
|
|
* GET /documents - documented information
|
|
* POST /documents - crea documento
|
|
* POST /documents/ai-generate - genera bozza documento via AI
|
|
* PUT /documents/{id} - aggiorna documento
|
|
* GET /readiness - % completamento SGSI + checklist clausole 4-10
|
|
* GET /export - export completo (model + SoA + ruoli + documenti)
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class IsmsModelController extends BaseController
|
|
{
|
|
private const STANDARD_LABELS = [
|
|
'iso27001' => 'ISO/IEC 27001:2022 Annex A',
|
|
'iso27017' => 'ISO/IEC 27017:2015 (cloud)',
|
|
'iso27018' => 'ISO/IEC 27018:2019 (PII in cloud)',
|
|
];
|
|
|
|
// ════════════════════════ MODEL ════════════════════════
|
|
|
|
/** GET /api/isms/model */
|
|
public function getModel(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$m = $this->loadModel();
|
|
if ($m) {
|
|
$m['interested_parties'] = $m['interested_parties'] ? json_decode($m['interested_parties'], true) : [];
|
|
$m['isms_objectives'] = $m['isms_objectives'] ? json_decode($m['isms_objectives'], true) : [];
|
|
}
|
|
$this->jsonSuccess(['model' => $m]);
|
|
}
|
|
|
|
/**
|
|
* POST/PUT /api/isms/model
|
|
* Upsert del SGSI dell'org (una riga per org). Salva i campi inviati;
|
|
* i campi assenti non vengono toccati (salvataggio per-step).
|
|
*/
|
|
public function saveModel(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$orgId = $this->getCurrentOrgId();
|
|
$userId = $this->getCurrentUserId();
|
|
$body = $this->getJsonBody();
|
|
|
|
// Whitelist campi testuali/flag.
|
|
$fields = [];
|
|
foreach (['scope_statement','context_internal','context_external','boundaries','exclusions','risk_methodology'] as $k) {
|
|
if (array_key_exists($k, $body)) {
|
|
$fields[$k] = $body[$k] !== null ? (string) $body[$k] : null;
|
|
}
|
|
}
|
|
foreach (['interested_parties','isms_objectives'] as $k) {
|
|
if (array_key_exists($k, $body)) {
|
|
$fields[$k] = json_encode($body[$k] ?? [], JSON_UNESCAPED_UNICODE);
|
|
}
|
|
}
|
|
foreach (['uses_public_cloud','is_cloud_provider','processes_pii_in_cloud'] as $k) {
|
|
if (array_key_exists($k, $body)) {
|
|
$fields[$k] = !empty($body[$k]) ? 1 : 0;
|
|
}
|
|
}
|
|
if (array_key_exists('status', $body) && in_array($body['status'], ['draft','active','under_review'], true)) {
|
|
$fields['status'] = $body['status'];
|
|
}
|
|
|
|
$existing = $this->loadModel();
|
|
if ($existing) {
|
|
if (!empty($fields)) {
|
|
Database::update('isms_models', $fields, 'id = ?', [$existing['id']]);
|
|
}
|
|
$modelId = (int) $existing['id'];
|
|
$this->logAudit('isms_model_updated', 'isms_model', $modelId, array_keys($fields));
|
|
} else {
|
|
$fields['organization_id'] = $orgId;
|
|
$fields['created_by'] = $userId;
|
|
$fields['status'] = $fields['status'] ?? 'draft';
|
|
$modelId = Database::insert('isms_models', $fields);
|
|
$this->logAudit('isms_model_created', 'isms_model', $modelId, null);
|
|
}
|
|
|
|
$this->jsonSuccess(['id' => $modelId], 'SGSI salvato');
|
|
}
|
|
|
|
// ════════════════════════ ANNEX CONTROLS ════════════════════════
|
|
|
|
/**
|
|
* GET /api/isms/annex-controls
|
|
* Dataset di riferimento, filtrato in base ai flag cloud/PII del modello:
|
|
* i controlli condizionali (27017/27018) compaiono solo se pertinenti.
|
|
*/
|
|
public function annexControls(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$m = $this->loadModel();
|
|
$standards = $this->applicableStandards($m);
|
|
|
|
$place = implode(',', array_fill(0, count($standards), '?'));
|
|
$rows = Database::fetchAll(
|
|
"SELECT control_code, standard, theme, title_it, title_en, iso27002_ref, condition_tag
|
|
FROM iso27001_annex_controls
|
|
WHERE standard IN ($place)
|
|
ORDER BY sort_order",
|
|
$standards
|
|
);
|
|
$this->jsonSuccess([
|
|
'standards' => array_map(fn($s) => ['key' => $s, 'label' => self::STANDARD_LABELS[$s] ?? $s], $standards),
|
|
'controls' => $rows,
|
|
'total' => count($rows),
|
|
]);
|
|
}
|
|
|
|
// ════════════════════════ SoA ════════════════════════
|
|
|
|
/**
|
|
* GET /api/isms/soa
|
|
* Restituisce il SoA raggruppato per standard -> tema. Se vuoto, lo deriva
|
|
* automaticamente dal NIS2 al primo accesso.
|
|
*/
|
|
public function getSoa(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$model = $this->requireModel();
|
|
|
|
$count = Database::count('isms_soa', 'isms_model_id = ?', [$model['id']]);
|
|
if ($count === 0) {
|
|
$this->doDerive($model);
|
|
}
|
|
|
|
$rows = Database::fetchAll(
|
|
"SELECT s.control_code, s.standard, s.applicable, s.justification_inclusion, s.justification_exclusion,
|
|
s.implementation_status, s.implementation_pct, s.derived_from_nis2, s.source_ref,
|
|
c.title_it, c.title_en, c.theme, c.condition_tag, c.sort_order
|
|
FROM isms_soa s
|
|
LEFT JOIN iso27001_annex_controls c ON c.control_code = s.control_code
|
|
WHERE s.isms_model_id = ?
|
|
ORDER BY c.sort_order, s.control_code",
|
|
[$model['id']]
|
|
);
|
|
|
|
$byStd = [];
|
|
foreach ($rows as $r) {
|
|
$std = $r['standard'];
|
|
$byStd[$std] ??= ['standard' => $std, 'label' => self::STANDARD_LABELS[$std] ?? $std, 'controls' => []];
|
|
$byStd[$std]['controls'][] = $r;
|
|
}
|
|
|
|
$this->jsonSuccess([
|
|
'model_id' => (int) $model['id'],
|
|
'groups' => array_values($byStd),
|
|
'stats' => $this->soaStats($model['id']),
|
|
]);
|
|
}
|
|
|
|
/** POST /api/isms/soa/derive — (ri)deriva lo stato iniziale dal NIS2. */
|
|
public function deriveSoa(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$model = $this->requireModel();
|
|
$added = $this->doDerive($model);
|
|
$this->logAudit('isms_soa_derived', 'isms_model', (int) $model['id'], ['added' => $added]);
|
|
$this->jsonSuccess(['added' => $added, 'stats' => $this->soaStats($model['id'])], 'SoA derivato dal NIS2');
|
|
}
|
|
|
|
/**
|
|
* PUT /api/isms/soa
|
|
* Body: { control_code, applicable?, justification_inclusion?, justification_exclusion?,
|
|
* implementation_status?, implementation_pct? }
|
|
*/
|
|
public function updateSoaControl(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
|
|
$model = $this->requireModel();
|
|
$body = $this->getJsonBody();
|
|
|
|
$code = trim((string) ($body['control_code'] ?? ''));
|
|
if ($code === '') {
|
|
$this->jsonError('control_code mancante', 400, 'MISSING_CODE');
|
|
}
|
|
$row = Database::fetchOne('SELECT id FROM isms_soa WHERE isms_model_id = ? AND control_code = ?', [$model['id'], $code]);
|
|
if (!$row) {
|
|
$this->jsonError('Controllo non presente nel SoA', 404, 'NOT_FOUND');
|
|
}
|
|
|
|
$fields = ['updated_by' => $this->getCurrentUserId()];
|
|
if (array_key_exists('applicable', $body)) {
|
|
$fields['applicable'] = !empty($body['applicable']) ? 1 : 0;
|
|
}
|
|
foreach (['justification_inclusion','justification_exclusion'] as $k) {
|
|
if (array_key_exists($k, $body)) {
|
|
$fields[$k] = $body[$k] !== null ? (string) $body[$k] : null;
|
|
}
|
|
}
|
|
if (isset($body['implementation_status']) && in_array($body['implementation_status'], ['not_started','in_progress','implemented','verified'], true)) {
|
|
$fields['implementation_status'] = $body['implementation_status'];
|
|
}
|
|
if (array_key_exists('implementation_pct', $body)) {
|
|
$fields['implementation_pct'] = max(0, min(100, (int) $body['implementation_pct']));
|
|
}
|
|
|
|
Database::update('isms_soa', $fields, 'id = ?', [$row['id']]);
|
|
$this->jsonSuccess(['stats' => $this->soaStats($model['id'])], 'Controllo aggiornato');
|
|
}
|
|
|
|
// ════════════════════════ ROLES ════════════════════════
|
|
|
|
/** GET /api/isms/roles */
|
|
public function listRoles(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$model = $this->requireModel();
|
|
$rows = Database::fetchAll(
|
|
"SELECT r.id, r.role_name, r.user_id, r.responsibility, r.raci,
|
|
u.full_name AS user_name
|
|
FROM isms_roles r
|
|
LEFT JOIN users u ON u.id = r.user_id
|
|
WHERE r.isms_model_id = ? ORDER BY r.id",
|
|
[$model['id']]
|
|
);
|
|
$this->jsonSuccess(['roles' => $rows]);
|
|
}
|
|
|
|
/** POST /api/isms/roles Body: { role_name, user_id?, responsibility?, raci? } */
|
|
public function saveRole(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$model = $this->requireModel();
|
|
$body = $this->getJsonBody();
|
|
|
|
$name = trim((string) ($body['role_name'] ?? ''));
|
|
if ($name === '') {
|
|
$this->jsonError('role_name obbligatorio', 400, 'MISSING_ROLE_NAME');
|
|
}
|
|
$raci = (string) ($body['raci'] ?? '');
|
|
$data = [
|
|
'isms_model_id' => $model['id'],
|
|
'organization_id' => $this->getCurrentOrgId(),
|
|
'role_name' => $name,
|
|
'user_id' => !empty($body['user_id']) ? (int) $body['user_id'] : null,
|
|
'responsibility' => isset($body['responsibility']) ? (string) $body['responsibility'] : null,
|
|
'raci' => in_array($raci, ['R','A','C','I'], true) ? $raci : null,
|
|
];
|
|
$id = Database::insert('isms_roles', $data);
|
|
$this->jsonSuccess(['id' => $id], 'Ruolo aggiunto', 201);
|
|
}
|
|
|
|
/** DELETE /api/isms/roles/{id} */
|
|
public function deleteRole(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$model = $this->requireModel();
|
|
Database::delete('isms_roles', 'id = ? AND isms_model_id = ?', [$id, $model['id']]);
|
|
$this->jsonSuccess(null, 'Ruolo eliminato');
|
|
}
|
|
|
|
// ════════════════════════ DOCUMENTS ════════════════════════
|
|
|
|
/** GET /api/isms/documents */
|
|
public function listDocuments(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$model = $this->requireModel();
|
|
$rows = Database::fetchAll(
|
|
"SELECT id, doc_type, title, status, ai_generated, version, updated_at
|
|
FROM isms_documents WHERE isms_model_id = ? ORDER BY updated_at DESC",
|
|
[$model['id']]
|
|
);
|
|
$this->jsonSuccess(['documents' => $rows]);
|
|
}
|
|
|
|
/** POST /api/isms/documents Body: { doc_type, title, body_html?, status? } */
|
|
public function createDocument(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$model = $this->requireModel();
|
|
$body = $this->getJsonBody();
|
|
|
|
$this->validateRequired(['doc_type', 'title']);
|
|
$id = Database::insert('isms_documents', [
|
|
'isms_model_id' => $model['id'],
|
|
'organization_id' => $this->getCurrentOrgId(),
|
|
'doc_type' => (string) $body['doc_type'],
|
|
'title' => (string) $body['title'],
|
|
'body_html' => isset($body['body_html']) ? (string) $body['body_html'] : null,
|
|
'status' => in_array($body['status'] ?? '', ['draft','review','approved'], true) ? $body['status'] : 'draft',
|
|
'ai_generated' => !empty($body['ai_generated']) ? 1 : 0,
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
$this->jsonSuccess(['id' => $id], 'Documento creato', 201);
|
|
}
|
|
|
|
/** PUT /api/isms/documents/{id} */
|
|
public function updateDocument(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$model = $this->requireModel();
|
|
$body = $this->getJsonBody();
|
|
$row = Database::fetchOne('SELECT id FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]);
|
|
if (!$row) {
|
|
$this->jsonError('Documento non trovato', 404, 'NOT_FOUND');
|
|
}
|
|
$fields = [];
|
|
foreach (['title','body_html'] as $k) {
|
|
if (array_key_exists($k, $body)) $fields[$k] = (string) $body[$k];
|
|
}
|
|
if (isset($body['status']) && in_array($body['status'], ['draft','review','approved'], true)) {
|
|
$fields['status'] = $body['status'];
|
|
}
|
|
if (!empty($fields)) {
|
|
Database::update('isms_documents', $fields, 'id = ?', [$id]);
|
|
}
|
|
$this->jsonSuccess(['id' => $id], 'Documento aggiornato');
|
|
}
|
|
|
|
/**
|
|
* POST /api/isms/documents/ai-generate
|
|
* Body: { doc_type, title? } - genera una bozza con AI (grounding fonti certe).
|
|
*/
|
|
public function aiGenerateDocument(): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
|
$model = $this->requireModel();
|
|
$body = $this->getJsonBody();
|
|
$docType = trim((string) ($body['doc_type'] ?? ''));
|
|
if ($docType === '') {
|
|
$this->jsonError('doc_type obbligatorio', 400, 'MISSING_DOC_TYPE');
|
|
}
|
|
|
|
$org = Database::fetchOne('SELECT sector, entity_type, employee_count FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
|
|
require_once APP_PATH . '/services/AIService.php';
|
|
$ai = new AIService();
|
|
|
|
try {
|
|
$ctx = [
|
|
'scope' => $model['scope_statement'] ?? null,
|
|
'methodology' => $model['risk_methodology'] ?? null,
|
|
'uses_cloud' => (bool) ($model['uses_public_cloud'] || $model['is_cloud_provider']),
|
|
'pii_in_cloud' => (bool) $model['processes_pii_in_cloud'],
|
|
];
|
|
$doc = $ai->generateIsmsDocument($docType, $org ?: [], $ctx);
|
|
} catch (Throwable $e) {
|
|
error_log('[ISMS] aiGenerateDocument fallita: ' . $e->getMessage());
|
|
$this->jsonError('Generazione AI temporaneamente non disponibile. Riprova piu tardi.', 503, 'AI_UNAVAILABLE');
|
|
}
|
|
|
|
$title = trim((string) ($body['title'] ?? ($doc['title'] ?? $docType)));
|
|
$id = Database::insert('isms_documents', [
|
|
'isms_model_id' => $model['id'],
|
|
'organization_id' => $this->getCurrentOrgId(),
|
|
'doc_type' => $docType,
|
|
'title' => $title,
|
|
'body_html' => (string) ($doc['body_html'] ?? $doc['content'] ?? ''),
|
|
'status' => 'draft',
|
|
'ai_generated' => 1,
|
|
'created_by' => $this->getCurrentUserId(),
|
|
]);
|
|
$this->logAudit('isms_document_ai_generated', 'isms_document', $id, ['doc_type' => $docType]);
|
|
$this->jsonSuccess([
|
|
'id' => $id,
|
|
'title' => $title,
|
|
'body_html' => (string) ($doc['body_html'] ?? $doc['content'] ?? ''),
|
|
'disclaimer' => 'Bozza generata dall\'AI: revisione umana obbligatoria prima dell\'approvazione.',
|
|
], 'Bozza generata');
|
|
}
|
|
|
|
// ════════════════════════ READINESS / EXPORT ════════════════════════
|
|
|
|
/** GET /api/isms/readiness — checklist clausole 4-10 + % complessiva. */
|
|
public function readiness(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$model = $this->loadModel();
|
|
if (!$model) {
|
|
$this->jsonSuccess(['started' => false, 'overall_pct' => 0, 'checklist' => []]);
|
|
}
|
|
|
|
$rolesCount = Database::count('isms_roles', 'isms_model_id = ?', [$model['id']]);
|
|
$docsCount = Database::count('isms_documents', 'isms_model_id = ?', [$model['id']]);
|
|
$soa = $this->soaStats($model['id']);
|
|
$soaAnswered = $soa['total'] > 0 ? ($soa['total'] - $soa['not_started']) : 0;
|
|
|
|
$checklist = [
|
|
['clause' => '4', 'label' => 'Contesto e ambito', 'done' => !empty($model['scope_statement'])],
|
|
['clause' => '5', 'label' => 'Leadership: policy e ruoli (RACI)', 'done' => $rolesCount > 0],
|
|
['clause' => '6', 'label' => 'Risk: metodologia e obiettivi', 'done' => !empty($model['risk_methodology'])],
|
|
['clause' => 'SoA', 'label' => 'Statement of Applicability avviato', 'done' => $soa['total'] > 0],
|
|
['clause' => '7-8', 'label' => 'Documented information', 'done' => $docsCount > 0],
|
|
['clause' => '9-10', 'label' => 'Monitoraggio e miglioramento (audit/NCR esistenti)', 'done' => $soaAnswered > 0],
|
|
];
|
|
$done = count(array_filter($checklist, fn($c) => $c['done']));
|
|
$overall = (int) round($done / count($checklist) * 100);
|
|
|
|
$this->jsonSuccess([
|
|
'started' => true,
|
|
'overall_pct' => $overall,
|
|
'checklist' => $checklist,
|
|
'soa' => $soa,
|
|
'roles_count' => $rolesCount,
|
|
'docs_count' => $docsCount,
|
|
]);
|
|
}
|
|
|
|
/** GET /api/isms/export — model + SoA + ruoli + documenti (per stampa). */
|
|
public function export(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$model = $this->requireModel();
|
|
$org = Database::fetchOne('SELECT name, sector, entity_type FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
|
|
|
|
$model['interested_parties'] = $model['interested_parties'] ? json_decode($model['interested_parties'], true) : [];
|
|
$model['isms_objectives'] = $model['isms_objectives'] ? json_decode($model['isms_objectives'], true) : [];
|
|
|
|
$soa = Database::fetchAll(
|
|
"SELECT s.control_code, s.standard, s.applicable, s.implementation_status, s.implementation_pct,
|
|
s.justification_inclusion, s.justification_exclusion, s.derived_from_nis2, s.source_ref,
|
|
c.title_it
|
|
FROM isms_soa s LEFT JOIN iso27001_annex_controls c ON c.control_code = s.control_code
|
|
WHERE s.isms_model_id = ? ORDER BY c.sort_order",
|
|
[$model['id']]
|
|
);
|
|
$roles = Database::fetchAll('SELECT role_name, responsibility, raci FROM isms_roles WHERE isms_model_id = ? ORDER BY id', [$model['id']]);
|
|
$docs = Database::fetchAll('SELECT doc_type, title, status, version FROM isms_documents WHERE isms_model_id = ? ORDER BY id', [$model['id']]);
|
|
|
|
$this->logAudit('isms_export', 'isms_model', (int) $model['id'], null);
|
|
$this->jsonSuccess([
|
|
'organization' => $org,
|
|
'model' => $model,
|
|
'soa' => $soa,
|
|
'roles' => $roles,
|
|
'documents' => $docs,
|
|
'stats' => $this->soaStats($model['id']),
|
|
'generated_at' => date('c'),
|
|
'disclaimer' => 'Documento di supporto/pre-audit. Non costituisce certificazione ISO 27001 ne parere professionale vincolante.',
|
|
]);
|
|
}
|
|
|
|
// ════════════════════════ HELPER ════════════════════════
|
|
|
|
private function loadModel(): ?array
|
|
{
|
|
return Database::fetchOne('SELECT * FROM isms_models WHERE organization_id = ?', [$this->getCurrentOrgId()]);
|
|
}
|
|
|
|
private function requireModel(): array
|
|
{
|
|
$m = $this->loadModel();
|
|
if (!$m) {
|
|
$this->jsonError('SGSI non ancora avviato. Completa prima il passo Contesto e Ambito.', 422, 'ISMS_NOT_STARTED');
|
|
}
|
|
return $m;
|
|
}
|
|
|
|
/** Standard applicabili in base ai flag del modello. */
|
|
private function applicableStandards(?array $model): array
|
|
{
|
|
$standards = ['iso27001'];
|
|
if ($model && ($model['uses_public_cloud'] || $model['is_cloud_provider'])) {
|
|
$standards[] = 'iso27017';
|
|
}
|
|
if ($model && $model['processes_pii_in_cloud']) {
|
|
$standards[] = 'iso27018';
|
|
}
|
|
return $standards;
|
|
}
|
|
|
|
/**
|
|
* Deriva/integra il SoA: inserisce i controlli applicabili mancanti,
|
|
* pre-compilando stato e motivazione dalle risposte NIS2 dove possibile.
|
|
* INSERT IGNORE => non sovrascrive il lavoro manuale gia presente.
|
|
* @return int controlli aggiunti
|
|
*/
|
|
private function doDerive(array $model): int
|
|
{
|
|
$standards = $this->applicableStandards($model);
|
|
$place = implode(',', array_fill(0, count($standards), '?'));
|
|
$controls = Database::fetchAll(
|
|
"SELECT control_code, standard FROM iso27001_annex_controls WHERE standard IN ($place) ORDER BY sort_order",
|
|
$standards
|
|
);
|
|
|
|
$nis2 = $this->nis2ControlStatus(); // [iso_control_code => ['status'=>..,'pct'=>..,'sources'=>[..]]]
|
|
$orgId = $this->getCurrentOrgId();
|
|
$userId = $this->getCurrentUserId();
|
|
|
|
$existing = array_column(
|
|
Database::fetchAll('SELECT control_code FROM isms_soa WHERE isms_model_id = ?', [$model['id']]),
|
|
'control_code'
|
|
);
|
|
$existing = array_flip($existing);
|
|
|
|
$added = 0;
|
|
foreach ($controls as $c) {
|
|
$code = $c['control_code'];
|
|
if (isset($existing[$code])) {
|
|
continue;
|
|
}
|
|
$d = $nis2[$code] ?? null;
|
|
$row = [
|
|
'isms_model_id' => $model['id'],
|
|
'organization_id' => $orgId,
|
|
'control_code' => $code,
|
|
'standard' => $c['standard'],
|
|
'applicable' => 1,
|
|
'updated_by' => $userId,
|
|
];
|
|
if ($d) {
|
|
$row['implementation_status'] = $d['status'];
|
|
$row['implementation_pct'] = $d['pct'];
|
|
$row['derived_from_nis2'] = 1;
|
|
$row['source_ref'] = implode(', ', array_slice($d['sources'], 0, 4));
|
|
$row['justification_inclusion'] = 'Applicabile: collegato alle misure NIS2 ' . $row['source_ref']
|
|
. '. Stato derivato dall\'assessment Art.21 (da confermare).';
|
|
}
|
|
// INSERT IGNORE manuale tramite query (Database::insert non supporta IGNORE).
|
|
$cols = implode(',', array_keys($row));
|
|
$ph = implode(',', array_fill(0, count($row), '?'));
|
|
Database::query("INSERT IGNORE INTO isms_soa ($cols) VALUES ($ph)", array_values($row));
|
|
$added++;
|
|
}
|
|
return $added;
|
|
}
|
|
|
|
/**
|
|
* Aggrega lo stato dei controlli ISO 27001 a partire dalle risposte NIS2.
|
|
* Usa l'ultimo assessment dell'org e il mapping question->iso27001_control
|
|
* presente nel questionario.
|
|
* @return array<string,array{status:string,pct:int,sources:array}>
|
|
*/
|
|
private function nis2ControlStatus(): array
|
|
{
|
|
$orgId = $this->getCurrentOrgId();
|
|
$assessment = Database::fetchOne(
|
|
'SELECT id FROM assessments WHERE organization_id = ? ORDER BY created_at DESC LIMIT 1',
|
|
[$orgId]
|
|
);
|
|
if (!$assessment) {
|
|
return [];
|
|
}
|
|
$responses = Database::fetchAll(
|
|
'SELECT question_code, response_value FROM assessment_responses WHERE assessment_id = ?',
|
|
[$assessment['id']]
|
|
);
|
|
if (empty($responses)) {
|
|
return [];
|
|
}
|
|
$respByCode = [];
|
|
foreach ($responses as $r) {
|
|
$respByCode[$r['question_code']] = $r['response_value'];
|
|
}
|
|
|
|
// mappa question_code -> iso27001_control + nis2_article dal questionario
|
|
$q = $this->questionnaire();
|
|
$pctVal = ['implemented' => 100, 'partial' => 50, 'not_implemented' => 0];
|
|
|
|
$agg = []; // iso_code => ['sum'=>,'cnt'=>,'sources'=>[]]
|
|
foreach ($q['categories'] ?? [] as $cat) {
|
|
foreach ($cat['questions'] ?? [] as $question) {
|
|
$iso = $question['iso27001_control'] ?? null;
|
|
$code = $question['code'] ?? null;
|
|
if (!$iso || !$code || !isset($respByCode[$code])) {
|
|
continue;
|
|
}
|
|
$resp = $respByCode[$code];
|
|
if ($resp === 'not_applicable' || $resp === null || !isset($pctVal[$resp])) {
|
|
continue;
|
|
}
|
|
$agg[$iso] ??= ['sum' => 0, 'cnt' => 0, 'sources' => []];
|
|
$agg[$iso]['sum'] += $pctVal[$resp];
|
|
$agg[$iso]['cnt']++;
|
|
$art = $question['nis2_article'] ?? '';
|
|
$src = 'Art.' . $art . ' (' . $code . ')';
|
|
if (!in_array($src, $agg[$iso]['sources'], true)) {
|
|
$agg[$iso]['sources'][] = $src;
|
|
}
|
|
}
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($agg as $iso => $a) {
|
|
$pct = $a['cnt'] > 0 ? (int) round($a['sum'] / $a['cnt']) : 0;
|
|
$status = $pct >= 100 ? 'implemented' : ($pct > 0 ? 'in_progress' : 'not_started');
|
|
$out[$iso] = ['status' => $status, 'pct' => $pct, 'sources' => $a['sources']];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private ?array $questionnaireCache = null;
|
|
private function questionnaire(): array
|
|
{
|
|
if ($this->questionnaireCache === null) {
|
|
$path = APP_PATH . '/data/nis2_questionnaire.json';
|
|
$json = is_readable($path) ? json_decode((string) file_get_contents($path), true) : null;
|
|
$this->questionnaireCache = is_array($json) ? $json : ['categories' => []];
|
|
}
|
|
return $this->questionnaireCache;
|
|
}
|
|
|
|
private function soaStats(int $modelId): array
|
|
{
|
|
$rows = Database::fetchAll(
|
|
'SELECT applicable, implementation_status, COUNT(*) AS n
|
|
FROM isms_soa WHERE isms_model_id = ? GROUP BY applicable, implementation_status',
|
|
[$modelId]
|
|
);
|
|
$s = ['total' => 0, 'applicable' => 0, 'excluded' => 0,
|
|
'not_started' => 0, 'in_progress' => 0, 'implemented' => 0, 'verified' => 0];
|
|
foreach ($rows as $r) {
|
|
$n = (int) $r['n'];
|
|
$s['total'] += $n;
|
|
if ((int) $r['applicable'] === 1) {
|
|
$s['applicable'] += $n;
|
|
$st = $r['implementation_status'];
|
|
if (isset($s[$st])) $s[$st] += $n;
|
|
} else {
|
|
$s['excluded'] += $n;
|
|
}
|
|
}
|
|
// % implementazione media sui controlli applicabili
|
|
$impl = Database::fetchOne(
|
|
'SELECT AVG(implementation_pct) AS avg_pct FROM isms_soa WHERE isms_model_id = ? AND applicable = 1',
|
|
[$modelId]
|
|
);
|
|
$s['avg_implementation_pct'] = $impl && $impl['avg_pct'] !== null ? (int) round($impl['avg_pct']) : 0;
|
|
return $s;
|
|
}
|
|
}
|