Fichier artefact CI
Publication avec les releases GitHub :
app.vex.jsonSimple mais non signé
Mise à jour :
« 12 vulnérabilités détectées (1 critical, 2 high, 7 medium) » — J’ai scanné
mon API FastAPI devops-status-api avec Grype. En analysant chaque CVE, je
réalise que CVE-2025-12084 (xml.dom.minidom) affecte Python 3.12, mais mon code
ne parse jamais de XML, uniquement du JSON. C’est un faux positif qui pollue
mes rapports.
C’est l’alert fatigue : à force de faux positifs, mes équipes finissent par ignorer les alertes. Le VEX (Vulnerability Exploitability eXchange) résout ce problème en permettant de documenter l’exploitabilité réelle d’une vulnérabilité dans mon contexte spécifique.
Dans ce guide, je montre concrètement :
VEX (Vulnerability Exploitability eXchange) est un format standardisé pour documenter si une vulnérabilité est exploitable dans votre produit.
Analogie : un constructeur auto identifie un défaut d’airbag (CVE). Sans VEX, tous les propriétaires reçoivent l’alerte et doivent vérifier manuellement si leur véhicule est concerné. Avec VEX, le constructeur envoie un document explicite : “Votre véhicule VIN ABC123 n’est pas affecté car vous avez la version 2.0 de l’airbag (sans défaut)”.
Dans le logiciel : votre SBOM liste des centaines de composants. Quand une CVE sort, les scanners alertent tous les produits contenant ce composant. VEX documente : “Dans mon produit, cette CVE n’est pas exploitable car [raison]”. scanners alertent sur toutes les images qui contiennent cette bibliothèque. Mais en réalité, votre code n’utilise peut-être même pas la fonction vulnérable. VEX permet de documenter cette réalité de façon standardisée.
Un scan de vulnérabilités sur un SBOM complet peut remonter des centaines de CVE. En pratique, la majorité ne sont pas exploitables dans le contexte.
Scénario concret : une matinée de DevSecOps sans VEX
9h00 : Je lance grype sur mon image api-users:latest qui vient de builder.
Résultat : 237 vulnérabilités (87 critical, 102 high, 48 medium).
9h10 : Je commence à analyser. Première CVE : CVE-2024-1234 sur libxml2,
sévérité CRITICAL. Je vais sur la NVD, je lis l’exploit : il faut parser du XML
malveillant.
9h25 : Je vérifie mon code : on n’utilise jamais libxml2. C’est une
dépendance de l’image de base Alpine, mais mon API REST en Node.js ne touche pas
au XML. Faux positif #1.
9h40 : Deuxième CVE : CVE-2024-5678 sur openssl. Je lis : vulnérabilité dans
le handshake TLS. Je vérifie : mon API est derrière un ingress Nginx qui gère
tout le TLS. Mon conteneur ne fait jamais de TLS directement. Faux positif
#2.
10h30 : J’en suis à la 15ème CVE. 13 faux positifs. 2 vrais problèmes identifiés.
12h00 : J’ai traité 40 CVE sur 237. Je suis épuisé. Les 197 restantes ? Je les mets en “sourdine” mentale. Si une vraie vulnérabilité critique arrive cet après-midi, je risque de la manquer : c’est l’alert fatigue.
Situations courantes de faux positifs :
libxml2 dans
une API REST qui ne traite que du JSON)mod_php désactivé)Sans moyen de qualifier ces vulnérabilités, vos équipes passent leur temps à trier du bruit au lieu de corriger les vrais problèmes. VEX résout ce problème en documentant une fois pour toutes qu’une CVE n’est pas exploitable, pour ne plus avoir à le ré-analyser.
VEX associe chaque CVE à un statut d’exploitabilité :
| Statut | Exemple |
|---|---|
| not_affected | CVE-2025-12084 (xml.dom.minidom) dans API JSON-only : code jamais exécuté |
| affected | GHSA-6c5p-j8vq-pqhj (python-jose) : utilisé pour JWT auth, upgrade requis |
| fixed | starlette 0.41.3→0.49.1 : vulnérabilité résolue (commit abc123) |
| under_investigation | CVE récente, analyse en cours, résultats attendus 23/12 |
L’avantage : ces statuts sont standardisés. Grype, Trivy, Dependency-Track les comprennent tous. Vous documentez une fois, tous vos outils appliquent automatiquement le filtrage.
grype --vex app.vex.json applique vos décisionsPoints forts : standardisé (OpenVEX, CycloneDX, CSAF), intégré (Grype, Trivy, Dependency-Track), signable (Cosign), versionnable.
Connaissances :
Outils à installer :
-sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
# Installation vexctl (optionnel) go install github.com/openvex/vexctl@latest
# Vérification syft version grype versionanchore/syft:v1.38.2 docker pull anchore/grype:v0.104.2 docker pullghcr.io/openvex/vexctl:v0.4.1| Format | Porté par | Cas d’usage | Lien |
|---|---|---|---|
| OpenVEX | Chainguard, Sigstore | DevSecOps, écosystème cloud-native | openvex.dev ↗ |
| CycloneDX VEX | OWASP | Intégré dans SBOM CycloneDX (v1.4+) | cyclonedx.org ↗ |
| CSAF VEX | OASIS | Grands éditeurs (Red Hat, Cisco), conformité | oasis-open.org ↗ |
Recommandation : pour un projet cloud-native, OpenVEX est le plus simple. Si vous utilisez déjà CycloneDX pour vos SBOM, CycloneDX VEX s’intègre nativement.
OpenVEX = format JSON léger listant des statements sur l’exploitabilité de CVE.
Exemple :
{ "@context": "https://openvex.dev/ns/v0.2.0", "@id": "https://mon-org.com/vex/mon-app-v1.2.0", "author": "Security Team <security@mon-org.com>", "timestamp": "2024-12-20T10:00:00Z", "version": 1, "statements": [{ "vulnerability": { "name": "CVE-2024-1234" }, "products": ["pkg:oci/mon-app@sha256:abc123..."], "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", "impact_statement": "API n'utilise jamais XML, code vulnérable non exécuté" }]}Champs clés :
@id : identifiant unique du VEXproducts : format purl ↗ (pkg:oci/nom@sha256:digest)status : not_affected, affected, fixed, under_investigationjustification : code normalisé (scanners le comprennent)impact_statement : explication humaine (auditeurs, collègues)
cette CVE critical ?”.La justification est un code normalisé qui explique pourquoi le statut
est ce qu’il est. C’est ce code que les scanners utilisent pour filtrer
automatiquement.
| Justification | Signification | Exemple concret |
|---|---|---|
component_not_present | Le composant vulnérable n’existe pas dans le produit | CVE sur postgresql-client mais vous utilisez MySQL : “Nous n’utilisons pas PostgreSQL, le package n’est pas installé.” |
vulnerable_code_not_present | Le code vulnérable a été supprimé (patch custom) | Vous avez recompilé nginx sans le module ngx_http_dav_module vulnérable : “Module DAV désactivé à la compilation, code absent du binaire.” |
vulnerable_code_not_in_execute_path | Le code existe mais n’est jamais appelé | libxml2 présent dans l’image mais votre app Node.js ne fait que du JSON : “Notre API REST ne parse jamais de XML, les fonctions libxml2 ne sont jamais invoquées.” |
vulnerable_code_cannot_be_controlled_by_adversary | L’attaquant ne peut pas atteindre le code | CVE sur endpoint admin https://app/admin/debug mais votre NetworkPolicy bloque tout accès externe : “Endpoint exposé uniquement sur localhost, inaccessible depuis l’extérieur.” |
inline_mitigations_already_exist | Des mitigations (WAF, sandbox) neutralisent l’exploit | CVE injection SQL mais vous avez un WAF ModSecurity en amont : “WAF bloque toutes les injections SQL, exploit impossible même si code vulnérable.” |
Comment choisir la bonne justification ?
component_not_presentvulnerable_code_not_presentvulnerable_code_not_in_execute_pathvulnerable_code_cannot_be_controlled_by_adversaryinline_mitigations_already_existAstuce : en cas de doute, utilisez vulnerable_code_not_in_execute_path (le
plus courant) et détaillez dans l’impact_statement.
CycloneDX 1.4+ intègre VEX directement dans le SBOM via la propriété
vulnerabilities. Au lieu d’avoir deux fichiers (sbom.json + vex.json),
vous avez un seul fichier qui contient à la fois :
components)vulnerabilities)Exemple détaillé :
{ "bomFormat": "CycloneDX", "specVersion": "1.6", "components": [ /* ... */ ], "vulnerabilities": [ { "id": "CVE-2024-1234", "source": { "name": "NVD" }, "ratings": [{ "severity": "high" }], "affects": [ { "ref": "pkg:apk/alpine/libxml2@2.9.14", "versions": [{ "version": "2.9.14", "status": "affected" }] } ], "analysis": { "state": "not_affected", "justification": "code_not_reachable", "response": ["will_not_fix"], "detail": "libxml2 n'est pas utilisé par l'application" } } ]}Explication de la section analysis (c’est le VEX) :
state : équivalent du status OpenVEX (not_affected, affected,
resolved, in_triage)justification : raison technique (similaire à OpenVEX mais codes
légèrement différents : code_not_reachable, requires_configuration,
requires_environment, etc.)response : action prise (will_not_fix, update, rollback,
workaround_available)detail : explication humaine (comme impact_statement en OpenVEX)Avantages :
Inconvénients :
Quand utiliser CycloneDX VEX vs OpenVEX ?
Voici un scénario réel complet, étape par étape. Vous venez de builder une image
Docker devops-status-api:test et vous voulez scanner les vulnérabilités, puis
créer un VEX pour les faux positifs.
Générer le SBOM de votre image
# Générer SBOM au format CycloneDX JSONsyft devops-status-api@sha256:0c4d2ee8... -o cyclonedx-json > devops-status-api.sbom.json# ✔ Cataloged packages [118 packages]Résultat : fichier devops-status-api.sbom.json (620K).
Scanner les vulnérabilités avec Grype
grype sbom:devops-status-api.sbom.json -o table# ✔ Scanned for vulnerabilities [12 vulnerability matches]# ├── by severity: 1 critical, 2 high, 7 medium, 2 low# python-jose 3.3.0 GHSA-6c5p-j8vq-pqhj Critical# python 3.12.12 CVE-2025-12084 Medium# ... 10 autres CVE ...Analyser chaque CVE pour déterminer si elle est exploitable
Pour CVE-2025-12084 (xml.dom.minidom) :
xml.dom.minidomvulnerable_code_not_in_execute_pathCréer le VEX avec vexctl
# Récupérer le digest de l'imageDIGEST=$(docker inspect devops-status-api:test | jq -r '.[0].Id')# sha256:0c4d2ee8...
# Créer le VEX pour CVE-2025-12084vexctl create \ --author="security@stephane-robert.info" \ --product="pkg:oci/devops-status-api@sha256:0c4d2ee81f66cc0a4fc155b6cf233a1e435a6c2102e0b5a613ecd70ac3c55f4c" \ --vuln="CVE-2025-12084" \ --status="not_affected" \ --justification="vulnerable_code_not_in_execute_path" \ --impact-statement="Notre API FastAPI ne parse jamais de XML, uniquement du JSON. Le module xml.dom.minidom est présent dans Python 3.12 mais les fonctions vulnérables ne sont jamais appelées." \ > devops-status-api.vex.jsonAjouter d’autres CVE au même VEX
Pour documenter que GHSA-6c5p-j8vq-pqhj (python-jose) nécessite une action
:
# vexctl peut merger des statementsvexctl create \ --product="pkg:oci/devops-status-api@sha256:0c4d2ee81f66cc0a4fc155b6cf233a1e435a6c2102e0b5a613ecd70ac3c55f4c" \ --vuln="GHSA-6c5p-j8vq-pqhj" \ --status="affected" \ --impact-statement="python-jose utilisé pour JWT auth. Upgrade vers 3.4.0+ requis." \ | vexctl merge devops-status-api.vex.json - > devops-status-api-updated.vex.json
mv devops-status-api-updated.vex.json devops-status-api.vex.jsonRescanner avec le VEX appliqué
grype sbom:devops-status-api.sbom.json --vex devops-status-api.vex.json# ✔ Scanned for vulnerabilities [11 vulnerability matches]# ├── by severity: 1 critical, 2 high, 7 medium, 2 low# CVE-2025-12084 a disparu !Vous venez de créer votre premier VEX ! CVE-2025-12084 ne pollue plus vos
rapports de scan. Chaque fois que Grype scannera
devops-status-api@sha256:0c4d2ee8..., seules les 11 CVE réellement
exploitables seront affichées.
Le workflow ci-dessus montre la création manuelle de VEX en suivant chaque étape. Pour des workflows plus avancés (automation, fusion de VEX, gestion de multiples versions), consultez le guide complet vexctl qui détaille :
Voici ce qui se passe quand vous scannez une image classique sans VEX :
grype devops-status-api@sha256:0c4d2ee8... -o table# ✔ Scanned [12 vulnerability matches]# ├── 1 critical, 2 high, 7 medium, 2 low# python-jose 3.3.0 GHSA-6c5p-j8vq-pqhj Critical# python 3.12.12 CVE-2025-12084 Medium ← faux positif# ... 10 autresProblème : sur ces 12 CVE, au moins 1 est un faux positif avré (CVE-2025-12084 sur xml.dom.minidom). Les autres peuvent être :
Vous devez manuellement trier chaque CVE pour savoir quoi traiter en priorié. C’est exactement ce que VEX automatise.
Maintenant, même scan avec un fichier VEX :
grype devops-status-api@sha256:0c4d2ee8... --vex ./devops-status-api.vex.json# ✔ Scanned [11 vulnerability matches]# ├── 1 critical, 2 high, 7 medium, 2 low# CVE-2025-12084 filtré ✓Résultat : 11 CVE affichées (au lieu de 12). CVE-2025-12084 a été automatiquement filtrée par Grype grâce au VEX.
Ce qui s’est passé :
devops-status-api.vex.jsonstatus: not_affected → CVE
supprimée de l’outputstatus: affected → CVE affichée
normalementGrype supporte plusieurs méthodes pour appliquer un VEX. Voici les 3 modes les plus courants.
1. VEX externe (OpenVEX JSON)
Le VEX est un fichier séparé que vous passez explicitement à Grype :
grype sbom:./app.sbom.json --vex ./app.vex.jsonCas d’usage : vous générez le SBOM avec Syft (CycloneDX), puis vous créez un VEX OpenVEX à part. Avantage : vous pouvez mettre à jour le VEX sans rebuilder l’image ni régénérer le SBOM.
2. VEX intégré (CycloneDX avec section vulnerabilities)
Le VEX est dans le SBOM lui-même (CycloneDX 1.4+) :
grype sbom:./app-with-vex.cdx.json# Grype détecte automatiquement la section VEX, pas besoin de --vexCas d’usage : vous voulez distribuer un seul fichier (SBOM+VEX) pour simplifier. Utile pour les audits, les clients, les releases publiques. Inconvénient : si vous ajoutez une CVE au VEX, vous devez régénérer tout le SBOM.
3. Plusieurs sources VEX (cumul)
Vous pouvez fournir plusieurs fichiers VEX à Grype, qui les fusionnera :
grype nginx@sha256:4c0fdaa8b6341bfdeca5f18f7a2f0536b32447f805c90e1f2e79a6fa7e98f0b8 \ --vex ./vendor-vex.json \ --vex ./custom-vex.jsonCas d’usage : vous utilisez une image de base (Ubuntu, Alpine, Red Hat) qui
fournit son propre VEX vendor (vendor-vex.json), et vous ajoutez vos propres
statements (custom-vex.json) pour vos composants applicatifs.
Exemple concret :
vendor-vex.json : fourni par Red Hat, documente que CVE-2024-XXXX sur
systemd n’affecte pas les conteneurs (systemd désactivé)custom-vex.json : votre équipe documente que CVE-2024-YYYY sur votre
bibliothèque libapp est mitigée par un WAFGrype applique les deux. Si un statement existe dans plusieurs VEX pour la même CVE, le plus spécifique (dernier fourni) gagne.
Une fois vos VEX créés, vous devez les distribuer de façon sécurisée. Consultez le guide vexctl pour les workflows détaillés d’attachement avec Cosign, signature et distribution.
Fichier artefact CI
Publication avec les releases GitHub :
app.vex.jsonSimple mais non signé
Attaché à l'image OCI
Avec Cosign (recommandé) :
app.vex.json mon-app:1.0.0Le VEX suit l’image dans la registry
Attestation signée
Pour la production :
cosign attest --predicate app.vex.json \ --type https://openvex.dev/ns/v0.2.0 \ mon-app:1.0.0Garantit l’authenticité du VEX
Pour automatiser la création et l’application de VEX dans vos pipelines, consultez le guide vexctl - Section CI/CD qui propose des exemples complets pour GitHub Actions, GitLab CI et scripts bash.
Principe général :
name: Scan with VEX
on: [push]
jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Build image run: docker build -t mon-app:test .
- name: Generate SBOM uses: anchore/sbom-action@v0 with: image: mon-app:test format: cyclonedx-json output-file: sbom.json
- name: Create VEX for known false positives run: | DIGEST=$(docker inspect mon-app:test | jq -r '.[0].Id') vexctl create \ --product="pkg:oci/mon-app@${DIGEST}" \ --vuln="CVE-2024-1234" \ --status="not_affected" \ --justification="vulnerable_code_not_in_execute_path" \ --impact-statement="libxml2 non utilisé" \ > app.vex.json
- name: Scan with VEX uses: anchore/scan-action@v3 with: sbom: sbom.json vex: app.vex.json fail-build: true severity-cutoff: highPour des workflows plus avancés (multi-versions, VEX template, scripts d’auto-génération), voir le guide vexctl complet.
Le CRA impose de notifier les vulnérabilités exploitables sous 24h. VEX permet de documenter pourquoi certaines CVE ne nécessitent pas de notification :
Exemple : CVE-2024-1234 remontée par un scan automatique
not_affected → documentation de non-exploitabilité, pas de
notificationNIS2 impose une gestion des risques fournisseurs. VEX aide à qualifier les vulnérabilités dans les composants tiers :
L’EO impose aux fournisseurs du gouvernement US de fournir SBOM + VEX. Le VEX doit documenter :
fixed)not_affected avec justification)under_investigation avec délai)mon-app-v1.2.0.vex.jsonmon-app-v1.2.1.vex.jsonmon-app-v1.3.0.vex.jsonChaque version d’image a son propre VEX. Ne réutilisez pas un VEX entre versions (les dépendances changent).
{ "status": "not_affected", "justification": "vulnerable_code_not_in_execute_path", "impact_statement": "libxml2 est présent dans Alpine 3.19 (image de base) mais notre application Go n'utilise aucune bibliothèque C. Le code vulnérable n'est jamais chargé en mémoire."}L’impact_statement est crucial pour les audits. Expliquez clairement le
raisonnement.
Un CVE marqué not_affected peut devenir affected si :
Recommandation : réviser les VEX à chaque changement majeur de l’application.
Dependency-Track supporte VEX nativement :
# Upload SBOM + VEXcurl -X POST https://dtrack.mon-org.com/api/v1/bom \ -H "X-Api-Key: $TOKEN" \ -H "Content-Type: multipart/form-data" \ -F "project=$PROJECT_UUID" \ -F "bom=@sbom-with-vex.cdx.json"
# Dependency-Track applique automatiquement le VEX# Les CVE not_affected sont marquées "suppressed"Un VEX non signé peut être falsifié. En production, signez avec Cosign :
# Signature keyless (OIDC GitHub)cosign sign-blob --bundle vex-signature.bundle app.vex.json
# Vérificationcosign verify-blob \ --bundle vex-signature.bundle \ --certificate-identity="https://github.com/mon-org/mon-app/.github/workflows/ci.yml@refs/heads/main" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ app.vex.jsonSymptôme : vous créez un VEX avec 200 CVE en not_affected sans analyse.
Problème : perte de confiance, audit impossible, risque réel non détecté.
Solution : analysez CVE par CVE. Si trop nombreuses, concentrez-vous sur critical/high uniquement.
Symptôme : un seul fichier app.vex.json pour toutes les versions.
Problème : CVE corrigée dans v1.3 mais VEX appliqué à v1.2 → fausse sécurité.
Solution : un VEX par digest d’image (pkg:oci/app@sha256:abc123...).
Symptôme : vous créez un VEX mais Grype continue d’afficher toutes les CVE.
Problème : le flag --vex n’est pas passé à Grype.
Solution : vérifiez dans les logs Grype : [INFO] VEX applied: X vulnerabilities suppressed.
Guides sur ce site :
Spécifications :
Outils :
Articles :
3 points clés :
grype --vex app.vex.json filtre automatiquement les
faux positifsProchaines étapes :