Aller au contenu

vexctl : créer et gérer des documents VEX

Mise à jour :

Vous avez scanné votre image avec Grype. Résultat : 12 vulnérabilités. Après analyse, vous réalisez que CVE-2025-12084 concerne xml.dom.minidom, mais votre API ne parse jamais de XML. C’est un faux positif.

Le problème : demain, vous rescannerez. Cette CVE réapparaîtra. Vous re-analyserez. Vous re-conclurez “faux positif”. Et ainsi de suite, à chaque build, à chaque sprint, à chaque release.

La solution : vexctl. Cet outil vous permet de documenter une fois que cette CVE n’est pas exploitable dans votre contexte. Au prochain scan, Grype lira votre document VEX et filtrera automatiquement ce faux positif.

Ce que fait vexctl

vexctl est l’outil en ligne de commande officiel du projet OpenVEX. Il génère des documents VEX valides en quelques commandes, sans risque d’erreur de syntaxe JSON.

Le workflow complet :

Workflow vexctl : scanner, analyser, documenter avec VEX, rescanner

  1. Scanner : Grype détecte 12 CVE sur votre image
  2. Analyser : vous triez par sévérité et vérifiez le contexte de chaque CVE
  3. Documenter : vexctl crée un fichier .vex.json pour les faux positifs
  4. Corriger : vous mettez à jour les vraies vulnérabilités
  5. Rescanner : grype --vex filtre les faux positifs documentés → 11 CVE

Anatomie d’un document VEX

Avant d’utiliser vexctl, comprenez ce qu’il génère :

Structure d'un document VEX OpenVEX

Un document VEX contient :

  • Métadonnées : auteur, date, version, identifiant unique
  • Statements : une liste de déclarations, chacune associant une CVE à un produit avec un statut d’exploitabilité

Chaque statement précise :

  • vulnerability : l’identifiant CVE ou GHSA
  • products : le ou les produits concernés (au format purl)
  • status : not_affected, affected, fixed, ou under_investigation
  • justification : le code standardisé expliquant pourquoi (si not_affected)
  • impact_statement : l’explication humaine détaillée

Prérequis

Connaissances :

  • Comprendre ce qu’est un VEX et à quoi il sert
  • Avoir déjà scanné une image avec Grype ou Trivy
  • Connaître les bases des CVE

Outils à installer :

Terminal window
# Installer Go (version 1.21+)
# Ubuntu/Debian
sudo snap install go --classic
# macOS
brew install go
# Installer vexctl
go install github.com/openvex/vexctl@v0.4.1
# Vérifier l'installation
vexctl version
# vexctl version v0.4.1

Commandes principales

vexctl propose deux commandes essentielles : create pour générer des statements VEX, et merge pour les combiner.

create : créer un document VEX

La commande create génère un nouveau document VEX. C’est la commande que vous utiliserez 90% du temps.

Syntaxe de base :

Terminal window
vexctl create \
--author="<email ou nom>" \
--product="<purl du produit>" \
--vuln="<CVE ou GHSA>" \
--status="<not_affected|affected|fixed|under_investigation>" \
--justification="<code justification>" \
--impact-statement="<explication humaine>" \
> fichier.vex.json

Paramètres obligatoires :

ParamètreDescriptionExemple
--authorAuteur du VEX (email ou organisation)security@mon-org.com
--productProduit concerné au format purlpkg:oci/mon-app@sha256:abc123...
--vulnIdentifiant de la vulnérabilitéCVE-2024-1234 ou GHSA-xxxx-yyyy-zzzz
--statusStatut d’exploitabiliténot_affected, affected, fixed, under_investigation

Paramètres optionnels :

ParamètreDescriptionQuand l’utiliser
--justificationCode justification standardiséPour status=not_affected (voir tableau ci-dessous)
--impact-statementExplication en langage naturelToujours recommandé (pour les humains)
--action-statementAction prise ou planifiéePour status=fixed ou affected

Les 4 statuts possibles

Chaque CVE que vous documentez doit avoir un statut. Choisissez celui qui correspond à votre situation :

not_affected

La CVE ne vous concerne pas. Le composant vulnérable est présent mais non exploitable dans votre contexte.

Exemple : CVE sur xml.dom.minidom mais votre API ne parse que du JSON.

Requiert : --justification obligatoire

affected

La CVE vous concerne. Le composant est utilisé de manière vulnérable. Action corrective nécessaire.

Exemple : CVE sur python-jose que vous utilisez pour l’auth JWT.

Requiert : --action-statement recommandé

fixed

La CVE a été corrigée. Mise à jour appliquée ou patch déployé.

Exemple : Upgrade de starlette 0.41.3 vers 0.49.1.

Requiert : --action-statement recommandé

under_investigation

Analyse en cours. Vous n’avez pas encore déterminé l’impact.

Exemple : CVE publiée ce matin, l’équipe sécu évalue.

Temporaire : à mettre à jour rapidement

Justifications standardisées

Utilisez ces codes avec --justification uniquement pour status=not_affected. Ces codes sont standardisés par la spécification VEX et compris par tous les outils compatibles :

CodeSignificationExemple concret
component_not_presentLe composant vulnérable n’est pas installé”CVE sur PostgreSQL mais nous utilisons MySQL”
vulnerable_code_not_presentLe code vulnérable a été supprimé (patch custom)“Nginx recompilé sans module DAV vulnérable”
vulnerable_code_not_in_execute_pathLe code existe mais n’est jamais appelé”libxml2 présent mais API ne parse jamais de XML”
vulnerable_code_cannot_be_controlled_by_adversaryL’attaquant ne peut pas atteindre le code”Endpoint admin bloqué par NetworkPolicy”
inline_mitigations_already_existDes mitigations (WAF, sandbox) neutralisent l’exploit”WAF ModSecurity bloque les injections SQL”

Exemples pratiques

Voici les 4 cas d’usage les plus fréquents, avec les commandes complètes.

1. CVE non exploitable (faux positif)

Terminal window
# xml.dom.minidom présent dans Python 3.12 mais jamais utilisé
vexctl 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.json

2. CVE corrigée

Terminal window
# python-jose upgradé vers version corrigée
vexctl create \
--author="security@stephane-robert.info" \
--product="pkg:oci/devops-status-api@sha256:0c4d2ee81f66cc0a4fc155b6cf233a1e435a6c2102e0b5a613ecd70ac3c55f4c" \
--vuln="GHSA-6c5p-j8vq-pqhj" \
--status="fixed" \
--action-statement="Mise à jour python-jose 3.3.0 → 3.4.0 dans requirements.txt (commit abc1234)" \
> devops-status-api.vex.json

3. CVE en cours d’analyse

Terminal window
# CVE récente, équipe sécu évalue l'impact
vexctl create \
--author="security@stephane-robert.info" \
--product="pkg:oci/devops-status-api@sha256:0c4d2ee81f66cc0a4fc155b6cf233a1e435a6c2102e0b5a613ecd70ac3c55f4c" \
--vuln="GHSA-9hjg-9r4m-mvj7" \
--status="under_investigation" \
--impact-statement="CVE publiée le 19/12/2024, équipe analyse si notre usage de requests (API internes uniquement) est concerné. Résultats attendus 23/12 17h." \
> devops-status-api.vex.json

4. CVE exploitable (nécessite action)

Terminal window
# CVE critique confirmée, upgrade planifié
vexctl create \
--author="security@stephane-robert.info" \
--product="pkg:oci/devops-status-api@sha256:0c4d2ee81f66cc0a4fc155b6cf233a1e435a6c2102e0b5a613ecd70ac3c55f4c" \
--vuln="GHSA-6c5p-j8vq-pqhj" \
--status="affected" \
--action-statement="python-jose utilisé pour JWT auth. Upgrade vers 3.4.0+ requis sous 48h (ticket JIRA SEC-1234)." \
> devops-status-api.vex.json

merge : fusionner plusieurs statements

La commande merge combine plusieurs documents VEX en un seul. Utile pour ajouter des statements à un VEX existant sans l’écraser.

Ajouter un statement à un VEX existant :

Terminal window
# Créer un nouveau statement
vexctl create \
--product="pkg:oci/devops-status-api@sha256:0c4d2ee8..." \
--vuln="CVE-2025-13836" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path" \
--impact-statement="CVE sur zipimport mais application ne charge jamais de .zip" \
> new-statement.vex.json
# Fusionner avec le VEX existant
vexctl merge devops-status-api.vex.json new-statement.vex.json > merged.vex.json
# Remplacer le VEX original
mv merged.vex.json devops-status-api.vex.json
# Nettoyer
rm new-statement.vex.json

Fusionner plusieurs VEX :

Terminal window
# Fusionner VEX vendor + VEX custom
vexctl merge \
vendor-alpine.vex.json \
vendor-python.vex.json \
custom-app.vex.json \
> complete.vex.json

Comportement en cas de doublon : si un statement existe pour la même CVE dans plusieurs VEX, merge conserve tous les statements. Le dernier fourni en ligne de commande a priorité lors de l’évaluation par les scanners.

Workflows courants

Workflow 1 : Traiter les faux positifs d’un scan Grype

  1. Scanner et exporter les CVE en JSON

    Terminal window
    # Générer SBOM
    syft mon-app:test -o cyclonedx-json > mon-app.sbom.json
    # Scanner avec Grype (format JSON)
    grype sbom:mon-app.sbom.json -o json > vulns.json
  2. Extraire la liste des CVE uniques

    Terminal window
    jq -r '.matches[].vulnerability.id' vulns.json | sort -u > cve-list.txt
    # Afficher la liste
    cat cve-list.txt
    # CVE-2024-1234
    # CVE-2024-5678
    # GHSA-xxxx-yyyy-zzzz
  3. Analyser chaque CVE manuellement

    Pour chaque CVE, vérifier :

    • La description NVD : curl https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=CVE-2024-1234
    • Votre code source : le composant est-il utilisé ?
    • Votre architecture : l’exploit est-il possible ?
  4. Créer un statement VEX pour chaque faux positif

    Terminal window
    # Récupérer le digest de l'image
    DIGEST=$(docker inspect mon-app:test | jq -r '.[0].Id')
    # Créer le premier statement
    vexctl create \
    --author="security@mon-org.com" \
    --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é" \
    > mon-app.vex.json
  5. Ajouter les autres CVE au même VEX

    Terminal window
    # Pour chaque faux positif supplémentaire
    vexctl create \
    --product="pkg:oci/mon-app@${DIGEST}" \
    --vuln="CVE-2024-5678" \
    --status="not_affected" \
    --justification="component_not_present" \
    --impact-statement="PostgreSQL non installé, nous utilisons MySQL" \
    | vexctl merge mon-app.vex.json - > temp.vex.json
    mv temp.vex.json mon-app.vex.json
  6. Rescanner avec le VEX

    Terminal window
    # Scanner avec VEX pour filtrer les faux positifs
    grype sbom:mon-app.sbom.json --vex mon-app.vex.json
    # Comparer le nombre de CVE avant/après
    echo "Sans VEX: $(jq '.matches | length' vulns.json)"
    grype sbom:mon-app.sbom.json --vex mon-app.vex.json -o json > vulns-filtered.json
    echo "Avec VEX: $(jq '.matches | length' vulns-filtered.json)"

Workflow 2 : VEX automatique pour CVE récurrentes

Si certains faux positifs reviennent à chaque build (dépendances de l’image de base Alpine, Python, etc.), automatisez leur création :

generate-base-vex.sh
#!/bin/bash
DIGEST=$(docker inspect mon-app:test | jq -r '.[0].Id')
PRODUCT="pkg:oci/mon-app@${DIGEST}"
# Liste des CVE connues comme faux positifs
KNOWN_FP=(
"CVE-2024-1234:libxml2 jamais utilisé"
"CVE-2024-5678:Module PostgreSQL absent"
"GHSA-xxxx-yyyy-zzzz:Endpoint admin bloqué par NetworkPolicy"
)
# Créer le premier statement
IFS=':' read -r CVE REASON <<< "${KNOWN_FP[0]}"
vexctl create \
--author="security@mon-org.com" \
--product="$PRODUCT" \
--vuln="$CVE" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path" \
--impact-statement="$REASON" \
> mon-app.vex.json
# Ajouter les autres
for i in "${KNOWN_FP[@]:1}"; do
IFS=':' read -r CVE REASON <<< "$i"
vexctl create \
--product="$PRODUCT" \
--vuln="$CVE" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path" \
--impact-statement="$REASON" \
| vexctl merge mon-app.vex.json - > temp.vex.json
mv temp.vex.json mon-app.vex.json
done
echo "✓ VEX créé avec ${#KNOWN_FP[@]} statements"

Workflow 3 : VEX pour plusieurs versions d’image

Chaque version d’image doit avoir son propre VEX (les dépendances changent entre versions) :

vex-per-version.sh
#!/bin/bash
VERSIONS=("1.0.0" "1.1.0" "1.2.0")
for VERSION in "${VERSIONS[@]}"; do
echo "Traitement de mon-app:${VERSION}..."
# Récupérer le digest
DIGEST=$(docker inspect mon-app:${VERSION} | jq -r '.[0].Id')
# Scanner
syft mon-app:${VERSION} -o cyclonedx-json > mon-app-${VERSION}.sbom.json
grype sbom:mon-app-${VERSION}.sbom.json -o json > vulns-${VERSION}.json
# Créer VEX (exemple simplifié)
vexctl create \
--author="security@mon-org.com" \
--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é dans version ${VERSION}" \
> mon-app-${VERSION}.vex.json
# Rescanner avec VEX
grype sbom:mon-app-${VERSION}.sbom.json --vex mon-app-${VERSION}.vex.json
done

Distribuer les VEX avec Cosign

Une fois vos VEX créés avec vexctl, distribuez-les de façon sécurisée.

Attacher le VEX à l’image OCI

Terminal window
# Générer SBOM + VEX
syft mon-app:1.0.0 -o cyclonedx-json > sbom.json
vexctl create [...] > app.vex.json
# Attacher SBOM et VEX à l'image dans la registry
cosign attach sbom --sbom sbom.json mon-app:1.0.0
cosign attach artifact --artifact app.vex.json mon-app:1.0.0
# Vérifier les artefacts attachés
cosign tree mon-app:1.0.0
# mon-app:1.0.0
# ├── sha256:abc123... (SBOM)
# └── sha256:def456... (VEX)

Signer le VEX (recommandé en production)

Terminal window
# Signature keyless avec OIDC GitHub Actions
cosign attest \
--predicate app.vex.json \
--type https://openvex.dev/ns/v0.2.0 \
mon-app:1.0.0
# Vérifier la signature
cosign verify-attestation \
--type https://openvex.dev/ns/v0.2.0 \
mon-app:1.0.0

Récupérer le VEX depuis la registry

Terminal window
# Télécharger le VEX attaché à une image
cosign download attestation mon-app:1.0.0 \
| jq -r '.payload' \
| base64 -d \
| jq -r '.predicate' \
> downloaded.vex.json
# Utiliser le VEX téléchargé
grype mon-app:1.0.0 --vex downloaded.vex.json

Intégration CI/CD

GitHub Actions

name: Build + SBOM + VEX
on:
push:
branches: [main]
jobs:
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
- name: Install tools
run: |
# Syft
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin v1.38.2
# Grype
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin v0.104.2
# vexctl
go install github.com/openvex/vexctl@v0.4.1
- name: Generate SBOM
run: syft ghcr.io/${{ github.repository }}:${{ github.sha }} -o cyclonedx-json > sbom.json
- name: Initial scan
id: scan
run: |
grype sbom:sbom.json -o json > vulns.json
echo "cve_count=$(jq '.matches | length' vulns.json)" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Generate VEX for known false positives
run: |
DIGEST=$(docker inspect ghcr.io/${{ github.repository }}:${{ github.sha }} | jq -r '.[0].Id')
# CVE-2024-1234 : libxml2 non utilisé
vexctl create \
--author="security@${{ github.repository_owner }}.com" \
--product="pkg:oci/${{ github.repository }}@${DIGEST}" \
--vuln="CVE-2024-1234" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path" \
--impact-statement="libxml2 non utilisé par l'application" \
> app.vex.json
- name: Re-scan with VEX
run: |
grype sbom:sbom.json --vex app.vex.json -o json > vulns-filtered.json
echo "CVE avant VEX: $(jq '.matches | length' vulns.json)"
echo "CVE après VEX: $(jq '.matches | length' vulns-filtered.json)"
- name: Fail on critical/high CVE
run: |
HIGH_COUNT=$(jq '[.matches[] | select(.vulnerability.severity == "Critical" or .vulnerability.severity == "High")] | length' vulns-filtered.json)
if [ "$HIGH_COUNT" -gt 0 ]; then
echo "❌ $HIGH_COUNT CVE Critical/High détectées"
exit 1
fi
- name: Push image + SBOM + VEX
run: |
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
cosign attach sbom --sbom sbom.json ghcr.io/${{ github.repository }}:${{ github.sha }}
cosign attach artifact --artifact app.vex.json ghcr.io/${{ github.repository }}:${{ github.sha }}

Bonnes pratiques

1. Versionner les VEX avec les images

Terminal window
# Un VEX par digest d'image
vexctl create --product="pkg:oci/mon-app@sha256:abc123..."
# PAS un VEX par tag (les tags sont mutables)
# ❌ vexctl create --product="pkg:oci/mon-app:latest"

2. Documenter toujours l’impact_statement

Terminal window
# ✅ Bon : explication claire pour les humains
vexctl create \
--vuln="CVE-2024-1234" \
--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."
# ❌ Mauvais : justification seule (pas d'explication)
vexctl create \
--vuln="CVE-2024-1234" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path"

3. Réviser les VEX régulièrement

Un VEX marqué not_affected peut devenir affected si :

  • Vous ajoutez une fonctionnalité qui utilise le composant vulnérable
  • Une nouvelle variante d’exploit contourne les mitigations
  • La configuration change (ouverture d’un port, désactivation d’un WAF)

Recommandation : réviser les VEX à chaque changement majeur de l’application ou tous les 3 mois.

4. Ne pas abuser de not_affected

Terminal window
# ❌ Mauvais : marquer toutes les CVE en not_affected sans analyse
for cve in $(cat all-cves.txt); do
vexctl create --vuln="$cve" --status="not_affected" --justification="vulnerable_code_not_in_execute_path"
done
# ✅ Bon : analyser CVE par CVE, documenter le raisonnement
# Concentrez-vous sur les Critical/High uniquement si trop nombreuses

5. Utiliser merge plutôt que recréer

Terminal window
# ✅ Bon : ajouter un statement sans écraser l'existant
vexctl create [...] | vexctl merge existing.vex.json - > updated.vex.json
# ❌ Mauvais : écraser le VEX à chaque fois
vexctl create [...] > existing.vex.json # perte des statements précédents

Erreurs courantes

❌ Oublier le digest dans —product

Terminal window
# ❌ Tag mutable (peut changer)
vexctl create --product="pkg:oci/mon-app:latest"
# ✅ Digest SHA256 (immuable)
vexctl create --product="pkg:oci/mon-app@sha256:abc123def456..."

❌ Utiliser —statement au lieu de —impact-statement

Terminal window
# ❌ Flag qui n'existe pas dans vexctl v0.4.1
vexctl create --statement="Explication..."
# Error: unknown flag: --statement
# ✅ Flag correct
vexctl create --impact-statement="Explication..."

❌ Ne pas valider le JSON généré

Terminal window
# ✅ Toujours vérifier le contenu généré
vexctl create [...] > app.vex.json
cat app.vex.json | jq .
# Si jq retourne une erreur, le JSON est invalide

❌ Utiliser vexctl verify (commande supprimée)

Terminal window
# ❌ vexctl verify n'existe plus dans v0.4.1
vexctl verify app.vex.json
# Error: unknown command "verify"
# ✅ Valider avec jq
cat app.vex.json | jq . > /dev/null && echo "✓ VEX valide"

Ressources

Documentation officielle :

Guides connexes :

À retenir

3 commandes essentielles :

  1. create : générer un statement VEX pour une CVE

    Terminal window
    vexctl create --product=... --vuln=... --status=... > file.vex.json
  2. merge : combiner plusieurs VEX ou ajouter un statement

    Terminal window
    vexctl merge existing.vex.json new.vex.json > merged.vex.json
  3. Validation : vérifier le JSON avec jq

    Terminal window
    cat file.vex.json | jq .

Prochaines étapes :

  • Scannez une de vos images avec Grype
  • Identifiez 1-2 faux positifs
  • Créez un VEX avec vexctl
  • Rescannez avec --vex pour vérifier le filtrage