Aller au contenu
CI/CD & Automatisation medium

Pipeline CI durci : passer les quatre scanners

16 min de lecture

logo github

Après le socle du dépôt, on écrit le workflow d'intégration continue qui teste et analyse le projet. L'objectif n'est pas seulement qu'il soit vert : il doit résister aux scanners de sécurité de workflow. Ce guide construit le ci.yml du dépôt de référence secure-python-pipeline job par job, applique chaque règle de durcissement (permissions minimales, épinglage par SHA, harden-runner), puis le fait passer à zéro finding sur les quatre scanners de workflow : actionlint, zizmor, poutine et plumber, ce dernier en mode bloquant sur une trust policy. Public visé : un mainteneur qui écrit déjà des workflows et veut les durcir au niveau d'une référence auditable.

  • Structurer un workflow CI aux permissions minimales, job par job
  • Rappeler les règles de durcissement déjà vues (SHA, permissions, persist-credentials)
  • Épingler les outils du CI par hash comme les dépendances applicatives
  • Valider le workflow avec actionlint, zizmor et poutine à zéro finding
  • Déclarer une trust policy plumber bloquante et la faire échouer sur toute source non autorisée

Le dépôt et son socle sont en place : application FastAPI, Dockerfile multi-stage, dépendances figées par hash. Installez les scanners en local : actionlint, zizmor, poutine et plumber. Le lab suppose la lecture préalable d'Épingler par SHA et de Permissions : les deux règles sont appliquées ici sans être réexpliquées.

Le dépôt de référence est public et consultable : github.com/stephrobert/secure-python-pipeline. Vous pouvez le cloner pour comparer votre ci.yml, votre .poutine.yml et votre .plumber.yaml avec les fichiers réels commentés dans ce guide.

Le workflow enchaîne cinq jobs indépendants : lint (ruff), test (pytest), sast (bandit), audit (pip-audit et Trivy) et build-check (construction de l'image et scan). Chacun tourne dans un runner propre, avec ses propres permissions. La déclaration de tête pose les garde-fous globaux, valables pour tous les jobs.

.github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Aucune permission par défaut : chaque job demande le strict nécessaire.
permissions: {}
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

Deux garde-fous tiennent déjà dans cet en-tête. permissions: {} retire tous les droits du GITHUB_TOKEN au niveau du workflow : chaque job redemande ensuite le strict minimum. concurrency annule les exécutions obsolètes sur une même référence pour ne pas gaspiller de minutes ni faire tourner deux builds concurrents sur la même branche.

Le job de lint sert de modèle aux quatre autres. Il installe ruff épinglé par hash et vérifie le style du code. Sa structure de tête (durcissement du runner, checkout, installation de Python) est identique dans les autres jobs.

jobs:
lint:
name: Lint (ruff)
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
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: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
- name: Install ruff (épinglé par hash)
run: pip install --require-hashes -r requirements-tools.txt
- name: Run ruff
run: ruff check .

Les jobs test, sast et audit reprennent la même tête (harden-runner, checkout persist-credentials: false, actions/setup-python épinglé) et ne diffèrent que par leur étape utile. Le job test installe les dépendances applicatives et de test, toujours avec hash, puis lance la suite :

test:
name: Tests (pytest)
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
# ... harden-runner, checkout, setup-python identiques au job lint ...
- name: Install dependencies
run: |
pip install --require-hashes -r requirements.txt
pip install --require-hashes -r requirements-test.txt
- name: Run pytest
run: pytest -q

Le job sast (name: SAST (bandit)) lance bandit sur le code source pour repérer les motifs dangereux (usage de eval, subprocess avec shell=True, secrets en dur). Son étape utile tient en une commande, bandit -r src, précédée de l'installation de l'outil épinglé par hash. Son name: est le libellé exact repris comme status check SAST (bandit) dans le ruleset de protection.

Le job audit croise deux analyses de vulnérabilités sur les dépendances : pip-audit interroge la base des avis Python, et Trivy scanne le fichier verrouillé en mode fs (filesystem). Le choix de Trivy plutôt que google/osv-scanner-action n'est pas anodin : l'action OSV de Google déclenche un finding poutine (github_action_from_unverified_creator_used), alors que aquasecurity/trivy-action couvre le même besoin sans alerte.

audit:
name: Audit dépendances (pip-audit + Trivy)
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
# ... harden-runner, checkout, setup-python identiques ...
- name: Install pip-audit (épinglé par hash)
run: pip install --require-hashes -r requirements-tools.txt
- name: Run pip-audit
run: pip-audit -r requirements.txt
- name: Scan dépendances (Trivy filesystem)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
scan-type: fs
scan-ref: requirements.txt
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: "1"
scanners: vuln

L'option ignore-unfixed: true applique dans le CI le même arbitrage qu'au socle (voir bootstrap) : on ignore les CVE sans correctif amont et on garde exit-code: "1" pour échouer sur tout ce qui est réellement corrigeable.

Le dernier job, build-check (name: Build image + scan (trivy)), construit l'image Docker sans la pousser (push: false, load: true) via docker/build-push-action, puis la scanne avec Trivy en mode image. C'est le même durcissement partout : runner figé, timeout-minutes, permissions: contents: read, actions épinglées par SHA. Ce job valide que l'image produite ne porte aucune vulnérabilité HIGH ou CRITICAL corrigeable avant même de parler de release. Son name: correspond au status check Build image + scan (trivy) du ruleset.

Chaque ligne des jobs ci-dessus applique une règle déjà introduite ailleurs dans la formation. Ce guide les applique, il ne les réexplique pas. Voici le rappel express et le renvoi vers le guide canonique :

  • runs-on: ubuntu-24.04 fige le runner ; ubuntu-latest rendrait le build non reproductible.
  • permissions: {} au workflow puis contents: read par job donnent le moindre privilège au GITHUB_TOKEN (voir Permissions).
  • harden-runner en mode audit installe un pare-feu de sortie qui enregistre le trafic réseau du runner ; le mode block le restreindrait.
  • persist-credentials: false empêche le GITHUB_TOKEN de persister dans la configuration git, où il pourrait fuiter.
  • Actions épinglées par SHA de commit 40 caractères, suivi d'un commentaire de version : un tag comme @v7 est mutable (voir Épingler par SHA).
  • timeout-minutes et concurrency évitent les jobs zombies et les exécutions obsolètes.

Les outils du CI (ruff, bandit, pip-audit) reçoivent le même verrou par hash que le socle applicatif, décrit dans le bootstrap : versions déclarées dans un .in, compilé avec --generate-hashes, installé avec --require-hashes. Seul le fichier d'entrée change, ici requirements-tools.in :

# requirements-tools.in : les trois outils du CI, versions figées
ruff==0.14.9
bandit==1.8.0
pip-audit==2.10.1

On le compile en un fichier verrouillé avec hashes, installé par chaque job en refusant tout paquet dont l'empreinte ne correspond pas :

Fenêtre de terminal
uv pip compile --generate-hashes requirements-tools.in -o requirements-tools.txt
pip install --require-hashes -r requirements-tools.txt

Quatre scanners passent en revue les workflows, chacun sous un angle différent. L'objectif est zéro finding sur les quatre. Les trois premiers tournent en local sans configuration ; plumber exige une trust policy, traitée juste après.

actionlint valide la syntaxe et les bonnes pratiques. Sur un workflow propre, il ne renvoie aucune sortie et un code de retour 0 :

Fenêtre de terminal
actionlint
echo "code retour : $?"
code retour : 0

zizmor traque les failles de sécurité des workflows (injection, permissions excessives, action non épinglée, persist-credentials oublié). On le lance sur le workflow CI en mode hors-ligne :

Fenêtre de terminal
zizmor --offline .github/workflows/ci.yml
INFO zizmor: 🌈 zizmor v1.26.1
INFO audit: zizmor: 🌈 completed .github/workflows/ci.yml
No findings to report. Good job!

poutine cherche les chaînes d'exploitation CI/CD en analysant tout le dépôt. Il évalue ici treize règles et n'en fait échouer aucune :

Fenêtre de terminal
poutine analyze_local .
Summary of findings:
| RULE ID | FAILURES | STATUS |
| default_permissions_on_risky_events | 0 | Passed |
| github_action_from_unverified_creator_used | 0 | Passed |
| injection | 0 | Passed |
| job_all_secrets | 0 | Passed |
| pr_runs_on_self_hosted | 0 | Passed |
| untrusted_checkout_exec | 0 | Passed |
| unverified_script_exec | 0 | Passed |
| ... (13 règles au total, 0 failure) | 0 | Passed |

poutine signale les actions dont le créateur n'est pas « vérifié » sur le Marketplace, via la règle github_action_from_unverified_creator_used. Le piège : l'action officielle getplumber/plumber, pourtant légitime et utilisée pour le scan de confiance, n'est pas encore référencée comme verified creator. Plutôt que de désactiver la règle pour tout le dépôt, on acquitte ce seul faux positif de façon ciblée dans un fichier .poutine.yml :

# .poutine.yml : la règle reste active pour toute AUTRE action tierce.
skip:
- rule: github_action_from_unverified_creator_used
purl:
- pkg:githubactions/getplumber/plumber

Le champ purl (Package URL) désigne exactement l'action à exempter. Toute autre action non vérifiée continuerait de déclencher un finding : c'est la différence entre un acquittement ciblé et une règle désactivée en aveugle, qui laisserait passer une action réellement suspecte.

plumber va plus loin que les trois autres : il construit un graphe de confiance et vérifie que chaque composant tiers vient d'une source autorisée. Il exige un fichier de politique .plumber.yaml, généré puis affiné :

Fenêtre de terminal
plumber config generate # écrit le template complet (le mode init exige un TTY)

Le contrôle central côté GitHub est githubActionMustComeFromAuthorizedSources. Les actions officielles (actions/*, github/*) et celles de votre organisation sont couvertes par deux bascules ; les tierces se déclarent explicitement dans trustedGithubActions :

githubActionMustComeFromAuthorizedSources:
enabled: true
# Actions GitHub officielles (actions/*, github/*). Défaut : true.
trustGithubOfficialActions: true
# Actions du même owner que le dépôt scanné. Défaut : true.
trustSameOrgActions: true
minimumStars: 0
# Allowlist des sources tierces : owner/repo exact ou owner/* pour une org.
trustedGithubActions:
- getplumber/plumber # l'action Plumber elle-même
- docker/setup-buildx-action
- docker/build-push-action
- step-security/harden-runner # durcissement du runner (egress)
- aquasecurity/trivy-action # scan de vulnérabilités image et deps
- sigstore/cosign-installer # signature keyless Sigstore

Déclarer ses sources de confiance est le coeur de la démarche : on rend explicite ce à quoi on accorde sa confiance, au lieu de subir la liste implicite du Marketplace. Une action absente de cette allowlist ferait échouer le contrôle.

Dans ce lab, plumber est bloquant : le workflow qui l'exécute passe min-points: "100" et soft-fail: false, si bien que la moindre non-conformité fait échouer le job au lieu de simplement baisser un badge. Cette exigence stricte garantit qu'aucune source non déclarée ne se glisse dans le pipeline sans un échec net. Le workflow plumber.yml complet, avec le token GitHub App qui donne à plumber la lecture complète de la protection de branche, est détaillé dans Protection et gouvernance.

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

  • permissions: {} au workflow puis le strict minimum par job ferme la surface du GITHUB_TOKEN ; ici chaque job se contente de contents: read.
  • Actions épinglées par SHA, persist-credentials: false, harden-runner, runner figé ubuntu-24.04, timeout-minutes et concurrency sont les réflexes de base, appliqués sans réexplication.
  • Les outils du CI (ruff, bandit, pip-audit) s'épinglent par hash via un .in compilé en .txt avec --generate-hashes, installé en --require-hashes.
  • actionlint, zizmor et poutine passent à zéro finding ; le faux positif poutine sur getplumber/plumber s'acquitte de façon ciblée dans .poutine.yml.
  • plumber valide les sources de confiance via .plumber.yaml et tourne en mode bloquant (min-points: "100", soft-fail: false), ce qui fait échouer le job à la moindre source non déclarée.
  • Le dépôt complet est public : github.com/stephrobert/secure-python-pipeline.

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