Fixing zizmor findings without suppressions
Three fix patterns for common zizmor findings: creating environments via gh api, hardening git auth with remote set-url, and replacing third-party actions with native commands.
I wrote earlier about discovering six findings that --pedantic missed. The --persona=auditor --no-ignores flag surfaced them. This post covers what I did with them.
Findings to resolve
Suppressions rot. A zizmor.yml entry or an inline # zizmor: ignore[rule] comment takes seconds; a real fix takes understanding the rule and finding the right approach. I covered the same pattern with pnpm exclusions in keeping supply chain exclusions honest: trustPolicyExclude entries age out as upstream packages adopt provenance. minimumReleaseAgeExclude entries stay long after the package passes the age window. zizmor suppressions decay the same way. The override outlives the reason for it.
When --persona=auditor --no-ignores surfaced six findings across two repos, my first instinct for one of them was a suppression comment. It took five minutes to realize every finding had a real fix that was simpler than maintaining a suppression over time.
Remediation patterns
Creating an environment via gh api
The secrets-outside-env rule flags secrets accessed from jobs that lack an explicit environment: declaration1. Without one, a secret is available to any job in the workflow, including jobs triggered by pull_request from forked repos. An attacker who submits a PR that modifies .github/workflows/ci.yml gains access to every secret the workflow references.
GitHub deployment environments provide scoping plus three optional protection rules:
| Mechanism | What it does |
|---|---|
| Environment scoping | Secrets are only available to jobs that declare environment: name |
| Required reviewers | A human must approve before the job can access the environment |
| Wait timer | A minimum delay before the environment is available |
| Branch restrictions | Only runs from specific branches can access the environment |
Environments without any protection rules are purely declarative scoping: the secret is gated behind the environment name, and the environment gate has no approval friction.
A DAGGER_CLOUD_TOKEN was used in a CI job without an environment. Dagger Cloud’s token is a monitoring credential with zero infrastructure access, so protection rules would add CI friction for no security gain. The fix is adding environment: dagger and creating an empty environment. gh has no environment subcommand, but the REST API does:
gh api repos/owner/repo/environments/dagger --method PUT -f wait_timer=0
This creates an environment named dagger with zero protection rules. The workflow change is one line:
# .github/workflows/ci.yml
jobs:
ci:
name: Run CI checks
runs-on: ubuntu-latest
environment: dagger
steps:
- uses: dagger/dagger-for-github@v8
with:
cloud-token: ${{ secrets.DAGGER_CLOUD_TOKEN }}
The token stays at the repo level; the environment only scopes where it is referenced. For deploy secrets (PRODUCTION_TOKEN, NPM_TOKEN), add required reviewers and branch restrictions to the same environment: block. For CI monitoring tokens, an empty environment is enough.
My first instinct was an inline # zizmor: ignore[secrets-outside-env] comment. The real fix was a single API call and one YAML line. The suppression would have outlived both the tool version and my memory of why it was there.
Hardening git auth without persisting credentials
The artipacked rule flags persist-credentials: true on actions/checkout2. By default, the action writes the GITHUB_TOKEN to the local .git/config, where it can be captured by subsequent steps and uploaded as build artifacts. Three dispatch workflows had it because they push generated content files to branches, which needs git authentication. Setting it to false breaks the push.
The fix keeps persist-credentials: false on checkout and injects the token into the origin URL at runtime, right before the push:
# Before
- uses: actions/checkout@v6
with:
persist-credentials: true
- name: Create pull request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
# After
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Configure git for push
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
git remote set-url origin "https://git:${GH_TOKEN}@github.com/${REPO}.git"
# Subsequent git push commands work normally
The token lives in the git remote URL for the duration of the step. When the step ends, the environment variable is gone and the remote URL resets on the next checkout. Same git push capability, no persisted credential in the git config.
Native git + gh pr create instead of a third-party action
Two dispatch workflows used peter-evans/create-pull-request to commit generated files and open a PR. The superfluous-actions rule flags it because the runner already provides equivalent functionality through gh pr create and native git commands.
Replacing it required replicating five behaviors: set git identity, create a branch, commit files, push the branch, open a PR with labels and a body. None of this needs a third-party action:
# Before
- uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "article: add ${{ github.event.client_payload.slug }}"
branch: "article/${{ github.event.client_payload.slug }}"
delete-branch: true
labels: |
content
auto-merge
body: |
## New article from dispatch
...
# After
- name: Create pull request
env:
GH_TOKEN: ${{ github.token }}
BRANCH: article/${{ github.event.client_payload.slug }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add .
git commit -m "article: add ${SLUG}"
git push origin "$BRANCH"
gh pr create \
--repo "$REPO" \
--base main --head "$BRANCH" \
--title "article: add ${SLUG}" \
--label "content" --label "auto-merge" \
--body-file /tmp/pr-body.md
The --body-file flag avoids shell quoting issues with multiline PR bodies. A heredoc writes the body to a temp file, gh pr create reads it. Branch cleanup moved to the existing gh pr merge --auto --squash --delete-branch step.
Follow-up
I would run --persona=auditor --no-ignores before adding any suppression. For the secrets-outside-env finding, I had the inline comment typed out before I stopped and checked whether a fix existed. The API call was shorter than the comment.
All three patterns apply to any repo. The gh api call for environments works anywhere gh is authenticated. The git remote set-url pattern works in any workflow that needs git push. Replacing peter-evans/create-pull-request with native commands removes a dependency from the supply chain. Each fix took less time than writing a justified suppression comment that would need maintaining.
References
- zizmor auditor persona TIL. Finding the issues this post fixes.
- zizmor. Static analysis for GitHub Actions.
- zizmor audit: artipacked. Official rule documentation for credential persistence in artifacts.
- zizmor audit: secrets-outside-env. Rule documentation for secrets used outside environments.
- GitHub Environments REST API. Create and manage deployment environments.
- GitHub: using environments for deployment. Protection rules, environment secrets, and deployment scoping.
- gh api. Authenticated GitHub API calls from the CLI.
- actions/checkout persist-credentials. Documentation for the
persist-credentialsinput. - actions/checkout issue #485. Proposal to change the default
persist-credentialstofalse. - ArtiPACKED: credential leakage through artifacts. StepSecurity’s detection and analysis of the attack.
- Token leakage through GitHub Actions artifacts. Unit 42 research on artifact-based credential exfiltration.
- actions/checkout can leak GitHub credentials. TIL by woodruffw explaining the default behavior.
- gh pr create. Create pull requests from the CLI.
- keeping supply chain exclusions honest. Why suppressions rot across tooling.
Footnotes
-
Without an
environment:declaration, secrets are available to any job in the workflow. This includespull_requestruns from forked repos, where an attacker who modifies the workflow gains access to every secret it references. GitHub’s environment documentation describes required reviewers, wait timers, and branch restrictions as optional controls layered on top of environment scoping. An environment with zero protection rules provides pure scoping: the secret is gated behind the environment name, and the gate has no approval friction. ↩ -
The ArtiPACKED attack exploits the default
persist-credentials: trueinactions/checkout. The token written to.git/configcan be captured by any subsequent step and uploaded as a build artifact. StepSecurity’s ArtiPACKED detection and Palo Alto Networks’ analysis of token leakage through artifacts document the attack in detail. Theactions/checkoutmaintainers have an open issue proposing to change the default tofalse. ↩
This post was written with AI assistance.