
This guide builds the local base of a Python project the rest of the series
hardens: a governed public repository, a small FastAPI application,
hash-pinned dependencies and a non-root Docker image with no fixable
vulnerability. It is the first brick of the lab, for a reader comfortable with
Git, Python and Docker. Everything is validated locally before a
single workflow is written: each choice (repository visibility,
--require-hashes, purging the build tools) serves a security control the
following guides will build on. By the end, pytest, docker build and a
Trivy scan are green on your machine.
What you will learn
- Create a public repository with a licence, a
.gitignoreand governance files - Write a
SECURITY.md, aCODEOWNERSand a Dependabot config with quarantine - Pin the Python dependencies by hash (
--generate-hashes,--require-hashes) - Separate runtime dependencies from test and tooling dependencies
- Harden a multi-stage Docker image running as a non-root user
- Validate the base locally with
pytest,docker buildandtrivy
Prerequisites
This guide handles a Python and container toolchain. Check they are present
before starting: a version of uv or Docker that is too old changes the
behaviour of the locking and build commands.
| Tool | Version | Check |
|---|---|---|
| Git | 2.34+ | git --version |
| Python | 3.11+ | python --version |
| Docker | 24+ | docker --version |
| uv (or pip-tools) | recent | uv --version |
| GitHub CLI | 2.x | gh auth status |
| Trivy | 0.72+ | trivy --version |
The lab rests on a public repository: that is what will later allow third-party verification of the attestations through the Rekor transparency log, and a good OpenSSF Scorecard score. This guide covers neither the CI workflows nor the attestations: it lays the ground they will stand on.
The reference repository built in this series stays public and browsable: github.com/stephrobert/secure-python-pipeline. You can clone it to compare your base with the expected result at every stage.
Step 1: create the public repository
The repository is created in one command, with an MIT licence, a Python
.gitignore and a README. Going through the command line avoids clicking
and makes the operation reproducible: you can replay it identically.
gh repo create secure-python-pipeline \ --public \ --description "Lab: a secure CI/CD pipeline" \ --gitignore Python --license mit --add-readme --clonecd secure-python-pipelineThe command should report the repository creation then its local clone. Check that you are inside the repository and that it is public:
gh repo view --json visibility,name --jq '.name + " : " + .visibility'The expected output is secure-python-pipeline : PUBLIC.
Step 2: the governance files
These files are not paperwork: several security controls read their presence and their content. The final target looks like this, before any 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/
- …
The licence, recognised by its SPDX syntax
The --license mit option already placed a LICENSE file at the root.
Scorecard's License check spots it not by its name but by its recognised
SPDX content (MIT here), with the copyright line. Leave the file as it is; it
earns the maximum score with no extra effort.
The SECURITY.md, which feeds Security-Policy
The SECURITY.md file describes how to report a vulnerability, through which
private channel and under which deadlines. This is not cosmetic:
Scorecard's Security-Policy check scores that file in tiers. It adds
points for a link or an email, for real text (not an empty template) and
for disclosure markers with stated deadlines. Ours carries the GitHub private
security advisory, a security@ address, genuine prose and explicit deadlines.
# Security policy
## Reporting a vulnerability
We practise **coordinated disclosure**. Please do **not** open a public issuefor a vulnerability.
Two private channels to report a vulnerability:
- **Preferably**, through the GitHub private security advisory: <https://github.com/stephrobert/secure-python-pipeline/security/advisories/new>- By email, at: **security@stephane-robert.info**
## Response commitments
- **Acknowledgement** within **48 hours**.- **Initial assessment** and first reply within **5 working days**.- **Fix or remediation plan** communicated within **30 days**.- **Coordinated public disclosure** after the fix, within a maximum of **90 days** from the report.The concrete deadlines (48 hours, 5 days, 30 days, 90 days) are what separates a real policy from a copied template. Announce what you can hold: an acknowledgement within 48 hours is a commitment.
The CODEOWNERS, the base of mandatory review
The .github/CODEOWNERS file designates the owners who will have to
approve the pull requests. It is the base of the mandatory review set up
when branch protection is configured. We protect the CI/CD chain and the
Dockerfile first, two sensitive surfaces.
# Default owners: every PR requires their review.* @stephrobert @coconux3 @outscale-srt20
# The CI/CD chain is sensitive: mandatory review on the workflows./.github/workflows/ @stephrobert @coconux3 @outscale-srt20/Dockerfile @stephrobert @coconux3 @outscale-srt20Dependabot with an anti-supply-chain quarantine
The .github/dependabot.yml file enables dependency updates for the three
ecosystems of the project: pip, github-actions and docker. The mere
presence of that file at the right path satisfies the
Dependency-Update-Tool check. We take the opportunity to add the real
hardening: the cooldown block, a quarantine forbidding the adoption of
a version on the day it is published.
version: 2updates: # Python dependencies (updates requirements.in, to be recompiled afterwards) - package-ecosystem: "pip" directory: "/" schedule: interval: "weekly" open-pull-requests-limit: 5 labels: - "dependencies" - "python" # Anti-supply-chain quarantine: we do not adopt a version on release day # (the window where a compromise has not been detected yet). cooldown: default-days: 7 semver-major-days: 14 semver-minor-days: 7 semver-patch-days: 3
# GitHub Actions: Dependabot moves the pinned SHAs up to the latest version - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" cooldown: default-days: 7
# The Dockerfile base image (follows the digest) - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" cooldown: default-days: 7Step 3: the running application
The application is deliberately minimal: it is not what matters, the
pipeline that builds and attests it is. It exposes three routes, including a
/version that reads the version through importlib.metadata, with a
hardcoded fallback when the package is not installed (the case of the runtime
image, where only the source code is copied).
"""Demo API for a secure supply chain pipeline."""
from importlib import metadata
from fastapi import FastAPI
try: _VERSION = metadata.version("secure-python-pipeline")except metadata.PackageNotFoundError: # pragma: no cover - outside an installed package _VERSION = "1.0.0"
app = FastAPI( title="Secure Python Pipeline Demo", version=_VERSION, description="Demo application with a secured supply chain",)
@app.get("/")async def root() -> dict[str, str]: """Root endpoint.""" return {"message": "Hello, Secure World!"}
@app.get("/health")async def health() -> dict[str, str]: """Health probe for Kubernetes.""" return {"status": "healthy"}
@app.get("/version")async def version() -> dict[str, str]: """Application version, useful to trace an image back to its provenance.""" return {"version": _VERSION}Add tests right away: they will feed the continuous integration test check.
One test per route is enough at this stage; what matters is that pytest
proves a behaviour, without which the CI-Tests check stays empty.
"""API tests."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health() -> None: """The health probe answers 200 and reports the healthy state.""" response = client.get("/health") assert response.status_code == 200 assert response.json()["status"] == "healthy"
def test_version() -> None: """The version endpoint exposes a non-empty version string.""" response = client.get("/version") assert response.status_code == 200 assert response.json()["version"]Step 4: pin the dependencies by hash
Pinning a version (fastapi==0.139.1) is not enough: a version can be
republished under the same number with different content, and a compromised
registry will then serve something else. The real lock is the package hash.
You declare the top-level dependencies in an .in file, then compile a
locked file freezing the whole tree, transitive dependencies included, with
their fingerprints.
# Declare only the direct dependencies, with no version frozen by handcat requirements.in# fastapi# uvicorn
# Compilation: produces a locked requirements.txt with hashesuv pip compile --generate-hashes requirements.in -o requirements.txtThe resulting requirements.txt holds, for every package, one or more
--hash=sha256:... fingerprints. Do not read it as a mere list of versions: it
is an integrity lock over the whole dependency tree.
# 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 \ # ... (one fingerprint per platform wheel) # via pydanticYou then install with --require-hashes, which refuses any dependency
whose hash does not match the file. It is that flag, not the compilation alone,
that actually enforces the lock at install time.
uv pip install --require-hashes -r requirements.txtSeparating runtime, tests, tooling and fuzzing
A production image must carry only the runtime. So the dependencies are
separated by purpose, each in its own .in file compiled into a distinct
hashed lockfile. Only requirements.txt (the runtime) enters the image; the rest
serves the development machine and the CI.
| Source file | Content | Compiled into |
|---|---|---|
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 |
Each .in compiles the same way, and all produce a lockfile with hashes.
That separation keeps the image lean and shrinks its surface: tooling
scanners such as bandit or ruff have no business in 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.txtStep 5: the hardened Dockerfile
The image follows three principles: multi-stage (build tools do not end up in
production), a non-root user, and a base image pinned by @sha256
digest. The digest freezes the image down to the byte: unlike a mutable tag
such as python:3.11-slim, it cannot be republished under your feet.
# syntax=docker/dockerfile:1
# Stage 1: builder, installs the hash-verified dependencies into a 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 refuses any dependency whose hash does not matchRUN pip install --no-cache-dir --require-hashes -r requirements.txt
# Removes the build tools (pip, setuptools, wheel) from the runtime venv:# they regularly carry CVEs and serve no purpose at run time.RUN pip uninstall -y setuptools wheel pip
# Stage 2: production, a minimal image running as a non-root userFROM 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
# The base image carries pip/setuptools/wheel in /usr/local: those build tools# carry CVEs and are useless at run time (we execute from /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"]Every decision carries a precise reason. ENV PYTHONDONTWRITEBYTECODE avoids
writing useless .pyc files. The appuser account at uid 1000 with
/bin/false as its shell forbids any interactive session inside the container.
The HEALTHCHECK queries the /health route with no external dependency, in
pure standard-library Python.
Step 6: validate locally
Before any pipeline, you validate the base locally. That is the rule of the lab: code and prove locally first, write the workflows afterwards. Every command has an expected output; if one fails, fix it before going further.
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:localThe docker build must end with naming to ...:local done. And the final
Trivy scan must report zero fixable HIGH or CRITICAL vulnerability.
The --ignore-unfixed flag is essential. After the hardening, about twenty
CVEs remain in the Debian system packages of the base image (util-linux,
gzip, ncurses) that have no upstream fix yet. We do not block on those:
we only block on the vulnerabilities we can fix. Without the flag, the scan
cries wolf over CVEs outside your control.
Finally, you can run the image and check the three routes, proving that the
non-root user and the PYTHONPATH are correct:
docker run --rm -d -p 8000:8000 --name spp secure-python-pipeline:localcurl -s localhost:8000/health # {"status":"healthy"}docker rm -f sppThe {"status":"healthy"} response confirms the base runs. The rest of the
series builds on exactly this image and this repository.
Key points
- A public repository with
LICENSE,README,SECURITY.md,CODEOWNERSand Dependabot lays the foundations the security controls will check. - The
SECURITY.mdis scored in tiers: an email, real text and stated deadlines (48 h, 90 days) are what makes the difference. - The Dependabot quarantine (
cooldown) protects against adopting a freshly compromised version, and earns no scoring point. - Pinning by hash (
--require-hashes) locks integrity, where pinning a version does not: a version can be republished. - We separate runtime, tests, tooling and fuzzing into distinct lockfiles to keep the image lean.
- A multi-stage non-root image with a base pinned by digest and build tools purged from both locations leaves no fixable CVE.
--ignore-unfixedseparates a real flaw from a CVE with no upstream fix: we only block on what we can fix.- We validate locally (
pytest,docker build,trivy) before writing a single workflow.
The reference repository is public and clonable: compare your base with it before moving on.
Next steps
- Lab 3: verifiable build: what the hardened image of this page becomes once published with SLSA provenance, an attested SBOM and a Cosign signature.
- Lab 4: protection and governance: the ruleset and the code-owner review that prevent bypassing the base you have just laid.
- Lab 5: scoring and hardening: the measured result, with OpenSSF Scorecard and the bestpractices.dev badge.