Container image security: vulnerabilità nelle immagini Docker che usi ogni giorno
A novembre 2025 ho eseguito un audit di container security su cinque PMI italiane che avevano adottato Docker per le loro applicazioni Laravel/Symfony in produzione. La conclusione è stata sobria: nessuna delle cinque aveva un processo sistematico di gestione delle vulnerabilità delle immagini Docker, e tutte avevano accumulato debito di sicurezza significativo. I numeri grezzi: su 23 immagini di produzione scansionate con Trivy, lo scanner open source mantenuto da Aqua Security documentato ufficialmente su trivy.dev, la media era di 89 CVE per immagine con 8 CVE critiche (CVSS ≥ 9.0) di cui 3 con exploit attivi noti. L'immagine "peggiore" - un'immagine php:8.2-fpm di base che un cliente usava da 8 mesi senza aggiornare - aveva 147 CVE totali con 12 critiche. Il punto critico è che tutte queste vulnerabilità erano in librerie di sistema operativo (OpenSSL, glibc, libxml2) che nessuno del team aveva pensato potessero essere problema - perché la percezione era "ma noi non usiamo OpenSSL direttamente nel codice applicativo". Realtà: se l'immagine ha OpenSSL vulnerabile, e l'applicazione ha qualunque code path che possa indirettamente coinvolgerlo (connessioni HTTPS outbound, elaborazione di certificati, qualunque call che usa libcurl), la vulnerabilità è sfruttabile.
In sei settimane ho ristrutturato la pipeline Docker di tre dei cinque clienti (gli altri due erano in attesa di budget) implementando un processo completo: scelta di base image più sicure e manutenute, multi-stage build per ridurre la superficie di attacco, esecuzione come utente non-root, scanning automatico in CI con block su CVE critiche, policy di refresh settimanale delle immagini base. Al termine del lavoro, il numero medio di CVE per immagine è sceso da 89 a 11 (riduzione 87%), con zero CVE critiche per ognuna delle immagini in produzione. Questo articolo descrive il processo completo, le scelte di design sulle base image, i tool di scanning, e la disciplina operativa che mantiene le immagini sane nel tempo senza richiedere attenzione continua del team.
Il problema strutturale: immagini che accumulano debito nel tempo
Il pattern che ho osservato sui cinque clienti è prevedibile e riproducibile. Il team sceglie un'immagine base (tipicamente php:8.2-fpm o php:8.3-fpm-alpine), scrive il Dockerfile una volta, lo deploya in produzione, e si ferma lì. L'immagine gira in produzione per mesi o anni, senza essere mai ricostruita a partire da una base aggiornata. Nel frattempo, la community Debian/Alpine rilascia patch di sicurezza per le librerie di sistema inclusi nella base - patch che non arrivano mai nel container in produzione perché il container non viene mai rebuildato.
Il problema si aggrava perché la maggior parte dei team non distingue fra immagine applicativa (il codice della loro app, che rilasciano frequentemente) e immagine base (il runtime PHP + OS, che tende a essere trattato come "cosa che c'è"). Quando rilasciano una nuova versione della loro app, fanno docker build che parte da un Dockerfile che dice FROM php:8.2-fpm. Se il tag php:8.2-fpm è cambiato da ultimo pull (perché la community ha rilasciato aggiornamenti sul registry), il nuovo build prende la versione aggiornata. Se il tag non è cambiato (o se c'è Docker cache locale), il nuovo build usa la vecchia versione. In produzione, senza disciplina esplicita di refresh base image, si finisce con immagini vecchie di mesi anche se il codice applicativo è stato rilasciato ieri.
La documentazione Docker sulle best practice per Dockerfile copre la dimensione tecnica ma tende a trascurare la dimensione organizzativa - la disciplina di trattare le immagini base come dipendenze che invecchiano e richiedono update sistematico. Il pattern operativo che applico sui clienti colma esattamente questo gap.
La scelta della base image: alpine vs debian-slim vs distroless
La prima decisione architetturale è quale base image usare. Le tre opzioni principali per applicazioni PHP sono Alpine Linux (php:8.3-fpm-alpine), Debian slim (php:8.3-fpm-bookworm-slim), e immagini distroless.
Alpine è la scelta storicamente popolare per container "minimal". L'immagine base è circa 5 MB vs i 30+ MB di Debian slim. Usa musl libc invece di glibc. La superficie di attacco è minore perché contiene meno pacchetti di sistema. Il trade-off: alcune librerie PHP hanno comportamento leggermente diverso su musl rispetto a glibc (estensioni specifiche come intl richiedono build più attento), e il parco pacchetti APK di Alpine è meno ricco del parco APT di Debian. In produzione reale ho riscontrato 2-3 edge case di compatibilità in 18 mesi su quattro progetti Alpine - gestibili ma reali.
Debian slim è la scelta più conservativa. L'immagine è più grande ma il comportamento è identico a un server Debian standard, senza sorprese di compatibilità. Gli aggiornamenti di sicurezza arrivano tipicamente più rapidamente sul parco Debian rispetto al parco Alpine per librerie meno comuni. Il trade-off: immagine più pesante, più superficie di attacco, più CVE potenziali da gestire.
Le immagini distroless (mantenute da Google Container Tools) sono la terza opzione, meno comune ma interessante per contesti ad alta sicurezza. L'immagine include solo il runtime dell'applicazione e nessun shell, nessun package manager, nessun utility di debugging. Superficie di attacco minima in assoluto - un attaccante che ottiene esecuzione codice nel container non ha nemmeno bash o sh per muoversi. Il trade-off: debugging in produzione è significativamente più difficile (non puoi docker exec -it e navigare), e non tutte le runtime PHP hanno immagini distroless ufficiali, richiedendo build custom.
La scelta che applico di default sui clienti PMI italiani è Debian slim pinned a specific sha256 digest, con refresh settimanale automatico. Il ragionamento è: la differenza di 25 MB rispetto ad Alpine è irrilevante in contesti di PMI (non si gestiscono decine di migliaia di container), la compatibilità maggiore di Debian riduce il rischio di bug sottili di runtime, e la disciplina di refresh settimanale (invece del pinning a singola versione) mantiene le CVE sotto controllo.
Multi-stage build: la tecnica che riduce la superficie di attacco
Il pattern di multi-stage build è uno dei singoli miglioramenti più impattanti sulla sicurezza delle immagini Docker. L'idea è separare l'immagine di build (che ha tutti i compiler, package manager, dev dependencies necessari per compilare l'applicazione) dall'immagine di runtime (che ha solo il minimo necessario per eseguirla). L'immagine finale in produzione non contiene strumenti di build - riduce sia dimensione che superficie di attacco.
Un Dockerfile multi-stage tipico per Laravel:
# Dockerfile - multi-stage build per Laravel
# syntax=docker/dockerfile:1.4
# STAGE 1: build - ha tutti i tool necessari
FROM php:8.3-fpm-bookworm AS builder
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
libicu-dev \
libpng-dev \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install \
pdo_mysql \
opcache \
zip \
intl \
gd \
bcmath
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev
# Opzionale: pre-compila view, config, route cache
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
# STAGE 2: runtime - solo il minimo necessario
FROM php:8.3-fpm-bookworm-slim AS runtime
# Installa solo runtime packages (no -dev)
RUN apt-get update && apt-get install -y \
libzip4 \
libicu72 \
libpng16-16 \
&& rm -rf /var/lib/apt/lists/* \
&& docker-php-ext-install pdo_mysql opcache zip intl gd bcmath
# Crea utente non-root
RUN groupadd -g 1000 app && useradd -u 1000 -g app -s /bin/bash -m app
WORKDIR /var/www/html
COPY --from=builder --chown=app:app /app .
USER app
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD php /var/www/html/docker/healthcheck.php || exit 1
EXPOSE 9000
CMD ["php-fpm", "-F"]Tre dettagli meritano attenzione. Primo: l'immagine di builder usa php:8.3-fpm-bookworm (non slim) perché ha più tool disponibili per la build. L'immagine finale usa -bookworm-slim per ridurre superficie. Secondo: gli apt-get install di runtime includono solo i pacchetti lib runtime, senza i corrispondenti -dev. Questo è importante: un attaccante che ottiene esecuzione codice non ha gcc, make, libtool per compilare exploit custom. Terzo: l'utente non-root app con UID/GID 1000 esegue l'applicazione. Se il container viene compromesso, l'attaccante è limitato nei permessi - non può modificare file di sistema, non può bindare porte privilegiate.
Esecuzione come non-root: il principio del minimo privilegio
L'esecuzione come utente non-root è probabilmente la misura di hardening con il miglior rapporto beneficio/effort su container PHP. Il default delle immagini php:*-fpm ufficiali è di eseguire PHP-FPM come root (master) che poi spawna worker come www-data. Questa architettura è storicamente sensata su server bare-metal ma è subottimale in container dove la separazione dei privilegi del kernel è diversa.
Il pattern corretto è far girare sia il master che i worker di PHP-FPM come utente non-root. Il Dockerfile sopra crea l'utente app (UID 1000) e lo usa con USER app. PHP-FPM deve essere configurato per non richiedere capability elevate:
; php-fpm.d/www.conf - configurazione per non-root
[www]
listen = /run/php/php-fpm.sock
listen.owner = app
listen.group = app
listen.mode = 0660
user = app
group = app
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 500Il file PID /run/php/php-fpm.pid deve essere in una directory scrivibile dall'utente app. Il socket deve essere configurato con owner corretto. Queste sono modifiche piccole ma essenziali - senza di esse, PHP-FPM fallirà silenziosamente al boot come non-root.
Un controllo di verifica da integrare in CI per assicurarsi che l'immagine sia effettivamente non-root:
# Verifica che il container giri come non-root
USER_IN_IMAGE=$(docker run --rm myapp:latest id -u)
if [ "$USER_IN_IMAGE" = "0" ]; then
echo "ERRORE: container gira come root"
exit 1
fiStai cercando un Consulente Informatico esperto per ristrutturare la pipeline Docker del tuo team con security hardening sistematico, multi-stage build ottimizzati e processo di refresh automatico delle base image? Nel mio profilo professionale trovi l'esperienza concreta su container security, Kubernetes, orchestrazione Docker e pipeline DevSecOps per PMI italiane.
Lo scanning automatico in CI: Trivy come baseline
Il secondo layer di difesa è la scansione automatica delle immagini prima del deploy. Trivy è il tool che uso come baseline per tutti i clienti - open source, maintained attivamente, supporta ampia varietà di formati (OS packages, language dependencies, IaC, secrets hardcoded).
L'integrazione in GitHub Actions:
# .github/workflows/container-security.yml
name: Container Security
on:
pull_request:
paths:
- "Dockerfile"
- "docker-compose.yml"
- "composer.lock"
- "package-lock.json"
push:
branches: [main]
schedule:
- cron: "0 2 * * 1" # Lunedi notte rescan
jobs:
build-and-scan:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: 1
- name: Upload results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifDue dettagli operativi. Primo: severity: CRITICAL,HIGH filtra solo vulnerabilità di severity alta. Senza questo filtro, Trivy riporterebbe centinaia di CVE (inclusa la lunga coda di LOW e MEDIUM su librerie di sistema) che sommergono il team di rumore. Secondo: ignore-unfixed: true esclude le CVE per le quali non esiste ancora fix disponibile - report-arle sarebbe actionable zero, e il team perde fiducia nel sistema se riceve alert che non sono risolvibili.
Il schedule settimanale a Lunedì notte è importante. Le CVE emergono nel tempo: un'immagine buildata oggi può essere pulita, ma dopo tre settimane può aver accumulato CVE HIGH in librerie di sistema. Il rescan settimanale rileva queste situazioni prima che diventino critiche e prima che un deploy sia necessario per applicazioni fix. Gli alert del rescan scheduled generano PR automatiche per rebuild dell'immagine con base image aggiornata.
Il refresh automatico delle base image: il pattern che risolve il problema strutturale
L'elemento che separa una pipeline Docker "OK" da una "resiliente nel tempo" è il refresh automatico delle base image. Il pattern è questo: settimanalmente, un job CI re-esegue il build dell'immagine da zero (senza cache), pullando la versione più recente del base image (php:8.3-fpm-bookworm-slim al suo tag, che potrebbe essere avanzato dalla community). Se il rebuild produce un'immagine con meno CVE (o senza nuove CVE emerse), viene taggata e preparata per il deploy nella successiva release. Se produce un'immagine con comportamento cambiato (test di smoke fallisce), invece, viene alert al team per review.
L'implementazione:
# .github/workflows/weekly-refresh.yml
name: Weekly Base Image Refresh
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@...
- name: Pull latest base image
run: docker pull php:8.3-fpm-bookworm-slim
- name: Rebuild without cache
run: docker build --no-cache -t myapp:refresh-${{ github.run_number }} .
- name: Smoke test nuova immagine
run: |
docker run --rm myapp:refresh-${{ github.run_number }} \
php artisan --version
- name: Scan with Trivy
run: trivy image --exit-code 1 --severity CRITICAL,HIGH myapp:refresh-${{ github.run_number }}
- name: Open PR with new base digest if changed
run: |
NEW_DIGEST=$(docker inspect php:8.3-fpm-bookworm-slim --format='{{index .RepoDigests 0}}')
if ! grep -q "$NEW_DIGEST" Dockerfile; then
# aggiorna Dockerfile con nuovo digest
sed -i "s|FROM php:8.3-fpm-bookworm-slim.*|FROM $NEW_DIGEST|" Dockerfile
git checkout -b refresh/base-image-$(date +%Y%m%d)
git add Dockerfile
git commit -m "Refresh base image to latest"
git push
gh pr create --title "Refresh base image (weekly)" --body "..."
fiQuesto pattern trasforma le immagini base da "cose statiche che invecchiano silenziosamente" a "dipendenze gestite come codice". Il team riceve settimanalmente (al massimo) una PR di update, la rivede, mergeia, triggera un deploy. Le CVE vengono applicate entro una settimana dalla loro disponibilità upstream - SLA di security molto più stretto di quello tipico senza questa automation.
L'audit pattern: cosa cerco quando arrivo in un progetto Docker-based
Quando arrivo su un progetto Docker maturo per fare audit di sicurezza, il processo è strutturato. Il checklist è in otto punti.
Primo: scan Trivy di tutte le immagini in produzione. Produce la baseline numerica. Secondo: review dei Dockerfile per pattern subottimali (utente root, tool di build nell'immagine finale, secrets hardcoded, cache non ottimizzata). Terzo: review dei tag utilizzati - pinned a digest SHA o tag mutabili? L'uso di latest o 8.3 (senza minor) è un rischio. Quarto: analisi della pipeline CI/CD per verificare se c'è scanning automatico e dove. Quinto: verifica della frequenza di rebuild - quando è stata buildata l'ultima volta l'immagine in produzione? Sesto: review dei privilegi runtime - il container gira come root? Ha capability privilegiate? Monta volumi sensitive? Settimo: network policy - il container può comunicare con qualunque altro container, o c'è segmentazione? Ottavo: secrets management - le credenziali di database sono in environment variables o in un vault dedicato?
Su ognuno dei cinque clienti originari, la combinazione di questi otto check ha prodotto report di 20-30 pagine con raccomandazioni di priorità. Il pattern più ricorrente è che i punti 4, 5 e 7 sono quasi sempre carenti: scanning assente, frequenza di rebuild "mai", network flat dove ogni container può contattare ogni altro.
Le metriche post-intervento: come misurare il successo
Dopo 3-6 mesi dall'intervento, le metriche che monitoro sono quattro. Primo: numero medio di CVE HIGH+CRITICAL per immagine in produzione (target: <10 per immagine, con zero critiche senza fix). Secondo: età della base image più vecchia in produzione (target: <14 giorni). Terzo: percentuale di immagini che girano come non-root (target: 100%). Quarto: percentuale di build che passano lo scan Trivy al primo tentativo (indicatore di quanto la pipeline è sostenibile - target >80%).
Sul cliente principale dei cinque, dopo 6 mesi: media CVE per immagine 8 (da 89 iniziali), base image più vecchia 7 giorni (da mai-aggiornata), 100% non-root (da 20%), 87% build pass primo tentativo (da ~30% iniziale quando fu inserito lo scanning). I numeri sono sostenibili nel tempo, il team li mantiene con effort di manutenzione di 2-3 ore/settimana aggregato.
Il pattern più interessante emerso è che l'adozione di un refresh settimanale automatico delle base image riduce radicalmente la pressione sul team. Prima del refresh automatico, il team doveva rispondere reattivamente alle CVE man mano che emergevano - pattern stressante e scomposto. Dopo, il ciclo settimanale assorbe la maggioranza delle CVE in modo invisibile (una PR che si mergeia dopo review rapida), e solo le CVE veramente critiche che emergono fra i refresh settimanali richiedono intervento ad hoc. Lo stesso pattern è applicabile all'aggiornamento automatico dei container Docker in produzione senza downtime che descrivo in un articolo dedicato - scanning e refresh delle immagini lavorano insieme al deploy zero-downtime per chiudere il cerchio.
Se gestisci applicazioni containerizzate in produzione ma non hai un processo sistematico di container security - scanning, base image refresh, runtime privilege management - oppure hai subito un alert di CVE critica che hai dovuto gestire manualmente con il cuore in gola, contattami per un audit: in una settimana di lavoro analizzo il tuo stack Docker attuale, identifico i gap prioritari di sicurezza, implemento il processo di scanning in CI, rielaboro i Dockerfile con multi-stage e non-root, imposto il refresh automatico settimanale - con l'obiettivo che dopo il mio intervento la gestione delle CVE nei container sia un processo silenzioso e automatico, non un'emergenza ricorrente che interrompe il lavoro del team.