
Ce guide construit le socle local d'un projet Python que le reste de la
série durcit : un dépôt public gouverné, une petite API FastAPI, des
dépendances épinglées par hash et une image Docker non-root sans
vulnérabilité corrigeable. C'est la première brique du lab, pour un lecteur à
l'aise avec Git, Python et Docker. Tout est validé en local avant
d'écrire le moindre workflow : chaque choix (visibilité du dépôt,
--require-hashes, purge des outils de build) sert un contrôle de sécurité
que les guides suivants exploiteront. À la fin, pytest, docker build et un
scan Trivy passent au vert sur votre poste.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Créer un dépôt public avec licence,
.gitignoreet fichiers de gouvernance - Rédiger un
SECURITY.md, unCODEOWNERSet un Dependabot avec quarantaine - Épingler les dépendances Python par hash (
--generate-hashes,--require-hashes) - Séparer les dépendances de runtime des dépendances de test et d'outillage
- Durcir une image Docker multi-stage en utilisateur non-root
- Valider le socle en local avec
pytest,docker buildettrivy
Prérequis
Section intitulée « Prérequis »Ce guide manipule une chaîne d'outils Python et conteneur. Vérifiez leur
présence avant de commencer : une version trop ancienne de uv ou de Docker
change le comportement des commandes de verrouillage et de build.
| Outil | Version | Vérification |
|---|---|---|
| Git | 2.34+ | git --version |
| Python | 3.11+ | python --version |
| Docker | 24+ | docker --version |
| uv (ou pip-tools) | récent | uv --version |
| GitHub CLI | 2.x | gh auth status |
| Trivy | 0.72+ | trivy --version |
Le lab s'appuie sur un dépôt public : c'est ce qui permettra, plus tard, la vérification tierce des attestations via le log de transparence Rekor et un bon score OpenSSF Scorecard. Ce guide ne traite ni les workflows CI ni les attestations : il pose le terrain sur lequel ils s'appuieront.
Le dépôt de référence construit dans cette série reste public et consultable : github.com/stephrobert/secure-python-pipeline. Vous pouvez le cloner pour comparer votre socle au résultat attendu à chaque étape.
Étape 1 : créer le dépôt public
Section intitulée « Étape 1 : créer le dépôt public »Le dépôt est créé en une commande, avec une licence MIT, un .gitignore
Python et un README. Passer par la ligne de commande évite les clics et
rend l'opération reproductible : vous pourrez la rejouer à l'identique.
gh repo create secure-python-pipeline \ --public \ --description "Lab : pipeline CI/CD securise" \ --gitignore Python --license mit --add-readme --clonecd secure-python-pipelineLa commande doit afficher la création du dépôt puis son clonage local. Vérifiez que vous êtes bien dans le dépôt et qu'il est public :
gh repo view --json visibility,name --jq '.name + " : " + .visibility'La sortie attendue est secure-python-pipeline : PUBLIC.
Étape 2 : les fichiers de gouvernance
Section intitulée « Étape 2 : les fichiers de gouvernance »Ces fichiers ne sont pas de la paperasse : plusieurs contrôles de sécurité lisent leur présence et leur contenu. La cible finale ressemble à ceci, avant tout workflow :
Répertoiresecure-python-pipeline/
- LICENSE
- README.md
- SECURITY.md
- CONTRIBUTING.md
- .gitignore
- .dockerignore
Répertoire.github/
- CODEOWNERS
- dependabot.yml
- PULL_REQUEST_TEMPLATE.md
Répertoiresrc/app/
- …
Répertoiretests/
- …
La licence, reconnue par sa syntaxe SPDX
Section intitulée « La licence, reconnue par sa syntaxe SPDX »L'option --license mit a déjà posé un fichier LICENSE à la racine. Le
contrôle License de Scorecard le repère non par son nom mais par son
contenu SPDX reconnu (ici MIT), avec la ligne de copyright. Laissez le
fichier tel quel ; il vaut la note maximale sans effort supplémentaire.
Le SECURITY.md, qui alimente Security-Policy
Section intitulée « Le SECURITY.md, qui alimente Security-Policy »Le fichier SECURITY.md décrit comment signaler une vulnérabilité, par quel
canal privé et sous quels délais. Ce n'est pas cosmétique : le contrôle
Security-Policy de Scorecard note ce fichier à paliers. Il additionne des
points pour un lien ou un email, pour du texte réel (pas un gabarit vide)
et pour des marqueurs de divulgation avec des délais chiffrés. Le nôtre porte
l'avis de sécurité privé GitHub, une adresse security@, une vraie prose et des
délais explicites.
# Politique de sécurité
## Signaler une vulnérabilité
Nous pratiquons la **divulgation coordonnée** (coordinated disclosure). Merci de**ne pas** ouvrir d'issue publique pour une vulnérabilité.
Deux canaux privés pour signaler une vulnérabilité :
- **De préférence**, via l'avis de sécurité privé GitHub : <https://github.com/stephrobert/secure-python-pipeline/security/advisories/new>- Par courriel, à l'adresse : **security@stephane-robert.info**
## Engagements de délai
- **Accusé de réception** sous **48 heures**.- **Évaluation initiale** et première réponse sous **5 jours** ouvrés.- **Correctif ou plan de remédiation** communiqué sous **30 jours**.- **Divulgation publique coordonnée** après correctif, dans un délai maximum de **90 jours** à compter du signalement.Les délais concrets (48 heures, 5 jours, 30 jours, 90 jours) sont ce qui distingue une vraie politique d'un gabarit copié. Annoncez ce que vous tenez : un accusé de réception sous 48 heures engage.
Le CODEOWNERS, base de la revue obligatoire
Section intitulée « Le CODEOWNERS, base de la revue obligatoire »Le fichier .github/CODEOWNERS désigne les propriétaires qui devront
approuver les pull requests. C'est la base de la revue obligatoire posée
au moment de la protection de branche. On protège en priorité la chaîne CI/CD
et le Dockerfile, deux surfaces sensibles.
# 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-srt20Dependabot avec quarantaine anti-supply-chain
Section intitulée « Dependabot avec quarantaine anti-supply-chain »Le fichier .github/dependabot.yml active la mise à jour des dépendances
pour les trois écosystèmes du projet : pip, github-actions et docker. La
seule présence de ce fichier au bon chemin satisfait le contrôle
Dependency-Update-Tool. On en profite pour ajouter le vrai durcissement : le
bloc cooldown, une quarantaine qui interdit d'adopter une version le
jour de sa publication.
version: 2updates: # Dépendances Python (met à jour requirements.in, à recompiler ensuite) - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 5 labels: - "dependencies" - "python" # Quarantaine anti-supply-chain : on n'adopte pas une version le jour de sa # sortie (fenêtre où une compromission n'est pas encore détectée). cooldown: default-days: 7 semver-major-days: 14 semver-minor-days: 7 semver-patch-days: 3
# Actions GitHub : Dependabot remonte les SHA épinglés vers la dernière version - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" cooldown: default-days: 7
# Image de base du Dockerfile (suit le digest) - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" cooldown: default-days: 7Étape 3 : l'application fil rouge
Section intitulée « Étape 3 : l'application fil rouge »L'application est volontairement minimale : ce n'est pas elle qui compte,
mais le pipeline qui la construit et l'atteste. Elle expose trois routes,
dont un /version qui lira la version via importlib.metadata, avec un
repli codé en dur quand le paquet n'est pas installé (cas de l'image
runtime, où seul le code source est copié).
"""API de démonstration pour un pipeline supply chain sécurisé."""
from importlib import metadata
from fastapi import FastAPI
try: _VERSION = metadata.version("secure-python-pipeline")except metadata.PackageNotFoundError: # pragma: no cover - hors paquet installé _VERSION = "1.0.0"
app = FastAPI( title="Secure Python Pipeline Demo", version=_VERSION, 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]: """Sonde de santé pour les probes Kubernetes.""" return {"status": "healthy"}
@app.get("/version")async def version() -> dict[str, str]: """Version applicative, utile pour tracer une image jusqu'à sa provenance.""" return {"version": _VERSION}Ajoutez des tests dès maintenant : ils alimenteront le contrôle des tests en
intégration continue. Un test par route suffit à ce stade ; l'important est que
pytest prouve un comportement, sans quoi le check CI-Tests reste vide.
"""Tests de l'API."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health() -> None: """La sonde de santé répond 200 et signale l'état healthy.""" response = client.get("/health") assert response.status_code == 200 assert response.json()["status"] == "healthy"
def test_version() -> None: """L'endpoint version expose une chaîne de version non vide.""" response = client.get("/version") assert response.status_code == 200 assert response.json()["version"]Étape 4 : épingler les dépendances par hash
Section intitulée « Étape 4 : épingler les dépendances par hash »Épingler une version (fastapi==0.139.1) ne suffit pas : une version peut
être republiée sous le même numéro avec un contenu différent, et un registre
compromis servira alors autre chose. Le vrai verrou est le hash du paquet. On
déclare les dépendances de haut niveau dans un fichier .in, puis on compile
un fichier verrouillé qui fige tout l'arbre, transitives comprises, avec leur
empreinte.
# On déclare seulement les dépendances directes, sans version figée à la maincat requirements.in# fastapi# uvicorn
# Compilation : produit un requirements.txt verrouillé avec hashesuv pip compile --generate-hashes requirements.in -o requirements.txtLe fichier requirements.txt obtenu contient, pour chaque paquet, une ou
plusieurs empreintes --hash=sha256:.... Ne le lisez pas comme une simple liste
de versions : c'est un verrou d'intégrité sur tout l'arbre de dépendances.
# This file was autogenerated by uv via the following command:# uv pip compile --generate-hashes requirements.in -o requirements.txtfastapi==0.139.1 \ --hash=sha256:17faa81907751a8a85cd44c46f37fb576bde0078cb37de40bf1cd55de7104d87 \ --hash=sha256:99461bde7ac3fc34c78443da1f4dad3ca8f3182580029a2827692db216a8d7ae # via -r requirements.inpydantic-core==2.46.4 \ --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ # ... (une empreinte par wheel de la plateforme) # via pydanticOn installe ensuite avec --require-hashes, qui refuse toute dépendance
dont le hash ne correspond pas à ceux du fichier. C'est ce drapeau, pas la
compilation seule, qui applique réellement le verrou au moment de
l'installation.
uv pip install --require-hashes -r requirements.txtSéparer runtime, tests, outillage et fuzzing
Section intitulée « Séparer runtime, tests, outillage et fuzzing »Une image de production ne doit embarquer que le runtime. On sépare donc les
dépendances par usage, chacune dans son fichier .in compilé en lockfile
hashé distinct. Seul requirements.txt (runtime) entre dans l'image ; le reste
sert au poste de dev et à la CI.
| Fichier source | Contenu | Compilé vers |
|---|---|---|
requirements.in | fastapi, uvicorn (runtime) | requirements.txt |
requirements-test.in | pytest, pytest-cov, httpx | requirements-test.txt |
requirements-tools.in | ruff, bandit, pip-audit | requirements-tools.txt |
requirements-fuzz.in | atheris, httpx | requirements-fuzz.txt |
Chaque .in se compile de la même façon, et tous produisent un lockfile avec
hashes. Cette séparation garde l'image mince et réduit sa surface : les
scanners d'outils comme bandit ou ruff n'ont rien à faire en production.
uv pip compile --generate-hashes requirements-test.in -o requirements-test.txtuv pip compile --generate-hashes requirements-tools.in -o requirements-tools.txtuv pip compile --generate-hashes requirements-fuzz.in -o requirements-fuzz.txtÉtape 5 : le Dockerfile durci
Section intitulée « Étape 5 : le Dockerfile durci »L'image suit trois principes : multi-stage (les outils de build ne finissent
pas en production), utilisateur non-root, et image de base épinglée par
digest @sha256. Le digest fige l'image à l'octet près : contrairement à un
tag mutable comme python:3.11-slim, il ne peut pas être republié sous vos
pieds.
# syntax=docker/dockerfile:1
# Stage 1 : builder - installe les dépendances vérifiées par hash dans un venvFROM python:3.11-slim@sha256:baf89808ec37adeaab83cec287adb4a2afa4a11c1d51e961c7ec737877e61af6 AS builder
ENV PYTHONDONTWRITEBYTECODE=1ENV PYTHONUNBUFFERED=1WORKDIR /app
COPY requirements.txt .RUN python -m venv /opt/venvENV PATH="/opt/venv/bin:$PATH"
# --require-hashes refuse toute dépendance dont le hash ne correspond pasRUN pip install --no-cache-dir --require-hashes -r requirements.txt
# Retire les outils de build (pip, setuptools, wheel) du venv runtime :# ils portent régulièrement des CVE et ne servent pas à l'exécution.RUN pip uninstall -y setuptools wheel pip
# Stage 2 : production - image minimale, utilisateur non-rootFROM python:3.11-slim@sha256:baf89808ec37adeaab83cec287adb4a2afa4a11c1d51e961c7ec737877e61af6 AS production
LABEL org.opencontainers.image.source="https://github.com/stephrobert/secure-python-pipeline"LABEL org.opencontainers.image.licenses="MIT"
RUN groupadd --gid 1000 appgroup && \ useradd --uid 1000 --gid 1000 --shell /bin/false appuser
WORKDIR /app
# L'image de base embarque pip/setuptools/wheel dans /usr/local : ces outils de# build portent des CVE et ne servent pas au runtime (on exécute depuis /opt/venv).RUN python -m pip uninstall -y pip setuptools wheel jaraco.context 2>/dev/null || true; \ rm -rf /usr/local/lib/python3.11/site-packages/pip* \ /usr/local/lib/python3.11/site-packages/setuptools* \ /usr/local/lib/python3.11/site-packages/wheel* \ /usr/local/lib/python3.11/site-packages/pkg_resources \ /usr/local/lib/python3.11/site-packages/_distutils_hack \ /usr/local/lib/python3.11/site-packages/jaraco* \ /usr/local/bin/pip*
COPY --from=builder /opt/venv /opt/venvENV PATH="/opt/venv/bin:$PATH"ENV PYTHONPATH="/app/src"
COPY --chown=appuser:appgroup src/ ./src/
USER appuserEXPOSE 8000
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
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Chaque décision porte une raison précise. ENV PYTHONDONTWRITEBYTECODE évite
d'écrire des .pyc inutiles. Le compte appuser en uid 1000 avec
/bin/false comme shell interdit toute session interactive dans le conteneur. Le
HEALTHCHECK interroge la route /health sans dépendance externe, en pur
Python de la bibliothèque standard.
Étape 6 : valider en local
Section intitulée « Étape 6 : valider en local »Avant tout pipeline, on valide le socle en local. C'est la règle du lab : coder et prouver localement d'abord, écrire les workflows ensuite. Chaque commande a une sortie attendue ; si l'une échoue, on corrige avant d'aller plus loin.
ruff check . # lint : "All checks passed!"pytest -q # tests : "3 passed"bandit -r src # SAST : "No issues identified."pip-audit -r requirements.txt # deps : "No known vulnerabilities found"docker build -t secure-python-pipeline:local .trivy image --severity HIGH,CRITICAL --ignore-unfixed secure-python-pipeline:localLe docker build doit se terminer par un naming to ...:local done. Et le scan
Trivy final doit afficher zéro vulnérabilité HIGH ou CRITICAL corrigeable.
Le drapeau --ignore-unfixed est essentiel. Après le durcissement, il reste
une vingtaine de CVE dans les paquets système Debian de l'image de base
(util-linux, gzip, ncurses) qui n'ont pas encore de correctif amont. On
ne bloque pas dessus : on ne bloque que sur les vulnérabilités qu'on peut
corriger. Sans ce drapeau, le scan crie au loup sur des CVE hors de votre
contrôle.
Vous pouvez enfin lancer l'image et vérifier les trois routes, pour prouver que
l'utilisateur non-root et le PYTHONPATH sont corrects :
docker run --rm -d -p 8000:8000 --name spp secure-python-pipeline:localcurl -s localhost:8000/health # {"status":"healthy"}docker rm -f sppLa réponse {"status":"healthy"} confirme que le socle tourne. Le reste de la
série s'appuie sur exactement cette image et ce dépôt.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- Un dépôt public avec
LICENSE,README,SECURITY.md,CODEOWNERSet Dependabot pose les fondations que les contrôles de sécurité vérifieront. - Le
SECURITY.mdse note à paliers : email, texte réel et délais chiffrés (48 h, 90 jours) sont ce qui fait la différence. - La quarantaine Dependabot (
cooldown) protège contre l'adoption d'une version fraîchement compromise, sans rapporter de point de scoring. - Épingler par hash (
--require-hashes) verrouille l'intégrité, là où épingler une version ne suffit pas : une version peut être republiée. - On sépare runtime, tests, outillage et fuzzing en lockfiles distincts pour garder l'image mince.
- Une image multi-stage non-root avec base par digest et outils de build purgés des deux emplacements ne laisse aucune CVE corrigeable.
--ignore-unfixeddistingue une vraie faille d'une CVE sans correctif amont : on ne bloque que sur ce qu'on peut corriger.- On valide en local (
pytest,docker build,trivy) avant d'écrire le moindre workflow.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Dépôt de référence : le dépôt public complet, à cloner pour comparer votre socle au résultat final.
- Retour au hub du lab : la série complète et le dépôt de référence.
- Épingler les actions par SHA : la même logique d'intégrité, appliquée aux actions et aux images de conteneur.
- Permissions du GITHUB_TOKEN : le moindre privilège que les workflows du lab appliqueront.
- Attaques supply chain : ce contre quoi la quarantaine Dependabot et le verrouillage par hash protègent.