Aller au contenu
CI/CD & Automatisation medium

Protection de branche et gouvernance

20 min de lecture

logo github

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.

  • Choisir un ruleset plutôt que la classic branch protection, et savoir pourquoi
  • Poser le ruleset complet en une commande gh api et 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

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èreClassic branch protectionRepository Ruleset
Lisible par le GITHUB_TOKEN par défautNon (token admin requis)Oui
Règles multiples cumulables sur une brancheUne seule règle par motifPlusieurs rulesets superposables
Statut GitHubHistoriqueRecommandé
Lecture par Scorecard et plumberPartielle sans token adminComplè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.

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)).

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.

  1. Blocage deletion et non_fast_forward : le socle. Il faut les deux pour valider le premier palier ; l'un sans l'autre laisse le score au plancher.

  2. Pull request obligatoire avec au moins une approbation (pull_request avec required_approving_review_count >= 1).

  3. Status checks nommés exigés avant merge, avec strict_required_status_checks_policy qui force la branche à être à jour (notre job CI complet).

  4. Deux approbations et revue par les code owners (require_code_owner_review plus required_approving_review_count: 2).

  5. Dismiss stale reviews (dismiss_stale_reviews_on_push) et zéro bypass admin (bypass_actors vide, 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.

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.

Fenêtre de terminal
gh api --method POST repos/stephrobert/secure-python-pipeline/rulesets \
--input ruleset.json

La 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 :

Fenêtre de terminal
gh api repos/stephrobert/secure-python-pipeline/rulesets \
--jq '.[] | "\(.id) \(.name) \(.enforcement)"'
19057499 Protection de main active

Si 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 :

Fenêtre de terminal
gh api --method DELETE repos/stephrobert/secure-python-pipeline/branches/main/protection

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-srt20

Le 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.

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.

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.

.github/workflows/plumber.yml
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.

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.

  1. 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.

  2. 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.

  3. Installer l'App sur le dépôt (onglet Install App), sinon create-github-app-token échoue avec Not Found - repository-installation. L'App existe alors, mais n'a accès à aucun dépôt.

  4. Enregistrer les identifiants : PLUMBER_APP_CLIENT_ID en variable du dépôt (non sensible) et PLUMBER_APP_PRIVATE_KEY en secret (le contenu du .pem, sensible). Le workflow les lit via vars. et secrets..

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.

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

6 questions
6 min.
70% requis

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

  • Un ruleset est lisible par le GITHUB_TOKEN par défaut, contrairement à la classic branch protection : c'est le choix moderne, adoptez-le.
  • Les règles deletion et non_fast_forward forment le socle de la protection ; sans elles, aucun palier supérieur ne compte.
  • Les context des 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.
  • administration n'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: 100 plus soft-fail: false rendent plumber bloquant.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn