
After the repository base,
you write the continuous integration workflow that tests and analyses the
project. The goal is not merely that it turns green: it has to withstand the
workflow security scanners. This guide builds the ci.yml of the reference
repository secure-python-pipeline job by job, applies every hardening rule
(minimal permissions, SHA pinning, harden-runner), then takes it to zero
findings on the four workflow scanners: actionlint, zizmor, poutine and
plumber, the last one in blocking mode on a trust policy. The audience is a
maintainer who already writes workflows and wants to harden them to the level of
an auditable reference.
What you will learn
- Structure a CI workflow with minimal permissions, job by job
- Recall the hardening rules already covered (SHA,
permissions,persist-credentials) - Pin the CI tools by hash, like the application dependencies
- Validate the workflow with actionlint, zizmor and poutine at zero findings
- Declare a blocking plumber trust policy and make it fail on any unauthorised source
Prerequisites
The repository and its base
are in place: the FastAPI application, the multi-stage Dockerfile, the
hash-frozen dependencies. Install the scanners locally: actionlint,
zizmor, poutine and plumber. The lab assumes you have read
Pinning actions by SHA and
GITHUB_TOKEN permissions:
both rules are applied here without being explained again.
The reference repository is public and browsable:
github.com/stephrobert/secure-python-pipeline.
You can clone it to compare your ci.yml, your .poutine.yml and your
.plumber.yaml with the real files commented in this guide.
The CI workflow, job by job
The workflow chains five independent jobs: lint (ruff), test (pytest), sast (bandit), audit (pip-audit and Trivy) and build-check (building the image and scanning it). Each runs on a clean runner, with its own permissions. The header declaration sets the global guardrails, valid for every job.
name: CI
on: push: branches: [main] pull_request: branches: [main]
# No permission by default: each job asks for the strict minimum.permissions: {}
concurrency: group: ci-${{ github.ref }} cancel-in-progress: trueTwo guardrails already sit in that header. permissions: {} strips every
right from the GITHUB_TOKEN at workflow level: each job then asks for the bare
minimum. concurrency cancels stale runs on the same reference, to avoid
wasting minutes and running two concurrent builds on the same branch.
Lint: the template for every job
The lint job is the model for the other four. It installs ruff pinned by
hash and checks the code style. Its header structure (hardening the runner,
checkout, installing Python) is identical in the other jobs.
jobs: lint: name: Lint (ruff) runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read steps: - name: Harden runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 with: egress-policy: audit - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: Install ruff (pinned by hash) run: pip install --require-hashes -r requirements-tools.txt - name: Run ruff run: ruff check .Test, SAST and audit: the verification jobs
The test, sast and audit jobs reuse the same header (harden-runner,
checkout with persist-credentials: false, a pinned actions/setup-python) and
differ only in their useful step. The test job installs the application and
test dependencies, always with hashes, then runs the suite:
test: name: Tests (pytest) runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read steps: # ... harden-runner, checkout, setup-python identical to the lint job ... - name: Install dependencies run: | pip install --require-hashes -r requirements.txt pip install --require-hashes -r requirements-test.txt - name: Run pytest run: pytest -qThe sast job (name: SAST (bandit)) runs bandit over the source code to
spot dangerous patterns (use of eval, subprocess with shell=True,
hardcoded secrets). Its useful step fits in one command, bandit -r src,
preceded by installing the hash-pinned tool. Its name: is the exact label
reused as the SAST (bandit) status check in the protection ruleset.
The audit job crosses two vulnerability analyses on the dependencies:
pip-audit queries the Python advisory database, and Trivy scans the locked
file in fs (filesystem) mode. Choosing Trivy over
google/osv-scanner-action is not incidental: Google's OSV action triggers a
poutine finding (github_action_from_unverified_creator_used), whereas
aquasecurity/trivy-action covers the same need without an alert.
audit: name: Dependency audit (pip-audit + Trivy) runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read steps: # ... harden-runner, checkout, setup-python identical ... - name: Install pip-audit (pinned by hash) run: pip install --require-hashes -r requirements-tools.txt - name: Run pip-audit run: pip-audit -r requirements.txt - name: Scan dependencies (Trivy filesystem) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: fs scan-ref: requirements.txt severity: HIGH,CRITICAL ignore-unfixed: true exit-code: "1" scanners: vulnThe ignore-unfixed: true option applies in CI the same arbitration as
the base (see bootstrap):
we ignore CVEs with no upstream fix and keep exit-code: "1" so the job
fails on everything genuinely fixable.
Build-check: building and scanning the image
The last job, build-check (name: Build image + scan (trivy)), builds the
Docker image without pushing it (push: false, load: true) through
docker/build-push-action, then scans it with Trivy in image mode. The
hardening is the same everywhere: a frozen runner, timeout-minutes,
permissions: contents: read, actions pinned by SHA. This job proves the
produced image carries no fixable HIGH or CRITICAL vulnerability before a
release is even discussed. Its name: matches the Build image + scan (trivy)
status check of the ruleset.
The hardening rules applied
Every line of the jobs above applies a rule already introduced elsewhere in the course. This guide applies them, it does not explain them again. Here is the quick recall and the pointer to the canonical guide:
runs-on: ubuntu-24.04freezes the runner;ubuntu-latestwould make the build irreproducible.permissions: {}at workflow level thencontents: readper job give theGITHUB_TOKENleast privilege (see GITHUB_TOKEN permissions).harden-runnerinauditmode installs an egress firewall recording the runner's network traffic;blockmode would restrict it.persist-credentials: falsestops theGITHUB_TOKENpersisting in the git configuration, where it could leak.- Actions pinned by a 40-character commit SHA, followed by a version
comment: a tag such as
@v7is mutable (see Pinning actions by SHA). timeout-minutesandconcurrencyavoid zombie jobs and stale runs.
Pinning the CI tools by hash
The CI tools (ruff, bandit, pip-audit) get the same hash lock as
the application base, described in the
bootstrap: versions
declared in an .in, compiled with --generate-hashes, installed with
--require-hashes. Only the input file changes, here requirements-tools.in:
# requirements-tools.in: the three CI tools, versions frozenruff==0.14.9bandit==1.8.0pip-audit==2.10.1You compile it into a locked file with hashes, installed by each job while refusing any package whose fingerprint does not match:
uv pip compile --generate-hashes requirements-tools.in -o requirements-tools.txtpip install --require-hashes -r requirements-tools.txtValidating with the workflow scanners
Four scanners review the workflows, each from a different angle. The goal is zero findings on all four. The first three run locally with no configuration; plumber requires a trust policy, covered right after.
actionlint validates the syntax and the good practices. On a clean
workflow, it returns no output and an exit code of 0:
actionlintecho "exit code: $?"exit code: 0zizmor hunts workflow security flaws (injection, excessive permissions, an
unpinned action, a forgotten persist-credentials). You run it over the CI
workflow in offline mode:
zizmor --offline .github/workflows/ci.yml INFO zizmor: 🌈 zizmor v1.26.1 INFO audit: zizmor: 🌈 completed .github/workflows/ci.ymlNo findings to report. Good job!poutine looks for CI/CD exploitation chains by analysing the whole
repository. It evaluates thirteen rules here and fails none of them:
poutine analyze_local .Summary of findings:| RULE ID | FAILURES | STATUS || default_permissions_on_risky_events | 0 | Passed || github_action_from_unverified_creator_used | 0 | Passed || injection | 0 | Passed || job_all_secrets | 0 | Passed || pr_runs_on_self_hosted | 0 | Passed || untrusted_checkout_exec | 0 | Passed || unverified_script_exec | 0 | Passed || ... (13 rules in total, 0 failures) | 0 | Passed |Acknowledging the poutine false positive
poutine flags actions whose creator is not "verified" on the Marketplace,
through the github_action_from_unverified_creator_used rule. The trap: the
official getplumber/plumber action, legitimate and used for the trust scan,
is not yet listed as a verified creator. Rather than disabling the rule for the
whole repository, you acknowledge that single false positive in a targeted
way, in a .poutine.yml file:
# .poutine.yml: the rule stays active for every OTHER third-party action.skip: - rule: github_action_from_unverified_creator_used purl: - pkg:githubactions/getplumber/plumberThe purl field (Package URL) designates exactly the action to exempt. Any
other unverified action would still raise a finding: that is the difference
between a targeted acknowledgement and a rule disabled blindly, which would
let a genuinely suspicious action through.
Plumber: the blocking trust policy
plumber goes further than the other three: it builds a trust graph and
verifies that every third-party component comes from an authorised source. It
requires a .plumber.yaml policy file, generated then refined:
plumber config generate # writes the full template (init mode requires a TTY)The central control on the GitHub side is
githubActionMustComeFromAuthorizedSources. The official actions
(actions/*, github/*) and those of your own organisation are covered by two
switches; third parties are declared explicitly in trustedGithubActions:
githubActionMustComeFromAuthorizedSources: enabled: true # Official GitHub actions (actions/*, github/*). Default: true. trustGithubOfficialActions: true # Actions from the same owner as the scanned repository. Default: true. trustSameOrgActions: true minimumStars: 0 # Allowlist of third-party sources: exact owner/repo, or owner/* for an org. trustedGithubActions: - getplumber/plumber # the Plumber action itself - docker/setup-buildx-action - docker/build-push-action - step-security/harden-runner # runner hardening (egress) - aquasecurity/trivy-action # image and dependency vulnerability scan - sigstore/cosign-installer # Sigstore keyless signingDeclaring your trusted sources is the heart of the approach: you make explicit what you trust, instead of inheriting the implicit list of the Marketplace. An action missing from that allowlist would fail the control.
In this lab, plumber blocks: the workflow running it passes
min-points: "100" and soft-fail: false, so the slightest non-compliance
fails the job rather than merely lowering a badge. That strict requirement
guarantees no undeclared source slips into the pipeline without a clean
failure. The complete plumber.yml workflow, with the GitHub App token
that gives plumber full read access to the branch protection, is detailed in
Protection and governance.
Key points
permissions: {}at workflow level then the strict minimum per job closes theGITHUB_TOKENsurface; here every job settles forcontents: read.- Actions pinned by SHA,
persist-credentials: false,harden-runner, the frozenubuntu-24.04runner,timeout-minutesandconcurrencyare the base reflexes, applied without re-explanation. - The CI tools (ruff, bandit, pip-audit) are pinned by hash through an
.incompiled into a.txtwith--generate-hashes, installed with--require-hashes. - actionlint, zizmor and poutine reach zero findings; the poutine false
positive on
getplumber/plumberis acknowledged in a targeted way in.poutine.yml. - plumber validates the trusted sources through
.plumber.yamland runs in blocking mode (min-points: "100",soft-fail: false), failing the job on the slightest undeclared source. - The full repository is public: github.com/stephrobert/secure-python-pipeline.
Next steps
- Lab 4: protection and governance: making these four scanners mandatory through a ruleset, so no PR bypasses them.
- Lab 5: scoring and hardening: turning the scanners' verdict into a measurable OpenSSF score.
- Debugging workflows: taking back control when a scan job fails without a usable message.