Skip to content
Français
CI/CD & Automatisation medium

Bootstrapping a secure GitHub repository

65 min de lecture

Read this page in French

GitHub logo

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 .gitignore and governance files
  • Write a SECURITY.md, a CODEOWNERS and 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 build and trivy

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.

ToolVersionCheck
Git2.34+git --version
Python3.11+python --version
Docker24+docker --version
uv (or pip-tools)recentuv --version
GitHub CLI2.xgh auth status
Trivy0.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.

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

The command should report the repository creation then its local clone. Check that you are inside the repository and that it is public:

Fenêtre de terminal
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 issue
for 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-srt20

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

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

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

tests/test_main.py
"""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.

Fenêtre de terminal
# Declare only the direct dependencies, with no version frozen by hand
cat requirements.in
# fastapi
# uvicorn
# Compilation: produces a locked requirements.txt with hashes
uv pip compile --generate-hashes requirements.in -o requirements.txt

The 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.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 \
# ... (one fingerprint per platform wheel)
# via pydantic

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

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

Separating 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 fileContentCompiled into
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

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.

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

Step 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 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 refuses any dependency whose hash does not match
RUN 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 user
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
# 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/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"]

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.

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

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

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

The {"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, CODEOWNERS and Dependabot lays the foundations the security controls will check.
  • The SECURITY.md is 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-unfixed separates 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

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn