Securing our GitHub Actions workflows with zizmor

Securing our GitHub Actions workflows with zizmor

This is the next post in our supply chain security series, following the supply chain security update, the Composer 2.10 release and immutable version metadata on Packagist.org. The earlier posts covered Composer behavior, changes to Packagist.org, and Private Packagist features. Today we’ll cover how we hardened the pipelines that build and publish Composer, Packagist.org, and Private Packagist, and how to do the same for yours.

CI/CD pipelines are a prime target in the software supply chain. A workflow file runs automatically on every push and pull request, often with write permissions and access to deployment and registry secrets. We've hardened the GitHub Actions setup for both Composer and Packagist.org repositories, as well as our private repos for Private Packagist.  

One tool did most of the work: zizmor, a static analysis tool for GitHub Actions. Here's why we adopted it, what it catches, and the pitfalls we hit.

Why this matters

Workflow files are code. They run with real credentials, and attacks against them are common. Untrusted input, such as an issue title, could get interpolated into a shell command via ${{ ... }}. Or a pull_request_target workflow checks out and runs fork code while holding write credentials. This opens up the possibility that an outsider can run code with your permissions and take your secrets or push changes to your repository. 

In March 2025, an attacker compromised tj-actions/changed-files (CVE-2025-30066) and rewrote its version tags to point at malicious code. That code dumped the runner's memory and printed secrets into the workflow logs. Over 23,000 repositories used the action, most by a mutable tag like @v45. Repositories that pinned it to a commit SHA were not affected.

Pinning is not a complete answer on its own. A SHA is only as trustworthy as the review it received when you pinned it, and pinned actions no longer pick up fixes until someone bumps them. Pinning turns silent tag updates into explicit dependency updates you review, so pair it with an update tool like Dependabot or Renovate that proposes new SHAs as pull requests.

CoreShop, an e-commerce suite installed with Composer, shipped a workflow that ran on pull_request_target and checked out fork code, then executed a script from it (CVE-2026-41249). Any attacker could open a pull request and run code on the runner with the base repository's token. That is a pwn request, and zizmor's dangerous-triggers rule would have flagged it directly.

Not every case is caught outright. In May 2026, a token format change met an old validation regex and Composer leaked full GitHub Actions tokens into CI logs. The common entry point was the shivammathur/setup-php action, which registers GITHUB_TOKEN automatically, so Composer had the token without anyone explicitly providing it. 

zizmor wouldn't have caught the Composer bug, but it limits the fallout. A leaked token can only publish code if it has write access. zizmor's excessive-permissions rule pushes you toward a read-only token, and a read-only token that leaks does far less damage.

Securing a pipeline is not a one-time task. Actions get added, permissions drift, and platform behavior changes. A static analyzer on every change keeps up.

What zizmor checks for

zizmor reads your workflow and action files and reports findings. Several rules map directly to the attacks above:

  • unpinned-uses: flags actions referenced by tag or branch instead of a commit SHA. This is the main defense against the tj-actions attack.
  • template-injection: flags ${{ ... }} inside run: blocks, where values like github.event.issue.title become shell injection.
  • dangerous-triggers: flags pull_request_target and workflow_run, the trigger behind the CoreShop pwn request.
  • excessive-permissions: flags jobs with more token scope than they need, including those that get broad scope by declaring none. This is what limits the damage of a leaked token like the Composer one.

zizmor also checks for issues beyond those examples:

  • artipacked: flags actions/checkout steps that persist the git credential on disk when it isn't needed.
  • impostor-commit, typosquat-uses, known-vulnerable-actions: catch actions that resolve to a fork or a lookalike namespace, or that have a known vulnerability. These three rules are online audits and require a GitHub API token that zizmor uses to access the repositories of the actions it examines. The zizmor/zizmor-action enables online audits by default since every GitHub Actions job receives a GitHub API token. The zizmor CLI command only runs online audits if you enable them explicitly. The zizmor documentation explains how it uses the API token and which permissions it needs.
  • and many more: the audit documentation lists them all with remediation examples, and many can be fixed automatically.

Our configuration

Here is a config similar to the one we use in the Composer repository. It runs zizmor whenever a workflow file changes on main or in a pull request:

name: GitHub Actions Security Analysis with zizmor 🌈


on:
    push:
        branches:
            - main
        paths:
            - '.github/**.yml'
            - '.github/**.yaml'
    pull_request:
        paths:
            - '.github/**.yml'
            - '.github/**.yaml'


concurrency:
    group: ${{ github.workflow }}-${{ github.ref }}
    cancel-in-progress: true


permissions:
    contents: read


jobs:
    zizmor:
        name: Run zizmor 🌈
        runs-on: ubuntu-latest
        steps:
            - name: Checkout repository
              uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
              with:
                  persist-credentials: false


            - name: Run zizmor 🌈
              uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
              with:
                  advanced-security: false
                  annotations: true
                  persona: 'pedantic'

The config follows the rules zizmor enforces. Both actions are pinned to a commit SHA. Checkout uses persist-credentials: false, since zizmor pushes nothing. Permissions are contents: read and nothing more. We run the pedantic persona, so the bar is high and zizmor reports findings other personas would let pass.

We set advanced-security: false, so zizmor runs on its own instead of reporting into GitHub Advanced Security. When set to false, the job fails whenever zizmor finds something, which makes it a blocking check. With Advanced Security enabled, findings would instead appear in the repository's Security tab and the job would not fail on its own.

What to watch out for

zizmor is easy to start with, but a clean report takes judgment. These pitfalls below caught us out.

persist-credentials: false can break later steps

Setting persist-credentials: false on the actions/checkout step clears the artipacked finding, but it also removes the git credential later steps may need. Steps that push a commit, fetch a branch, or create a tag will fail without it. If a later step fails for this reason, don't revert to persisting credentials for the whole job. Find the steps that need git auth, provide a token there or explicitly set persist-credentials: true and make it clear why the workflow requires the git credential.

Default permissions can be broader than you think

The permissions a job has are not always the ones in the file. A workflow with no permissions: block inherits the default GITHUB_TOKEN scope, which an admin sets at the enterprise, organization, or repository level. 

That default is either read-only or write across all scopes. A repository owned by an organization inherits the organization's default, whatever the repository's age. GitHub made read-only the default only for enterprises, organizations, and personal-account repositories created after February 2, 2023, and left every existing one unchanged. So a repository in an organization that defaults to write gets a write-scoped token, no matter when the repository was created. A job that "uses no permissions" may run with write access it never asked for. 

Set permissions: {} at the workflow level, then grant scopes per job as needed.

Cover every maintained branch

Workflow files live in the repository, so each branch has its own copy. Composer, for example, is maintained on main and the 2.2 LTS branch at the same time. Fixing a workflow on main does nothing for the 2.2 branch. Apply zizmor and its fixes to every maintained branch, or the older ones stay exposed.

Branches you no longer maintain and don't want to fix are better deleted than left in place. A stale branch with a vulnerable workflow is still an attack surface. If you ever need it back, you can recreate it from an old tag.

Don't blanket ignore warnings

When a tool floods your repository with findings, the urge is to suppress them all. Each finding is a decision: fix it, or accept and document the risk. Suppressing findings you haven't read defeats the point. When you need an exception, scope it narrowly and comment why. Don't disable a rule globally.

Limit which actions are allowed to run

zizmor checks the workflow files in a repository but workflows can still reference risky third-party actions. GitHub settings allow you to restrict which third-party actions and reusable workflows can be used in your repositories. Restrictions can be defined in your organization's Actions settings or in individual repository’s Actions settings. Allow only actions from your own organization, or keep an allowlist of specific external ones you trust. 

Getting started on your repositories

Adding zizmor is a small change with a large payoff. Install it locally, audit your workflows, review each finding, then wire it into CI. The quickstart guide takes a few minutes.

To speed up the first pass, we created zizmorify, a Claude Code skill that automates the whole rollout. It adds a zizmor workflow and a Dependabot config, runs zizmor in pedantic mode, and iterates until there are no findings left, pinning actions to commit SHAs, tightening permissions, removing template injection, and more. You can find installation and usage instructions in the Seldaek/zizmorify repository.

CI/CD security is part of supply chain security, a theme we care about at Private Packagist and in the Composer and Packagist.org infrastructure we maintain. Hardening the workflows that build and publish your code protects the packages your users depend on.

More posts in this series are coming, covering further Composer changes and Packagist and Private Packagist features that build on them.