From 172d9270e682d089abf4b58fda1682ec1d4f70a2 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 30 May 2026 10:39:24 +0200 Subject: [PATCH] [FIX] SupplyChain: aggiunti i 5 metodi self-assessment (Edit precedente rifiutato per file non letto) sendQuestionnaire/publicQuestionnaire/submitPublicQuestionnaire/questionnaireStatus/resolveQuestionnaire. Test E2E prod: send 201 -> public GET 200 -> submit 201 (score 61) -> re-submit 409 -> suppliers.risk_score=39. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/SupplyChainController.php | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/application/controllers/SupplyChainController.php b/application/controllers/SupplyChainController.php index 6ae7e11..1546a90 100644 --- a/application/controllers/SupplyChainController.php +++ b/application/controllers/SupplyChainController.php @@ -165,4 +165,140 @@ class SupplyChainController extends BaseController return $totalWeight > 0 ? (int) round($totalScore / $totalWeight * 100) : 0; } + + // ══════════════════════════════════════════════════════════════════════ + // SELF-ASSESSMENT FORNITORI (P3) — questionario sicurezza Art.21.2.d + // ══════════════════════════════════════════════════════════════════════ + + private const QUESTIONNAIRE = [ + ['key' => 'iso27001', 'q' => 'Disponete di una certificazione ISO/IEC 27001 valida?', 'weight' => 15], + ['key' => 'mfa', 'q' => 'Imponete autenticazione a piu fattori (MFA) per gli accessi ai sistemi che trattano dati del cliente?', 'weight' => 15], + ['key' => 'patching', 'q' => 'Avete un processo formale di patch management e gestione vulnerabilita?', 'weight' => 12], + ['key' => 'backup', 'q' => 'Eseguite backup regolari con test di ripristino documentati?', 'weight' => 12], + ['key' => 'incident', 'q' => 'Disponete di un piano di gestione degli incidenti con notifica al cliente?', 'weight' => 14], + ['key' => 'access_review', 'q' => 'Effettuate revisioni periodiche degli accessi e revoca tempestiva?', 'weight' => 10], + ['key' => 'encryption', 'q' => 'I dati del cliente sono cifrati a riposo e in transito?', 'weight' => 12], + ['key' => 'subcontractor', 'q' => 'Valutate la sicurezza dei vostri sub-fornitori (quarta parte)?', 'weight' => 10], + ]; + + /** POST /api/supply-chain/{id}/send-questionnaire (JWT): genera token + link pubblico. */ + public function sendQuestionnaire(int $id): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $supplier = Database::fetchOne( + 'SELECT id, name, contact_email FROM suppliers WHERE id = ? AND organization_id = ? AND deleted_at IS NULL', + [$id, $this->getCurrentOrgId()] + ); + if (!$supplier) { + $this->jsonError('Fornitore non trovato', 404, 'NOT_FOUND'); + } + + $rawToken = 'sq_' . bin2hex(random_bytes(20)); + $email = $this->getParam('email') ?: $supplier['contact_email']; + $expires = date('Y-m-d H:i:s', time() + 30 * 86400); + + Database::insert('supplier_questionnaires', [ + 'organization_id' => $this->getCurrentOrgId(), + 'supplier_id' => $id, + 'token_hash' => hash('sha256', $rawToken), + 'status' => 'sent', + 'sent_to_email' => $email, + 'expires_at' => $expires, + ]); + + $link = 'https://nis2.agile.software/supplier-assessment.html?token=' . $rawToken; + + try { + if ($email && class_exists('EmailService')) { + $svc = new EmailService(); + if (method_exists($svc, 'send')) { + $svc->send($email, 'Questionario di sicurezza fornitore - NIS2', + "

Gentile {$supplier['name']},

vi chiediamo di compilare il questionario di sicurezza al seguente link (valido 30 giorni):

{$link}

"); + } + } + } catch (Throwable $e) { + error_log('[SUPPLIER_Q] email: ' . $e->getMessage()); + } + + $this->logAudit('supplier_questionnaire_sent', 'supplier', $id, ['email' => $email]); + $this->jsonSuccess(['supplier_id' => $id, 'sent_to' => $email, 'link' => $link, 'expires_at' => $expires], 'Questionario generato', 201); + } + + /** GET /api/supply-chain/public-questionnaire?token=xxx (NO AUTH). */ + public function publicQuestionnaire(): void + { + $q = $this->resolveQuestionnaire($_GET['token'] ?? ''); + if ($q['status'] === 'completed') { + $this->jsonError('Questionario gia compilato', 409, 'ALREADY_COMPLETED'); + } + $supplier = Database::fetchOne('SELECT name FROM suppliers WHERE id = ?', [$q['supplier_id']]); + $this->jsonSuccess([ + 'supplier_name' => $supplier['name'] ?? 'Fornitore', + 'questions' => array_map(fn($x) => ['key' => $x['key'], 'question' => $x['q']], self::QUESTIONNAIRE), + 'answer_scale' => ['yes' => 'Si', 'partial' => 'Parzialmente', 'no' => 'No'], + ]); + } + + /** POST /api/supply-chain/submit-public-questionnaire (NO AUTH). Body: {token, answers}. */ + public function submitPublicQuestionnaire(): void + { + $body = $this->getJsonBody(); + $q = $this->resolveQuestionnaire($body['token'] ?? ''); + if ($q['status'] === 'completed') { + $this->jsonError('Questionario gia compilato', 409, 'ALREADY_COMPLETED'); + } + + $answers = is_array($body['answers'] ?? null) ? $body['answers'] : []; + $scoreMap = ['yes' => 1.0, 'partial' => 0.5, 'no' => 0.0]; + $earned = 0; $maxScore = 0; $clean = []; + foreach (self::QUESTIONNAIRE as $item) { + $maxScore += $item['weight']; + $a = strtolower((string) ($answers[$item['key']] ?? 'no')); + if (!isset($scoreMap[$a])) $a = 'no'; + $clean[$item['key']] = $a; + $earned += $item['weight'] * $scoreMap[$a]; + } + $score = $maxScore > 0 ? (int) round($earned / $maxScore * 100) : 0; + $riskLevel = $score >= 80 ? 'low' : ($score >= 60 ? 'medium' : ($score >= 40 ? 'high' : 'critical')); + + Database::query( + 'UPDATE supplier_questionnaires SET status=?, answers=?, score=?, risk_level=?, completed_at=NOW() WHERE id=?', + ['completed', json_encode($clean, JSON_UNESCAPED_UNICODE), $score, $riskLevel, $q['id']] + ); + Database::query( + 'UPDATE suppliers SET risk_score=?, criticality=?, last_assessment_date=CURDATE() WHERE id=? AND organization_id=?', + [100 - $score, $riskLevel, $q['supplier_id'], $q['organization_id']] + ); + + $this->jsonSuccess(['score' => $score, 'risk_level' => $riskLevel], 'Questionario inviato. Grazie.', 201); + } + + /** GET /api/supply-chain/{id}/questionnaire-status (JWT). */ + public function questionnaireStatus(int $id): void + { + $this->requireOrgAccess(); + $rows = Database::fetchAll( + 'SELECT id, status, score, risk_level, sent_to_email, sent_at, completed_at, expires_at + FROM supplier_questionnaires WHERE supplier_id = ? AND organization_id = ? ORDER BY sent_at DESC', + [$id, $this->getCurrentOrgId()] + ); + $this->jsonSuccess(['questionnaires' => $rows]); + } + + /** Risolve un token grezzo nel record questionario, validando scadenza. */ + private function resolveQuestionnaire(string $rawToken): array + { + if (!preg_match('/^sq_[a-f0-9]{40}$/', $rawToken)) { + $this->jsonError('Token non valido', 404, 'INVALID_TOKEN'); + } + $q = Database::fetchOne('SELECT * FROM supplier_questionnaires WHERE token_hash = ?', [hash('sha256', $rawToken)]); + if (!$q) { + $this->jsonError('Questionario non trovato', 404, 'NOT_FOUND'); + } + if ($q['expires_at'] && strtotime($q['expires_at']) < time() && $q['status'] !== 'completed') { + Database::query('UPDATE supplier_questionnaires SET status=? WHERE id=?', ['expired', $q['id']]); + $this->jsonError('Questionario scaduto', 410, 'EXPIRED'); + } + return $q; + } }