Hardening Laravel e Symfony: checklist NIS2-ready per PMI
Qualche tempo fa mi ha contattato una PMI manifatturiera del Nord Italia, alcune decine di dipendenti e un gestionale Laravel che coordina produzione, magazzino e fatturazione, per un audit di sicurezza chiesto non da loro ma dal loro principale cliente enterprise: quest'ultimo aveva richiesto evidenze di conformità NIS2 come condizione per il rinnovo del contratto di fornitura. È lo scenario che vedo più spesso oggi: la Direttiva NIS2 non si applica direttamente a una micro o piccola impresa sotto le soglie dimensionali, ma si applica ai loro clienti in scope, che a cascata sono obbligati a valutare e mitigare il rischio dei propri fornitori. Per quella PMI, "dimostrare di essere sicuri" non significava una dichiarazione: significava produrre prove verificabili di ogni controllo.
L'audit iniziale ha restituito un quadro che trovo tipico nelle PMI italiane con applicazioni PHP in produzione, sviluppate da fornitori che hanno chiuso il rapporto anni prima: APP_DEBUG=true nel .env di produzione (che espone stack trace, variabili d'ambiente e percorsi di file a chiunque provochi un errore), diverse CVE critiche nelle dipendenze Composer non aggiornate da mesi, zero multi-factor authentication anche sugli account amministrativi, nessun header di sicurezza HTTP, nessun rate limiting oltre il login, logging solo su file locale senza alerting, deploy manuale via SFTP senza alcun gate di qualità, e backup del database solo su disco locale, sulla stessa macchina, mai cifrati e mai testati in ripristino. Un'applicazione funzionante, usata ogni giorno, che generava fatturato, ma esposta a rischi che un attaccante moderatamente capace avrebbe sfruttato in poche ore.
In 14 giorni lavorativi quell'applicazione è passata da "rischio latente" a "NIS2-ready operativo", con evidenze documentali per ogni intervento. In questo articolo ti racconto la checklist che ho usato, gli stessi sette blocchi che applico a ogni sprint di hardening su Laravel e Symfony, le versioni dello stack aggiornate al 2026 e, soprattutto, la sezione che la maggior parte degli articoli su questo tema omette: cosa una checklist di 14 giorni non copre, perché spacciarla per "conformità NIS2 raggiunta" sarebbe disonesto.
Stai cercando un consulente informatico esperto per mettere in sicurezza un'applicazione PHP in produzione? Nel mio profilo professionale trovi l'esperienza concreta su hardening Laravel, Symfony e adeguamento NIS2 per PMI, dalla diagnosi offensiva alle contromisure difensive.
Che cos'è l'hardening NIS2-ready e perché serve anche alle PMI non direttamente in scope?
L'hardening NIS2-ready è un insieme di misure tecniche e organizzative che riduce la superficie d'attacco dell'applicazione e produce prove verificabili di ogni controllo implementato. L'articolo 21 della NIS2 elenca dieci categorie di misure di gestione del rischio: policy di sicurezza, gestione degli incidenti, continuità operativa, sicurezza della supply chain, gestione delle vulnerabilità, crittografia, controllo degli accessi, multi-factor authentication, comunicazioni sicure e formazione. Per un'applicazione web Laravel o Symfony, queste categorie si traducono in interventi concreti, che organizzo in sette blocchi distribuiti su 14 giorni.
Il punto cruciale per le PMI è la supply chain: l'articolo 21 richiede che le entità in scope valutino la sicurezza dei propri fornitori. Un gestionale insicuro usato da un fornitore diventa un rischio per il cliente enterprise, e quel cliente ha l'obbligo legale di mitigarlo, anche chiedendo al fornitore di adeguarsi o sostituirlo. È esattamente l'effetto a cascata che porta una piccola impresa, formalmente fuori scope, a doversi adeguare comunque per non perdere il contratto.
Tradotto in una frase operativa: la NIS2 non bussa alla porta della tua PMI, ma bussa alla porta del tuo cliente, e il tuo cliente gira la richiesta a te. La compliance "di carta" non basta più: serve evidenza tecnica.
Giorni 1-2: segreti, configurazione e il disastro di APP_DEBUG
Il primo intervento è sempre la configurazione sicura, perché è il fix con il rapporto rischio/effort più alto. APP_DEBUG=true in produzione espone l'intera configurazione dell'applicazione a chiunque provochi un errore 500, incluse le credenziali del database, le API key di terze parti e il percorso del filesystem. È l'equivalente di lasciare la cassaforte aperta con un cartello "guardare dentro".
# .env di produzione - VERIFICARE SEMPRE questi tre parametri
# APP_DEBUG DEVE essere false in produzione
# APP_ENV DEVE essere "production"
# APP_KEY DEVE essere generata e mai committata nel repository
APP_DEBUG=false
APP_ENV=production
APP_KEY=base64:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXOltre a APP_DEBUG, verifico che nessun segreto sia hardcodato nel codice sorgente:
# Cercare credenziali hardcodate nel codice
grep -rn "password\|api_key\|secret\|token" app/ config/ --include="*.php" | grep -v "env(" | grep -v ".env"Per Symfony il componente Secrets Management (bin/console secrets:set) gestisce i segreti in modo cifrato: ogni segreto è cifrato con una chiave che varia per ambiente, e il vault può essere committato nel repository senza rischi. Su Laravel la best practice è env() per tutto e .env nel .gitignore, con i segreti reali iniettati dall'ambiente di deploy o da un secret manager, mai dal repository.
Giorni 3-4: dipendenze, supply chain e composer audit in CI
Il secondo blocco è l'audit delle dipendenze: Composer per PHP, npm per il frontend. Le CVE nelle dipendenze sono il vettore di attacco più sfruttato sulle applicazioni web dopo le misconfigurazioni, e la NIS2 richiede esplicitamente la gestione della supply chain software. Il comando base, in forma riproducibile e machine-readable per essere consumato da uno script:
# Audit dipendenze PHP - solo lockfile, output JSON parsabile
composer audit --locked --format=json
# In CI, considerando solo le dipendenze di runtime (no dev)
composer audit --no-devLa distinzione conta. In build di produzione installo con composer install --no-dev, quindi in CI ha senso anche un audit che valuta la sola superficie di runtime con composer audit --no-dev: una CVE in un pacchetto solo-dev (un linter, una libreria di test) non finisce in produzione e non deve bloccare un deploy d'emergenza, mentre --locked --format=json mi dà l'inventario completo da archiviare come evidenza. L'aggiornamento, quando le CVE ci sono, va fatto incrementale, una dipendenza alla volta con test dopo ogni composer update: un aggiornamento bulk che rompe qualcosa è peggio delle CVE che stai cercando di risolvere.
Il gate permanente che installo nella CI fallisce la pipeline se compaiono advisory critiche o alte:
# GitHub Actions: security gate sulle dipendenze
- name: Composer audit (runtime)
run: composer audit --no-dev
# Il job FALLISCE (exit code != 0) se ci sono CVE note nelle dipendenze di runtimePer un approfondimento sulla sicurezza della supply chain Composer, inclusi typosquatting, dependency confusion e policy allow-plugins, ho scritto un articolo dedicato alla supply chain security per Laravel e Symfony.
Giorni 5-6: header HTTP e una CSP scritta davvero
Gli header HTTP sono la prima linea di difesa contro intere classi di attacchi: XSS, clickjacking, MIME sniffing, downgrade HTTPS. Li configuro a livello di reverse proxy anziché nel middleware applicativo, perché così coprono anche gli asset statici e le pagine di errore generate prima che l'applicazione entri in gioco:
# /etc/nginx/conf.d/security-headers.conf
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;La Content-Security-Policy è la più complessa e la più potente, ed è anche quella che la maggior parte delle checklist si limita a nominare senza scriverla. Una CSP che esiste solo come parola in un report non protegge nulla. Ecco un punto di partenza realistico per un'applicazione Laravel o Symfony, restrittivo ma compatibile con un'app server-rendered:
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" always;Tre dettagli che fanno la differenza tra una CSP cosmetica e una CSP che regge. Primo, script-src usa un nonce per-richiesta ($request_id di Nginx, da propagare al render dei tag <script>) invece di 'unsafe-inline': è la sola via per neutralizzare l'XSS riflesso senza spegnere il JavaScript legittimo. Secondo, frame-ancestors 'none' rende ridondante X-Frame-Options e copre i browser moderni in modo più granulare. Terzo, e non negoziabile: la CSP va deployata prima in modalità report-only.
Una CSP mal configurata che blocca risorse legittime rompe l'applicazione, e un'applicazione rotta è peggio di un'applicazione senza CSP. Per questo la prima settimana gira sempre come
Content-Security-Policy-Report-Only, raccogliendo i violation report; solo quando il rumore è zero la passo a enforcement.
Il riferimento autorevole per la configurazione è l'OWASP Secure Headers Project, e il risultato si verifica con securityheaders.com puntando al grado A.
Giorni 7-8: MFA, l'unico controllo NIS2 chiamato per nome nella direttiva
Qui sta il blocco che mancava del tutto nell'audit iniziale e che la maggior parte delle checklist liquida in una riga. La multi-factor authentication non è un optional di best practice: è l'unico controllo tecnico specifico citato esplicitamente nel testo della NIS2. L'articolo 21(2)(j) impone "l'uso di soluzioni di autenticazione a più fattori o di autenticazione continua" insieme a comunicazioni sicure. La formula "ove opportuno" non rende la MFA facoltativa: impone una decisione basata sul rischio, da documentare, su quali asset proteggere. Il regolamento di esecuzione che dettaglia i controlli, la Commission Implementing Regulation (UE) 2024/2690 del 17 ottobre 2024, richiede MFA proporzionata alla classificazione dell'asset e la revisione periodica delle procedure di autenticazione.
In pratica, su Laravel il modo più diretto è Fortify, che fornisce TOTP a sei cifre compatibile con qualsiasi app authenticator. Il modello usa il trait dedicato:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable
{
use TwoFactorAuthenticatable;
}Il flusso ha un dettaglio cruciale per non farti chiudere fuori gli utenti: dopo l'abilitazione, Fortify richiede una conferma esplicita prima di attivare davvero la MFA. L'utente abilita il secondo fattore (POST /user/two-factor-authentication), scansiona il QR code, e solo inserendo un TOTP valido la configurazione viene confermata. È la garanzia che l'app authenticator sia stata realmente collegata e che i recovery code siano stati salvati. Al login, l'endpoint /two-factor-challenge accetta un campo code con il token TOTP oppure un campo recovery_code con uno dei codici di recupero monouso. Quei recovery code sono il piano B obbligatorio: senza, lo smarrimento del telefono diventa un lockout permanente.
Su Symfony l'equivalente è il bundle scheb/2fa, modulare per sotto-pacchetti, così installi solo ciò che ti serve:
composer require scheb/2fa-totp # TOTP
composer require scheb/2fa-qr-code # rendering del QR code
composer require scheb/2fa-backup-code # recovery code monousoL'entità utente implementa Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface, e nelle access control rules va in cima la regola IS_AUTHENTICATED_2FA_IN_PROGRESS, che vincola la pagina del codice alla sola fase di autenticazione in corso. Un avvertimento sul QR code, valido per entrambi i framework: il secret TOTP non deve mai transitare per un servizio esterno di generazione immagini. Si renderizza il QR in casa, perché passarne il contenuto a terzi equivale a esporre il segreto.
Resta un punto che le PMI sottovalutano: la governance dell'emergency bypass. Quando un dirigente perde il telefono e ha esaurito i recovery code, qualcuno deve poter ripristinare l'accesso, e quel "qualcuno" è una backdoor se non è governata. La regola che impongo: il reset MFA passa solo da un secondo amministratore (mai self-service via email, vettore classico di account takeover), è loggato sul canale di sicurezza, e l'azione richiede una verifica out-of-band dell'identità. Un bypass non documentato e non auditabile vanifica l'intera MFA, e in un'ispezione NIS2 è esattamente la prima cosa che un auditor cerca.
Giorni 9-10: validazione input e rate limiting oltre il login
Ogni input che entra nell'applicazione (form, API, URL parameter, header, cookie) deve essere validato. Laravel e Symfony hanno meccanismi di validazione eccellenti, ma nella pratica trovo regolarmente endpoint che accettano input non validato, specialmente nelle API interne "che usa solo il frontend":
<?php
// Laravel: Form Request con validazione rigorosa
class StoreOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'product_id' => ['required', 'uuid', 'exists:products,id'],
'quantity' => ['required', 'integer', 'min:1', 'max:999'],
'note' => ['nullable', 'string', 'max:500'],
];
}
}Il rate limiting, però, è il punto in cui quasi tutti si fermano al login. Proteggere solo /login lascia scoperta la metà più interessante della superficie d'attacco. In Laravel 13 il RateLimiter permette limiter compositi e dedicati per ogni vettore di abuso:
<?php
// bootstrap/app.php - limiter dedicati per famiglia di abuso
// 1) Brute force sul login: per IP
RateLimiter::for('login', fn (Request $r) => Limit::perMinute(5)->by($r->ip()));
// 2) Enumeration sul password-reset: chiave sull'email, non sull'IP,
// e risposta sempre identica (non rivelare se l'account esiste)
RateLimiter::for('password-reset', fn (Request $r) =>
Limit::perMinutes(15, 3)->by((string) $r->input('email')));
// 3) Quota per API key: il tenant, non l'IP, è l'unità di throttling
RateLimiter::for('api', fn (Request $r) =>
Limit::perMinute(120)->by($r->user()?->getApiKeyId() ?? $r->ip()));
// 4) Upload DoS: pochi upload per minuto, oltre alla validazione su size/MIME
RateLimiter::for('uploads', fn (Request $r) => Limit::perMinute(10)->by($r->user()->id));Ogni famiglia ha la sua logica di chiave. Il brute force sul login si limita per IP. L'enumeration sul password-reset va limitata sull'email e, soprattutto, l'endpoint deve restituire la stessa risposta che l'account esista o no, altrimenti diventa un oracolo che mappa gli utenti registrati. La quota delle API key si misura sul tenant o sulla chiave, non sull'IP, perché dietro un solo NAT aziendale ci sono decine di client legittimi. L'upload DoS richiede un limite di frequenza in aggiunta ai controlli su dimensione e MIME-type, perché un endpoint di upload non protetto è un modo banale per saturare disco e CPU. Sul versante webhook, il rate limiting da solo non basta: ogni payload in ingresso va autenticato con una firma HMAC verificata in tempo costante prima ancora di toccarne il corpo.
<?php
// Verifica firma HMAC di un webhook, in tempo costante contro timing attack
$expected = hash_hmac('sha256', $request->getContent(), $webhookSecret);
if (! hash_equals($expected, $request->header('X-Signature', ''))) {
abort(401, 'Invalid signature');
}Giorni 11-12: logging strutturato, alerting e CI/CD con security gate
Passare dal logging "debug su file locale" a un sistema di osservabilità che rilevi anomalie in tempo reale. L'intervento minimo è un canale di log dedicato alla sicurezza, separato dall'applicativo:
<?php
// config/logging.php - canale security separato dall'applicativo
'channels' => [
'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => 'info',
'days' => 30,
],
],Con alert automatico su eventi critici (login falliti ripetuti, errori 403/500 anomali, reset MFA, modifiche a utenti admin). Per una guida completa all'osservabilità PHP, inclusi Monolog, canali separati e centralizzazione, ho scritto un articolo dedicato all'osservabilità minima per applicazioni PHP.
Sul fronte CI/CD, l'obiettivo è impedire che codice insicuro arrivi in produzione. Il gate minimo che installo blocca il deploy se anche un solo step fallisce:
# GitHub Actions: pipeline con gate di sicurezza
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: composer install --no-dev --prefer-dist
- run: composer audit --no-dev # CVE check sul runtime
- run: vendor/bin/phpstan analyse -l 6 # analisi statica
- run: vendor/bin/phpunit # test suiteL'articolo sulla sicurezza Docker per container PHP estende questo approccio alla supply chain dei container.
Giorni 13-14: backup cifrato, off-site e ripristino testato
L'ultimo blocco è il backup, ma non "il backup esiste": "ho verificato che il ripristino funziona". Tre proprietà sono non negoziabili. Il backup deve essere off-site (su una macchina diversa, in un datacenter diverso da quello dell'applicazione): un backup sullo stesso disco o sulla stessa macchina non è un backup, è una copia che muore insieme all'originale in un ransomware o in un guasto hardware. Deve essere cifrato a riposo e in transito, perché un dump di database non cifrato spedito altrove è una violazione di dati che cammina. E deve essere testato, con un restore reale su ambiente isolato e un RTO (Recovery Time Objective) misurato col cronometro.
Per questo uso strumenti che cifrano nativamente prima del push remoto. Nella mia pipeline personale di disaster recovery mi appoggio ad ArkHive, un tool open source costruito su Laravel Zero che fa backup AES-256 con push off-site via SSH: il dato lascia la macchina già cifrato, e la chiave non vive sul server di backup. Il numero che produco al termine, il RTO misurato sul restore reale, è precisamente l'evidenza che la NIS2 chiede sotto la voce "continuità operativa". Ho descritto la strategia completa di backup per VPS unmanaged in un articolo dedicato.
La documentazione prodotta durante i 14 giorni (decision log, report degli audit dipendenze, screenshot dei gate CI, log del test di restore, policy di autenticazione con la motivazione di rischio della MFA) è ciò che il cliente presenta al proprio cliente enterprise come evidenza. Non è compliance di carta: sono prove di misure operative funzionanti.
Aggiornare lo stack: quali versioni nel 2026
Una checklist di hardening su versioni morte è un ossimoro. Al 2026 i riferimenti corretti sono Laravel 13 (rilasciato a marzo 2026, richiede PHP da 8.3 a 8.5), mentre Laravel 11 è senza fix di sicurezza da marzo 2026: chi è fermo lì sta accumulando debito di sicurezza ogni giorno. Su Symfony, la versione da raccomandare in produzione è la 7.4 LTS (manutenzione fino a novembre 2028); la 8.1 è la current stable ma la 8.0 esce di supporto a luglio 2026, quindi non è una destinazione su cui fermarsi. Sul runtime, PHP 8.4 è la scelta sana per un nuovo deploy (PHP 8.2 va in EOL di sicurezza il 31 dicembre 2026, PHP 8.3 è già in fase security-only). Aggiornare il framework non è un capriccio: la maggior parte delle CVE che composer audit segnala si chiude semplicemente uscendo da una versione fuori supporto.
Cosa NON copre una checklist di 14 giorni
Qui faccio l'operazione che la maggior parte degli articoli evita, perché è scomoda da vendere: dichiarare i limiti. Una checklist di hardening applicativo di 14 giorni mette in sicurezza l'applicazione web e produce evidenza tecnica difendibile. Non rende, da sola, un'organizzazione "conforme alla NIS2", e chi te lo promette ti sta vendendo fumo.
L'articolo 21 copre dieci categorie organizzative e tecniche; una sprint applicativo ne tocca direttamente una parte. Restano fuori dal perimetro di questi 14 giorni: la governance del rischio a livello di azienda e la responsabilità degli organi di gestione, il piano formalizzato di gestione e notifica degli incidenti al CSIRT nei tempi previsti (in Italia, pre-notifica entro 24 ore, notifica entro 72 ore, relazione finale entro un mese), la business continuity e il disaster recovery oltre il singolo restore di database, la sicurezza delle reti e degli endpoint fuori dall'applicazione, la formazione e la security awareness del personale, e l'intera componente di policy, audit periodici e gestione dei fornitori a livello contrattuale. La NIS2 è un programma continuo, non un progetto a termine: il regolamento di esecuzione UE 2024/2690 declina l'articolo 21 in oltre centocinquanta controlli, e la conformità si dimostra nel tempo, con revisioni periodiche, non con un report una tantum.
L'hardening applicativo è la base tecnica su cui poggia l'adeguamento NIS2, non l'adeguamento stesso. È il primo passo concreto e verificabile, e spesso quello che sblocca il rinnovo di un contratto. Ma confonderlo con la compliance completa è proprio il tipo di overstatement che un auditor smonta in cinque minuti.
Per inquadrare il quadro normativo e organizzativo completo ho scritto un pezzo sulla Direttiva NIS2 e i rischi cyber per le aziende, e uno sul ruolo del consulente di cybersecurity nell'adeguamento NIS2, che spiega dove finisce il lavoro tecnico e inizia quello di governance.
Come misuro i risultati e quali KPI presento al management?
I KPI che produco alla fine dei 14 giorni sono cinque, e sono gli stessi che il management può portare in un audit o davanti al cliente enterprise: CVE critiche/alte nelle dipendenze da diverse a zero, con patch policy entro 7 giorni dalla disclosure; copertura MFA sugli account amministrativi e sensibili portata al 100%, con la policy di autenticazione documentata; rate limiting esteso dal solo login a password-reset, API e upload; header di sicurezza a grado A verificabile su securityheaders.com; RTO testato sul restore reale, documentato con timestamp. Questi numeri sono la differenza tra "diciamo di essere sicuri" e "possiamo dimostrare di essere sicuri": la NIS2 richiede verificabilità, e verificabilità significa numeri, date e prove.
Sul piano economico, il punto che ripeto sempre ai titolari di PMI è che l'hardening non è un costo, è un'assicurazione con ROI misurabile. Il costo di un incidente, tra downtime, ripristino, danni reputazionali e sanzioni, supera di ordini di grandezza il costo di uno sprint strutturato. E le sanzioni NIS2 non sono simboliche: per i soggetti essenziali arrivano fino a 10 milioni di euro o il 2% del fatturato annuo mondiale, a seconda di quale sia il maggiore; per i soggetti importanti fino a 7 milioni di euro o l'1,4%, sempre prendendo il valore maggiore. A questo si aggiunge la responsabilità personale degli organi di gestione in caso di mancata implementazione delle misure, un aspetto che i titolari di PMI sottovalutano sistematicamente.
Se la tua applicazione Laravel o Symfony è in produzione senza un hardening strutturato, e un tuo cliente o il tuo settore inizia a chiedere evidenze di sicurezza, la checklist che ho descritto è un punto di partenza realistico e onesto: 14 giorni, sette blocchi, outcome verificabili, con i suoi limiti dichiarati. Se vuoi adattarla alla tua realtà specifica, partendo da un audit che ti dica esattamente dove sei oggi e cosa ti separa da una posizione difendibile, contattami per una consulenza diretta: tra la diagnosi e la prima contromisura concreta, di solito, passano ore, non settimane.