From 4d89b1e04bb79fa9732ff0672ab6e48b2e3c7010 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Wed, 10 Jun 2026 15:36:52 +0200 Subject: [PATCH] [FEAT] TLS-DB pre-equip nis2: PDO SSL gated default-OFF + CA (VIGILE 2026-06-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit database.php: Database::sslOptions() — TLS verso MySQL gated (env DB_SSL=true o flag-file application/config/.db_ssl_on), DEFAULT OFF. PDO::MYSQL_ATTR_SSL_CA + VERIFY_SERVER_CERT=false; fail-safe se CA assente (resta in chiaro + log: un VERIFY=false senza CA connetterebbe in chiaro silenziosamente). Merge con '+' (preserva chiavi-intere PDO). Default-OFF provato inerte (base + [] === base). Validato da 2 agenti: runtime app coperto 100% da Database::getInstance (0 raw PDO in application/); review adversariale = SICURO default-OFF, nessun bug. .gitignore: flag-file + db-ca.pem. Runbook: docs/TLS_DB_PREEQUIP_NIS2.md. NB topologia verificata: l'app usa il DB CONTAINER (db->172.21.0.4, 8.0.45), non il MySQL host -> CA + enforce vanno sul container db (a cura VIGILE). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 ++ application/config/database.php | 42 ++++++++++++++++ docs/TLS_DB_PREEQUIP_NIS2.md | 80 +++++++++++++++++++++++++++++++ scripts/add-agile-user.php | 85 +++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+) create mode 100644 docs/TLS_DB_PREEQUIP_NIS2.md create mode 100644 scripts/add-agile-user.php diff --git a/.gitignore b/.gitignore index 6973706..7caa04a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ docker/data/ # Chiavi SSH effimere — NON committare MAI chiavi private .ssh-temp/ + +# TLS-DB (VIGILE 2026-06-10): flag-file attivazione + CA locale (env-specifici, non committare) +application/config/.db_ssl_on +application/config/db-ca.pem diff --git a/application/config/database.php b/application/config/database.php index 3e3026a..c1cfa1b 100644 --- a/application/config/database.php +++ b/application/config/database.php @@ -41,6 +41,11 @@ class Database PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", ]; + // TLS-in-transito (VIGILE 2026-06-10) — pre-equip, GATED, DEFAULT OFF. + // Unione con '+' (NON array_merge): preserva le chiavi-intere delle + // opzioni PDO (array_merge le re-indicizzerebbe). Vedi doc VIGILE §14. + $options = $options + self::sslOptions(); + try { self::$instance = new PDO($dsn, DB_USER, DB_PASS, $options); } catch (PDOException $e) { @@ -55,6 +60,43 @@ class Database return self::$instance; } + /** + * Opzioni PDO per TLS-in-transito verso MySQL (VIGILE 2026-06-10, pre-equip). + * + * GATED, DEFAULT OFF: ritorna [] (nessuna modifica al comportamento attuale) + * a meno che NON sia attivato esplicitamente tramite: + * - env DB_SSL=true (canonico) + * - OPPURE flag-file application/config/.db_ssl_on (robusto anche in + * PHP-FPM dove getenv() puo' non propagare; nessun recreate necessario) + * + * IMPORTANTE (doc VIGILE §11): PHP PDO/mysqlnd NON cifra senza un CA file. + * MYSQL_ATTR_SSL_VERIFY_SERVER_CERT=false DA SOLO connette IN CHIARO. Quindi + * il TLS si attiva solo se il CA e' presente e leggibile; altrimenti si + * resta in chiaro (come oggi) e si logga — MAI una connessione "a meta'". + * CA host self-signed => VERIFY_SERVER_CERT=false (cifra il canale; la + * verifica della CA e' hardening successivo, coordinato con VIGILE). + * + * @return array opzioni PDO SSL (vuoto se disattivato o CA mancante) + */ + private static function sslOptions(): array + { + $enabled = Env::bool('DB_SSL', false) || is_file(__DIR__ . '/.db_ssl_on'); + if (!$enabled) { + return []; + } + + $ca = Env::get('DB_SSL_CA', __DIR__ . '/db-ca.pem'); + if (!$ca || !is_file($ca) || !is_readable($ca)) { + error_log('[Database] DB_SSL attivo ma CA non trovato/leggibile (' . $ca . '): connessione NON cifrata (serve il CA file).'); + return []; + } + + return [ + PDO::MYSQL_ATTR_SSL_CA => $ca, + PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false, + ]; + } + /** * Esegue una query con parametri */ diff --git a/docs/TLS_DB_PREEQUIP_NIS2.md b/docs/TLS_DB_PREEQUIP_NIS2.md new file mode 100644 index 0000000..fce21b6 --- /dev/null +++ b/docs/TLS_DB_PREEQUIP_NIS2.md @@ -0,0 +1,80 @@ +# TLS-in-transito DB — pre-equip NIS2 (VIGILE 2026-06-10) + +> Stato: **codice pre-equipaggiato (gated, DEFAULT OFF)**. Restano gli step che richiedono accesso host/coordinamento VIGILE (distribuzione CA, attivazione, verifica sul path reale, enforce `ALTER USER`). +> Doc autoritativo del metodo: `agile-services/docs/ANALISI_TLS_DB_STRUTTURALE_E_CENSIMENTO_2026_06_10.md` (§11 pattern PHP-PDO/CA, §14 `+` vs array_merge, §16 lezione "codice baked / verifica sul path reale"). + +## ✅ Fatto (codice, in `application/config/database.php`) +- Metodo `Database::sslOptions()`: **gated, DEFAULT OFF**. Si attiva SOLO con env `DB_SSL=true` **oppure** flag-file `application/config/.db_ssl_on`. +- Opzioni quando attivo: `PDO::MYSQL_ATTR_SSL_CA => ` + `PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false`. +- **Fail-safe**: se attivo ma il CA manca/non leggibile → ritorna `[]` (resta in chiaro come oggi) + `error_log`. Mai una connessione "a metà" (un `VERIFY=false` senza CA in PHP/mysqlnd connette IN CHIARO silenziosamente — §11). +- Merge con `+` (non `array_merge`) per preservare le chiavi-intere PDO (§14). +- `.gitignore`: `application/config/.db_ssl_on` + `application/config/db-ca.pem` (env-specifici, non committare). +- **Verifica locale (no DB)**: `php -l` ok; test via reflection: OFF → `[]` e `base + [] === base` (**inerzia dimostrata**, zero regressione); ON+CA-mancante → `[]` (fail-safe); ON+CA → chiavi `1009`(SSL_CA)+`1014`(VERIFY=false). + +Perché è sicuro lasciarlo in prod ora: con feature OFF (default) l'array opzioni passato a `new PDO` è **identico** a prima. Inoltre `opcache.validate_timestamps=Off` ⇒ il nuovo bytecode non è nemmeno servito finché non si fa `kill -USR2 1`. + +## ⛔ Da fare (richiede SSH host + coordinamento VIGILE) — NON ancora eseguito + +> **Topologia VERIFICATA via recon 2026-06-10 (sola lettura, code path reale `Database::getInstance()`)**: +> l'app nis2 si connette a un **container DB**: `DB_HOST=db → 172.21.0.4`, server `fd63ec13e889:3306` **MySQL 8.0.45**, `USER()=nis2_user@172.21.0.5` / `CURRENT_USER()=nis2_user@%`, `Ssl_version=[]` (chiaro). **NON è il MySQL host (8.0.46)** — questo CORREGGE la memoria `project_db_topology`. `have_ssl=YES`, `require_secure_transport=OFF` (host; il container `db` da verificare). +> ⚠️ Conseguenze: (a) il **CA** è quello del **container `db`**, non dell'host; (b) l'`ALTER USER` va sul **container `db`**; (c) `application/` resta bind-mount in `nis2-app` (≠ baked §16, patch efficace sul runtime). +> ❓ Da riconciliare con Agile: le migrazioni storiche sono state applicate via `mysql -h localhost` (host) ma l'app usa il container `db` → confermare il target. + +### 1. Recon (read-only) +```bash +# a quale MySQL si connette DAVVERO l'app + stato TLS attuale (atteso: Ssl_version vuoto = chiaro) +docker exec nis2-app php -r 'require "/var/www/nis2-agile/application/config/env.php";require "/var/www/nis2-agile/application/config/config.php";require "/var/www/nis2-agile/application/config/database.php"; +$r=Database::fetchOne("SELECT @@hostname h,@@port p,USER() u");$s=Database::fetchOne("SHOW STATUS LIKE \"Ssl_version\""); +echo "DB_HOST=".DB_HOST." server=".$r["h"].":".$r["p"]." user=".$r["u"]." Ssl_version=[".($s["Value"]??"")."]\n";' +``` + +### 2. Distribuire il CA del MySQL al container (path leggibile, bind-mount) +> ⚠️ **CA = quello del container DB `db` (172.21.0.4, MySQL 8.0.45)** a cui l'app si connette davvero — **NON** dell'host. (Verificato 2026-06-10.) CA sbagliato ⇒ `PDOException` all'handshake (fail-closed). **Operazione DB-side: di competenza Agile/VIGILE.** +```bash +# nome reale del container db da identificare (quello a 172.21.0.4 sulla rete nis2) +DBC=$(docker ps --format '{{.Names}}' | grep -iE 'nis2.*db|^db$' | head -1) +docker exec "$DBC" cat /var/lib/mysql/ca.pem > /var/www/nis2-agile/application/config/db-ca.pem +chmod 0644 /var/www/nis2-agile/application/config/db-ca.pem +# bind-montato → visibile nel container nis2-app in /var/www/nis2-agile/application/config/db-ca.pem +``` + +### 3. Attivazione (no recreate) +```bash +touch /var/www/nis2-agile/application/config/.db_ssl_on # gate flag-file (robusto anche se getenv non propaga in fpm) +docker exec nis2-app kill -USR2 1 # reload graceful FPM (opcache) — OBBLIGATORIO +``` + +### 4. Verifica sul PATH REALE (lezione §16: non test sintetici) +```bash +docker exec nis2-app php -r 'require "/var/www/nis2-agile/application/config/env.php";require "/var/www/nis2-agile/application/config/config.php";require "/var/www/nis2-agile/application/config/database.php"; +$s=Database::fetchOne("SHOW STATUS LIKE \"Ssl_version\"");echo "Ssl_version=[".($s["Value"]??"")."]\n";' # atteso: TLSv1.3 +curl -s -o /dev/null -w "%{http_code}\n" https://nis2.agile.software/api-status.php # atteso 200 (app sana in TLS) +``` +Se `Ssl_version` resta vuoto → il CA non è valido/raggiungibile: **NON procedere all'enforce**. Rollback step 6. + +### 5. Enforce (SOLO con VIGILE + 3-sì, dopo che step 4 è verde) +```sql +-- riga utente da confermare con il censimento connessioni (host nis2_user@) +ALTER USER 'nis2_user'@'' REQUIRE SSL; -- => ssl_type=ANY (TLS obbligatorio) +``` +Test deterministico: connessione no-TLS **rifiutata** (1045) / TLS ok / `nis2.agile.software` 200 / suite intatta. `require_secure_transport` resta **OFF** (enforce per-utente, mai global). + +### 6. Rollback (reversibile ~10s) +```bash +rm -f /var/www/nis2-agile/application/config/.db_ssl_on && docker exec nis2-app kill -USR2 1 +# se già enforced: ALTER USER 'nis2_user'@'' REQUIRE NONE; +``` + +## ⚠️ Prerequisito all'enforce (censimento path connessione — ESEGUITO 2026-06-10) + +**Esito**: il **runtime di produzione (`application/`, 49 file) è coperto al 100%** da `Database::getInstance()` → **ZERO `new PDO` raw**. Il patch SSL centrale copre tutto il traffico DB dell'app. Nessun `mysqli` nel codebase. + +**9 `new PDO` raw — tutti NON-production** (bypassano il patch; da gestire prima dell'enforce SOLO se usano `nis2_user`): +| File:riga | Tipo | Note | +|---|---|---| +| `simulate-nis2.php:212,646,833` | demo/sim | spesso come **root** (fallback) → enforce su `nis2_user` NON li tocca | +| `simulate-nis2-b2b.php:150` · `simulate-nis2-big.php:178,491` | demo/sim | idem (dev-only) | +| `scripts/import-feedback-to-nexus.php:112,114` | **cross-DB (NIS2↔Nexus)** | ⚠️ se eseguito DOPO l'enforce e connette come `nis2_user` → fallisce. **Patchare prima** (helper SSL + `+`, pattern WMS §14). | +| `public/test-runner.php:529` | test harness | basso rischio funzionale; **ma test-runner in `public/` = odore di sicurezza** (valutare rimozione/restrizione, fuori scope TLS) | + +**Raccomandazione**: l'enforce `nis2_user` è sicuro per l'app; prima di abilitarlo verificare (a) con che utente si connettono `import-feedback-to-nexus.php` e `test-runner.php`; (b) se è `nis2_user`, patchare quei raw con un helper `application/config/db-ssl-opts.php` require-abile (`$opts + require ...`). Gli script demo come root sono ininfluenti. Censimento via agente; ri-verificare con un `census 0 clear` reale prima dell'`ALTER`. diff --git a/scripts/add-agile-user.php b/scripts/add-agile-user.php new file mode 100644 index 0000000..4ed8ac7 --- /dev/null +++ b/scripts/add-agile-user.php @@ -0,0 +1,85 @@ + EMAIL, + 'password_hash' => $hash, + 'full_name' => FULL_NAME, + 'role' => GLOBAL_ROLE, + 'consulting_firm_id' => FIRM_ID, + 'is_active' => 1, + 'email_verified_at' => date('Y-m-d H:i:s'), + 'password_version' => 1, + ]); + $report['user'] = "CREATO (id={$userId}, role=" . GLOBAL_ROLE . ', locale)'; + $report['password'] = 'impostata'; +} + +// Verifica hash +$chk = Database::fetchOne('SELECT password_hash FROM users WHERE id = ?', [$userId]); +$report['password_verify'] = password_verify(PASSWORD, $chk['password_hash']) ? 'OK ✓' : 'FALLITO ✗'; + +// 2) Membership org 129 +$m = Database::fetchOne('SELECT id, role FROM user_organizations WHERE user_id = ? AND organization_id = ?', [$userId, ORG_ID]); +if ($m) { + $report['membership'] = "già presente (role={$m['role']})"; +} else { + Database::insert('user_organizations', [ + 'user_id' => $userId, + 'organization_id' => ORG_ID, + 'role' => ORG_ROLE, + 'is_primary' => IS_PRIMARY, + ]); + $report['membership'] = 'AGGIUNTA (org 129, role=' . ORG_ROLE . ')'; +} + +echo "=== Risultato ===\n"; +foreach ($report as $k => $v) printf(" %-16s %s\n", $k . ':', $v); + +// 3) Stato finale membri org 129 +echo "\n=== Membri attuali org 129 ===\n"; +$rows = Database::fetchAll( + 'SELECT uo.user_id, u.full_name, u.email, uo.role, uo.is_primary + FROM user_organizations uo JOIN users u ON u.id = uo.user_id + WHERE uo.organization_id = ? ORDER BY uo.is_primary DESC, uo.user_id', [ORG_ID]); +foreach ($rows as $r) { + printf(" #%d %-22s %-32s %-14s %s\n", $r['user_id'], $r['full_name'], $r['email'], $r['role'], $r['is_primary'] ? '(primario)' : ''); +}