Files
RF_ACTIONS/build-push/action.yml
T
george-claudeandClaude Opus 5.5 e6a5fd6252 🔒️ build-push refuse de publier une image qui porte une CVE HIGH ou CRITICAL corrigeable (raggaroth-factory/RF_CICD#7)
Entre la construction et la publication, Trivy examine l'image, exportée
en répertoire OCI : pas de démon, et rien n'est publié avant le verdict.

Seuil décidé par Benjamin le 2026-10-07 : HIGH et CRITICAL corrigeables
bloquent ; le reste est rapporté sans bloquer. Le seuil n'est pas une
entrée : un dépôt ne l'abaisse pas pour lui seul. Exceptions dans
.trivyignore.yaml, chacune avec « statement » et « expired_at » —
vérifiés par l'action, faute de quoi elle refuse le fichier.

Banc du 2026-10-07, Ubuntu 24.04 non privilégié, buildah 1.33.7, trivy
0.75.0 vérifié par empreinte, registre jetable :
  1. frontend actuel          → bloqué (libexpat, pcre2 HIGH corrigeables)
  2. + apk upgrade            → publié
  3. + exceptions futures     → publié
  4. + exceptions expirées    → bloqué
  5. exception sans date      → refusé à la validation

Constaté en route : « trivy --input » refuse une archive OCI ; il veut
l'archive Docker ou le répertoire OCI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyxLNGHojdXeRMQgWFgzRV
2026-10-07 22:15:54 +02:00

215 lines
10 KiB
YAML

name: 'Build & push'
description: >-
Construit une image avec buildah, sans démon ni socket, et la publie dans le
registre de Gitea sous les tags de sa filière (ADR 0002 et 0003 de RF_CICD). Ne
déploie rien : la filière dev tire l'image, la production se déploie sur
ordre. Les tests et le lint du projet tournent AVANT cette étape, dans le
workflow appelant ; l'action ne filtre rien elle-même.
# Tags publiés, selon ce qui a déclenché le workflow :
#
# poussée sur develop → :dev et :<sha court, 12 caractères>
# tag vX.Y.Z → :vX.Y.Z, orthographié comme le tag git, et rien d'autre
# toute autre référence → échec, sans rien construire
#
# Le refus des autres références est voulu : une fusion sur main ne publie
# rien (ADR 0002), et une branche de fonctionnalité qui appellerait l'action
# par erreur ne doit pas pouvoir écraser :dev. Mieux vaut un job rouge qui dit
# pourquoi qu'une image publiée sous un nom que personne n'a choisi.
#
# Il n'y a volontairement aucun tag flottant de production (pas de :prod, pas
# de :latest), et rien n'est élagué.
inputs:
image:
description: >-
Nom complet de l'image, sans tag — doit commencer par le registre, ex.
gitea.raggaroth-factory.fr/raggaroth-factory/scrum-board-backend
required: true
dockerfile:
description: Chemin du Dockerfile/Containerfile à construire.
required: true
context:
description: Contexte de construction.
required: false
default: '.'
registry:
description: Hôte du registre.
required: false
default: gitea.raggaroth-factory.fr
registry_user:
description: Compte qui publie dans le registre.
required: true
registry_password:
description: Jeton du compte qui publie. Par un secret Gitea Actions, jamais en clair.
required: true
outputs:
tags:
description: Les tags publiés, séparés par des espaces.
value: ${{ steps.tags.outputs.tags }}
digest:
description: L'empreinte (sha256:…) de l'image publiée — c'est elle que la filière dev compare.
value: ${{ steps.publish.outputs.digest }}
runs:
using: composite
steps:
# Les entrées passent TOUJOURS par l'environnement, jamais par « ${{ }} »
# dans le script : une expression est recopiée telle quelle dans le texte
# bash avant son exécution, et une valeur contenant « "; … » deviendrait du
# code. L'ancienne version interpolait ainsi le mot de passe du registre
# dans un « echo ».
- name: Tags de la filière
id: tags
shell: bash
env:
INPUT_IMAGE: ${{ inputs.image }}
INPUT_REGISTRY: ${{ inputs.registry }}
run: |
set -euo pipefail
case "$INPUT_IMAGE" in
"$INPUT_REGISTRY"/*) ;;
*) echo "::error::l'image « $INPUT_IMAGE » n'est pas dans le registre « $INPUT_REGISTRY » : les identifiants seraient envoyés au mauvais hôte, ou l'image poussée sans eux"
exit 1 ;;
esac
# GITHUB_REF et non GITHUB_REF_NAME : seul le premier distingue une
# branche « v1 » d'un tag « v1 ».
case "$GITHUB_REF" in
refs/heads/develop)
TAGS="dev ${GITHUB_SHA:0:12}" ;;
refs/tags/v*)
VERSION=${GITHUB_REF#refs/tags/}
# Un tag d'image n'accepte que [A-Za-z0-9_.-] : on exige une
# version lisible plutôt que de corriger en silence ce qui a été
# posé, pour que le tag git et le tag d'image restent identiques.
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
echo "::error::tag « $VERSION » : attendu vX.Y.Z (ou vX.Y.Z-rc.1)"
exit 1
fi
TAGS="$VERSION" ;;
*)
echo "::error::« $GITHUB_REF » ne publie rien : seules une poussée sur develop et un tag vX.Y.Z publient (ADR 0002)"
exit 1 ;;
esac
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
echo "Tags : $TAGS"
- name: Construire et publier
id: publish
shell: bash
env:
INPUT_IMAGE: ${{ inputs.image }}
INPUT_DOCKERFILE: ${{ inputs.dockerfile }}
INPUT_CONTEXT: ${{ inputs.context }}
INPUT_REGISTRY: ${{ inputs.registry }}
INPUT_TAGS: ${{ steps.tags.outputs.tags }}
REGISTRY_USER: ${{ inputs.registry_user }}
REGISTRY_PASSWORD: ${{ inputs.registry_password }}
run: |
set -euo pipefail
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
# seule variable, un « build » a laissé un stockage overlay que l'appel
# suivant a refusé d'ouvrir (« 'overlay' is not supported over
# overlayfs »). Avec le drapeau explicite, build, images et push
# passent. vfs est le seul pilote qui tienne
# dans un conteneur de job sans privilège ni /dev/fuse (ADR 0003).
b() { buildah --storage-driver vfs "$@"; }
# Fichier d'authentification explicite, dans le répertoire temporaire du
# 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"
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/<pid>/cmdline.
printf '%s' "$REGISTRY_PASSWORD" \
| b login --username "$REGISTRY_USER" --password-stdin "$INPUT_REGISTRY"
TAG_ARGS=()
for TAG in $INPUT_TAGS; do TAG_ARGS+=(--tag "$INPUT_IMAGE:$TAG"); done
# --isolation chroot : pas d'espace de noms imbriqué à créer pour les
# RUN, c'est ce qui permet de construire sans privilège (ADR 0003).
# Les étiquettes OCI disent de quel commit vient l'image : c'est ce que
# lira un « deployer.sh --constat » pour dire ce qui tourne.
b build \
--isolation chroot \
--file "$INPUT_DOCKERFILE" \
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
--label "org.opencontainers.image.source=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \
"${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"
echo "Publié : $INPUT_IMAGE:$TAG@$(cat "$DIGEST_FILE")"
done
echo "digest=$(cat "$DIGEST_FILE")" >> "$GITHUB_OUTPUT"
b logout "$INPUT_REGISTRY" || true