Docker container security per PHP: da immagini root con 42 CVE a hardening con cap-drop, read-only e Trivy in CI/CD

Docker container security per PHP: da immagini root con 42 CVE a hardening con cap-drop, read-only e Trivy in CI/CD

In un'attività di penetration testing su un'infrastruttura containerizzata, il container PHP-FPM dell'applicazione Laravel girava come root, con tutte le Linux capabilities di default, filesystem scrivibile e Composer installato nell'immagine di produzione. Un'SSRF nel codice applicativo ha permesso di scrivere un webshell in /var/www/html/public/, escalare al container con CAP_SYS_ADMIN e infine raggiungere il Docker socket montato come volume - game over sull'intero host. Il Sysdig 2024 Cloud-Native Security Report documenta che l'83% dei container in produzione gira come root e il 91% delle scansioni runtime fallisce. Il NIST SP 800-190 (Application Container Security Guide, 2017) identifica cinque aree di rischio: immagini, registry, orchestratore, container runtime e host OS. L'OWASP Docker Security Cheat Sheet traduce questi rischi in 14 regole operative.

Perché le immagini Docker PHP sono vulnerabili di default e come si hardena un Dockerfile?

Il Datadog State of DevSecOps 2024 documenta la correlazione diretta tra dimensione dell'immagine e vulnerabilità: immagini sotto i 100 MB hanno in media 4.4 CVE high/critical, quelle tra 250-500 MB ne hanno 42.2. Un'immagine php:8.3-fpm basata su Debian pesa ~500 MB e include gcc, make, apt e strumenti di build - superficie d'attacco inutile in produzione. La documentazione Docker raccomanda i multi-stage build: uno stage per la compilazione (Composer, estensioni), uno per il runtime minimale. L'OWASP Cheat Sheet Rule #3 specifica: "The most secure setup is to drop all capabilities --cap-drop all and then add only required ones." Rule #4 aggiunge: "Always run your docker images with --security-opt=no-new-privileges":

## Stage 1: build - Composer e dipendenze
FROM php:8.3-fpm-alpine AS builder
WORKDIR /app

RUN apk add --no-cache libzip-dev icu-dev \
    && docker-php-ext-install pdo_mysql zip opcache intl bcmath

COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/root/.composer \
    composer install --no-dev --no-interaction --optimize-autoloader --no-scripts

COPY . .
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache

## Stage 2: produzione - minimale, non-root, read-only
FROM php:8.3-fpm-alpine AS production

## Installa SOLO le estensioni runtime (no build tools)
RUN apk add --no-cache libzip icu-libs \
    && docker-php-ext-install pdo_mysql zip opcache intl bcmath

## Utente non-root (OWASP Rule #2, CIS Docker Benchmark)
RUN addgroup -g 1000 -S app && adduser -u 1000 -S app -G app

COPY --from=builder --chown=app:app /app /var/www/html

## Directory scrivibili via tmpfs (il resto è read-only)
RUN mkdir -p /var/www/html/storage/logs \
             /var/www/html/storage/framework/cache \
             /var/www/html/storage/framework/sessions \
             /var/www/html/storage/framework/views \
             /var/www/html/bootstrap/cache \
    && chown -R app:app /var/www/html/storage /var/www/html/bootstrap/cache

USER app
WORKDIR /var/www/html
EXPOSE 9000
CMD ["php-fpm"]

Il docker run di produzione completa l'hardening runtime:

docker run -d --name php-app \
  --user app \
  --read-only \
  --tmpfs /tmp \
  --tmpfs /var/www/html/storage:uid=1000,gid=1000 \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --memory 512m \
  --cpus 1 \
  php-app:production

--read-only (OWASP Rule #8) monta il filesystem root come sola lettura - un attaccante non può scrivere webshell, installare pacchetti o modificare file di configurazione. --cap-drop ALL rimuove tutte le Linux capabilities (inclusa CAP_SYS_ADMIN che equivale quasi a root). --security-opt no-new-privileges (OWASP Rule #4) impedisce l'escalation via setuid/setgid anche se binari setuid esistono nell'immagine. Trivy (Aqua Security, 34K+ GitHub stars, 100M+ Docker Hub pulls) scansiona l'immagine risultante per CVE, misconfigurazioni e secret esposti - integrabile in CI/CD con la GitHub Action.

Come si integra la scansione vulnerabilità nel ciclo di build?

La scansione deve avvenire in due momenti: al build (immagine statica) e al runtime (drift detection). Il CIS Docker Benchmark (v1.8.0, luglio 2025) organizza le raccomandazioni in 6 sezioni: Host Configuration, Docker Daemon, Daemon Files, Container Images, Container Runtime, Docker Security Operations. Lo strumento open-source docker-bench-security automatizza l'audit di queste raccomandazioni. Per la scansione immagini, Trivy in CI/CD blocca il deploy se rileva CVE critical:

## .github/workflows/docker-security.yml
name: Docker Security Scan
on: [push]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t app:${{ github.sha }} .
      - name: Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          format: table
          exit-code: 1
          severity: CRITICAL,HIGH
      - name: Trivy misconfiguration scan
        uses: aquasecurity/trivy-action@master
        with:
          scan-type: config
          scan-ref: .
          exit-code: 1

Le immagini Chainguard rappresentano l'alternativa più sicura alle immagini Alpine per PHP: basate su Wolfi, ricostruite ogni notte con tutte le patch disponibili, non-root di default, zero o near-zero CVE, conformi SLSA Level 3. Google Distroless segue lo stesso principio (nessun shell, nessun package manager) ma non offre un'immagine PHP ufficiale - Chainguard colma questa lacuna.

Errori comuni nella containerizzazione di applicazioni PHP

Il primo errore è girare come root. Il Sysdig 2024 report mostra che l'83% dei container in produzione gira come root. Per un'applicazione PHP-FPM, root non è mai necessario: PHP-FPM può girare come qualsiasi utente, Nginx comunica via socket o porta TCP. Il flag USER nel Dockerfile e --user nel docker run risolvono il problema.

Il secondo è montare il Docker socket (/var/run/docker.sock) come volume. L'OWASP Cheat Sheet Rule #1 lo vieta esplicitamente: l'accesso al socket equivale a root sull'host. Se un tool di monitoring o CI/CD richiede il socket, deve girare in un container separato con accesso limitato via API proxy.

Il terzo è usare la stessa immagine per build e produzione. Un'immagine con Composer, git, gcc e le dipendenze di sviluppo in produzione espone decine di CVE inutili e fornisce strumenti a un attaccante. Il multi-stage build separa build e runtime - l'immagine di produzione contiene solo il codice applicativo, le estensioni PHP compilate e le librerie runtime.

Il quarto è non gestire i secret correttamente. Le variabili d'ambiente nel Dockerfile (ENV DB_PASSWORD=...) finiscono nei layer dell'immagine, visibili con docker history. I secret vanno iniettati al runtime via Docker Secrets (Swarm), Kubernetes Secrets, o variabili d'ambiente passate al docker run - mai nel Dockerfile o nel codice sorgente.

La sicurezza dei container è il complemento dell'hardening applicativo di Laravel e Symfony: il container isola il runtime, l'applicazione valida gli input. La gestione degli errori API con APP_DEBUG=false evita che stack trace escano dal container. Per conoscere il mio approccio alla sicurezza infrastrutturale, visita la mia pagina professionale. Se i tuoi container PHP girano come root senza cap-drop e le immagini non sono scansionate in CI/CD, contattami per una consulenza dedicata - partiamo dall'audit con docker-bench-security e dalla riscrittura del Dockerfile con multi-stage build.

Ultima modifica: