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

Hardened CI pipeline: passing the four scanners

50 min de lecture

Read this page in French

GitHub logo

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.

.github/workflows/ci.yml
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: true

Two 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 -q

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

The 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.04 freezes the runner; ubuntu-latest would make the build irreproducible.
  • permissions: {} at workflow level then contents: read per job give the GITHUB_TOKEN least privilege (see GITHUB_TOKEN permissions).
  • harden-runner in audit mode installs an egress firewall recording the runner's network traffic; block mode would restrict it.
  • persist-credentials: false stops the GITHUB_TOKEN persisting 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 @v7 is mutable (see Pinning actions by SHA).
  • timeout-minutes and concurrency avoid 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 frozen
ruff==0.14.9
bandit==1.8.0
pip-audit==2.10.1

You compile it into a locked file with hashes, installed by each job while refusing any package whose fingerprint does not match:

Fenêtre de terminal
uv pip compile --generate-hashes requirements-tools.in -o requirements-tools.txt
pip install --require-hashes -r requirements-tools.txt

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

Fenêtre de terminal
actionlint
echo "exit code: $?"
exit code: 0

zizmor hunts workflow security flaws (injection, excessive permissions, an unpinned action, a forgotten persist-credentials). You run it over the CI workflow in offline mode:

Fenêtre de terminal
zizmor --offline .github/workflows/ci.yml
INFO zizmor: 🌈 zizmor v1.26.1
INFO audit: zizmor: 🌈 completed .github/workflows/ci.yml
No 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:

Fenêtre de terminal
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/plumber

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

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

Declaring 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 the GITHUB_TOKEN surface; here every job settles for contents: read.
  • Actions pinned by SHA, persist-credentials: false, harden-runner, the frozen ubuntu-24.04 runner, timeout-minutes and concurrency are the base reflexes, applied without re-explanation.
  • The CI tools (ruff, bandit, pip-audit) are pinned by hash through an .in compiled into a .txt with --generate-hashes, installed with --require-hashes.
  • actionlint, zizmor and poutine reach zero findings; the poutine false positive on getplumber/plumber is acknowledged in a targeted way in .poutine.yml.
  • plumber validates the trusted sources through .plumber.yaml and 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

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