
Un pipeline durci ne vaut rien si un push direct sur main peut le
contourner. Ce guide gouverne le dépôt en trois temps : il protège la
branche par défaut avec un Repository Ruleset (le fichier ruleset.json posé
en une commande gh api), impose la revue code-owner par un compte tiers via
CODEOWNERS, puis donne au scanner plumber la lecture complète de la
protection grâce à un token GitHub App éphémère. Public visé : un mainteneur
avancé qui veut une gouvernance effective et lisible par les outils qui la
notent. À la fin, aucun changement n'atteint main sans passer par une pull
request revue, et plumber bloque le pipeline au moindre écart.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Choisir un ruleset plutôt que la classic branch protection, et savoir pourquoi
- Poser le ruleset complet en une commande
gh apiet lire chaque palier - Imposer la revue code-owner et franchir l'amorçage du premier propriétaire
- Fournir un token GitHub App aux scanners pour lire les 21 réglages de protection
Prérequis
Section intitulée « Prérequis »Le pipeline CI tourne : ses
cinq jobs plus le check Plumber du workflow plumber.yml servent de
status checks requis dans le ruleset, soit six status checks. Vous êtes
administrateur d'un dépôt public ; le dépôt de référence de cette série,
github.com/stephrobert/secure-python-pipeline,
reste ouvert et consultable pour cloner la configuration réelle. La CLI
gh est authentifiée (gh auth status renvoie Logged in). Deux comptes collaborateurs supplémentaires sont
disponibles pour la revue croisée : sans un second relecteur, la revue code-owner
reste bloquée.
Ruleset ou classic branch protection : lequel choisir
Section intitulée « Ruleset ou classic branch protection : lequel choisir »GitHub propose deux mécanismes pour protéger une branche. Le choix n'est pas cosmétique : il conditionne la lisibilité de la protection par les scanners de sécurité, donc la note que vous obtiendrez sans effort supplémentaire.
| Critère | Classic branch protection | Repository Ruleset |
|---|---|---|
Lisible par le GITHUB_TOKEN par défaut | Non (token admin requis) | Oui |
| Règles multiples cumulables sur une branche | Une seule règle par motif | Plusieurs rulesets superposables |
| Statut GitHub | Historique | Recommandé |
| Lecture par Scorecard et plumber | Partielle sans token admin | Complète |
La classic branch protection n'expose ses réglages qu'à un token portant la
permission Administration. Un Repository Ruleset, à l'inverse, est lisible
par le GITHUB_TOKEN par défaut en lecture : Scorecard voit la protection
sans jeton spécial, et plumber en lit l'essentiel. C'est le choix moderne,
celui que GitHub recommande depuis 2023. Nous partons donc sur un ruleset, et le
token GitHub App n'interviendra que pour combler les deux derniers réglages
qu'un ruleset n'expose pas encore en lecture simple.
Configurer le ruleset de protection de main
Section intitulée « Configurer le ruleset de protection de main »Un ruleset est un descripteur JSON qui cible une branche et empile des
règles. Chaque règle correspond à un palier que le scoring de sécurité
récompense. Le fichier ci-dessous, ruleset.json, reproduit à l'identique la
protection appliquée sur le dépôt de référence. Il n'existait pas dans le dépôt
sous cette forme (la protection y a été posée par appels API successifs) : ce
fichier est reconstitué depuis le ruleset actif, lu via gh api repos/OWNER/REPO/rulesets.
{ "name": "Protection de main", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "pull_request", "parameters": { "required_approving_review_count": 2, "require_code_owner_review": true, "dismiss_stale_reviews_on_push": true, "require_last_push_approval": true, "required_review_thread_resolution": true, "allowed_merge_methods": ["squash", "merge", "rebase"] } }, { "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": true, "required_status_checks": [ { "context": "Lint (ruff)" }, { "context": "Tests (pytest)" }, { "context": "SAST (bandit)" }, { "context": "Audit dépendances (pip-audit + Trivy)" }, { "context": "Build image + scan (trivy)" }, { "context": "Plumber (trust policy CI/CD)" } ] } }, { "type": "deletion" }, { "type": "non_fast_forward" }, { "type": "required_linear_history" } ]}Deux règles sont souvent oubliées et pourtant décisives : deletion
(interdire la suppression de la branche) et non_fast_forward (interdire le
force-push, qui réécrit l'historique). Elles semblent secondaires, mais le scoring
de protection en fait le socle : sans elles, aucun palier supérieur ne compte,
même avec deux approbations et six status checks.
Les six context des status checks recouvrent les cinq jobs du CI plus le
check Plumber du workflow plumber.yml ; ce sont les noms exacts tels
qu'ils apparaissent dans l'onglet Checks d'une pull request. Un nom approximatif est
silencieusement ignoré : le check n'est jamais considéré comme requis, et une PR
peut passer sans l'avoir exécuté. On copie donc les libellés au caractère près,
accents compris (Audit dépendances (pip-audit + Trivy)).
Les paliers cumulatifs de la protection
Section intitulée « Les paliers cumulatifs de la protection »Le scoring de branche fonctionne par paliers séquentiels : chaque palier doit être plein pour débloquer le suivant. Comprendre cet ordre évite de croire qu'ajouter une règle isolée au sommet rapporte quoi que ce soit tant que le socle n'est pas complet. Du plus bas au plus haut, les paliers sont les suivants.
-
Blocage
deletionetnon_fast_forward: le socle. Il faut les deux pour valider le premier palier ; l'un sans l'autre laisse le score au plancher. -
Pull request obligatoire avec au moins une approbation (
pull_requestavecrequired_approving_review_count>= 1). -
Status checks nommés exigés avant merge, avec
strict_required_status_checks_policyqui force la branche à être à jour (notre job CI complet). -
Deux approbations et revue par les code owners (
require_code_owner_reviewplusrequired_approving_review_count: 2). -
Dismiss stale reviews (
dismiss_stale_reviews_on_push) et zéro bypass admin (bypass_actorsvide, personne ne contourne la règle).
Le ruleset ci-dessus couvre tous ces paliers, y compris le dernier. C'est un
choix fort : sur le dépôt de référence, bypass_actors est vide et
current_user_can_bypass vaut never. Personne, pas même l'administrateur, ne
force un merge sur main. Ce zéro bypass admin est le palier le plus dur à
tenir en solo, car l'auteur perd son filet de secours ; nous verrons juste après
comment franchir le seul moment où ce filet manque vraiment, l'amorçage.
Appliquer le ruleset en une commande
Section intitulée « Appliquer le ruleset en une commande »Le ruleset se pose par un POST sur l'API des rulesets, en passant le fichier JSON en entrée. C'est idempotent à la création près : un second POST crée un doublon, il faut donc lister puis mettre à jour plutôt que reposter.
gh api --method POST repos/stephrobert/secure-python-pipeline/rulesets \ --input ruleset.jsonLa sortie renvoie le ruleset créé avec son identifiant et son statut
enforcement: active :
{ "id": 19057499, "name": "Protection de main", "target": "branch", "enforcement": "active", "current_user_can_bypass": "never"}On vérifie que la protection est bien active et lisible en relisant la liste
des rulesets. Cette lecture réussit avec le GITHUB_TOKEN par défaut, ce qui
confirme l'intérêt du ruleset face à la classic protection :
gh api repos/stephrobert/secure-python-pipeline/rulesets \ --jq '.[] | "\(.id) \(.name) \(.enforcement)"'19057499 Protection de main activeSi vous migrez depuis une classic branch protection existante, retirez-la après avoir posé le ruleset, pour éviter que deux mécanismes se superposent et brouillent le diagnostic des scanners :
gh api --method DELETE repos/stephrobert/secure-python-pipeline/branches/main/protectionLa revue code-owner : imposer un relecteur tiers
Section intitulée « La revue code-owner : imposer un relecteur tiers »Le ruleset interdit les push directs, mais il faut encore garantir que la pull
request soit revue par quelqu'un d'autre que son auteur. C'est le rôle du
fichier CODEOWNERS : il déclare qui possède quoi, et le paramètre
require_code_owner_review exige leur approbation avant tout merge. On ne peut pas
approuver sa propre PR, donc la présence d'un propriétaire tiers force la
revue croisée.
# CODEOWNERS - propriétaires par défaut : toute PR requiert leur revue.* @stephrobert @coconux3 @outscale-srt20
# La chaîne CI/CD est sensible : revue obligatoire sur les workflows./.github/workflows/ @stephrobert @coconux3 @outscale-srt20/Dockerfile @stephrobert @coconux3 @outscale-srt20Le motif * couvre tout le dépôt : n'importe quel fichier modifié réclame la
revue d'un des trois propriétaires. Les deux lignes suivantes renforcent la
règle sur les chemins sensibles, le répertoire des workflows et le
Dockerfile, là où une modification malveillante ferait le plus de dégâts. GitHub
applique la règle la plus spécifique : une PR qui touche un workflow doit être
approuvée par un propriétaire déclaré sur /.github/workflows/, pas seulement par
le motif générique.
Concrètement, une fois cette gouvernance en place, un changement suit toujours le
même parcours : l'auteur pousse sur une branche de fonctionnalité, ouvre une
pull request, un compte tiers l'approuve, les six status checks passent au
vert, puis le merge devient possible. Le push direct sur main est refusé par le
serveur, sans exception.
L'amorçage du premier code-owner
Section intitulée « L'amorçage du premier code-owner »Un dépôt qui démarre bute sur un paradoxe d'amorçage : la revue code-owner est
obligatoire, mais on ne peut pas approuver sa propre pull request. On ajoute
donc un collaborateur et on l'inscrit dans CODEOWNERS. Sauf que la
première pull request, justement celle qui l'ajoute au CODEOWNERS, reste
bloquée : le collaborateur n'est reconnu comme propriétaire qu'après le
merge du fichier qui le déclare.
Ce blocage se franchit une seule fois. Deux options existent : un merge
administrateur exceptionnel pour cette PR d'amorçage, ou un assouplissement
temporaire de la règle code-owner le temps de fusionner. Sur le dépôt de
référence, bypass_actors étant vide, l'amorçage a été fait avant de poser le
palier zéro bypass : on ajoute d'abord le collaborateur, on merge, puis on
resserre le ruleset à sa forme finale. Ensuite, toutes les pull requests
passent par une revue effective, sans jamais contourner la protection.
Pourquoi le GITHUB_TOKEN ne suffit pas aux scanners
Section intitulée « Pourquoi le GITHUB_TOKEN ne suffit pas aux scanners »Le ruleset rend la plupart des réglages lisibles en lecture simple, mais pas
tous. Le scanner plumber vérifie la protection de branche réglage par
réglage, et deux d'entre eux ne sont exposés qu'à un token portant la permission
Administration: Read. Avec le GITHUB_TOKEN par défaut, plumber ne résout
que 19 réglages sur 21 : il croit à tort que la revue code-owner n'est pas
requise et plafonne son score à B, alors que la protection est correctement
posée.
Le réflexe naïf serait d'ajouter administration: read au bloc permissions: du
workflow. C'est impossible : administration n'est pas une permission de
workflow valide. actionlint la refuse, et le GITHUB_TOKEN ne peut donc jamais
l'obtenir. La permission existe côté API GitHub, mais elle n'est pas exposée
au jeton automatique des Actions. Cette limite est volontaire : un token de
workflow qui pourrait lire et modifier l'administration du dépôt serait une cible
de choix pour une action compromise.
La bonne réponse est un token de GitHub App, généré à la volée, à permission
minimale (Administration: Read seule) et scopé au dépôt courant. Il est
éphémère : créé au début du job, il expire à la fin. C'est très supérieur à un
jeton personnel (PAT) longue durée stocké en secret, qui porterait bien plus de
droits et survivrait des mois.
Le token GitHub App pour la lecture complète
Section intitulée « Le token GitHub App pour la lecture complète »L'action officielle actions/create-github-app-token
échange l'identité de l'App contre un token d'installation valable le temps du
job. Le workflow plumber.yml du dépôt de référence le génère, puis le passe à
plumber via l'entrée github-token. Voici le workflow complet, épinglé par SHA
et en moindre privilège.
name: Plumber
on: push: branches: [main] pull_request: branches: [main]
permissions: {}
jobs: plumber: name: Plumber (trust policy CI/CD) runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read security-events: write # upload du SARIF id-token: write # publication du score (badge) steps: - name: Harden runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Générer un token GitHub App (Administration:read) id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ vars.PLUMBER_APP_CLIENT_ID }} private-key: ${{ secrets.PLUMBER_APP_PRIVATE_KEY }} - name: Analyse Plumber uses: getplumber/plumber@6363eb0229636fb7463e9431281ad7584a65c964 # v0.4.6 with: # Gate strict dès le départ : la moindre non-conformité fait échouer # le workflow (min-points 100, pas de soft-fail). min-points: "100" soft-fail: false # Vérifie la provenance SLSA du binaire plumber téléchargé. verify-attestation: true # Publie le score sur le badge (OIDC, dépôt public). score-push: true # Token éphémère de GitHub App (Administration:read) pour lire la # protection de branche complète. github-token: ${{ steps.app-token.outputs.token }}Deux détails rendent plumber bloquant plutôt qu'informatif. min-points: "100" exige un score parfait : la moindre non-conformité fait chuter le total
sous 100. soft-fail: false transforme cette chute en échec de job, donc
en check rouge sur la pull request. Comme ce check Plumber (trust policy CI/CD) figure dans les status checks requis du ruleset, un pipeline non conforme
ne peut plus être mergé. La boucle est fermée : plumber lit la protection
grâce au token App, et la protection exige que plumber passe.
Créer et installer l'App
Section intitulée « Créer et installer l'App »L'App se crée une fois, puis s'installe sur le dépôt. L'ordre compte : sans installation, la génération de token échoue avec une erreur peu explicite. Suivez les étapes en accordant strictement la permission nécessaire, rien de plus.
-
Créer l'App sur
github.com/settings/apps/new: décochez le webhook, accordez uniquement Repository permissions > Administration : Read-only. Aucune autre permission n'est nécessaire pour lire la protection de branche. -
Récupérer le Client ID et générer une private key (fichier
.pem). Attention : c'est bien la private key qu'il faut, pas le client secret. Ce dernier sert à l'OAuth, pas à générer un token d'installation, et donne une erreur d'authentification si on le confond. -
Installer l'App sur le dépôt (onglet Install App), sinon
create-github-app-tokenéchoue avecNot Found - repository-installation. L'App existe alors, mais n'a accès à aucun dépôt. -
Enregistrer les identifiants :
PLUMBER_APP_CLIENT_IDen variable du dépôt (non sensible) etPLUMBER_APP_PRIVATE_KEYen secret (le contenu du.pem, sensible). Le workflow les lit viavars.etsecrets..
Une fois le token App en place, relancez le workflow plumber. Il lit désormais
les 21 réglages sur 21, reconnaît la revue code-owner comme requise, et son
score passe de B à A. Sans ce token, tout le reste de la configuration est
correct mais invisible au scanner : le durcissement existe, la preuve manque.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- Un ruleset est lisible par le
GITHUB_TOKENpar défaut, contrairement à la classic branch protection : c'est le choix moderne, adoptez-le. - Les règles
deletionetnon_fast_forwardforment le socle de la protection ; sans elles, aucun palier supérieur ne compte. - Les
contextdes status checks sont les noms exacts des jobs CI : un libellé approximatif est ignoré et laisse passer une PR non testée. - La revue code-owner force une approbation tierce ; l'amorçage du premier propriétaire se franchit une seule fois, jamais en habitude.
administrationn'est pas une permission de workflow : la lecture complète passe par un token de GitHub App éphémère àAdministration: Read.- L'App doit être installée sur le dépôt, avec la private key (pas le client
secret) et le client-id ;
min-points: 100plussoft-fail: falserendentplumberbloquant.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Retour au hub du lab : la série complète et le dépôt de référence à cloner.
- github.com/stephrobert/secure-python-pipeline : le dépôt public réel, ouvert et clonable, avec le ruleset, le
CODEOWNERSet le workflowplumber.ymlexacts de ce guide. - Le moindre privilège du GITHUB_TOKEN : pourquoi
administrationn'est pas un scope de workflow, et comment régler chaque permission au plus juste. - La CLI gh en pratique : automatiser
gh api, lister et mettre à jour un ruleset sans passer par l'interface web.