Aller au contenu

Parcourir une remédiation de CVE

Ce que vous allez apprendre :

  • Comment un paquet qui déclenche votre politique CVE se retrouve en pending_review au lieu d'être publié silencieusement
  • Comment lire le résultat comme le ferait un relecteur (sévérité, EPSS, KEV)
  • Comment enregistrer une décision RSSI et voir le résultat prendre effet

Durée : ~15 minutes Prérequis : Repod démarré en local avec l'overlay du registre OCI activé (docker compose -f docker-compose.yaml -f docker-compose.oci.yml up -d), curl, jq, un compte admin

Ce qui est réel ici, et ce qui ne l'est pas

Tout dans ce tutoriel — l'import, la file de revue, le point de terminaison de décision, le résultat — fait partie du pipeline réel ; rien dans le mécanisme n'est simulé. Ce qu'une page de documentation ne peut pas garantir, c'est quelle CVE, s'il y en a une, la base de données de vulnérabilités de Grype signalera pour une image donnée le jour où vous exécutez ce tutoriel — cela dépend de ce qui a été publié depuis la construction de l'image et de la date du dernier rafraîchissement de la base de Grype sur votre instance. Donc, plutôt que de nommer un ID de CVE spécifique (qui pourrait facilement être obsolète ou faux au moment où vous lisez ceci), l'étape 1 assouplit temporairement votre politique CVE pour que tout ce que Grype trouve — même une seule correspondance de sévérité moyenne — soit garanti de passer par la file de revue. Les détails de CVE que vous verrez réellement sont de vraies découvertes provenant de votre propre base de données Grype, pas des découvertes fabriquées.


Étape 1 — Rendre ceci reproductible : router chaque découverte vers la revue

Par défaut, cve_policy bloque purement et simplement les découvertes critical (elles n'atteignent jamais la file de revue) et ne route que les high vers la revue. Pour garantir que ce parcours atteigne la file de revue quel que soit ce qu'une ancienne image révèle réellement, réglez temporairement chaque sévérité sur review :

TOKEN=$(curl -s -X POST http://YOUR_HOST:8000/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"YourPassword1!"}' \
  | jq -r .access_token)

curl -s -X PATCH http://YOUR_HOST:8000/api/v1/settings/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cve_policy": {"critical": "review", "high": "review", "medium": "review"}}' \
  | jq '.cve_policy'

Réponse attendue :

{
  "critical": "review",
  "high": "review",
  "medium": "review",
  "low": "allow",
  "negligible": "allow",
  "sla_critical_days": 0,
  "sla_high_days": 30,
  "sla_medium_days": 90,
  "auto_enrich": true
}

PATCH /settings/ fusionne en profondeur — seules les clés que vous envoyez sont modifiées, donc sla_*/auto_enrich restent inchangées. Pensez à annuler ceci à la fin (étape 7) — en dehors d'un tutoriel, ne rien bloquer au niveau critical n'est pas une politique à laisser en place.


Étape 2 — Importer un paquet susceptible de contenir des découvertes

Un paquet construit récemment, à partir d'une base maintenue, peut très bien revenir propre — ce qui ferait un tutoriel court et sans intérêt. Importez à la place une image Docker Hub suffisamment ancienne pour qu'une CVE ait presque certainement été publiée contre ses paquets embarqués depuis sa construction :

curl -s -X POST http://YOUR_HOST:8000/api/v1/oci/import \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"image": "python", "tag": "3.6.0", "repository": "cve-walkthrough"}' \
  | jq .

L'Importeur exécute le même pipeline synchrone qu'un upload natif : téléchargement, ClamAV, Grype, puis votre politique CVE décide du résultat. Réponse attendue (avec la politique assouplie de l'étape 1 en vigueur) :

{
  "status": "pending_review",
  "name": "python",
  "version": "3.6.0",
  "message": "en attente de révision RSSI (non publiée)",
  "steps": [
    { "name": "antivirus", "passed": true, "message": "ClamAV — clean" },
    { "name": "cve", "passed": true, "message": "Grype — N vulnerabilities found, policy: review" }
  ]
}

Vous avez obtenu \"status\": \"added\" à la place ?

Grype n'a réellement rien trouvé à signaler pour ce tag exact sur la base de données actuelle de votre instance — rare pour python:3.6.0, mais possible si votre base Grype vient d'être rafraîchie et est exceptionnellement clémente, ou si le tag avait déjà été importé propre auparavant. Choisissez un tag encore plus ancien (par ex. python:2.7.0 ou node:8.0.0) et relancez cette étape.

L'image n'a pas été poussée vers le registre — pending_review signifie que les octets n'existent que dans une zone de staging temporaire pendant le scan ; rien n'est servable via docker pull tant qu'une décision n'a pas été prise.


Étape 3 — L'examiner depuis la file de revue

Voici ce qu'un maintainer ou un admin voit dans Sécurité → File de revue :

curl -s -H "Authorization: Bearer $TOKEN" \
  "http://YOUR_HOST:8000/api/v1/security/review-queue" | jq '.packages[] | select(.name=="python")'

Forme attendue (vos comptes réels et la pire sévérité varieront) :

{
  "name": "python",
  "version": "3.6.0",
  "arch": "linux_amd64",
  "distribution": "cve-walkthrough",
  "pkg_format": "oci",
  "status": "pending_review",
  "worst_severity": "High",
  "cve_counts": { "critical": 0, "high": 2, "medium": 5, "low": 3, "negligible": 0, "unknown": 0 },
  "total_cve": 10,
  "kev_count": 0,
  "high_epss_count": 1,
  "cve_results": [ "... full per-CVE detail, see Step 4 ..." ],
  "decision": null,
  "sla": { "has_sla": false }
}

Notez le champ arch (linux_amd64 pour une image amd64) — vous en aurez besoin pour les deux appels ci-dessous, car les manifestes OCI sont indexés par plateforme, pas par une chaîne d'architecture fixe.


Étape 4 — Examiner les découvertes réelles

curl -s -H "Authorization: Bearer $TOKEN" \
  "http://YOUR_HOST:8000/api/v1/security/packages/python/3.6.0/cve?arch=linux_amd64" | jq .

Chaque entrée de cve_results ressemble à ceci (une découverte réelle et individuelle provenant de votre base Grype — id/sévérité/description seront ce que Grype a réellement trouvé) :

{
  "id": "CVE-XXXX-XXXXX",
  "severity": "High",
  "cvss": 7.5,
  "description": "...",
  "package_name": "...",
  "package_version": "...",
  "fix_state": "fixed",
  "fix_versions": ["..."],
  "urls": ["https://..."],
  "epss_percent": "1.2%",
  "epss_label": "Faible",
  "in_kev": false
}
  • epss_percent — la probabilité prédite par FIRST.org que cette CVE soit exploitée dans la nature au cours des 30 prochains jours
  • in_kev — si le catalogue des vulnérabilités activement exploitées (Known Exploited Vulnerabilities) de la CISA liste cette CVE comme activement exploitée (un signal bien plus fort que la sévérité seule)
  • fix_state — si l'amont a déjà publié une version corrigée

C'est exactement l'information qu'un relecteur pèse avant de décider.


Étape 5 — Enregistrer la décision RSSI

Quatre actions sont disponibles : accept_risk (publier quand même, risque accepté), exception (idem, mais avec une expiration — expires_in_days), reject (mise en quarantaine, jamais publié), et upgrade_required (bloqué jusqu'à une version cible). Pour ce parcours, acceptons le risque — un choix raisonnable pour une image de démo que vous allez de toute façon supprimer :

curl -s -X POST http://YOUR_HOST:8000/api/v1/security/packages/python/3.6.0/decide \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "accept_risk",
    "justification": "Tutorial walkthrough — demo image, not used in production.",
    "arch": "linux_amd64"
  }' | jq .

Réponse attendue :

{
  "status": "ok",
  "package": "python",
  "version": "3.6.0",
  "action": "accept_risk",
  "new_status": "accepted_risk",
  "decision": {
    "action": "accept_risk",
    "justification": "Tutorial walkthrough — demo image, not used in production.",
    "decided_by": "admin",
    "...": "..."
  },
  "message": "python accepté avec risque — publié dans cve-walkthrough"
}

justification est obligatoire — le point de terminaison retourne 400 sans elle. Cela compte pour l'audit : chaque décision est liée de manière permanente à qui l'a prise, quand, et pourquoi.


Étape 6 — Voir le résultat prendre effet

accept_risk sur une image importée (pas encore publiée) fait quelque chose de réel : Repod retélécharge le même digest exact que celui scanné à l'étape 2 et le pousse vers le registre — c'est la décision qui la rend réellement servable.

docker pull YOUR_HOST:5000/cve-walkthrough/python:3.6.0

(Si vous n'avez pas encore configuré insecure-registries pour des tests en local, voir d'abord l'étape 1 de Pousser votre première image de conteneur.)

La sortie attendue se termine par :

Status: Downloaded newer image for YOUR_HOST:5000/cve-walkthrough/python:3.6.0

Confirmez que le manifeste est cohérent :

curl -s -H "Authorization: Bearer $TOKEN" \
  "http://YOUR_HOST:8000/api/v1/security/packages/python/3.6.0/decision?arch=linux_amd64" | jq '.status, .decision.action'

"accepted_risk"
"accept_risk"

Si vous aviez exécuté reject à la place, l'image serait restée définitivement non publiée (pour un import OCI, reject supprime le contenu — il n'y a pas de quarantaine façon pool depuis laquelle le restaurer plus tard ; il faudrait réimporter depuis la source pour réessayer).


Étape 7 — Nettoyer

Supprimez l'image de démo, et remettez votre politique CVE dans son état d'origine (valeurs par défaut affichées — ajustez si vous l'aviez personnalisée avant ce tutoriel) :

curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "http://YOUR_HOST:8000/api/v1/oci/repositories/cve-walkthrough/tags/3.6.0"

curl -s -X PATCH http://YOUR_HOST:8000/api/v1/settings/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"cve_policy": {"critical": "block", "high": "review", "medium": "warn"}}' \
  | jq '.cve_policy'

docker rmi YOUR_HOST:5000/cve-walkthrough/python:3.6.0

Résolution de problèmes

L'étape 2 retourne \"status\": \"blocked\" même après l'étape 1

Vérifiez que la réponse de PATCH /settings/ à l'étape 1 montre bien critical/high/medium tous réglés sur \"review\" — une faute de frappe dans le corps JSON laisse silencieusement la valeur par défaut en place pour la clé mal orthographiée (la fusion en profondeur ne touche que les clés que vous envoyez correctement).

La file de revue de l'étape 3 est vide

GET /review-queue ne liste que les paquets dont le status est pending_review/blocked — si l'étape 2 a retourné \"status\": \"added\", il n'y a rien à réviser pour l'instant. Voir la note sous l'étape 2.

decide retourne 409 'Ce paquet n'est pas en révision'

Une décision a déjà été enregistrée pour ce name/version/arch exact (par exemple en relançant l'étape 5 deux fois) — vérifiez GET /security/packages/python/3.6.0/decision?arch=linux_amd64 pour voir la décision existante.


Ce que vous venez de faire

  • Effectué un changement contrôlé de politique CVE pour que le parcours fonctionne de manière déterministe, quel que soit ce qu'une image spécifique contient réellement aujourd'hui
  • Importé une vraie image publique via le pipeline validé complet (ClamAV + Grype) et observé une correspondance de politique la router vers pending_review
  • Lu la sévérité, le score EPSS et le statut KEV d'une vraie découverte comme le ferait un relecteur
  • Enregistré une décision accept_risk avec une justification obligatoire, et observé qu'elle a réellement publié l'image jusque-là retenue

Étapes suivantes