diff --git a/README.md b/README.md index b05f4cc..7af54e3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,31 @@ le registre de Gitea sous les tags de sa filière. **Ne déploie rien.** Sorties : `tags` (les tags publiés) et `digest` (l'empreinte `sha256:…` de l'image publiée). -**Exigences** : l'image du conteneur de job doit porter `buildah`. L'action +**Scan de vulnérabilités bloquant.** Entre la construction et la publication, +**Trivy** examine l'image. Une CVE **HIGH ou CRITICAL pour laquelle un +correctif existe** fait échouer le job : rien n'est publié. Les autres — dont +les CVE sans correctif, contre lesquelles personne ne peut rien — sont +rapportées dans le journal sans bloquer. Le seuil n'est pas une entrée de +l'action : un dépôt ne peut pas l'abaisser pour lui seul. + +Une CVE **acceptée sciemment** va dans `.trivyignore.yaml`, à la racine du +dépôt appelant. Chaque entrée porte une justification et une date +d'expiration ; sans l'une ou l'autre, l'action refuse le fichier. À +l'échéance, Trivy cesse d'appliquer l'exception et la CVE bloque de nouveau. + +```yaml +vulnerabilities: + - id: CVE-2026-93990 + statement: libexpat de l'image nginx ; correctif attendu dans la prochaine base + expired_at: 2026-11-15 +``` + +Le plus souvent, la correction est une ligne dans le Dockerfile — mettre à +jour les paquets de la base (`apk upgrade --no-cache`, `apt-get upgrade`) — +plutôt qu'une exception. + +**Exigences** : l'image du conteneur de job doit porter `buildah` **et +`trivy`**. L'action construit avec `--storage-driver vfs --isolation chroot`, ce qui fonctionne dans un conteneur non privilégié, sans capacité ajoutée ni `/dev/fuse`. Les `FROM` des Dockerfiles construits doivent être des **noms complets** diff --git a/build-push/action.yml b/build-push/action.yml index 9730a81..8442851 100644 --- a/build-push/action.yml +++ b/build-push/action.yml @@ -111,10 +111,12 @@ runs: run: | set -euo pipefail - if ! command -v buildah >/dev/null; then - echo "::error::buildah absent de l'image de job : le runner doit exécuter ce job dans une image qui le porte (voir README de RF_ACTIONS)" - exit 1 - fi + for OUTIL in buildah trivy; do + if ! command -v "$OUTIL" >/dev/null; then + echo "::error::$OUTIL absent de l'image de job : le runner doit exécuter ce job dans une image qui le porte (voir README de RF_ACTIONS)" + exit 1 + fi + done # « --storage-driver vfs » sur CHAQUE appel, et pas par la variable # STORAGE_DRIVER : constaté le 2026-10-07 sur buildah 1.33.7, avec la @@ -129,7 +131,8 @@ runs: # job, et effacé en sortie quoi qu'il arrive. Le conteneur est jeté à la # fin du job, mais un jeton n'a pas à lui survivre d'une étape. export REGISTRY_AUTH_FILE="${RUNNER_TEMP:-/tmp}/build-push-auth.json" - trap 'rm -f "$REGISTRY_AUTH_FILE"' EXIT + IMAGE_OCI="${RUNNER_TEMP:-/tmp}/build-push-image-oci" + trap 'rm -rf -- "$REGISTRY_AUTH_FILE" "$IMAGE_OCI"' EXIT # printf est une primitive du shell : le jeton ne passe dans l'argv # d'aucun processus, donc pas dans /proc//cmdline. @@ -151,6 +154,56 @@ runs: "${TAG_ARGS[@]}" \ "$INPUT_CONTEXT" + # ── Scan de vulnérabilités, BLOQUANT, avant toute publication ────────── + # Seuil décidé le 2026-10-07 (RF_CICD#7) : une CVE HIGH ou CRITICAL + # pour laquelle un correctif est publié fait échouer le job, comme un + # test rouge. Le reste — dont les CVE sans correctif, contre lesquelles + # personne ne peut rien — est rapporté sans bloquer. Le seuil n'est PAS + # une entrée de l'action : un dépôt ne peut pas l'abaisser pour lui seul. + # Une CVE acceptée sciemment va dans .trivyignore.yaml, à la racine du + # dépôt appelant, avec une justification et une date d'expiration. + # + # Trivy lit l'image construite sous forme de RÉPERTOIRE OCI : il n'y a + # pas de démon à interroger, et rien n'est publié avant son verdict. + # Un répertoire et non une archive : « --input » n'accepte que l'archive + # au format Docker (manifest.json) ou le répertoire OCI (index.json) — + # une archive OCI est refusée (constaté, trivy 0.75.0). + PREMIER_TAG=${INPUT_TAGS%% *} + b push "$INPUT_IMAGE:$PREMIER_TAG" "oci:$IMAGE_OCI" + + IGNORE=() + FICHIER_IGNORE="${GITHUB_WORKSPACE:-.}/.trivyignore.yaml" + if [ -f "$FICHIER_IGNORE" ]; then + # Chaque exception porte un « statement » (pourquoi) et un + # « expired_at » (jusqu'à quand) : à l'échéance, Trivy cesse de + # l'appliquer et la CVE bloque de nouveau. Une exception sans date + # serait une CVE acceptée pour toujours, sans que personne l'ait dit. + if ! awk ' + /^[[:space:]]*-[[:space:]]*id:/ { n++; id[n]=$0 } + /^[[:space:]]*statement:/ { s[n]=1 } + /^[[:space:]]*expired_at:/ { e[n]=1 } + END { ok=1; for (i=1;i<=n;i++) if (!s[i] || !e[i]) { print " incomplète :" id[i]; ok=0 } exit !ok } + ' "$FICHIER_IGNORE"; then + echo "::error::.trivyignore.yaml : chaque exception doit porter « statement » et « expired_at »" + exit 1 + fi + IGNORE=(--ignorefile "$FICHIER_IGNORE") + echo "Exceptions lues dans .trivyignore.yaml" + fi + + # Rapport complet des HIGH/CRITICAL, corrigeables ou non — pour voir, + # pas pour juger. Puis le verdict, sur les seules corrigeables. + echo "::group::Vulnérabilités HIGH et CRITICAL, corrigeables ou non (rapport)" + trivy image --quiet --scanners vuln --severity HIGH,CRITICAL \ + "${IGNORE[@]}" --format table --exit-code 0 --input "$IMAGE_OCI" + echo "::endgroup::" + if ! trivy image --quiet --skip-db-update --scanners vuln --severity HIGH,CRITICAL \ + --ignore-unfixed "${IGNORE[@]}" --format table --exit-code 1 --input "$IMAGE_OCI"; then + echo "::error::CVE HIGH ou CRITICAL corrigeable dans l'image : rien n'est publié. Corriger (mettre à jour la base ou le paquet), ou l'accepter sciemment dans .trivyignore.yaml, avec justification et date d'expiration." + exit 1 + fi + echo "Scan : aucune CVE HIGH ou CRITICAL corrigeable — publication autorisée" + DIGEST_FILE="${RUNNER_TEMP:-/tmp}/build-push-digest" for TAG in $INPUT_TAGS; do b push --digestfile "$DIGEST_FILE" "$INPUT_IMAGE:$TAG" "docker://$INPUT_IMAGE:$TAG"