[FEAT] Modello Organizzativo SGSI (ISO 27001/27017/27018) + SoA pre-popolato da NIS2
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>
This commit is contained in:
@@ -81,4 +81,43 @@ return [
|
||||
'authority' => 'Agenzia per la Cybersicurezza Nazionale (ACN)',
|
||||
'url' => 'https://www.acn.gov.it/portale/nis/modalita-specifiche-base',
|
||||
],
|
||||
|
||||
// ── Standard ISO/IEC 27000 (best practice, NON fonti normative vincolanti) ──
|
||||
// Usate dal modulo Modello Organizzativo SGSI per il grounding di AI e help.
|
||||
'iso_27001_2022' => [
|
||||
'key' => 'iso_27001_2022',
|
||||
'short' => 'ISO/IEC 27001:2022',
|
||||
'citation' => 'ISO/IEC 27001:2022',
|
||||
'full' => 'ISO/IEC 27001:2022 - Information security, cybersecurity and privacy protection - Information security management systems - Requirements. Definisce i requisiti del SGSI (clausole 4-10) e l\'Annex A con 93 controlli su 4 temi. BEST PRACTICE internazionale, NON obbligo normativo.',
|
||||
'file' => null,
|
||||
'authority' => 'ISO/IEC (best practice, non vincolante)',
|
||||
'url' => 'https://www.iso.org/standard/27001',
|
||||
],
|
||||
'iso_27002_2022' => [
|
||||
'key' => 'iso_27002_2022',
|
||||
'short' => 'ISO/IEC 27002:2022',
|
||||
'citation' => 'ISO/IEC 27002:2022',
|
||||
'full' => 'ISO/IEC 27002:2022 - Information security controls. Guida implementativa dei 93 controlli dell\'Annex A di ISO 27001:2022. BEST PRACTICE, non vincolante.',
|
||||
'file' => null,
|
||||
'authority' => 'ISO/IEC (best practice, non vincolante)',
|
||||
'url' => 'https://www.iso.org/standard/75652.html',
|
||||
],
|
||||
'iso_27017_2015' => [
|
||||
'key' => 'iso_27017_2015',
|
||||
'short' => 'ISO/IEC 27017:2015',
|
||||
'citation' => 'ISO/IEC 27017:2015',
|
||||
'full' => 'ISO/IEC 27017:2015 - Code of practice for information security controls based on ISO/IEC 27002 for cloud services. Aggiunge guida cloud-specifica e 7 controlli CLD.* (numerati su ISO 27002:2013). Applicabile a clienti e fornitori cloud. BEST PRACTICE, non vincolante.',
|
||||
'file' => null,
|
||||
'authority' => 'ISO/IEC (best practice, non vincolante)',
|
||||
'url' => 'https://www.iso.org/standard/43757.html',
|
||||
],
|
||||
'iso_27018_2019' => [
|
||||
'key' => 'iso_27018_2019',
|
||||
'short' => 'ISO/IEC 27018:2019',
|
||||
'citation' => 'ISO/IEC 27018:2019',
|
||||
'full' => 'ISO/IEC 27018:2019 - Code of practice for protection of personally identifiable information (PII) in public clouds acting as PII processors. Estende ISO 27002 con controlli privacy (principi ISO 29100). Sinergico con il GDPR per gli obblighi del responsabile del trattamento. BEST PRACTICE, non vincolante.',
|
||||
'file' => null,
|
||||
'authority' => 'ISO/IEC (best practice, non vincolante)',
|
||||
'url' => 'https://www.iso.org/standard/76559.html',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,53 @@ PROMPT;
|
||||
return $this->parseJsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera una bozza di documented information SGSI (ISO/IEC 27001:2022).
|
||||
* Es. doc_type: policy_sgsi, dichiarazione_applicabilita, procedura_incident,
|
||||
* politica_controllo_accessi, procedura_continuita, ecc.
|
||||
*
|
||||
* Ritorna ['title'=>, 'body_html'=>]. La bozza riporta un disclaimer e cita
|
||||
* le fonti certe (la conformita normativa primaria in Italia resta NIS2 /
|
||||
* D.Lgs. 138/2024; ISO 27001/27002/27017/27018 sono best practice).
|
||||
*/
|
||||
public function generateIsmsDocument(string $docType, array $organization, ?array $context = null): array
|
||||
{
|
||||
$sector = $organization['sector'] ?? 'n/d';
|
||||
$entityType = $organization['entity_type'] ?? 'n/d';
|
||||
$ctx = $context ? json_encode($context, JSON_UNESCAPED_UNICODE) : 'Non disponibile';
|
||||
$sourcesBlock = $this->authoritativeSourcesBlock();
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
Sei un esperto consulente ISO/IEC 27001:2022 (SGSI / Information Security Management System). Redigi una bozza di documento di tipo "{$docType}" per il Sistema di Gestione della Sicurezza delle Informazioni dell'organizzazione.
|
||||
|
||||
## Organizzazione (dati anonimizzati)
|
||||
- Settore: {$sector}
|
||||
- Tipo entita NIS2: {$entityType}
|
||||
|
||||
## Contesto SGSI
|
||||
{$ctx}
|
||||
{$sourcesBlock}
|
||||
|
||||
## Istruzioni
|
||||
- Documento in italiano, professionale, coerente con ISO/IEC 27001:2022 e ISO/IEC 27002:2022. Se il contesto indica uso del cloud, considera ISO/IEC 27017:2015; se tratta dati personali in cloud, ISO/IEC 27018:2019.
|
||||
- Ricorda che gli obblighi normativi italiani derivano da NIS2 / D.Lgs. 138/2024 / Determinazioni ACN; ISO e best practice non vincolante.
|
||||
- Struttura tipica: Scopo, Ambito, Ruoli e responsabilita, Riferimenti normativi, Contenuto/Regole, Controlli ISO 27001 collegati (Annex A), Monitoraggio e riesame.
|
||||
|
||||
Rispondi SOLO con JSON:
|
||||
{
|
||||
"title": "Titolo del documento",
|
||||
"content": "Contenuto completo in HTML semplice (h2/h3/p/ul/li/strong), pronto per essere salvato e modificato"
|
||||
}
|
||||
PROMPT;
|
||||
|
||||
$response = $this->callAPI($prompt, 'Sei un redattore di documentazione SGSI ISO 27001. Rispondi solo con JSON valido.');
|
||||
$parsed = $this->parseJsonResponse($response);
|
||||
return [
|
||||
'title' => (string) ($parsed['title'] ?? $docType),
|
||||
'body_html' => (string) ($parsed['content'] ?? $parsed['body_html'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifica un incidente e suggerisce severity
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user