jsonError('API Key mancante', 401, 'MISSING_API_KEY'); } // Hash SHA-256 della chiave $keyHash = hash('sha256', $rawKey); // Cerca in DB $record = Database::fetchOne( 'SELECT ak.*, o.name as org_name, o.nis2_entity_type, o.sector FROM api_keys ak JOIN organizations o ON o.id = ak.organization_id WHERE ak.key_hash = ? AND ak.is_active = 1 AND (ak.expires_at IS NULL OR ak.expires_at > NOW())', [$keyHash] ); if (!$record) { $this->jsonError('API Key non valida o scaduta', 401, 'INVALID_API_KEY'); } // Verifica scope $scopes = json_decode($record['scopes'], true) ?? []; if (!in_array($scope, $scopes) && !in_array('read:all', $scopes)) { $this->jsonError("Scope '{$scope}' non autorizzato per questa chiave", 403, 'SCOPE_DENIED'); } // Rate limiting per API key $this->checkRateLimit($record['key_prefix']); // Aggiorna last_used_at (async: non blocchiamo su errore) try { Database::execute( 'UPDATE api_keys SET last_used_at = NOW() WHERE id = ?', [$record['id']] ); } catch (Throwable $e) { // non critico } $this->apiKeyRecord = $record; $this->currentOrgId = (int) $record['organization_id']; } /** * Rate limiting file-based per API Key */ private function checkRateLimit(string $keyPrefix): void { if (!is_dir(self::RATE_LIMIT_DIR)) { @mkdir(self::RATE_LIMIT_DIR, 0755, true); } $file = self::RATE_LIMIT_DIR . 'key_' . preg_replace('/[^a-zA-Z0-9_]/', '_', $keyPrefix) . '.json'; $now = time(); $data = ['count' => 0, 'window_start' => $now]; if (file_exists($file)) { $raw = @json_decode(file_get_contents($file), true); if ($raw && ($now - $raw['window_start']) < self::RATE_LIMIT_WINDOW) { $data = $raw; } } if ($data['count'] >= self::RATE_LIMIT_MAX) { $retryAfter = self::RATE_LIMIT_WINDOW - ($now - $data['window_start']); header('Retry-After: ' . $retryAfter); header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX); header('X-RateLimit-Remaining: 0'); $this->jsonError('Rate limit superato. Max ' . self::RATE_LIMIT_MAX . ' req/h per API key.', 429, 'RATE_LIMITED'); } $data['count']++; file_put_contents($file, json_encode($data), LOCK_EX); header('X-RateLimit-Limit: ' . self::RATE_LIMIT_MAX); header('X-RateLimit-Remaining: ' . (self::RATE_LIMIT_MAX - $data['count'])); } /** * Headers standard per tutte le risposte Services API */ private function setServiceHeaders(): void { header('X-NIS2-API-Version: ' . self::API_VERSION); header('X-NIS2-Org-Id: ' . $this->currentOrgId); } // ══════════════════════════════════════════════════════════════════════ // ENDPOINT // ══════════════════════════════════════════════════════════════════════ /** * GET /api/services/status * Health check + info piattaforma. Nessuna auth richiesta. */ public function status(): void { $this->setServiceHeaders(); $this->jsonSuccess([ 'platform' => 'NIS2 Agile', 'version' => self::API_VERSION, 'status' => 'operational', 'regulation' => ['EU 2022/2555', 'D.Lgs. 138/2024', 'ISO 27001/27005'], 'ai_provider' => 'Anthropic Claude', 'timestamp' => date('c'), 'endpoints' => [ 'compliance_summary' => '/api/services/compliance-summary', 'risks_feed' => '/api/services/risks/feed', 'incidents_feed' => '/api/services/incidents/feed', 'controls_status' => '/api/services/controls/status', 'critical_assets' => '/api/services/assets/critical', 'suppliers_risk' => '/api/services/suppliers/risk', 'approved_policies' => '/api/services/policies/approved', 'openapi' => '/api/services/openapi', 'docs' => '/docs/api', ], ], 'NIS2 Agile Services API - Operational'); } /** * GET /api/services/compliance-summary * Compliance score aggregato per dominio Art.21. * Scope: read:compliance */ public function complianceSummary(): void { $this->requireApiKey('read:compliance'); $this->setServiceHeaders(); $orgId = $this->currentOrgId; // Score da assessment più recente completato $assessment = Database::fetchOne( 'SELECT * FROM assessments WHERE organization_id = ? AND status = "completed" ORDER BY completed_at DESC LIMIT 1', [$orgId] ); $overallScore = null; $domainScores = []; $recommendations = []; if ($assessment) { // Calcola score per dominio (10 categorie Art.21) $responses = Database::fetchAll( 'SELECT ar.*, q.category, q.weight FROM assessment_responses ar JOIN ( SELECT question_code, category, weight FROM ( SELECT question_code, JSON_UNQUOTE(JSON_EXTRACT(question_data, "$.category")) as category, CAST(JSON_UNQUOTE(JSON_EXTRACT(question_data, "$.weight")) AS DECIMAL(3,1)) as weight FROM assessment_responses WHERE assessment_id = ? ) t GROUP BY question_code ) q ON q.question_code = ar.question_code WHERE ar.assessment_id = ?', [$assessment['id'], $assessment['id']] ); // Semplificato: score per categoria $byCategory = []; foreach ($responses as $r) { $cat = $r['category'] ?? 'uncategorized'; if (!isset($byCategory[$cat])) { $byCategory[$cat] = ['total' => 0, 'count' => 0]; } $val = (int) ($r['response_value'] ?? 0); $byCategory[$cat]['total'] += $val; $byCategory[$cat]['count']++; } $totalScore = 0; $catCount = 0; foreach ($byCategory as $cat => $data) { $score = $data['count'] > 0 ? round(($data['total'] / ($data['count'] * 4)) * 100) : 0; $domainScores[] = [ 'domain' => $cat, 'score' => $score, 'status' => $score >= 70 ? 'compliant' : ($score >= 40 ? 'partial' : 'gap'), ]; $totalScore += $score; $catCount++; } $overallScore = $catCount > 0 ? round($totalScore / $catCount) : 0; // Raccomandazioni AI se disponibili if (!empty($assessment['ai_analysis'])) { $aiData = json_decode($assessment['ai_analysis'], true); $recommendations = $aiData['recommendations'] ?? []; } } // Risk summary $riskStats = Database::fetchOne( 'SELECT COUNT(*) as total, SUM(CASE WHEN status = "open" THEN 1 ELSE 0 END) as open_count, SUM(CASE WHEN risk_level IN ("high","critical") AND status = "open" THEN 1 ELSE 0 END) as high_critical, SUM(CASE WHEN status = "mitigated" THEN 1 ELSE 0 END) as mitigated FROM risks WHERE organization_id = ?', [$orgId] ); // Incident summary $incidentStats = Database::fetchOne( 'SELECT COUNT(*) as total, SUM(CASE WHEN status = "open" OR status = "investigating" THEN 1 ELSE 0 END) as open_count, SUM(CASE WHEN is_significant = 1 THEN 1 ELSE 0 END) as significant, SUM(CASE WHEN early_warning_sent = 1 THEN 1 ELSE 0 END) as notified_acn FROM incidents WHERE organization_id = ?', [$orgId] ); // Policy summary $policyStats = Database::fetchOne( 'SELECT COUNT(*) as total, SUM(CASE WHEN status = "approved" THEN 1 ELSE 0 END) as approved, SUM(CASE WHEN status IN ("draft","review") THEN 1 ELSE 0 END) as pending FROM policies WHERE organization_id = ?', [$orgId] ); $org = Database::fetchOne( 'SELECT name, nis2_entity_type, sector, employee_count FROM organizations WHERE id = ?', [$orgId] ); $this->jsonSuccess([ 'organization' => [ 'name' => $org['name'], 'entity_type' => $org['nis2_entity_type'], 'sector' => $org['sector'], ], 'overall_score' => $overallScore, 'score_label' => $this->scoreLabel($overallScore), 'domain_scores' => $domainScores, 'assessment' => $assessment ? [ 'id' => $assessment['id'], 'completed_at' => $assessment['completed_at'], 'status' => $assessment['status'], ] : null, 'risks' => [ 'total' => (int)($riskStats['total'] ?? 0), 'open' => (int)($riskStats['open_count'] ?? 0), 'high_critical'=> (int)($riskStats['high_critical'] ?? 0), 'mitigated' => (int)($riskStats['mitigated'] ?? 0), ], 'incidents' => [ 'total' => (int)($incidentStats['total'] ?? 0), 'open' => (int)($incidentStats['open_count'] ?? 0), 'significant' => (int)($incidentStats['significant'] ?? 0), 'notified_acn' => (int)($incidentStats['notified_acn'] ?? 0), ], 'policies' => [ 'total' => (int)($policyStats['total'] ?? 0), 'approved' => (int)($policyStats['approved'] ?? 0), 'pending' => (int)($policyStats['pending'] ?? 0), ], 'top_recommendations' => array_slice($recommendations, 0, 5), 'generated_at' => date('c'), ]); } /** * GET /api/services/risks/feed * Feed rischi filtrabili. * Scope: read:risks * Query: ?level=high,critical &from=2026-01-01 &area=it &limit=50 */ public function risksFeed(): void { $this->requireApiKey('read:risks'); $this->setServiceHeaders(); $orgId = $this->currentOrgId; $where = 'r.organization_id = ? AND r.deleted_at IS NULL'; $params = [$orgId]; if (!empty($_GET['level'])) { $levels = array_filter(explode(',', $_GET['level'])); $placeholders = implode(',', array_fill(0, count($levels), '?')); $where .= " AND r.risk_level IN ({$placeholders})"; $params = array_merge($params, $levels); } if (!empty($_GET['area'])) { $where .= ' AND r.category = ?'; $params[] = $_GET['area']; } if (!empty($_GET['status'])) { $where .= ' AND r.status = ?'; $params[] = $_GET['status']; } if (!empty($_GET['from'])) { $where .= ' AND r.created_at >= ?'; $params[] = $_GET['from'] . ' 00:00:00'; } $limit = min(200, max(1, (int)($_GET['limit'] ?? 50))); $risks = Database::fetchAll( "SELECT r.id, r.title, r.description, r.category, r.likelihood, r.impact, r.inherent_risk_score, r.risk_level, r.status, r.treatment_plan, r.owner_name, r.residual_risk_score, r.created_at, r.updated_at FROM risks r WHERE {$where} ORDER BY r.inherent_risk_score DESC, r.created_at DESC LIMIT {$limit}", $params ); $total = Database::count('risks', 'organization_id = ? AND deleted_at IS NULL', [$orgId]); $this->jsonSuccess([ 'risks' => $risks, 'total' => $total, 'fetched' => count($risks), 'filters' => [ 'level' => $_GET['level'] ?? null, 'area' => $_GET['area'] ?? null, 'status' => $_GET['status'] ?? null, 'from' => $_GET['from'] ?? null, ], 'generated_at' => date('c'), ]); } /** * GET /api/services/incidents/feed * Feed incidenti Art.23 filtrabili. * Scope: read:incidents * Query: ?status=open &severity=high,critical &from=2026-01-01 &significant=1 */ public function incidentsFeed(): void { $this->requireApiKey('read:incidents'); $this->setServiceHeaders(); $orgId = $this->currentOrgId; $where = 'organization_id = ?'; $params = [$orgId]; if (!empty($_GET['status'])) { $where .= ' AND status = ?'; $params[] = $_GET['status']; } if (!empty($_GET['severity'])) { $severities = array_filter(explode(',', $_GET['severity'])); $ph = implode(',', array_fill(0, count($severities), '?')); $where .= " AND severity IN ({$ph})"; $params = array_merge($params, $severities); } if (!empty($_GET['significant'])) { $where .= ' AND is_significant = 1'; } if (!empty($_GET['from'])) { $where .= ' AND detected_at >= ?'; $params[] = $_GET['from'] . ' 00:00:00'; } $limit = min(200, max(1, (int)($_GET['limit'] ?? 50))); $incidents = Database::fetchAll( "SELECT id, title, classification, severity, status, is_significant, detected_at, contained_at, resolved_at, early_warning_sent, early_warning_sent_at, notification_sent, notification_sent_at, final_report_sent, final_report_sent_at, notification_deadline, final_report_deadline, affected_systems, impact_description, created_at, updated_at FROM incidents WHERE {$where} ORDER BY detected_at DESC LIMIT {$limit}", $params ); // Aggiungi stato scadenze Art.23 $now = time(); foreach ($incidents as &$inc) { $detectedTs = strtotime($inc['detected_at']); $inc['art23_status'] = [ 'early_warning_24h' => [ 'required' => (bool)$inc['is_significant'], 'deadline' => date('c', $detectedTs + 86400), 'sent' => (bool)$inc['early_warning_sent'], 'overdue' => !$inc['early_warning_sent'] && $now > $detectedTs + 86400, ], 'notification_72h' => [ 'required' => (bool)$inc['is_significant'], 'deadline' => date('c', $detectedTs + 259200), 'sent' => (bool)$inc['notification_sent'], 'overdue' => !$inc['notification_sent'] && $now > $detectedTs + 259200, ], 'final_report_30d' => [ 'required' => (bool)$inc['is_significant'], 'deadline' => date('c', $detectedTs + 2592000), 'sent' => (bool)$inc['final_report_sent'], 'overdue' => !$inc['final_report_sent'] && $now > $detectedTs + 2592000, ], ]; } unset($inc); $total = Database::count('incidents', 'organization_id = ?', [$orgId]); $this->jsonSuccess([ 'incidents' => $incidents, 'total' => $total, 'fetched' => count($incidents), 'generated_at' => date('c'), ]); } /** * GET /api/services/controls/status * Stato controlli di sicurezza Art.21 per dominio. * Scope: read:compliance */ public function controlsStatus(): void { $this->requireApiKey('read:compliance'); $this->setServiceHeaders(); $orgId = $this->currentOrgId; $controls = Database::fetchAll( 'SELECT id, control_code, title, category, status, implementation_notes, due_date, updated_at FROM compliance_controls WHERE organization_id = ? ORDER BY category, control_code', [$orgId] ); // Raggruppa per categoria $byCategory = []; foreach ($controls as $ctrl) { $cat = $ctrl['category'] ?? 'uncategorized'; if (!isset($byCategory[$cat])) { $byCategory[$cat] = [ 'category' => $cat, 'controls' => [], 'stats' => ['total' => 0, 'implemented' => 0, 'partial' => 0, 'planned' => 0, 'not_applicable' => 0], ]; } $byCategory[$cat]['controls'][] = $ctrl; $byCategory[$cat]['stats']['total']++; $s = $ctrl['status'] ?? 'not_applicable'; if (isset($byCategory[$cat]['stats'][$s])) { $byCategory[$cat]['stats'][$s]++; } } // Score per categoria foreach ($byCategory as &$cat) { $t = $cat['stats']['total']; $i = $cat['stats']['implemented']; $p = $cat['stats']['partial']; $cat['score'] = $t > 0 ? round((($i + $p * 0.5) / $t) * 100) : 0; } unset($cat); $totals = [ 'total' => count($controls), 'implemented' => 0, 'partial' => 0, 'planned' => 0, 'not_applicable' => 0, ]; foreach ($controls as $ctrl) { $s = $ctrl['status'] ?? 'not_applicable'; if (isset($totals[$s])) $totals[$s]++; } $totals['overall_score'] = $totals['total'] > 0 ? round((($totals['implemented'] + $totals['partial'] * 0.5) / $totals['total']) * 100) : 0; $this->jsonSuccess([ 'summary' => $totals, 'by_category' => array_values($byCategory), 'generated_at' => date('c'), ]); } /** * GET /api/services/assets/critical * Asset critici e dipendenze. * Scope: read:assets * Query: ?type=server,network &criticality=high,critical */ public function assetsCritical(): void { $this->requireApiKey('read:assets'); $this->setServiceHeaders(); $orgId = $this->currentOrgId; $where = 'organization_id = ?'; $params = [$orgId]; if (!empty($_GET['type'])) { $types = array_filter(explode(',', $_GET['type'])); $ph = implode(',', array_fill(0, count($types), '?')); $where .= " AND asset_type IN ({$ph})"; $params = array_merge($params, $types); } if (!empty($_GET['criticality'])) { $crits = array_filter(explode(',', $_GET['criticality'])); $ph = implode(',', array_fill(0, count($crits), '?')); $where .= " AND criticality IN ({$ph})"; $params = array_merge($params, $crits); } else { // Default: solo high e critical $where .= " AND criticality IN ('high','critical')"; } $assets = Database::fetchAll( "SELECT id, name, asset_type, criticality, status, owner_name, location, ip_address, description, dependencies, created_at FROM assets WHERE {$where} ORDER BY FIELD(criticality,'critical','high','medium','low'), name", $params ); $this->jsonSuccess([ 'assets' => $assets, 'total' => count($assets), 'generated_at' => date('c'), ]); } /** * GET /api/services/suppliers/risk * Supplier risk overview (supply chain security). * Scope: read:supply_chain * Query: ?risk_level=high,critical &status=active */ public function suppliersRisk(): void { $this->requireApiKey('read:supply_chain'); $this->setServiceHeaders(); $orgId = $this->currentOrgId; $where = 's.organization_id = ? AND s.deleted_at IS NULL'; $params = [$orgId]; if (!empty($_GET['risk_level'])) { $levels = array_filter(explode(',', $_GET['risk_level'])); $ph = implode(',', array_fill(0, count($levels), '?')); $where .= " AND s.risk_level IN ({$ph})"; $params = array_merge($params, $levels); } if (!empty($_GET['status'])) { $where .= ' AND s.status = ?'; $params[] = $_GET['status']; } $suppliers = Database::fetchAll( "SELECT s.id, s.company_name, s.category, s.risk_level, s.status, s.last_assessment_date, s.assessment_score, s.contact_email, s.services_provided, s.critical_dependency, s.created_at, s.updated_at FROM suppliers s WHERE {$where} ORDER BY FIELD(s.risk_level,'critical','high','medium','low'), s.company_name", $params ); $stats = Database::fetchOne( "SELECT COUNT(*) as total, SUM(CASE WHEN risk_level IN ('high','critical') AND deleted_at IS NULL THEN 1 ELSE 0 END) as high_risk, SUM(CASE WHEN critical_dependency = 1 AND deleted_at IS NULL THEN 1 ELSE 0 END) as critical_deps, SUM(CASE WHEN last_assessment_date IS NULL AND deleted_at IS NULL THEN 1 ELSE 0 END) as unassessed FROM suppliers WHERE organization_id = ?", [$orgId] ); $this->jsonSuccess([ 'summary' => $stats, 'suppliers' => $suppliers, 'generated_at' => date('c'), ]); } /** * GET /api/services/policies/approved * Policy approvate con metadati (no contenuto full per default). * Scope: read:policies * Query: ?category=... &include_content=1 */ public function policiesApproved(): void { $this->requireApiKey('read:policies'); $this->setServiceHeaders(); $orgId = $this->currentOrgId; $includeContent = !empty($_GET['include_content']); $select = $includeContent ? 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated, content' : 'id, title, category, nis2_article, status, version, approved_at, next_review_date, ai_generated'; $where = 'organization_id = ? AND status = "approved"'; $params = [$orgId]; if (!empty($_GET['category'])) { $where .= ' AND category = ?'; $params[] = $_GET['category']; } $policies = Database::fetchAll( "SELECT {$select} FROM policies WHERE {$where} ORDER BY category, title", $params ); $this->jsonSuccess([ 'policies' => $policies, 'total' => count($policies), 'generated_at' => date('c'), ]); } /** * GET /api/services/openapi * Specifica OpenAPI 3.0 JSON per questa API. */ public function openapi(): void { $this->setServiceHeaders(); header('Content-Type: application/json; charset=utf-8'); $spec = [ 'openapi' => '3.0.3', 'info' => [ 'title' => 'NIS2 Agile Services API', 'description' => 'API pubblica per integrazione con sistemi esterni. Espone dati di compliance NIS2, rischi, incidenti, controlli, asset e supply chain.', 'version' => self::API_VERSION, 'contact' => ['email' => 'presidenza@agile.software'], 'license' => ['name' => 'Proprietary', 'url' => 'https://agile.software'], ], 'servers' => [ ['url' => 'https://nis2.agile.software', 'description' => 'Production'], ], 'security' => [ ['ApiKeyHeader' => []], ['BearerToken' => []], ], 'components' => [ 'securitySchemes' => [ 'ApiKeyHeader' => ['type' => 'apiKey', 'in' => 'header', 'name' => 'X-API-Key'], 'BearerToken' => ['type' => 'http', 'scheme' => 'bearer', 'bearerFormat' => 'nis2_xxxxx'], ], ], 'paths' => [ '/api/services/status' => [ 'get' => [ 'summary' => 'Status piattaforma', 'description' => 'Health check. Nessuna autenticazione richiesta.', 'security' => [], 'responses' => ['200' => ['description' => 'Platform operational']], 'tags' => ['System'], ], ], '/api/services/compliance-summary' => [ 'get' => [ 'summary' => 'Compliance summary', 'description' => 'Score aggregato per dominio Art.21, risk/incident/policy stats.', 'responses' => ['200' => ['description' => 'Compliance summary'], '401' => ['description' => 'API Key mancante']], 'tags' => ['Compliance'], ], ], '/api/services/risks/feed' => [ 'get' => [ 'summary' => 'Risk feed', 'description' => 'Feed rischi filtrabili per level, area, status, data.', 'parameters' => [ ['name' => 'level', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'], ['name' => 'area', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']], ['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 50, 'maximum' => 200]], ], 'responses' => ['200' => ['description' => 'List of risks']], 'tags' => ['Risks'], ], ], '/api/services/incidents/feed' => [ 'get' => [ 'summary' => 'Incident feed Art.23', 'description' => 'Feed incidenti con stato scadenze Art.23 (24h/72h/30d).', 'parameters' => [ ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'example' => 'high,critical'], ['name' => 'significant', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]], ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date']], ], 'responses' => ['200' => ['description' => 'List of incidents']], 'tags' => ['Incidents'], ], ], '/api/services/controls/status' => [ 'get' => [ 'summary' => 'Controlli Art.21 status', 'description' => 'Stato implementazione controlli per dominio di sicurezza.', 'responses' => ['200' => ['description' => 'Controls by domain']], 'tags' => ['Compliance'], ], ], '/api/services/assets/critical' => [ 'get' => [ 'summary' => 'Asset critici', 'description' => 'Inventario asset con criticality high/critical.', 'parameters' => [ ['name' => 'type', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'criticality', 'in' => 'query', 'schema' => ['type' => 'string']], ], 'responses' => ['200' => ['description' => 'Critical assets']], 'tags' => ['Assets'], ], ], '/api/services/suppliers/risk' => [ 'get' => [ 'summary' => 'Supplier risk overview', 'description' => 'Supply chain risk: fornitori per livello rischio.', 'parameters' => [ ['name' => 'risk_level', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string']], ], 'responses' => ['200' => ['description' => 'Suppliers risk data']], 'tags' => ['Supply Chain'], ], ], '/api/services/policies/approved' => [ 'get' => [ 'summary' => 'Policy approvate', 'description' => 'Lista policy con status approved.', 'parameters' => [ ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']], ['name' => 'include_content', 'in' => 'query', 'schema' => ['type' => 'integer', 'enum' => [0, 1]]], ], 'responses' => ['200' => ['description' => 'Approved policies']], 'tags' => ['Policies'], ], ], ], 'tags' => [ ['name' => 'System'], ['name' => 'Compliance'], ['name' => 'Risks'], ['name' => 'Incidents'], ['name' => 'Assets'], ['name' => 'Supply Chain'], ['name' => 'Policies'], ], ]; echo json_encode($spec, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); exit; } // ── Utility ─────────────────────────────────────────────────────────── private function scoreLabel(?int $score): string { if ($score === null) return 'not_assessed'; if ($score >= 80) return 'compliant'; if ($score >= 60) return 'substantially_compliant'; if ($score >= 40) return 'partial'; return 'significant_gaps'; } }