Skip to content

Comprendre le vuln management : VEX et risk acceptance

Ce guide explique comment on gère les vulnérabilités (CVE) qu'on ne peut pas patcher tout de suite, sans tout mettre dans le même sac. C'est le premier pas d'un système plus large de gestion continue des CVE (voir ADR 020).

Le point de départ : « présent » ≠ « exploitable »

Un scanner comme Trivy raisonne au niveau du paquet : « ta lib starlette 1.0.0 porte la CVE-2026-48818 ». C'est vrai, mais ça ne dit pas si ton application est réellement vulnérable. La CVE peut toucher une fonction de la lib que ton code n'appelle jamais.

D'où trois situations qu'il faut distinguer, et qui n'appellent pas la même réponse :

Situation Exemple Bonne réponse
Fixable et patchable bump de version simple on patche (pas d'exception)
Présente mais inexploitable code vulnérable jamais appelé VEX not_affected
Atteignable mais risque accepté impact limité, pas de fix applicable risk acceptance documentée

Le piège classique (et la mauvaise pratique) : tout balancer dans un .trivyignore « pour que ça passe ». On perd alors la différence entre « je prouve que je ne suis pas concerné » et « je suis concerné mais j'assume ».

Le VEX, en clair

VEX = Vulnerability Exploitability eXchange. C'est un format standardisé pour déclarer, CVE par CVE, si ton produit est affecté, avec une justification lisible par une machine. OpenVEX en est l'implémentation portée par l'OpenSSF.

Quatre statuts possibles :

Statut Sens
not_affected présent mais pas exploitable (exige une justification)
affected impacté, action à prévoir
fixed corrigé
under_investigation analyse en cours

Pour not_affected, la justification vient d'un vocabulaire fermé (donc exploitable par un outil), par exemple :

  • vulnerable_code_not_present
  • vulnerable_code_not_in_execute_path ← « le code existe dans la lib mais n'est jamais appelé »
  • component_not_present
  • inline_mitigations_already_exist

À quoi ressemble notre fichier .vex/openvex.json (simplifié) :

{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "statements": [
    {
      "vulnerability": { "name": "CVE-2026-48818" },
      "products": [ { "@id": "pkg:pypi/starlette" } ],
      "status": "not_affected",
      "justification": "vulnerable_code_not_in_execute_path",
      "impact_statement": "L'app n'utilise ni StaticFiles ni mount()."
    }
  ]
}

Trivy le consomme avec --vex .vex/openvex.json : il supprime de ses résultats les CVE déclarées not_affected qui matchent (ici, tout paquet starlette).

Pourquoi c'est mieux qu'un .trivyignore

.trivyignore VEX / OpenVEX
une liste d'IDs à taire statut + justification structurée
justification = commentaire libre justification dans un vocabulaire standard
spécifique à Trivy format interopérable (autres scanners)
pas de date de revue timestamp, traçable et auditable

Le risk acceptance, l'autre moitié

Le VEX ne sert que pour ce qui est réellement inexploitable. Si le code vulnérable est atteignable mais qu'on décide d'accepter le risque (impact faible, pas de correctif applicable), on ne ment pas avec un not_affected.

On garde l'entrée dans .trivyignore, mais étiquetée « risque accepté », avec :

  1. la preuve d'atteignabilité (où le code vulnérable est appelé),
  2. l'analyse d'impact (pourquoi c'est tolérable),
  3. une date de revue + le lien vers l'issue de dette.

C'est exactement la différence VEX / risk acceptance, et c'est un point qui fait mouche en entretien : ne jamais déguiser un risque accepté en inexploitabilité.

Notre cas concret (les 2 CVE starlette de #95/#96)

On ne peut pas bumper starlette (le fix exige fastapi 0.137, qui casse prometheus-fastapi-instrumentator → perte de /metrics, voir #96). Les deux CVE restantes ne sont pas du même type :

CVE Réalité dans notre code Traitement
CVE-2026-48818 (StaticFiles) aucun StaticFiles, aucun mount() → jamais atteint VEX not_affected
CVE-2026-54283 (DoS request.form()) le /login utilise OAuth2PasswordRequestForm → atteignable risk acceptance (datée, revue 2026-09-19)

Mise à jour 2026-06-30 : dette #96 levée. L'hypothèse « on ne peut pas bumper starlette » n'est plus vraie : starlette 1.3.1 + fastapi 0.138 + prometheus-fastapi-instrumentator 8.0.2 cohabitent (prouvé par test_metrics.py). Le bump, proposé par Renovate (Phase 2), corrige les deux CVE ; un scan sans filtre ne les détecte plus. Les exceptions ci-dessus ont donc été retirées et #96 fermée. C'est la bonne fin d'une remédiation : on nettoie les exceptions devenues mortes au lieu de les laisser masquer de futures CVE.

Vérifier en local (avant de pusher)

Pas besoin de builder l'image : un scan fs prouve le matching du VEX sur le lockfile.

T="docker run --rm -v $PWD:/scan:ro -v trivy-cache:/root/.cache/ aquasec/trivy:latest"

# Sans VEX : la CVE inexploitable réapparaît (elle n'est plus dans .trivyignore)
$T fs --severity HIGH,CRITICAL --ignorefile /scan/.trivyignore /scan | grep CVE-2026-48818

# Avec VEX : elle disparaît, le gate passe
$T fs --exit-code 1 --severity HIGH,CRITICAL \
     --ignorefile /scan/.trivyignore --vex /scan/.vex/openvex.json /scan && echo "gate vert"

Aller plus loin : le scan continu du registre (Phase 1)

Le gate de build ne voit une CVE que le jour du build. Une image poussée il y a deux semaines et toujours déployée peut devenir vulnérable sans aucun commit : une nouvelle CVE est publiée, la base de Trivy s'enrichit, mais la CI ne tourne pas. On le découvre par hasard au prochain build. La Phase 1 ferme cet angle mort.

L'idée, sans nouvel état à stocker : un pipeline GitLab scheduled (job registry-scan) rejoue le gate de build sur l'image réellement déployée, mais avec la base de vulns du jour. Mêmes filtres (.trivyignore + --vex), même seuil (CRITICAL,HIGH fixables). Vert au build, rouge maintenant sans nouveau commit = une CVE est apparue depuis. Si c'est le cas, une alerte Slack part.

Quelques choix qui comptent :

  • On scanne l'image servie par ArgoCD : le tag est lu dans k8s/base/kustomization.yaml (la source de vérité GitOps), pas une image au hasard.
  • Détecter ≠ remédier : le scan n'allume aucun rebuild. Il signale, on décide ensuite (patch, VEX ou risk acceptance). C'est l'inverse du réflexe « re-run la CI pour voir ».
  • Shared runner GitLab.com : toujours disponible (le cron tourne même quand le cluster EKS est éteint, car ECR reste up), et horloge juste (le runner self-hosted dérive après une pause et casse l'auth ECR).
  • Le schedule ne lance QUE ce job : les jobs de build/promote/deploy sont gardés pour qu'un scan quotidien ne redéploie jamais l'application.

Devancer la CVE : le SCA proactif (Phase 2)

Les phases 0 et 1 réagissent à une CVE déjà publiée. La Phase 2 prend le problème en amont : garder les dépendances et images à jour en continu, pour qu'une version corrective soit adoptée avant que la CVE ne devienne un incident. C'est le rôle de Renovate, en architecture centralisée.

Un seul orchestrateur pour les 3 clusters. Plutôt qu'un Renovate par repo, un projet dédié (yk-devops/renovate-runner) lance Renovate en mode autodiscover sur tout le groupe, avec une politique commune partagée (un preset que chaque repo étend). Une seule place pour régler le rythme, le groupage, les labels.

flowchart LR
    subgraph orch["renovate-runner (orchestrateur)"]
        R[Renovate scheduled<br/>autodiscover yk-devops]
    end
    P[preset partagé<br/>default.json]:::cfg --> R
    R -->|ouvre des MR de bump| EKS[fastapi-eks-project]
    R -.->|onboarding en attente| HL[homelab / platform]
    EKS -->|chaque MR déclenche| CI[CI EKS<br/>pytest + build + Trivy + VEX]
    CI -->|verte = bump sûr| M[merge]
    classDef cfg fill:#eef,stroke:#88a;

EKS s'y branche par un simple renovate.json à la racine, qui étend le preset central et restreint les managers au périmètre voulu (Python + images, on exclut Terraform et docker-compose au démarrage). Sa présence suffit à marquer le repo « onboardé ».

Le découplage détecter / remédier tient toujours. Renovate ne fait qu'ouvrir une MR ; il ne décide pas. C'est la CI de la MR (pytest, build, Trivy + VEX) qui prouve que le bump ne casse rien avant tout merge. Le filet de sécurité d'un bump, c'est donc la couverture de tests du repo, pas une vérification manuelle.

Quelques garde-fous qui comptent :

  • Image Renovate pinnée par digest (même règle que Trivy en Phase 1) : un tag est mutable, un digest non.
  • Stability days (minimumReleaseAge) : on n'adopte pas une version sortie il y a deux heures, on laisse les bugs de release remonter.
  • Groupage + priorités : minor/patch groupés en une MR (priority::low), chaque major isolé (priority::high, jamais d'automerge) car c'est là que vivent les breaking changes.
  • Rollout progressif : démarrage en dry-run (Renovate logge sans rien créer), puis activation repo par repo via l'onboarding (EKS d'abord).

À retenir

  • Trois réponses à une CVE non patchée : patcher, VEX (inexploitable prouvé), risk acceptance (atteignable mais assumé, daté).
  • Le scan ne s'arrête pas au build : le registre est re-scanné en continu (Phase 1), et détecter reste découplé de remédier (pas de rebuild automatique).
  • On devance les CVE de dépendances avec un Renovate centralisé (Phase 2) : MR de bump automatiques, validées par la CI de chaque repo (pas de merge à l'aveugle).
  • Le VEX est un artefact standard et auditable, pas un commentaire qu'on oublie.
  • L'inventaire (SBOM #68) reste exhaustif : on ne masque rien à l'inventaire, on filtre seulement le gate.