Aller au contenu
CI/CD & Automatisation medium

Bootstrap d'un dépôt GitHub sécurisé

23 min de lecture

logo github

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.

  • Créer un dépôt public avec licence, .gitignore et fichiers de gouvernance
  • Rédiger un SECURITY.md, un CODEOWNERS et 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 build et trivy

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.

OutilVersionVérification
Git2.34+git --version
Python3.11+python --version
Docker24+docker --version
uv (ou pip-tools)récentuv --version
GitHub CLI2.xgh auth status
Trivy0.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.

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.

Fenêtre de terminal
gh repo create secure-python-pipeline \
--public \
--description "Lab : pipeline CI/CD securise" \
--gitignore Python --license mit --add-readme --clone
cd secure-python-pipeline

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

Fenêtre de terminal
gh repo view --json visibility,name --jq '.name + " : " + .visibility'

La sortie attendue est secure-python-pipeline : PUBLIC.

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/

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

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: 2
updates:
# 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

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

src/app/main.py
"""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/test_main.py
"""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"]

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

Fenêtre de terminal
# On déclare seulement les dépendances directes, sans version figée à la main
cat requirements.in
# fastapi
# uvicorn
# Compilation : produit un requirements.txt verrouillé avec hashes
uv pip compile --generate-hashes requirements.in -o requirements.txt

Le 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.txt
fastapi==0.139.1 \
--hash=sha256:17faa81907751a8a85cd44c46f37fb576bde0078cb37de40bf1cd55de7104d87 \
--hash=sha256:99461bde7ac3fc34c78443da1f4dad3ca8f3182580029a2827692db216a8d7ae
# via -r requirements.in
pydantic-core==2.46.4 \
--hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \
--hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \
# ... (une empreinte par wheel de la plateforme)
# via pydantic

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

Fenêtre de terminal
uv pip install --require-hashes -r requirements.txt

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 sourceContenuCompilé vers
requirements.infastapi, uvicorn (runtime)requirements.txt
requirements-test.inpytest, pytest-cov, httpxrequirements-test.txt
requirements-tools.inruff, bandit, pip-auditrequirements-tools.txt
requirements-fuzz.inatheris, httpxrequirements-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.

Fenêtre de terminal
uv pip compile --generate-hashes requirements-test.in -o requirements-test.txt
uv pip compile --generate-hashes requirements-tools.in -o requirements-tools.txt
uv pip compile --generate-hashes requirements-fuzz.in -o requirements-fuzz.txt

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 venv
FROM python:3.11-slim@sha256:baf89808ec37adeaab83cec287adb4a2afa4a11c1d51e961c7ec737877e61af6 AS builder
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# --require-hashes refuse toute dépendance dont le hash ne correspond pas
RUN 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-root
FROM 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/venv
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONPATH="/app/src"
COPY --chown=appuser:appgroup src/ ./src/
USER appuser
EXPOSE 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.

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.

Fenêtre de terminal
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:local

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

Fenêtre de terminal
docker run --rm -d -p 8000:8000 --name spp secure-python-pipeline:local
curl -s localhost:8000/health # {"status":"healthy"}
docker rm -f spp

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

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • Un dépôt public avec LICENSE, README, SECURITY.md, CODEOWNERS et Dependabot pose les fondations que les contrôles de sécurité vérifieront.
  • Le SECURITY.md se 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-unfixed distingue 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.

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