CVE, SBOM & chaîne d'approvisionnementintermédiaire6 min de lecture

Scanner des images de conteneurs avec Trivy : en CI et sur l'hôte, sans 400 signalements par image

Trivy est gratuit, rapide et trouve tout — et c'est précisément le problème : une image moyenne donne des centaines de CVE, dont la plupart sans correctif ou dans un outil qui ne tourne jamais. Voici la configuration que nous utilisons nous-mêmes : --ignore-unfixed, seuils de sévérité, un .trivyignore avec raison et date, une barrière CI qui ne casse que sur de nouveaux résultats critiques, et un scan nocturne de ce qui tourne vraiment sur l'hôte.

Sommaire
  1. Étape 1 : installer et scanner une image
  2. Étape 2 : les trois filtres qui suppriment le bruit
  3. Étape 3 : la barrière CI — ne casser que sur ce qui est nouveau
  4. Étape 4 : ce qui tourne vraiment — scanner l'hôte
  5. Étape 5 : la vue hebdomadaire, y compris ce que vous avez filtré
  6. Pièges
  7. Ce qu'il vous manque encore
  8. Comment monsys fait
  9. FAQ

trivy image python:3.12 donne, un bon jour, environ 150 CVE. Ce n'est pas la faute de Trivy — la plupart des images basées sur Debian contiennent des centaines de paquets dont Debian connaît les vulnérabilités qu'elle ne corrige délibérément pas encore (no-dsa, risque faible). Qui met cette liste non filtrée dans un pipeline CI a un || true derrière en moins d'une semaine et donc plus de scanner du tout. Cet article fait de Trivy une barrière qui n'échoue que quand il le faut, et un contrôle nocturne de ce qui tourne réellement sur vos hôtes.

Étape 1 : installer et scanner une image

# Ubuntu/Debian : dépôt officiel
sudo apt install -y wget apt-transport-https gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update && sudo apt install -y trivy
trivy --version

# Le premier scan récupère la base de vulnérabilités (~60 Mo) ; ensuite elle est locale
trivy image --severity HIGH,CRITICAL grafana/grafana:latest

La sortie est par cible : la couche OS (alpine 3.24), puis chaque artefact applicatif détecté séparément — binaires Go, paquets Node, wheels Python. Cette distinction compte : une CVE dans un binaire Go d'un plugin que vous n'utilisez pas pèse autrement qu'une CVE dans la libc de l'OS.

trivy image --quiet --severity HIGH,CRITICAL --format json grafana/grafana:latest \
  | jq -r '.Results[] | "\(.Target)\t\(.Type)\t\(.Vulnerabilities|length)"' | column -t -s $'\t'
# grafana/grafana:latest (alpine 3.24.1)                          alpine    2
# usr/share/grafana/bin/grafana                                   gobinary  3
# usr/share/grafana/data/plugins-bundled/.../postgresql_datasource gobinary  14

Étape 2 : les trois filtres qui suppriment le bruit

# 1. --ignore-unfixed : seulement les CVE pour lesquelles la distribution A un correctif
# 2. --severity : seuil ; les MEDIUM se revoient chaque semaine, pas à chaque build
# 3. --ignorefile : résultats acceptés délibérément, avec raison et date d'expiration
trivy image --ignore-unfixed --severity HIGH,CRITICAL --ignorefile .trivyignore \
  ghcr.io/example/shop:2026.09.1

--ignore-unfixed est le plus important. Une CVE sans correctif dans la distribution ne peut pas être résolue dans l'image ; au mieux vous changez d'image de base. C'est une décision pour une revue trimestrielle, pas pour chaque build. Faites bien atterrir les MEDIUM/LOW et les non corrigées dans un rapport hebdomadaire (étape 5), pour qu'elles ne disparaissent pas.

.trivyignore — dans le dépôt, à côté du Dockerfile. Trivy ne lit que les identifiants CVE, mais les commentaires à côté sont pour les humains et pour l'auditeur :

# .trivyignore — une ligne par résultat accepté, avec raison et date d'expiration.
# Réévaluer à chaque changement d'image de base.
CVE-2026-1234   # libxml2 XInclude : non atteignable, nous ne parsons pas de XML externe. J. Peeters 2026-09-16, valable jusqu'au 2026-12-31
CVE-2026-5678   # curl dans l'étape de build seulement, pas dans l'image runtime. Voir Dockerfile ligne 12. Valable jusqu'au bump de l'image de base

Mieux que .trivyignore : une déclaration VEX : elle porte la raison de façon lisible par machine et peut être lue par plusieurs scanners. trivy image --vex vex.json --show-suppressed … montre ce qui a été supprimé et pourquoi.

Étape 3 : la barrière CI — ne casser que sur ce qui est nouveau

La barrière classique --exit-code 1 --severity CRITICAL fait échouer le build sur chaque résultat critique, y compris celui qui était déjà là hier avec un ticket en cours. Cela mène à || true. Mieux : ne casser que sur les résultats absents du dernier scan réussi.

# .gitlab-ci.yml (GitHub Actions est équivalent ; Woodpecker aussi)
image-scan:
  stage: test
  image: aquasec/trivy:latest
  variables:
    IMG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  script:
    - trivy image --ignore-unfixed --severity HIGH,CRITICAL --ignorefile .trivyignore --format json -o scan.json "$IMG"
    - trivy image --ignore-unfixed --severity HIGH,CRITICAL --ignorefile .trivyignore "$IMG"   # lisible dans le log
    # Baseline = dernier scan de main, stocké comme artefact
    - 'curl -sf -o baseline.json "$CI_API_V4_URL/projects/$CI_PROJECT_ID/jobs/artifacts/main/raw/scan.json?job=image-scan" || echo "{}" > baseline.json'
    - |
      NEW=$(jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' scan.json \
            | grep -vxFf <(jq -r '[.Results[]?.Vulnerabilities[]?.VulnerabilityID] | unique | .[]' baseline.json) || true)
      if [ -n "$NEW" ]; then echo "NEW HIGH/CRITICAL findings since main:"; echo "$NEW"; exit 1; fi
  artifacts:
    paths: [scan.json]
    expire_in: 90 days

Ce que cela fait : une merge request qui introduit une nouvelle vulnérabilité critique (nouvelle dépendance, nouvelle image de base) échoue. Les résultats existants ne bloquent pas, mais figurent dans le log et l'artefact. Sur main même, vous ajoutez un job hebdomadaire séparé qui signale tous les HIGH/CRITICAL vers un ticket ou ntfy — c'est là qu'est l'arriéré.

Étape 4 : ce qui tourne vraiment — scanner l'hôte

La CI scanne ce que vous construisez. Sur l'hôte tourne peut-être autre chose : une image tirée il y a trois mois, un latest d'un tiers, un conteneur lancé à la main. Scannez donc chaque nuit les conteneurs en cours, sur l'hôte lui-même :

sudo tee /usr/local/sbin/trivy-running.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Scanne chaque image unique d'un conteneur en cours ; signale HIGH/CRITICAL avec correctif.
NTFY="https://ntfy.example.be/cve"; HOST=$(hostname -s)
D=/var/lib/cve-scan/images; mkdir -p "$D"; OUT="$D/$(date +%F).tsv"; : > "$OUT"
trivy image --download-db-only --quiet 2>/dev/null
for img in $(docker ps --format '{{.Image}}' | sort -u); do
  digest=$(docker image inspect --format '{{index .RepoDigests 0}}' "$img" 2>/dev/null | cut -d@ -f2 | cut -c8-19)
  trivy image --quiet --ignore-unfixed --severity HIGH,CRITICAL --format json "$img" 2>/dev/null \
  | jq -r --arg img "$img" --arg d "$digest" '
      .Results[]? | .Target as $t | .Vulnerabilities[]? 
      | [$img, $d, .Severity, .VulnerabilityID, .PkgName, .InstalledVersion, (.FixedVersion // "-"), $t] | @tsv' >> "$OUT"
done
n=$(wc -l < "$OUT"); c=$(awk -F'\t' '$3=="CRITICAL"' "$OUT" | wc -l)
# Ne signaler que ce qui est nouveau depuis hier
prev=$(ls -1 "$D"/*.tsv | grep -v "$(date +%F)" | tail -1)
if [ -n "$prev" ]; then
  new=$(comm -13 <(cut -f1,4,5 "$prev" | sort -u) <(cut -f1,4,5 "$OUT" | sort -u))
  [ -n "$new" ] && printf 'NEW findings in running images on %s (%s total, %s critical):\n%s\n' "$HOST" "$n" "$c" "$new" \
    | curl -s -H "Title: trivy@$HOST" -H "Priority: high" --data-binary @- "$NTFY" >/dev/null
fi
echo "$OUT: $n findings ($c critical)"
EOF
sudo chmod 0755 /usr/local/sbin/trivy-running.sh
echo '45 3 * * * root /usr/local/sbin/trivy-running.sh' | sudo tee /etc/cron.d/trivy-running

Le tsv quotidien est votre historique : « depuis quand CVE-X est-elle ouverte dans l'image Y » est un grep sur le répertoire. Le message ntfy ne vient que sur de nouveaux résultats — généralement cela signifie : la base de Trivy a appris une nouvelle CVE pour un paquet que vous faites tourner depuis des mois. C'est exactement le moment de regarder.

Étape 5 : la vue hebdomadaire, y compris ce que vous avez filtré

Ce que vous filtrez à l'étape 2 ne doit pas disparaître. Une fois par semaine l'état complet, trié par ce que vous pouvez corriger :

trivy image --quiet --format json ghcr.io/example/shop:2026.09.1 \
| jq -r '.Results[]?.Vulnerabilities[]? | [.Severity, (if .FixedVersion then "fixable" else "unfixed" end)] | @tsv' \
| sort | uniq -c
#  12 CRITICAL fixable      ← cette semaine
#   3 CRITICAL unfixed      ← décision d'image de base
#  41 HIGH fixable
#  88 HIGH unfixed
# 140 MEDIUM unfixed        ← accepter, documenter, revue trimestrielle

Et la réponse à « fixable » est presque toujours la même : reconstruire sur une image de base fraîche. FROM python:3.12-slim sans apt upgrade dans le Dockerfile reprend les correctifs de l'image de base dès que vous reconstruisez — donc reconstruisez chaque semaine, même sans changement de code.

Pièges

  • Scanner latest en CI. Vous scannez ce qui est sous ce tag aujourd'hui, pas ce qui tourne en production. Scannez le digest de l'image que vous déployez, et épinglez les images de base par digest.
  • Paquets de l'étape de build dans l'image runtime. gcc, curl, git dans l'image finale donnent des dizaines de CVE sans rapport avec votre application. Les builds multi-étapes et --no-install-recommends divisent la liste par deux.
  • Base Trivy hors ligne. Dans un runner CI sans internet, la mise à jour de la base échoue silencieusement et Trivy scanne avec une vieille base. trivy image --skip-db-update est délibéré ; une base périmée par accident ne l'est pas. Vérifiez trivy --version (affiche la date de la base) dans le log.
  • Ignorer secrets et misconfigurations. trivy image scanne aussi par défaut les secrets (clés API dans les couches) et trivy config les erreurs de Dockerfile (utilisateur root, pas de healthcheck). Ces résultats sont souvent plus importants que la cinquantième CVE libc.
  • .trivyignore sans date d'expiration. Un ignore de 2024 sur une CVE pour laquelle un exploit existe depuis 2025 est un trou que plus personne ne voit. Chaque ligne une date, chaque trimestre une revue.

Ce qu'il vous manque encore

  • La corrélation avec l'hôte. Trivy dit « CVE dans l'image X ». Quel conteneur fait tourner cette image, sur quel port, exposé à internet ou non, avec quels autres conteneurs à côté — c'est ce qui détermine la priorité, et ce n'est pas dans le rapport de scan.
  • La vue de flotte. Trente hôtes, trente répertoires /var/lib/cve-scan/images. « Quels hôtes font encore tourner le digest abc… » est du travail ssh.
  • La preuve. Un tsv par jour est un historique ; un auditeur veut la date du scan, la version de base utilisée et la décision par résultat dans un seul document signé.

Comment monsys fait

L'agent monsys découvre les conteneurs en cours via la socket Docker et rapporte par conteneur le digest de l'image ; le hub scanne chaque digest unique avec Trivy (une fois par digest, pas par hôte) et relie les résultats au conteneur, à l'hôte et à la topologie réseau — un CRITICAL dans un conteneur derrière le port 443 sur un hôte exposé à internet se classe au-dessus du même CRITICAL dans une tâche batch interne. La dérive d'image de base (« cette image est construite sur une base vieille de 4 mois ») est un signalement séparé. Les déclarations VEX et les risques acceptés se consignent sur le résultat et apparaissent dans l'audit pack ; la version et la date de la base de scan accompagnent chaque résultat.

FAQ

Pourquoi Trivy trouve-t-il des centaines de CVE dans une image officielle ?

Parce que l'image contient des centaines de paquets OS et que la distribution connaît pour beaucoup d'entre eux des vulnérabilités qu'elle ne corrige délibérément pas (encore) — risque faible, no-dsa. Avec --ignore-unfixed, il reste ce que vous pouvez résoudre en reconstruisant sur une image de base fraîche.

Dois-je faire échouer le build sur chaque CRITICAL ?

Non : cela mène à || true en une semaine. Faites échouer une merge request sur de nouveaux résultats HIGH/CRITICAL par rapport à main, et traitez l'arriéré existant via un rapport hebdomadaire et des tickets.

À quelle fréquence reconstruire les images ?

Chaque semaine, même sans changement de code, pour que les correctifs de l'image de base suivent. Lors d'une inscription KEV ou d'une CVE critique dans votre runtime (glibc, openssl, node) : immédiatement.

Rédigé par l'équipe monsys — des sysadmins qui font cela tous les jours.

Fait à la main ? Laissez monsys s'en charger en continu.

Tout ce que contient ce guide tourne dans monsys comme contrôle permanent, avec historique, alertes et preuves d'audit. 5 serveurs gratuits, hébergés en Belgique, installés en 60 secondes.