Aller au contenu
CI/CD & Automatisation medium

Pipeline CI/CD sécurisé : guide pratique Python + GitHub Actions

94 min de lecture

En 2024, l'attaque xz-utils a démontré qu'un mainteneur malveillant pouvait injecter une backdoor dans un projet open source critique. La seule défense ? Une chaîne de build traçable et vérifiable où chaque étape est attestée.

Ce guide vous accompagne pas à pas pour construire un pipeline CI/CD hautement sécurisé pour un projet Python. À la fin, vous aurez :

  • Un dépôt GitHub avec branch protection et Scorecard
  • Des dépendances épinglées avec hash et auditées automatiquement
  • Une image Docker avec attestation SLSA L3 et SBOM
  • Des artefacts signés avec Sigstore (keyless)
  • Des vérifications automatiques à chaque étape
  • Configurer un dépôt GitHub durci : rulesets, signature des commits, Scorecard
  • Épingler les dépendances Python par hash et les actions par SHA de commit
  • Construire une image Docker multi-stage avec un utilisateur non-root
  • Générer une attestation SLSA L3 et un SBOM à chaque release
  • Signer l'image avec Cosign en mode keyless et vérifier sa provenance
  • Centraliser la visibilité supply chain de tous vos services avec GUAC

Une application Python minimaliste (API FastAPI) avec un pipeline qui :

  1. Analyse le code : linting, tests, SAST avec attestations in-toto
  2. Audite les dépendances : vulnérabilités, intégrité (hash pinning)
  3. Construit l'image Docker avec attestation SLSA L3
  4. Génère le SBOM (liste des composants) en SPDX et CycloneDX
  5. Signe l'image avec Cosign (keyless via Sigstore)
  6. Publie sur GHCR avec provenance vérifiable
  7. Ingère dans GUAC pour visibilité globale sur les dépendances
  8. Évalue avec Scorecard les bonnes pratiques du dépôt
┌─────────────────────────────────────────────────────────────────────────────┐
│ GitHub Actions │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Lint │ → │ Test │ → │ SAST │ → │ Audit │ │
│ │ ruff │ │ pytest │ │ bandit │ │pip-audit │ │
│ │ + in-toto│ │ + in-toto│ │ │ │ │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └──────────────┴──────────────┴──────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Build & Attest │ │
│ │ Docker build → SLSA L3 attestation → SBOM (Syft) → Cosign sign │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ GitHub Container Registry │ │
│ │ ghcr.io/owner/app@sha256:... │ │
│ │ ├── attestation SLSA L3 (in-toto format) │ │
│ │ ├── signature Cosign (Sigstore keyless) │ │
│ │ ├── SBOM SPDX + CycloneDX │ │
│ │ └── links in-toto (lint, test, audit, build) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌────────────────────────┐ ┌────────────────────────┐ │
│ │ GUAC │ │ Scorecard │ │
│ │ Graphe dépendances │ │ Score sécurité repo │ │
│ │ + certifier OSV │ │ Branch protection ✓ │ │
│ │ → alertes CVE 0-day │ │ CODEOWNERS ✓ │ │
│ └────────────────────────┘ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘

Avant de commencer, assurez-vous d'avoir les Connaissances préalables recommandées :

Si c'est le cas, allons-y ! Commençons pas installer les outils localement.

ÉlémentVersion minimaleVérificationInstallation
Git2.34+git --versionGuide Git
Python3.11+python --versionGuide pyenv
Docker24+docker --versionGuide Docker
Gitsign0.10+gitsign versionGuide Gitsign
Cosign2.0+cosign versionGuide Cosign
Syft1.0+syft versionGuide Syft
Trivy0.50+trivy versionGuide Trivy
slsa-verifier2.0+slsa-verifier versionGitHub

La première étape est de créer un dépôt GitHub correctement configuré. Les choix faits ici impactent directement votre score Scorecard.

  1. Créer le dépôt : Allez sur github.com/new

  2. Configurer les paramètres de base :

    ParamètreValeur recommandéePourquoi
    Repository namesecure-python-pipelineNom explicite
    Description"API Python avec pipeline CI/CD sécurisé"Aide au référencement
    VisibilityPublicScorecard fonctionne mieux sur les dépôts publics
    Add README✅ OuiPoint d'entrée pour les contributeurs
    Add .gitignorePythonExclut __pycache__, .venv, .pytest_cache
    LicenseMIT ou Apache 2.0Clarté juridique, check License Scorecard
  3. Cliquer sur "Create repository"

Cloner le dépôt et créer une branche de travail :

Fenêtre de terminal
# Cloner le dépôt (remplacez VOTRE_USER par votre username GitHub)
git clone https://github.com/VOTRE_USER/secure-python-pipeline.git
cd secure-python-pipeline
# Créer immédiatement une branche de travail
# (les rulesets bloqueront les commits directs sur main)
git checkout -b setup
# Vérifier que tout est en place
ls -la
# Vous devriez voir : .git/ .gitignore LICENSE README.md

Configurer Git localement (si pas déjà fait) :

Fenêtre de terminal
# Configurer votre identité (utilisée pour les commits)
git config user.name "Votre Nom"
git config user.email "votre.email@example.com"

Pour ce guide, nous utilisons Gitsign, la signature keyless avec Sigstore. Contrairement à GPG ou SSH, vous n'avez aucune clé à gérer : vous vous authentifiez avec votre compte GitHub et Sigstore s'occupe du reste.

Installer Gitsign :

Fenêtre de terminal
# macOS
brew install sigstore/tap/gitsign
# Linux (téléchargement direct)
GITSIGN_VERSION=$(curl -s https://api.github.com/repos/sigstore/gitsign/releases/latest | grep tag_name | cut -d '"' -f 4)
curl -LO "https://github.com/sigstore/gitsign/releases/download/${GITSIGN_VERSION}/gitsign_${GITSIGN_VERSION#v}_linux_amd64"
chmod +x gitsign_*_linux_amd64
sudo mv gitsign_*_linux_amd64 /usr/local/bin/gitsign
# Vérifier l'installation
gitsign version

Configurer Git pour utiliser Gitsign (dans ce projet uniquement) :

Fenêtre de terminal
# Activer la signature automatique des commits
git config --local commit.gpgsign true
# Utiliser le format x509 (requis pour Gitsign)
git config --local gpg.format x509
# Spécifier Gitsign comme programme de signature
git config --local gpg.x509.program gitsign

Tester la signature :

Fenêtre de terminal
# Créer un fichier de test
echo "test" > test.txt
git add test.txt
git commit -m "test: vérification signature gitsign"

Un navigateur s'ouvre pour vous authentifier avec votre compte GitHub (ou Google/Microsoft). Après authentification :

  • Fulcio délivre un certificat éphémère (~10 minutes)
  • Votre commit est signé avec ce certificat
  • La signature est enregistrée dans Rekor (log de transparence public)

Vérifier la signature :

Fenêtre de terminal
git log --show-signature -1

Sortie attendue :

commit abc123...
gitsign: Signature made Mon Dec 30 10:00:00 2024 CET
gitsign: Good signature from "votre-email@example.com"
gitsign: WARNING: no matching key found in keyring
This is expected for keyless signing

Les rulesets (ensembles de règles) remplacent les anciennes "branch protection rules". Ils permettent de définir des contraintes sur les branches et tags. C'est une exigence SLSA Source L2 pour protéger l'intégrité du code source.

  1. Accéder aux paramètres :

    • Allez sur votre dépôt GitHub
    • Cliquez sur Settings (onglet en haut à droite)
    • Dans la sidebar gauche, section "Code and automation", cliquez sur RulesRulesets
  2. Créer un nouveau ruleset :

    • Cliquez sur New rulesetNew branch ruleset
    • Ruleset name : main-protection (nom descriptif)
    • Enforcement status : Active (appliqué immédiatement)
  3. Configurer le ciblage (Target branches) :

    • Cliquez sur Add targetInclude default branch
    • Cela cible automatiquement main (ou master selon votre config)
  4. Configurer les règles de base :

    Dans la section Rules, activez ces protections :

    RègleActionEffetCheck Scorecard
    Restrict deletions✅ CocherEmpêche la suppression de la branche,
    Require linear history✅ CocherForce squash ou rebase (pas de merge commits),
    Block force pushes✅ Cocher (défaut)Empêche git push --force,
  5. Configurer "Require a pull request before merging" :

    Cette règle est essentielle pour le check Code-Review de Scorecard.

    • ✅ Cocher Require a pull request before merging
    • Cliquer sur la flèche pour déplier les options :
    OptionValeurExplication
    Required approvals1Nombre minimum de reviewers (augmentez pour les projets critiques)
    Dismiss stale pull request approvals when new commits are pushedInvalide les approvals si le code change après review
    Require review from Code OwnersForce l'approbation par les CODEOWNERS
    Require approval of the most recent reviewable pushL'auteur du dernier push ne peut pas auto-approuver
    Require conversation resolution before mergingTous les commentaires doivent être résolus
  6. Configurer "Require status checks to pass before merging" :

    Cette règle garantit que la CI passe avant tout merge.

    • ✅ Cocher Require status checks to pass
    • ✅ Cocher Require branches to be up to date before merging (mode strict)
    • Cliquer sur Add checks et ajouter les jobs de votre CI :
      • lint (job Ruff)
      • test (job Pytest)
      • sast (job Bandit)
      • audit (job pip-audit)
  7. Configurer "Require signed commits" :

    • ✅ Cocher Require signed commits
    • Seuls les commits signés seront acceptés

    GitHub reconnaît trois types de signatures :

    MéthodeBadge "Verified"Gestion de clés
    GPG✅ NatifClé à créer et protéger
    SSH✅ NatifUtilise votre clé SSH
    Gitsign⚠️ Via RekorAucune (keyless)
  8. Configurer le bypass (optionnel mais recommandé) :

    Dans la section Bypass list, vous pouvez autoriser certains rôles à contourner les règles en cas d'urgence :

    • Cliquez sur Add bypass → sélectionnez Repository admin
    • Choisissez For pull requests only (pas de push direct, mais peut merger sans attendre)

    ⚠️ Attention : n'abusez pas des bypass, ils réduisent votre score Scorecard.

  9. Sauvegarder :

    • Cliquez sur Create en bas de la page
    • Le ruleset est immédiatement actif

Récapitulatif des règles configurées :

┌─────────────────────────────────────────────────────────────────────────┐
│ Ruleset "main-protection" │
├─────────────────────────────────────────────────────────────────────────┤
│ Target: default branch (main) │
│ Status: Active │
├─────────────────────────────────────────────────────────────────────────┤
│ ✅ Restrict deletions │
│ ✅ Require linear history │
│ ✅ Block force pushes │
│ ✅ Require pull request (1 approval, dismiss stale, CODEOWNERS) │
│ ✅ Require status checks (lint, test, sast, audit + up-to-date) │
│ ✅ Require signed commits │
├─────────────────────────────────────────────────────────────────────────┤
│ Bypass: Repository admin (pull requests only) │
└─────────────────────────────────────────────────────────────────────────┘

OpenSSF Scorecard analyse automatiquement votre dépôt et attribue un score de sécurité.

Scorecard fonctionne via une GitHub Action, pas d'app à installer. Le workflow scorecard.yml sera ajouté à l'étape 4. Une fois en place :

  • Les résultats apparaissent dans l'onglet Security > Code scanning alerts
  • Votre score est visible sur scorecard.dev (dépôts publics)

Scorecard évalue plusieurs critères. Pour atteindre un score élevé (8+/10), vous devez ajouter ces fichiers.

Le fichier CODEOWNERS définit qui doit approuver les modifications sur chaque partie du code. C'est essentiel pour le check Code-Review.

.github/CODEOWNERS :

# Propriétaires par défaut pour tout le dépôt
* @VOTRE_USER
# Sécurité : équipe sécurité doit approuver les workflows
.github/workflows/ @VOTRE_USER
.github/CODEOWNERS @VOTRE_USER
# Configuration Docker
Dockerfile @VOTRE_USER
.dockerignore @VOTRE_USER
# Dépendances : revue obligatoire
requirements*.txt @VOTRE_USER
pyproject.toml @VOTRE_USER

Le fichier SECURITY.md décrit comment signaler les vulnérabilités. C'est obligatoire pour le check Security-Policy.

SECURITY.md :

# Politique de sécurité
## Versions supportées
| Version | Supportée |
| ------- | --------- |
| 1.x | ✅ |
| < 1.0 | ❌ |
## Signaler une vulnérabilité
**Ne créez PAS d'issue publique pour les vulnérabilités de sécurité.**
Utilisez plutôt le [signalement privé GitHub](../../security/advisories/new)
ou envoyez un email à : `security@votre-domaine.com`
### Informations à inclure
- Description de la vulnérabilité
- Étapes pour reproduire
- Version affectée
- Impact potentiel
### Délai de réponse
- Accusé de réception : 48h
- Première évaluation : 7 jours
- Correctif (selon sévérité) : 30-90 jours
## Pratiques de sécurité
Ce projet applique :
- Analyse statique (Bandit, Ruff)
- Audit des dépendances (pip-audit)
- Signature des images (Cosign/Sigstore)
- Attestation SLSA L3
- Scan de vulnérabilités (Trivy)

Les templates structurent les signalements et améliorent la qualité des issues.

.github/ISSUE_TEMPLATE/bug_report.yml :

name: "🐛 Bug Report"
description: "Signaler un bug ou un comportement inattendu"
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Merci de prendre le temps de signaler ce bug !
- type: textarea
id: description
attributes:
label: "Description du bug"
description: "Décrivez clairement le problème rencontré"
validations:
required: true
- type: textarea
id: steps
attributes:
label: "Étapes pour reproduire"
description: "Comment reproduire le bug ?"
placeholder: |
1. Aller sur '...'
2. Cliquer sur '...'
3. Voir l'erreur
validations:
required: true
- type: textarea
id: expected
attributes:
label: "Comportement attendu"
description: "Que devrait-il se passer ?"
validations:
required: true
- type: input
id: version
attributes:
label: "Version"
description: "Quelle version utilisez-vous ?"
placeholder: "v1.0.0"
validations:
required: true
- type: dropdown
id: os
attributes:
label: "Système d'exploitation"
options:
- Linux
- macOS
- Windows
- Autre
validations:
required: true

.github/ISSUE_TEMPLATE/feature_request.yml :

name: "✨ Feature Request"
description: "Proposer une nouvelle fonctionnalité"
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Merci de proposer une amélioration !
- type: textarea
id: problem
attributes:
label: "Problème à résoudre"
description: "Quel problème cette fonctionnalité résoudrait-elle ?"
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Solution proposée"
description: "Décrivez votre idée de solution"
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives considérées"
description: "Avez-vous envisagé d'autres approches ?"
- type: textarea
id: context
attributes:
label: "Contexte additionnel"
description: "Captures d'écran, liens, exemples..."

.github/ISSUE_TEMPLATE/config.yml :

blank_issues_enabled: false
contact_links:
- name: "💬 Discussions"
url: https://github.com/VOTRE_USER/secure-python-pipeline/discussions
about: "Questions générales et discussions"
- name: "📖 Documentation"
url: https://github.com/VOTRE_USER/secure-python-pipeline#readme
about: "Consultez la documentation avant de poser une question"

Le template PR guide les contributeurs et assure des descriptions complètes.

.github/PULL_REQUEST_TEMPLATE.md :

## Description
<!-- Décrivez vos changements en détail -->
## Type de changement
- [ ] 🐛 Bug fix (changement non-breaking qui corrige un problème)
- [ ] ✨ Nouvelle fonctionnalité (changement non-breaking qui ajoute une fonction)
- [ ] 💥 Breaking change (changement qui casse la compatibilité)
- [ ] 📝 Documentation (mise à jour de la doc uniquement)
- [ ] 🧪 Tests (ajout ou modification de tests)
- [ ] 🔧 Configuration (CI, dépendances, config)
## Checklist
- [ ] Mon code suit les conventions du projet
- [ ] J'ai testé mes changements localement
- [ ] J'ai ajouté des tests si nécessaire
- [ ] J'ai mis à jour la documentation si nécessaire
- [ ] Mes commits suivent les [Conventional Commits](https://conventionalcommits.org)
## Tests effectués
<!-- Décrivez les tests que vous avez effectués -->
## Captures d'écran (si applicable)
<!-- Ajoutez des captures d'écran pour les changements visuels -->
## Issues liées
<!-- Utilisez "Fixes #123" ou "Closes #123" pour lier automatiquement -->

CONTRIBUTING.md :

# Guide de contribution
Merci de votre intérêt pour ce projet !
## Prérequis
- Python 3.11+
- Docker 24+
- Git
## Installation locale
```bash
git clone https://github.com/VOTRE_USER/secure-python-pipeline.git
cd secure-python-pipeline
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
```
## Lancer les tests
```bash
pytest
ruff check src/ tests/
bandit -r src/
```
## Processus de contribution
1. Forkez le projet
2. Créez une branche (`git checkout -b feature/ma-feature`)
3. Commitez vos changements (`git commit -m 'feat: ajoute ma feature'`)
4. Poussez la branche (`git push origin feature/ma-feature`)
5. Ouvrez une Pull Request
## Conventions
- **Commits** : suivez [Conventional Commits](https://conventionalcommits.org)
- **Code** : formaté avec Ruff
- **Tests** : couverture minimale 80%
## Code de conduite
Ce projet suit le [Contributor Covenant](https://www.contributor-covenant.org/).
Soyez respectueux et inclusif.

Un bon README est essentiel pour le check Maintained et pour accueillir les contributeurs. Il doit inclure les badges de sécurité.

README.md :

# Secure Python Pipeline
[![CI](https://github.com/VOTRE_USER/secure-python-pipeline/actions/workflows/ci.yml/badge.svg)](https://github.com/VOTRE_USER/secure-python-pipeline/actions/workflows/ci.yml)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/VOTRE_USER/secure-python-pipeline/badge)](https://scorecard.dev/viewer/?uri=github.com/VOTRE_USER/secure-python-pipeline)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/XXXXX/badge)](https://www.bestpractices.dev/projects/XXXXX)
API Python de démonstration avec pipeline CI/CD hautement sécurisé.
## 🔒 Sécurité
Ce projet implémente les bonnes pratiques de supply chain security :
| Protection | Outil | Status |
|------------|-------|--------|
| Attestation SLSA L3 | GitHub Attestations | ✅ |
| Signature d'image | Cosign (Sigstore) | ✅ |
| SBOM | Syft (SPDX + CycloneDX) | ✅ |
| Scan vulnérabilités | Trivy, pip-audit | ✅ |
| Analyse statique | Bandit, Ruff | ✅ |
| Dépendances épinglées | Hash pinning | ✅ |
### Vérifier l'image
```bash
# Vérifier la signature Cosign
cosign verify \
--certificate-identity-regexp="https://github.com/VOTRE_USER/secure-python-pipeline" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/VOTRE_USER/secure-python-pipeline:latest
# Vérifier l'attestation SLSA
gh attestation verify oci://ghcr.io/VOTRE_USER/secure-python-pipeline:latest \
--owner VOTRE_USER
```
## 🚀 Démarrage rapide
### Prérequis
- Python 3.11+
- Docker 24+
### Installation locale
```bash
git clone
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
### Lancer l'application
```bash
uvicorn app.main:app --reload
```
L'API est disponible sur http://localhost:8000
### Avec Docker
```bash
docker pull ghcr.io/VOTRE_USER/secure-python-pipeline:latest
docker run -p 8000:8000 ghcr.io/VOTRE_USER/secure-python-pipeline:latest
```
## 📚 API
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/` | GET | Message de bienvenue |
| `/health` | GET | Health check pour Kubernetes |
## 🧪 Tests
```bash
# Installer les dépendances de dev
pip install -r requirements-dev.txt
# Lancer les tests
pytest
# Linting
ruff check src/ tests/
# Analyse de sécurité
bandit -r src/
```
## 📝 Licence
Sous licence MIT - voir le fichier `LICENSE` à la racine du dépôt.
## 🤝 Contribuer
Voir le fichier `CONTRIBUTING.md` à la racine du dépôt.
## 🔐 Signaler une vulnérabilité
Voir le fichier `SECURITY.md` à la racine du dépôt.

Ce tableau récapitule les checks Scorecard couverts par les fichiers et la configuration ajoutés jusqu'ici, et signale ceux qui restent optionnels.

CheckFichier/ConfigImpact
Branch-ProtectionRègles de branche✅ Déjà fait
Code-ReviewCODEOWNERS + branch rules
Security-PolicySECURITY.md
Dependency-Update-Tooldependabot.yml✅ Déjà fait
Pinned-DependenciesSHA dans workflows✅ Déjà fait
Token-Permissionspermissions: read✅ Déjà fait
SASTBandit dans CI✅ Déjà fait
Signed-ReleasesCosign signature✅ Déjà fait
VulnerabilitiesTrivy, pip-audit✅ Déjà fait
MaintainedCommits récents, CONTRIBUTING
LicenseLICENSE file✅ Déjà fait
Dangerous-WorkflowPas de pull_request_target✅ Déjà fait
FuzzingOSS-Fuzz (optionnel)⚠️ Avancé
CII-Best-PracticesBadge OpenSSF⚠️ Optionnel

Voici la structure finale du projet :

  • Répertoire.github/
    • CODEOWNERS
    • RépertoireISSUE_TEMPLATE/
      • bug_report.yml
      • feature_request.yml
      • config.yml
    • PULL_REQUEST_TEMPLATE.md
    • dependabot.yml
    • Répertoireworkflows/
      • ci.yml
      • release.yml
      • scorecard.yml
  • Répertoiresrc/
    • Répertoireapp/
      • __init__.py
      • main.py
  • Répertoiretests/
    • __init__.py
    • test_main.py
  • .dockerignore
  • .gitignore CRITIQUE : doit contenir .venv/
  • .python-version
  • CONTRIBUTING.md
  • Dockerfile
  • pyproject.toml
  • requirements.txt
  • requirements-dev.txt
  • SECURITY.md

src/app/main.py : une API FastAPI minimaliste :

"""API de démonstration pour pipeline sécurisé."""
from fastapi import FastAPI
app = FastAPI(
title="Secure Python Pipeline Demo",
version="1.0.0",
description="Application de démonstration avec supply chain sécurisée",
)
@app.get("/")
async def root() -> dict[str, str]:
"""Endpoint racine."""
return {"message": "Hello, Secure World!"}
@app.get("/health")
async def health() -> dict[str, str]:
"""Endpoint de santé pour les probes Kubernetes."""
return {"status": "healthy"}

src/app/__init__.py : fichier vide pour le package :

"""Package principal de l'application."""

tests/test_main.py : tests unitaires :

"""Tests pour l'API."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_root() -> None:
"""Test de l'endpoint racine."""
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello, Secure World!"}
def test_health() -> None:
"""Test de l'endpoint de santé."""
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"

tests/__init__.py : fichier vide :

"""Package de tests."""

Les dépendances épinglées avec hash garantissent l'intégrité. Même si PyPI était compromis, pip refuserait d'installer un fichier modifié.

pyproject.toml : configuration du projet :

[project]
name = "secure-python-pipeline"
version = "1.0.0"
description = "Démonstration pipeline CI/CD sécurisé"
requires-python = ">=3.11"
license = {text = "MIT"}
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "S", "B", "C4", "UP"]
[tool.ruff.lint.per-file-ignores]
# S101 = assert (normal dans les tests pytest)
"tests/**/*.py" = ["S101"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.bandit]
exclude_dirs = ["tests", ".venv"]

requirements.txt, À GÉNÉRER, ne pas copier-coller :

Fenêtre de terminal
# 1. Installer pip-tools
pip install pip-tools
# 2. Créer requirements.in (fichier source sans hash)
cat > requirements.in << EOF
fastapi
uvicorn
EOF
# 3. Générer requirements.txt avec TOUTES les dépendances et hashs
pip-compile --generate-hashes requirements.in -o requirements.txt

Exemple de sortie (extrait, le fichier complet fait 200-300 lignes) :

# This file is autogenerated by pip-compile with Python 3.11
annotated-types==0.7.0 \
--hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc879daf1b616d8de89816493b8fd5c794f47
# via pydantic
anyio==4.7.0 \
--hash=sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94
# via starlette
# ... (50+ autres packages avec leurs hashs)
fastapi==0.115.6 \
--hash=sha256:... (hash du jour)
# ... etc

requirements-dev.txt : dépendances de développement :

# Outils de qualité
ruff>=0.8.0
bandit>=1.7.0
pip-audit>=2.7.0
# Tests
pytest>=8.0.0
pytest-cov>=6.0.0 # Plugin coverage pour pytest
httpx>=0.27.0 # Pour TestClient FastAPI
# Build
pip-tools>=7.4.0

Critique : Le dossier .venv/ ne doit jamais être committé. Il contient des binaires spécifiques à votre système et fait échouer les checks Scorecard.

.gitignore :

# Python runtime
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Environnements virtuels (CRITIQUE - ne jamais commiter)
.venv/
venv/
ENV/
env/
# Outils Python
.pytest_cache/
.ruff_cache/
.mypy_cache/
htmlcov/
.coverage
.tox/
dist/
build/
*.egg-info/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

Un Dockerfile multi-stage réduit la surface d'attaque en n'incluant que le strict nécessaire dans l'image finale. L'image de base est épinglée par digest @sha256, comme les actions par SHA : c'est ce qui garantit un build reproductible même si le tag python:3.11-slim est republié sur le registre.

Dockerfile :

# syntax=docker/dockerfile:1
# =============================================================================
# Stage 1 : Builder - Installer les dépendances dans un venv
# =============================================================================
FROM python:3.11-slim@sha256:a3ab0b966bc4e91546a033e22093cb840908979487a9fc0e6e38295747e49ac0 AS builder
# Éviter les fichiers .pyc et activer le mode unbuffered
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Copier uniquement les fichiers de dépendances d'abord (cache Docker)
COPY requirements.txt .
# Créer un virtualenv et installer les dépendances
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# --require-hashes force la vérification des hash
RUN pip install --no-cache-dir --require-hashes -r requirements.txt
# =============================================================================
# Stage 2 : Production - Image minimale
# =============================================================================
FROM python:3.11-slim@sha256:a3ab0b966bc4e91546a033e22093cb840908979487a9fc0e6e38295747e49ac0 AS production
# Labels OCI pour la traçabilité (utilisés par Syft pour le SBOM)
LABEL org.opencontainers.image.source="https://github.com/VOTRE_USER/secure-python-pipeline"
LABEL org.opencontainers.image.description="API Python avec supply chain sécurisée"
LABEL org.opencontainers.image.licenses="MIT"
# Créer un utilisateur non-root
RUN groupadd --gid 1000 appgroup && \
useradd --uid 1000 --gid 1000 --shell /bin/false appuser
WORKDIR /app
# Copier le virtualenv depuis le builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copier le code source
COPY --chown=appuser:appgroup src/ ./src/
# Passer en utilisateur non-root
USER appuser
# Port exposé (documentation)
EXPOSE 8000
# Healthcheck intégré
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Commande de démarrage
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

.dockerignore : exclure les fichiers inutiles du contexte Docker :

# Git
.git
.gitignore
# Python
__pycache__
*.pyc
*.pyo
.venv
.pytest_cache
.ruff_cache
.mypy_cache
# IDE
.vscode
.idea
# Tests et docs
tests/
docs/
*.md
# CI/CD
.github/

Dependabot crée automatiquement des PR pour mettre à jour vos dépendances quand des vulnérabilités sont découvertes.

.github/dependabot.yml :

version: 2
updates:
# Dépendances Python
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
commit-message:
prefix: "chore(deps):"
labels:
- "dependencies"
- "python"
# Grouper les mises à jour mineures
groups:
python-minor:
patterns:
- "*"
update-types:
- "minor"
- "patch"
# Actions GitHub
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
commit-message:
prefix: "chore(ci):"
labels:
- "dependencies"
- "github-actions"
# Dockerfile
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "chore(docker):"

Ce workflow s'exécute sur chaque PR et commit sur main. Il vérifie la qualité du code avant le merge.

.github/workflows/ci.yml :

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
# Aucun droit par défaut : chaque job demande le strict nécessaire
permissions: {}
jobs:
# ===========================================================================
# Job 1 : Lint avec Ruff
# ===========================================================================
lint:
name: Lint (Ruff)
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"
- name: Install Ruff
run: pip install ruff==0.8.4
- name: Run Ruff formatter check
run: ruff format --check src/ tests/
- name: Run Ruff linter
run: ruff check src/ tests/
# ===========================================================================
# Job 2 : Tests avec Pytest
# ===========================================================================
test:
name: Test (Pytest)
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install --require-hashes -r requirements.txt
pip install --require-hashes -r requirements-dev.txt
- name: Run tests with coverage
run: pytest --cov=src/app --cov-report=xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
files: coverage.xml
fail_ci_if_error: false
# ===========================================================================
# Job 3 : SAST avec Bandit
# ===========================================================================
sast:
name: SAST (Bandit)
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"
- name: Install Bandit
run: pip install bandit==1.8.0
- name: Run Bandit
run: bandit -r src/ -f json -o bandit-report.json || true
- name: Upload Bandit report
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: bandit-report
path: bandit-report.json
# ===========================================================================
# Job 4 : Audit des dépendances
# ===========================================================================
audit:
name: Dependency Audit
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"
- name: Install pip-audit
run: pip install pip-audit==2.7.3
- name: Run pip-audit
run: pip-audit -r requirements.txt --strict
# ===========================================================================
# Job 5 : Build Docker (sans push, juste vérification)
# ===========================================================================
build-check:
name: Docker Build Check
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image (no push)
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: false
tags: test-build:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

Ce workflow se déclenche sur les tags v*. Il construit l'image avec attestation SLSA, génère le SBOM et signe avec Cosign.

.github/workflows/release.yml :

name: Release
on:
push:
tags:
- "v*"
# Aucun droit par défaut : chaque job demande le strict nécessaire
permissions: {}
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ===========================================================================
# Job 1 : Build, Attest, Sign, Push
# ===========================================================================
build-and-push:
name: Build, Attest & Push
runs-on: ubuntu-24.04
# Permissions spécifiques au job
permissions:
contents: read
packages: write # Pour push vers GHCR
id-token: write # Pour Sigstore OIDC
attestations: write # Pour GitHub Attestations
outputs:
digest: ${{ steps.build.outputs.digest }}
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern=v{{version}}
- name: Build and push image
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true # Génère l'attestation SLSA
sbom: true # Génère le SBOM avec Syft
# =========================================================================
# Attestation GitHub (SLSA Build L3)
# =========================================================================
- name: Generate SLSA attestation
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
# =========================================================================
# Signature Cosign (keyless via Sigstore)
# =========================================================================
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Sign image with Cosign
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
IMAGE_REF="${REGISTRY}/${IMAGE_NAME}@${DIGEST}"
echo "Signing: $IMAGE_REF"
cosign sign --yes "$IMAGE_REF"
echo "Verifying signature..."
cosign verify \
--certificate-identity-regexp="https://github.com/${GITHUB_REPOSITORY}" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
"$IMAGE_REF"
# ===========================================================================
# Job 2 : Générer SBOM détaillé avec Syft
# ===========================================================================
sbom:
name: Generate SBOM
runs-on: ubuntu-24.04
needs: build-and-push
permissions:
contents: read
packages: write # Pour attacher le SBOM à l'image dans GHCR
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Syft
uses: anchore/sbom-action/download-syft@a930d0ac434e3182448fe678398ba5713717112a # v0.21.0
- name: Generate SBOM (SPDX)
env:
IMAGE_REF: ${{ needs.build-and-push.outputs.image }}@${{ needs.build-and-push.outputs.digest }}
run: syft "$IMAGE_REF" -o spdx-json=sbom-spdx.json
- name: Generate SBOM (CycloneDX)
env:
IMAGE_REF: ${{ needs.build-and-push.outputs.image }}@${{ needs.build-and-push.outputs.digest }}
run: syft "$IMAGE_REF" -o cyclonedx-json=sbom-cyclonedx.json
- name: Upload SBOM artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: sbom
path: |
sbom-spdx.json
sbom-cyclonedx.json
# Attacher le SBOM à l'image avec Cosign
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
- name: Attach SBOM to image
env:
IMAGE_REF: ${{ needs.build-and-push.outputs.image }}@${{ needs.build-and-push.outputs.digest }}
run: cosign attach sbom --sbom sbom-spdx.json "$IMAGE_REF"
# ===========================================================================
# Job 3 : Scanner les vulnérabilités avec Trivy
# ===========================================================================
scan:
name: Vulnerability Scan
runs-on: ubuntu-24.04
needs: build-and-push
permissions:
packages: read # Pour récupérer l'image depuis GHCR
security-events: write # Pour publier le rapport SARIF
steps:
- name: Run Trivy scanner
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: "${{ needs.build-and-push.outputs.image }}@${{ needs.build-and-push.outputs.digest }}"
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH"
- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
sarif_file: "trivy-results.sarif"

4.3 Workflow CodeQL (analyse de sécurité statique)

Section intitulée « 4.3 Workflow CodeQL (analyse de sécurité statique) »

Ce workflow analyse automatiquement votre code Python pour détecter des vulnérabilités de sécurité et des erreurs de qualité avec CodeQL.

.github/workflows/codeql.yml :

name: CodeQL
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
# Analyser tous les lundis à 6h
- cron: '0 6 * * 1'
# Aucun droit par défaut : le job demande le strict nécessaire
permissions: {}
jobs:
analyze:
name: Analyze
runs-on: ubuntu-24.04
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
language: [ 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: ${{ matrix.language }}
queries: security-extended,security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: "/language:${{ matrix.language }}"

Ce workflow évalue les pratiques de sécurité de votre dépôt et publie les résultats sur OpenSSF Scorecard.

.github/workflows/scorecard.yml :

name: Scorecard
on:
# Exécution hebdomadaire
schedule:
- cron: "0 6 * * 1"
# Exécution sur push main (pour mise à jour rapide)
push:
branches: [main]
# Aucun droit par défaut : le job demande le strict nécessaire
permissions: {}
jobs:
analysis:
name: Scorecard Analysis
runs-on: ubuntu-24.04
permissions:
contents: read
security-events: write
id-token: write # Pour publier sur scorecard.dev
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload results to GitHub Security
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
sarif_file: results.sarif

Ce workflow vérifie automatiquement les attestations SLSA de vos releases, assurant qu'elles sont valides et n'ont pas été compromises.

.github/workflows/verify-slsa.yml :

name: SLSA Verification
on:
schedule:
# Vérifier toutes les nuits
- cron: '0 2 * * *'
workflow_dispatch:
# Aucun droit par défaut : le job demande le strict nécessaire
permissions: {}
jobs:
verify:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Install slsa-verifier
run: |
curl -LO https://github.com/slsa-framework/slsa-verifier/releases/latest/download/slsa-verifier-linux-amd64
chmod +x slsa-verifier-linux-amd64
sudo mv slsa-verifier-linux-amd64 /usr/local/bin/slsa-verifier
- name: Get latest tag
id: latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
LATEST_TAG=$(gh api "repos/$GITHUB_REPOSITORY/tags" --jq '.[0].name')
echo "tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
- name: Get image digest
id: digest
env:
RELEASE_TAG: ${{ steps.latest.outputs.tag }}
run: |
DIGEST=$(docker pull "ghcr.io/$GITHUB_REPOSITORY:$RELEASE_TAG" 2>&1 | grep "Digest:" | cut -d' ' -f2)
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
- name: Verify SLSA with GitHub Attestations
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
IMAGE_DIGEST: ${{ steps.digest.outputs.digest }}
run: |
gh attestation verify "oci://ghcr.io/$GITHUB_REPOSITORY@$IMAGE_DIGEST" \
--owner "$GITHUB_REPOSITORY_OWNER"
- name: Report status
if: always()
env:
JOB_STATUS: ${{ job.status }}
RELEASE_TAG: ${{ steps.latest.outputs.tag }}
run: |
if [ "$JOB_STATUS" = "success" ]; then
echo "Vérification SLSA réussie pour $RELEASE_TAG"
else
echo "Vérification SLSA échouée pour $RELEASE_TAG"
exit 1
fi

Vous avez travaillé dans la branche setup. Les rulesets bloquent les commits directs sur main, c'est normal, c'est ce qu'on veut ! Nous allons merger via une Pull Request en utilisant GitHub CLI.

  1. Installer les dépendances :

    Fenêtre de terminal
    # Créer un environnement virtuel Python
    python -m venv .venv
    source .venv/bin/activate # Linux/macOS
    # .venv\Scripts\activate # Windows
    # Mettre à jour pip (important pour éviter CVE-2025-8869)
    pip install --upgrade pip
    # Installer les dépendances de production et développement
    pip install -r requirements.txt -r requirements-dev.txt
  2. Tester localement avant de push :

    Fenêtre de terminal
    # Formatter le code automatiquement
    ruff format src/ tests/
    # Vérifier le linting
    ruff check src/ tests/
    # Lancer les tests
    pytest tests/ -v
    # Vérifier les vulnérabilités des dépendances
    pip-audit
    # Scanner le code avec Bandit
    bandit -r src/ -c pyproject.toml
  3. Commiter tous les fichiers dans la branche setup :

    Fenêtre de terminal
    # Vérifiez que vous êtes sur la branche setup
    git branch --show-current
    # Doit afficher : setup
    # ⚠️ CRITIQUE : Vérifier que .venv/ n'est PAS tracké
    git status --ignored
    # .venv/ doit apparaître sous "Ignored files"
    # Si .venv/ apparaît sous "Untracked" ou "Changes", c'est un problème !
    # Vérifier que .gitignore existe et contient .venv/
    grep -q "^\.venv/" .gitignore && echo "✓ .gitignore OK" || echo "❌ .gitignore manquant"
    git add .
    git commit -m "feat: initial secure pipeline setup"
    git push origin setup
  4. Créer une Pull Request avec gh :

    Fenêtre de terminal
    gh pr create \
    --title "feat: initial secure pipeline setup" \
    --body "Setup initial du pipeline CI/CD sécurisé :
    - Configuration Gitsign (signature keyless)
    - Projet Python FastAPI avec tests
    - Workflows CI, Release et Scorecard
    - Fichiers Scorecard (CODEOWNERS, SECURITY.md, templates)
    - Dockerfile multi-stage sécurisé" \
    --base main
  5. Suivre l'exécution de la CI :

    Fenêtre de terminal
    # Ouvrir la PR dans le navigateur pour voir les checks
    gh pr view --web
    # Ou suivre le statut en CLI
    gh pr checks --watch
  6. Merger la PR (une fois les checks verts) :

    Fenêtre de terminal
    gh pr merge --squash --delete-branch
  7. Mettre à jour votre copie locale :

    Fenêtre de terminal
    git checkout main
    git reset --hard origin/main
  8. Créer le premier tag :

    Fenêtre de terminal
    git tag v1.0.0
    git push origin v1.0.0
  9. Vérifier le workflow Release :

    Fenêtre de terminal
    # Suivre l'exécution du workflow Release
    gh run watch
  10. Vérifier Scorecard : Après quelques minutes, votre score apparaît sur scorecard.dev

Si votre score Scorecard est inférieur à 8/10, exécutez Scorecard localement pour identifier les problèmes :

Fenêtre de terminal
# Installer Scorecard CLI
go install github.com/ossf/scorecard/v5/cmd/scorecard@latest
# Scanner votre repo
scorecard --repo=github.com/VOTRE_USER/secure-python-pipeline --show-details
CheckScoreCauseSolution
Token-Permissions0/10permissions: read-all ou permissions: {}Définir permissions: contents: read explicitement au niveau global de chaque workflow
Binary-ArtifactsErreur.venv/ committéAjouter .venv/ au .gitignore et supprimer du repo
Pinned-DependenciesErreur.venv/ committéMême solution
SASTErreur.venv/ committéMême solution
Fuzzing0/10Pas de fuzzingOptionnel, voir section fuzzing ci-dessous

Fixer Token-Permissions :

Tous vos workflows doivent avoir permissions minimales :

# ❌ Mauvais - aucun bloc permissions : le jeton hérite des droits par défaut
# (pas de permissions: déclaré)
# ❌ Mauvais - read-all accorde la lecture sur toutes les portées
permissions: read-all
# ✅ Bon - explicite et minimal
permissions:
contents: read

Fixer Binary-Artifacts / Pinned-Dependencies / SAST :

Ces erreurs viennent du même problème : .venv/ ne doit jamais être committé.

Fenêtre de terminal
# 1. Ajouter au .gitignore
echo ".venv/" >> .gitignore
# 2. Supprimer du repo
git rm -r --cached .venv/
git commit -m "fix: remove venv from git"
git push
# 3. Re-scanner
scorecard --repo=github.com/VOTRE_USER/secure-python-pipeline --show-details

Exemple de .gitignore complet :

# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.pytest_cache/
.ruff_cache/
.mypy_cache/
dist/
build/
*.egg-info/
.venv/
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

Maintenant que l'image est publiée, vérifions que tout est correct.

La signature Cosign prouve que l'image vient bien de votre workflow de release. La commande cosign verify contrôle le certificat keyless contre l'identité GitHub attendue : si l'image a été substituée, la vérification échoue.

Fenêtre de terminal
# Définir les variables
export IMAGE=ghcr.io/VOTRE_USER/secure-python-pipeline
export TAG=v1.0.0
# Vérifier la signature
cosign verify \
--certificate-identity-regexp="https://github.com/VOTRE_USER/secure-python-pipeline" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
${IMAGE}:${TAG}

Sortie attendue :

Verification for ghcr.io/VOTRE_USER/secure-python-pipeline:v1.0.0 --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- Existence of the claims in the transparency log was verified
- The code-signing certificate was verified using trusted certificate authority certificates
[{"critical":{"identity":{"docker-reference":"ghcr.io/..."},...}]

L'attestation SLSA va plus loin que la signature : elle relie l'image au commit source et au workflow qui l'a construite. La commande gh attestation verify valide cette provenance auprès de l'API GitHub.

Fenêtre de terminal
# Récupérer le digest
DIGEST=$(crane digest ${IMAGE}:${TAG})
# Vérifier l'attestation SLSA
gh attestation verify oci://${IMAGE}@${DIGEST} \
--owner VOTRE_USER

Sortie attendue :

✓ Verification succeeded!
sha256:abc123... was attested by a]Sigstore entry with ID 12345678

Le SBOM attaché à l'image liste l'ensemble de ses composants. L'inspecter confirme qu'il a bien été généré et permet de repérer une dépendance inattendue avant de déployer.

Fenêtre de terminal
# Télécharger le SBOM attaché
cosign download sbom ${IMAGE}:${TAG} > sbom.json
# Voir le contenu (jq pour formater)
cat sbom.json | jq '.packages[:5]'

Le scan exécuté en CI peut dater de plusieurs heures. Relancer Trivy en local donne l'état des vulnérabilités connues au moment précis où vous contrôlez l'image, juste avant un déploiement.

Fenêtre de terminal
# Scanner l'image pour vulnérabilités
trivy image ${IMAGE}:${TAG}
# Scanner uniquement les CRITICAL et HIGH
trivy image --severity CRITICAL,HIGH ${IMAGE}:${TAG}

Étape 7 : Intégrer GUAC pour la visibilité globale

Section intitulée « Étape 7 : Intégrer GUAC pour la visibilité globale »

Vous avez maintenant des SBOM et des attestations pour chaque image. Mais si vous gérez 50 services, comment répondre rapidement à : "Quels services utilisent la dépendance vulnérable CVE-2024-XXXX ?"

GUAC (Graph for Understanding Artifact Composition) agrège tous vos SBOM et attestations dans un graphe de connaissances interrogeable. C'est le centre de contrôle de votre supply chain.

Pour ce tutoriel, nous déployons GUAC en local. En production, vous le déploierez sur un serveur dédié ou dans Kubernetes.

Fenêtre de terminal
# Cloner le dépôt GUAC
git clone https://github.com/guacsec/guac.git
cd guac
# Démarrer tous les services avec Docker Compose
docker compose up -d
# Vérifier que les services tournent
docker compose ps

Services disponibles :

ServicePortUsage
GraphQL API8080Requêtes programmatiques
Visualizer3000Interface web d'exploration

Ajoutez ce job dans .github/workflows/release.yml après le job sbom :

# ===========================================================================
# Job 4 : Ingérer dans GUAC
# ===========================================================================
guac-ingest:
name: Ingest to GUAC
runs-on: ubuntu-24.04
needs: [build-and-push, sbom]
permissions:
contents: read
# Uniquement si vous avez un GUAC hébergé (décommentez et adaptez l'URL)
# if: ${{ vars.GUAC_API_URL != '' }}
steps:
- name: Download SBOM artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: sbom
- name: Install guacone CLI
run: |
curl -sSL https://github.com/guacsec/guac/releases/latest/download/guacone-linux-amd64 \
-o /usr/local/bin/guacone
chmod +x /usr/local/bin/guacone
- name: Ingest SBOM to GUAC
env:
GUAC_API_URL: ${{ vars.GUAC_API_URL || 'http://localhost:8080/query' }}
run: |
# Ingérer le SBOM SPDX
guacone collect files sbom-spdx.json --gql-addr "$GUAC_API_URL"
# Ingérer le SBOM CycloneDX
guacone collect files sbom-cyclonedx.json --gql-addr "$GUAC_API_URL"
- name: Ingest image from OCI registry
env:
GUAC_API_URL: ${{ vars.GUAC_API_URL || 'http://localhost:8080/query' }}
IMAGE_REF: ${{ needs.build-and-push.outputs.image }}@${{ needs.build-and-push.outputs.digest }}
run: |
# Ingérer directement depuis le registry (récupère SBOM + attestations attachés)
guacone collect oci "$IMAGE_REF" --gql-addr "$GUAC_API_URL"

Si vous n'avez pas de GUAC hébergé, ingérez manuellement après chaque release :

Fenêtre de terminal
# Variables
export IMAGE=ghcr.io/VOTRE_USER/secure-python-pipeline
export TAG=v1.0.0
export GUAC_API=http://localhost:8080/query
# Télécharger le SBOM depuis les artifacts GitHub
gh run download --name sbom
# Ingérer les SBOM
guacone collect files sbom-spdx.json --gql-addr ${GUAC_API}
guacone collect files sbom-cyclonedx.json --gql-addr ${GUAC_API}
# Ingérer depuis le registry OCI (récupère aussi les attestations)
guacone collect oci ${IMAGE}:${TAG} --gql-addr ${GUAC_API}

Via l'interface web : Ouvrez http://localhost:3000 et explorez le graphe.

Via GraphQL : Trouvez tous les packages vulnérables dans votre image :

Fenêtre de terminal
curl -s http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"query": "query { CertifyVuln(certifyVulnSpec: {}) { package { namespaces { names { name } } } vulnerability { vulnerabilityIDs { vulnerabilityID } } } }"
}' | jq '.data.CertifyVuln[] | {package: .package.namespaces[0].names[0].name, cve: .vulnerability.vulnerabilityIDs[0].vulnerabilityID}'

Exemple de sortie :

{"package": "requests", "cve": "CVE-2023-32681"}
{"package": "starlette", "cve": "CVE-2024-12345"}
QuestionRequête GUAC
"Quelles images utilisent log4j ?"Rechercher IsDependencyOf avec package log4j
"Cette CVE nous affecte-t-elle ?"CertifyVuln filtré par CVE ID
"D'où vient cette dépendance ?"Traverser HasSourceAt depuis le package
"Cette image a-t-elle un SBOM ?"Vérifier HasSBOM pour l'artifact
"Provenance SLSA de cette image ?"Requêter HasSlsa

Le fuzzing teste votre application avec des entrées aléatoires pour détecter des bugs inattendus (crashes, comportements anormaux). C'est optionnel pour un projet simple, mais améliore votre score Scorecard.

Le fuzzing découvre :

  • Crashes : entrées qui font planter l'application
  • Exceptions non gérées : cas limites oubliés
  • Problèmes de sécurité : buffer overflows, injections, parsing errors

Atheris est un fuzzer Python basé sur libFuzzer.

tests/fuzz_main.py :

"""Fuzzing de l'API avec Atheris."""
import atheris
import sys
with atheris.instrument_imports():
from fastapi.testclient import TestClient
from app.main import app
def test_one_input(data: bytes) -> None:
"""Fuzz test pour l'API."""
client = TestClient(app)
try:
# Tester l'endpoint racine avec des données aléatoires
client.get("/", params={"input": data.decode("utf-8", errors="ignore")})
except (ValueError, UnicodeDecodeError):
# Erreurs attendues - pas un bug
pass
if __name__ == "__main__":
atheris.Setup(sys.argv, test_one_input)
atheris.Fuzz()

Ajouter à requirements-dev.txt :

atheris>=2.3.0

Ajouter à .github/workflows/ci.yml :

# ===========================================================================
# Job 6 : Fuzzing (optionnel)
# ===========================================================================
fuzz:
name: Fuzzing
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install -r requirements.txt -r requirements-dev.txt
- name: Run fuzzing (60 seconds)
run: |
python tests/fuzz_main.py -max_total_time=60

Tester localement :

Fenêtre de terminal
# Installer atheris
pip install atheris
# Lancer le fuzzing pendant 10 secondes
python tests/fuzz_main.py -max_total_time=10

Sortie attendue :

INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1234567890
INFO: -max_len is not provided; libFuzzer will guess a good value based on the corpus.
#2 INITED exec/s: 0 rss: 45Mb
#1024 pulse exec/s: 512 rss: 48Mb
...
Done 50000 runs in 10 second(s)

Si le fuzzing trouve un crash, Atheris l'affichera avec l'entrée qui a causé le problème.

Pour les projets open source largement utilisés, vous pouvez rejoindre OSS-Fuzz, le fuzzing continu de Google :

  1. Suivre le guide d'intégration
  2. Google exécute le fuzzing 24/7 sur vos commits
  3. Vous recevez des rapports de bugs privés

Critères OSS-Fuzz :

  • Projet open source avec > 10k utilisateurs ou dépendance critique
  • Responsable de sécurité actif (SECURITY.md + réponses rapides)
  • CI/CD configuré

Étape 9 : Politique in-toto pour la chaîne complète

Section intitulée « Étape 9 : Politique in-toto pour la chaîne complète »

Jusqu'ici, nous avons des attestations SLSA qui prouvent que le build a eu lieu sur GitHub Actions. Mais comment garantir que toutes les étapes obligatoires (lint, test, scan) ont bien été exécutées avant la release ?

in-toto permet de définir un layout (politique) qui décrit les étapes requises. À chaque étape, un link (attestation) est généré. À la fin, la vérification compare les links au layout : si une étape manque ou si les hachages ne correspondent pas, l'artefact est rejeté.

Ce que SLSA prouveCe qu'in-toto ajoute
Le build vient de GitHub ActionsToutes les étapes définies ont été exécutées
Le commit source est identifiéLes fichiers en entrée/sortie correspondent
Le builder est authentifiéUne politique signée définit les règles

Scénario d'attaque bloqué : un attaquant compromet le workflow et commente l'étape de scan de vulnérabilités. Avec SLSA seul, l'image est quand même signée. Avec in-toto, la vérification échoue car le link "scan" est manquant.

Fenêtre de terminal
# Installation via pip
pip install in-toto
# Vérifier l'installation
in-toto-run --version
in-toto-verify --version

Le layout définit les étapes obligatoires et les clés autorisées. Créez un fichier layout.json :

layout.json
{
"_type": "layout",
"expires": "2026-12-31T23:59:59Z",
"readme": "Layout pour secure-python-pipeline",
"keys": {},
"steps": [
{
"name": "lint",
"expected_command": [],
"threshold": 1,
"pubkeys": [],
"expected_materials": [
["MATCH", "*.py", "WITH", "PRODUCTS", "FROM", "lint"]
],
"expected_products": []
},
{
"name": "test",
"expected_command": [],
"threshold": 1,
"pubkeys": [],
"expected_materials": [
["MATCH", "*.py", "WITH", "PRODUCTS", "FROM", "lint"]
],
"expected_products": []
},
{
"name": "audit",
"expected_command": [],
"threshold": 1,
"pubkeys": [],
"expected_materials": [],
"expected_products": []
},
{
"name": "build",
"expected_command": [],
"threshold": 1,
"pubkeys": [],
"expected_materials": [
["MATCH", "*.py", "WITH", "PRODUCTS", "FROM", "test"],
["MATCH", "Dockerfile", "WITH", "PRODUCTS", "FROM", "test"]
],
"expected_products": [
["CREATE", "image.tar"]
]
}
],
"inspect": []
}

Modifiez votre workflow CI pour générer un link à chaque étape :

.github/workflows/ci.yml
jobs:
lint:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.11'
- name: Install in-toto
run: pip install in-toto
- name: Run lint with in-toto
run: |
in-toto-run \
--step-name lint \
--products src/*.py \
--no-command \
-- ruff check src/
- name: Upload link
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: link-lint
path: lint.link
test:
needs: lint
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Setup Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.11'
- name: Install dependencies
run: pip install in-toto pytest
- name: Download lint link
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: link-lint
- name: Run tests with in-toto
run: |
in-toto-run \
--step-name test \
--materials src/*.py \
--products src/*.py \
--no-command \
-- pytest tests/
- name: Upload link
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: link-test
path: test.link

Après le build, vérifiez que tous les links correspondent au layout :

Fenêtre de terminal
# Télécharger tous les links
gh run download --name link-lint
gh run download --name link-test
gh run download --name link-audit
gh run download --name link-build
# Vérifier contre le layout
in-toto-verify \
--layout layout.json \
--layout-keys owner-pubkey.pem \
--link-dir .

Résultat attendu :

The software product passed all verification.

Si une étape manque :

in-toto.exceptions.LinkNotFoundError: No link found for step 'audit'

Les attestations in-toto sont compatibles avec l'écosystème Sigstore :

Fenêtre de terminal
# Les attestations SLSA sont au format in-toto
cosign download attestation ghcr.io/VOTRE_USER/secure-python-pipeline:v1.0.0 \
| jq -r '.payload' | base64 -d | jq '._type'
# Sortie : "https://in-toto.io/Statement/v1"
# GUAC peut ingérer les links in-toto
guacone collect files *.link --gql-addr http://localhost:8080/query

Voici les erreurs les plus fréquentes rencontrées en construisant ce pipeline, avec leur cause probable et la correction à appliquer.

SymptômeCause probableSolution
permission denied sur GHCRToken sans scope packages:writeVérifier les permissions du workflow
Cosign sign échoueid-token: write manquantAjouter la permission dans le workflow
Attestation non trouvéeattestations: write manquantAjouter la permission
SBOM videImage sans packages détectablesVérifier que Syft détecte le bon format
Scorecard score basBranch protection désactivéeActiver les protections recommandées
pip-audit échoueVulnérabilité dans une dépendanceMettre à jour la dépendance concernée
GUAC ingestion échoueURL GraphQL incorrecteVérifier GUAC_API_URL et la connectivité
GUAC ne voit pas les CVECertifier OSV pas lancéExécuter guacone certifier osv
in-toto verify échoueLink manquant pour une étapeVérifier que tous les jobs génèrent leur link
LinkNotFoundErrorArtefact link non téléchargéVérifier download-artifact dans le workflow

Le pipeline empile plusieurs couches de protection, chacune fermant une voie d'attaque différente, du code source jusqu'à la visibilité globale de la supply chain. Ce tableau les récapitule de bout en bout.

CoucheOutilCe qu'il protège
CodeRuff, BanditQualité, vulnérabilités SAST
Dépendancespip-audit, hash pinningVulnérabilités, intégrité
BuildSLSA attestationTraçabilité du build
ImageCosign signatureAuthenticité de l'image
InventaireSyft SBOMListe des composants
VulnérabilitésTrivyCVE dans l'image
RepoScorecardPratiques de sécurité
Mises à jourDependabotDépendances obsolètes
Visibilité globaleGUACGraphe de toutes les dépendances et CVE
Politique pipelinein-totoPreuve que toutes les étapes ont été exécutées
  1. Branch protection est le premier rempart : pas de commit direct sur main
  2. Actions épinglées par SHA : les tags peuvent être modifiés, pas les SHA
  3. Hash pinning pour les dépendances : garantit l'intégrité même si PyPI est compromis
  4. SLSA attestation prouve que le build vient de votre CI, pas d'un laptop compromis
  5. Signature Cosign permet à vos consommateurs de vérifier l'authenticité
  6. SBOM documente exactement ce qui est dans l'image pour répondre aux audits
  7. GUAC connecte tous vos SBOM pour répondre rapidement aux CVE 0-day
  8. Scorecard vous donne une note et des axes d'amélioration concrets
  9. in-toto garantit que le pipeline complet a été respecté (lint → test → audit → build)

Pour approfondir chaque maillon de la chaîne, ces références officielles complètent le guide.

Ce site vous est utile ?

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

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn