Modernizzare un gestionale finanziario PHP 5.4 sotto vincolo NIS2: quattro mesi per portare a compliance un sistema di 93.000 righe con dati di 11.000 clienti
Il 28 agosto 2025 ho ricevuto una telefonata da un commercialista di Novara che gestisce un piccolo studio di intermediazione creditizia - una di quelle realtà che aiutano privati e piccole imprese a ottenere mutui, prestiti personali, cessioni del quinto, confrontando le offerte di istituti di credito diversi. Undici dipendenti, 11.000 clienti in portafoglio accumulati in tredici anni di attività, un fatturato di circa 1,2 milioni di euro, e un gestionale custom costruito nel 2012 da uno sviluppatore locale che lo aveva poi mantenuto fino al 2020, anno in cui aveva chiuso partita IVA ed era passato a lavorare come dipendente in un'azienda di Milano.
Il motivo della chiamata non era tecnico - era normativo. Il consulente compliance dello studio aveva appena completato un'analisi di gap rispetto alla direttiva NIS2, recepita in Italia con il D.Lgs. 138/2024, e al regolamento DORA (Reg. UE 2022/2554), applicabile dal 17 gennaio 2025 a tutte le entità finanziarie nell'Unione. La società di intermediazione creditizia rientrava nel perimetro come soggetto importante ai sensi dell'articolo 3 della NIS2, e il registro DORA degli accordi contrattuali con fornitori ICT avrebbe dovuto essere pronto per la prima scadenza di reporting. Il gap assessment aveva prodotto un documento di 34 pagine con 47 non-conformità. Il commercialista mi ha detto: "Il nostro consulente compliance dice che siamo fuori da tutto. L'auditor arriva a gennaio. Cosa possiamo fare?"
In quattro mesi - da settembre a dicembre 2025 - ho portato il gestionale da PHP 5.4 a PHP 8.4, implementato cifratura at-rest dei dati personali e finanziari, costruito un audit trail completo, introdotto autenticazione moderna con 2FA, riscritto la gestione delle password, predisposto il sistema di backup e disaster recovery, e prodotto la documentazione tecnica che ha permesso allo studio di superare la verifica dell'auditor nel gennaio 2026. In questo articolo racconto il percorso completo - non come guida teorica, ma come caso operativo con le decisioni reali, i vincoli di budget, e i compromessi tecnici che chiunque lavori con PMI italiane in settori regolamentati conosce.
Perché una società di intermediazione creditizia deve preoccuparsi di NIS2 e DORA contemporaneamente?
La risposta breve è che i due framework si sovrappongono ma non si sostituiscono. La guida tecnica ENISA per l'implementazione NIS2 definisce misure di cybersecurity risk management che si applicano orizzontalmente a tutti i settori coperti dalla direttiva. DORA - il Digital Operational Resilience Act - è "lex specialis" per il settore finanziario: definisce requisiti più stringenti e specifici su gestione del rischio ICT, testing di resilienza, gestione dei fornitori ICT terzi e incident reporting. L'EBA chiarisce esplicitamente che le entità finanziarie nel perimetro DORA sono esentate da alcune obbligazioni NIS2 dove DORA è più specifico, ma restano vincolate a entrambi i framework e soggette alla supervisione delle rispettive autorità competenti.
Per il titolare dello studio, tradotto in termini comprensibili: NIS2 dice "devi avere misure di sicurezza adeguate, un piano di incident response, e devi notificare gli incidenti entro 24 ore". DORA aggiunge "devi anche tenere un registro dei tuoi fornitori ICT, fare penetration test periodici, formare il personale sulla resilienza operativa, e dimostrare di poter continuare a operare anche se il tuo fornitore di hosting si spegne domani".
Per un decision maker che sta leggendo questo articolo, il punto chiave è: non si tratta di "adeguarsi alla normativa" come esercizio burocratico. Si tratta di proteggere un asset - i dati dei clienti - la cui compromissione avrebbe conseguenze reputazionali, legali e finanziarie potenzialmente terminali per una realtà di 11 dipendenti. Una sanzione NIS2 per soggetti importanti può arrivare a 7 milioni di euro o l'1,4% del fatturato mondiale annuo. Per una società da 1,2 milioni, anche una sanzione ridotta sarebbe un colpo devastante.
Se gestisci un'applicazione che tratta dati finanziari o personali sensibili e vuoi capire il tuo livello di esposizione rispetto a NIS2 e DORA, nel mio profilo professionale trovi l'esperienza concreta su progetti di adeguamento normativo nel settore dei servizi digitali e finanziari.
Cosa ho trovato: l'inventario dei problemi nei primi 5 giorni
I primi cinque giorni li ho dedicati esclusivamente all'audit. Non ho toccato una riga di codice. Ho mappato lo stato del sistema su quattro assi: sicurezza applicativa, sicurezza infrastrutturale, protezione dei dati, e capacità di risposta agli incidenti. Il metodo è lo stesso che descrivo nell'articolo sull'audit tecnico iniziale per i primi 30 giorni, ma con un focus specifico sulla compliance normativa.
Infrastruttura: il gestionale girava su un VPS OVH con Debian 9 (end-of-life dal giugno 2022), Apache 2.4.25, PHP 5.4.45 (end-of-life dal settembre 2015 - undici anni senza patch), MySQL 5.5 (end-of-life dal dicembre 2018). Il VPS non aveva firewall configurato, SSH con autenticazione password su porta 22, zero fail2ban, zero monitoring. Il backup era un cron job che faceva mysqldump e tar alle 3 di notte e copiava il risultato su un disco USB collegato al NAS dell'ufficio. Il disco non era cifrato. Nessuno aveva mai testato un ripristino.
Codice: 93.000 righe di PHP 5.4 procedurale distribuite su 620 file. 487 chiamate mysql_query() con concatenazione diretta di variabili - SQL injection sistematica su ogni form dell'applicazione. Nessun framework, nessun ORM, nessun template engine. Le password degli utenti erano hash MD5 senza salt - crackabili in secondi con qualsiasi rainbow table moderna. Nessun session management sicuro: le sessioni PHP usavano l'ID nel cookie senza flag HttpOnly, Secure o SameSite. Nessuna protezione CSRF. Nessuna validazione dei file caricati - i clienti uploadavano documenti di identità e buste paga che venivano salvati in una cartella dentro la webroot, accessibili con URL diretto senza autenticazione.
Dati: 11.000 schede cliente contenenti: nome, cognome, codice fiscale, indirizzo, telefono, email, professione, reddito annuo, documenti di identità (scan), buste paga (scan), contratti di mutuo (PDF), storico pratiche. Tutto in chiaro nel database MySQL. I documenti in chiaro nel filesystem, raggiungibili via URL. Nessuna cifratura at-rest, nessuna cifratura in-transit per i file (il sito era su HTTP, non HTTPS - il certificato era scaduto nel 2023 e nessuno lo aveva rinnovato).
Incident response: inesistente. Nessun audit log - nessuna traccia di chi accedeva a quali dati e quando. Nessun piano di risposta agli incidenti. Nessuna procedura di notifica al Garante. Nessun DPO formalmente designato (il commercialista faceva da referente privacy, ma non aveva il background tecnico per capire cosa succedeva nei server).
Quando ho presentato il report al titolare - un documento di 12 pagine con screenshot e comandi lanciati - la sua reazione è stata: "Ma il gestionale funziona benissimo da tredici anni." È la frase che sento più spesso nelle PMI italiane. Funziona, sì - come una macchina senza cinture che non ha ancora avuto un incidente. Il giorno che succede, non c'è airbag.
Fase 1 (settembre): migrazione runtime e chiusura delle SQL injection
Le prime quattro settimane le ho dedicate al fondamento: portare il codebase su un runtime supportato e chiudere il vettore di attacco più grave. La strategia era la stessa del caso torinese - layer di compatibilità, conversione meccanica poi parametrizzazione - ma su scala molto più grande: 487 query vs 340, e con la complessità aggiuntiva di query dinamiche costruite con logica condizionale complessa (query di ricerca con 15 filtri opzionali combinati con AND/OR).
Il salto era più lungo: da PHP 5.4 (non 5.6) a 8.4. Significava gestire anche breaking change che nella migrazione torinese non c'erano: i costruttori PHP4-style (funzioni con lo stesso nome della classe), il $this non più disponibile in closure senza use, la rimozione dei tag <? short open (il codebase ne aveva 340 - l'IDE dello sviluppatore originale li generava di default).
# Censimento completo delle incompatibilità PHP 5.4 → 8.4
./vendor/bin/phpcs --standard=PHPCompatibility \
--runtime-set testVersion 8.4 \
--report=csv --report-file=compat_report.csv \
--extensions=php src/
# Risultato: 2.847 incompatibilità
# Di cui: 1.461 mysql_* (487 query × ~3 finding ciascuna)
# 340 short open tag <?
# 287 funzioni rimosse (ereg, split, each, etc.)
# 198 costruttori PHP4-style
# 89 null passato a funzioni stringa
# 472 vari (tipo coercizione, variabili non inizializzate, etc.)Ho creato un VPS Hetzner CX32 come ambiente di staging (4 vCPU, 8 GB RAM, Debian 12, PHP 8.4) e ho iniziato il lavoro in parallelo: mentre convertivo le mysql_* a PDO, un collega di Polarity Bit si occupava dei fix meccanici (short tag, costruttori PHP4, funzioni rimosse) con Rector.
La parametrizzazione delle 487 query ha richiesto 12 giorni di lavoro. Ogni query andava analizzata individualmente perché il 30% di esse aveva logica di costruzione dinamica - clausole WHERE costruite con append condizionale:
<?php
// Pattern tipico: query builder procedurale legacy
$sql = "SELECT c.*, p.* FROM clienti c LEFT JOIN pratiche p ON c.id = p.cliente_id WHERE 1=1";
if (!empty($_POST['cognome'])) {
$sql .= " AND c.cognome LIKE '%" . $_POST['cognome'] . "%'"; // SQL injection
}
if (!empty($_POST['cf'])) {
$sql .= " AND c.codice_fiscale = '" . $_POST['cf'] . "'"; // SQL injection
}
if (!empty($_POST['stato'])) {
$sql .= " AND p.stato = '" . $_POST['stato'] . "'"; // SQL injection
}
// ... altri 12 filtri simili
// Conversione a prepared statement con binding dinamico
$sql = "SELECT c.*, p.* FROM clienti c LEFT JOIN pratiche p ON c.id = p.cliente_id WHERE 1=1";
$params = [];
if (!empty($_POST['cognome'])) {
$sql .= " AND c.cognome LIKE ?";
$params[] = '%' . $_POST['cognome'] . '%';
}
if (!empty($_POST['cf'])) {
$sql .= " AND c.codice_fiscale = ?";
$params[] = $_POST['cf'];
}
if (!empty($_POST['stato'])) {
$sql .= " AND p.stato = ?";
$params[] = $_POST['stato'];
}
$stmt = $pdo->prepare($sql);
$stmt->execute($params);A fine settembre, l'applicazione girava su PHP 8.4 in staging con zero fatal error e zero SQL injection. La superficie di attacco più grave era chiusa.
Fase 2 (ottobre): cifratura dei dati e protezione dei documenti
La NIS2 (Articolo 21, comma 2, lettera h) richiede "politiche e procedure relative all'uso di crittografia e, se del caso, della cifratura". DORA (Articolo 9, comma 4, lettera d) è ancora più esplicito: le entità finanziarie devono implementare meccanismi di crittografia per i dati sensibili at-rest e in-transit. Per uno studio che custodisce redditi, codici fiscali e scansioni di documenti di identità, la cifratura non è "se del caso" - è obbligatoria.
Cifratura at-rest dei campi database. Ho implementato cifratura AES-256-CBC su tutti i campi contenenti dati personali identificativi e finanziari: codice fiscale, indirizzo, telefono, email, professione, reddito annuo, IBAN. Ho usato le funzioni OpenSSL native di PHP con una chiave di cifratura derivata da una master key conservata in un file fuori dalla webroot, con permessi 600 e proprietario root.
<?php
// lib/DataEncryption.php - cifratura AES-256-CBC per dati sensibili
class DataEncryption
{
private string $key;
private string $cipher = 'aes-256-cbc';
public function __construct()
{
$keyFile = '/etc/gestionale/encryption.key';
if (!file_exists($keyFile)) {
throw new RuntimeException('Encryption key file not found');
}
$this->key = trim(file_get_contents($keyFile));
}
public function encrypt(string $plaintext): string
{
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($this->cipher));
$ciphertext = openssl_encrypt($plaintext, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv);
// Formato: base64(iv + ciphertext) - iv serve per la decifratura
return base64_encode($iv . $ciphertext);
}
public function decrypt(string $encrypted): string
{
$data = base64_decode($encrypted);
$ivLength = openssl_cipher_iv_length($this->cipher);
$iv = substr($data, 0, $ivLength);
$ciphertext = substr($data, $ivLength);
$result = openssl_decrypt($ciphertext, $this->cipher, $this->key, OPENSSL_RAW_DATA, $iv);
if ($result === false) {
throw new RuntimeException('Decryption failed - key mismatch or corrupted data');
}
return $result;
}
}La migrazione dei dati esistenti (11.000 clienti × 7 campi) ha richiesto uno script di batch che leggeva ogni record, cifrava i campi, e riscriveva. L'ho eseguito di notte, in una finestra di manutenzione concordata, con un backup completo immediatamente prima. Il processo ha impiegato 47 minuti - accettabile per un'operazione una tantum.
Protezione dei documenti. I 23.000+ file (scan di identità, buste paga, contratti) sono stati spostati fuori dalla webroot in una directory protetta (/var/gestionale/documents/), serviti solo attraverso uno script PHP con autenticazione, autorizzazione role-based (solo l'operatore assegnatario e l'amministratore possono accedere ai documenti di un dato cliente), e logging di ogni accesso.
<?php
// document_serve.php - accesso controllato ai documenti
session_start();
if (empty($_SESSION['user_id'])) {
http_response_code(403);
exit('Non autorizzato');
}
$docId = (int) ($_GET['id'] ?? 0);
$doc = DatabaseCompat::query(
"SELECT d.path, d.cliente_id, d.tipo FROM documenti d WHERE d.id = ?",
[$docId]
)->fetch();
if (!$doc) {
http_response_code(404);
exit;
}
// Verifica autorizzazione: solo operatore assegnatario o admin
$authorized = DatabaseCompat::query(
"SELECT 1 FROM clienti c WHERE c.id = ? AND (c.operatore_id = ? OR ? IN (SELECT u.id FROM utenti u WHERE u.ruolo = 'admin'))",
[$doc['cliente_id'], $_SESSION['user_id'], $_SESSION['user_id']]
)->fetch();
if (!$authorized) {
// Log tentativo di accesso non autorizzato
AuditLog::write('document_access_denied', [
'user_id' => $_SESSION['user_id'],
'document_id' => $docId,
'client_id' => $doc['cliente_id'],
]);
http_response_code(403);
exit('Accesso negato');
}
// Log accesso autorizzato
AuditLog::write('document_accessed', [
'user_id' => $_SESSION['user_id'],
'document_id' => $docId,
'client_id' => $doc['cliente_id'],
'document_type' => $doc['tipo'],
]);
// Serve il file con header appropriati
$filePath = '/var/gestionale/documents/' . $doc['path'];
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($doc['path']) . '"');
header('X-Content-Type-Options: nosniff');
readfile($filePath);HTTPS. Ho installato Certbot con rinnovo automatico e configurato Nginx (che ha sostituito Apache) con TLS 1.3, HSTS, e header di sicurezza completi (CSP, X-Frame-Options, X-Content-Type-Options). La configurazione di hardening applicativo è documentata nella mia checklist NIS2-ready per Laravel e Symfony - i principi si applicano identicamente a codice PHP procedurale.
Fase 3 (novembre): audit trail, password e autenticazione
Senza audit trail, la compliance NIS2 e DORA è impossibile. DORA (Articolo 10) richiede esplicitamente la capacità di rilevare anomalie e incidenti. NIS2 (Articolo 21, comma 2, lettera b) richiede "gestione degli incidenti". Se non registri chi accede a cosa e quando, non puoi né rilevare né gestire un incidente.
Audit logging. Ho costruito un sistema di audit log dedicato - non i log applicativi di debug, ma un log strutturato delle operazioni sui dati sensibili, conservato in una tabella MySQL separata con politica di retention di 5 anni (requisito del consulente compliance per il settore creditizio) e backup indipendente.
<?php
// lib/AuditLog.php - audit trail NIS2/DORA compliant
class AuditLog
{
public static function write(string $event, array $context = []): void
{
$data = [
'timestamp' => date('Y-m-d H:i:s'),
'event' => $event,
'user_id' => $_SESSION['user_id'] ?? null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'cli',
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? 'cli', 0, 500),
'context' => json_encode($context, JSON_UNESCAPED_UNICODE),
];
// Scrittura diretta - non usa il layer applicativo per evitare dipendenze circolari
$pdo = new PDO(
'mysql:host=localhost;dbname=gestionale_audit',
'audit_writer', // Utente MySQL con SOLO INSERT su audit_log
file_get_contents('/etc/gestionale/audit_db.key')
);
$stmt = $pdo->prepare(
"INSERT INTO audit_log (timestamp, event, user_id, ip_address, user_agent, context) VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt->execute(array_values($data));
}
}Il database di audit è separato dal database applicativo - con un utente MySQL dedicato che ha solo permesso INSERT. Nessuno, nemmeno l'amministratore dell'applicazione, può modificare o cancellare i log di audit. Questo requisito è esplicitamente richiesto dal framework DORA per garantire l'integrità delle evidenze in caso di forensics post-incidente.
Password. Le password in MD5 sono state migrate a password_hash() con algoritmo PASSWORD_ARGON2ID. La migrazione è avvenuta in modo trasparente: al primo login dopo la migrazione, il sistema verifica la password con MD5 (vecchio hash), e se corretta la ri-hash con Argon2ID e aggiorna il record. Nessun utente ha dovuto cambiare password - ma il sistema ha forzato un cambio password per tutti entro 30 giorni con un banner persistente.
<?php
// Migrazione trasparente MD5 → Argon2ID al login
function authenticateUser(string $username, string $password): ?array
{
$user = DatabaseCompat::query(
"SELECT * FROM utenti WHERE username = ?",
[$username]
)->fetch();
if (!$user) {
return null;
}
// Prova prima Argon2ID (nuovo hash)
if (password_verify($password, $user['password_hash'])) {
// Rehash se necessario (algorithm upgrade futuro)
if (password_needs_rehash($user['password_hash'], PASSWORD_ARGON2ID)) {
$newHash = password_hash($password, PASSWORD_ARGON2ID);
DatabaseCompat::query("UPDATE utenti SET password_hash = ? WHERE id = ?", [$newHash, $user['id']]);
}
return $user;
}
// Fallback: prova MD5 (vecchio hash - solo durante la migrazione)
if (md5($password) === $user['password_hash']) {
// Migra immediatamente a Argon2ID
$newHash = password_hash($password, PASSWORD_ARGON2ID);
DatabaseCompat::query("UPDATE utenti SET password_hash = ? WHERE id = ?", [$newHash, $user['id']]);
AuditLog::write('password_migrated_md5_to_argon2', ['user_id' => $user['id']]);
return $user;
}
AuditLog::write('login_failed', ['username' => $username]);
return null;
}2FA. Ho implementato TOTP (Time-based One-Time Password) con la libreria pragmarx/google2fa-php via Composer. Obbligatorio per tutti gli utenti con accesso ai dati dei clienti. L'auditor lo ha richiesto esplicitamente come misura di autenticazione forte ai sensi dell'Articolo 9 di DORA.
Fase 4 (dicembre): backup, disaster recovery e documentazione
L'ultimo mese è stato dedicato all'infrastruttura di resilienza e alla documentazione - la parte che molti trascurano e che invece è ciò che l'auditor guarda per primo.
Backup e disaster recovery. Ho implementato BorgBackup con deduplicazione verso una Hetzner Storage Box (1 TB, data center separato da quello del VPS applicativo). Retention: 7 giornalieri + 4 settimanali + 12 mensili. Verifica automatica settimanale con borg check. Il database di audit ha un backup separato, su un percorso di storage diverso, per garantire l'integrità delle evidenze anche in caso di compromissione del backup principale. Ho testato il ripristino completo tre volte prima della consegna - RTO verificato: 52 minuti dall'inizio della procedura all'applicazione operativa. Il piano completo di disaster recovery segue il framework che documento nel mio articolo sul piano DR per applicazioni PHP e continuità operativa per PMI.
Piano di incident response. Documento strutturato con la procedura di notifica 24-72-30 allineata a NIS2 e DORA: notifica iniziale all'autorità competente entro 24 ore dalla scoperta dell'incidente, notifica completa entro 72 ore con analisi di impatto, report finale entro 30 giorni. Per il dettaglio della procedura operativa di incident response, rimando al mio articolo sull'incident response in 72 ore per Laravel e Symfony NIS2-ready - la sequenza è identica per codice procedurale.
Documentazione tecnica. Ho prodotto sei documenti che l'auditor ha valutato nella verifica di gennaio:
- Inventario degli asset ICT - tutti i sistemi, le dipendenze, i fornitori (Hetzner, Certbot, librerie Composer)
- Politica di gestione del rischio ICT - analisi dei rischi, misure implementate, rischi residui accettati
- Procedura di incident response - timeline 24-72-30, responsabilità, template di notifica
- Piano di backup e disaster recovery - procedura, testing, RTO/RPO documentati
- Registro dei trattamenti dati - per ogni campo/documento: base giuridica, periodo di conservazione, misure di protezione
- Report di penetration test - condotto da un collega Red Team di Polarity Bit, con 6 finding risolti (4 durante la fase 2, 2 durante la fase 3)
Cosa serve sapere se sei un titolare o un decision maker in una situazione simile
Il costo totale dell'intervento - quattro mesi di consulenza, infrastruttura Hetzner, penetration test, supporto documentale - è stato contenuto entro un budget che una società da 1,2 milioni di fatturato poteva sostenere senza stress finanziario. Il costo della non-compliance - una sanzione NIS2, un data breach con notifica al Garante, la perdita di reputazione presso gli istituti di credito convenzionati - sarebbe stato ordini di grandezza superiore.
Il punto che ripeto a ogni titolare di PMI che lavora in settori regolamentati è questo: la compliance non è un progetto IT, è un progetto di business. L'IT è il mezzo, non il fine. Il fine è proteggere i dati dei tuoi clienti, garantire la continuità del servizio, e dimostrare a regolatori e partner commerciali che la tua organizzazione è affidabile. Un gestionale PHP 5.4 senza cifratura, senza audit trail, senza backup testato non può dimostrare nulla di tutto questo - e un auditor lo vede nei primi cinque minuti.
Se gestisci un'applicazione che tratta dati sensibili - finanziari, sanitari, identificativi - e non sei sicuro che le tue misure tecniche siano adeguate ai requisiti NIS2 e DORA, il primo passo non è una riscrittura completa. È un audit strutturato che ti dica dove sei, dove devi arrivare, e quale percorso ti ci porta con il budget e i tempi che hai. Contattami per un assessment iniziale - in una sessione di un'ora possiamo identificare i gap critici e stimare il percorso di adeguamento, senza impegno e senza allarmismo.