
Harden Github Actions
- 8 installs
- 76 repo stars
- Updated July 31, 2026
- basecamp/house-skills
harden-github-actions is a Claude Code skill that resolves zizmor security warnings and hardens GitHub Actions CI workflows.
About
harden-github-actions is a Claude Code skill for hardening GitHub Actions workflows against zizmor's security warnings. A developer uses it when a CI pipeline has zizmor findings such as template-injection, excessive-permissions, or artipacked. It provides a workflow order, auto-fix review steps, action SHA pinning with pinact, and a per-rule decision guide for when to fix versus suppress.
- Resolves zizmor security warnings in GitHub Actions workflows
- Pins actions to SHA hashes with pinact and scopes permissions per job
- Per-rule decision guide covering when to fix vs suppress
Harden Github Actions by the numbers
- 8 all-time installs (skills.sh)
- Ranked #1,684 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
harden-github-actions capabilities & compatibility
- Capabilities
- ci security audit · action pinning · workflow hardening
- Works with
- github
- Use cases
- security audit · ci cd · devops
What harden-github-actions says it does
zizmor identifies security vulnerabilities in GitHub Actions workflows. This skill documents the decision guidelines for resolving each warning type: when to fix, how to fix, and when to suppress with
Run `pinact run --min-age 10` from the repository root. This pins all actions in `.github/workflows/` to SHA hashes
npx skills add https://github.com/basecamp/house-skills --skill harden-github-actionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 76 |
| Last updated | July 31, 2026 |
| Repository | basecamp/house-skills ↗ |
What it does
Harden GitHub Actions CI workflows by resolving zizmor warnings and pinning actions to SHA hashes.
Who is it for?
Fixing zizmor findings and hardening GitHub Actions workflows.
When should I use this skill?
You are resolving zizmor warnings, hardening CI, or pinning actions to SHA hashes.
What you get
A hardened CI pipeline with a clean zizmor check and correctly justified suppressions.
- hardened GitHub Actions workflows
- SHA-pinned actions
- justified suppression comments
By the numbers
- 7-step workflow order
- decision guide covers 11 zizmor rules
Files
Resolving Zizmor Warnings in GitHub Actions
Overview
zizmor identifies security vulnerabilities in GitHub Actions workflows. This skill documents the decision guidelines for resolving each warning type: when to fix, how to fix, and when to suppress with an inline comment explaining why.
Core principle: Fix the vulnerability whenever possible. Suppress only when the fix would break required functionality, and always include a reason in the suppression comment.
Prerequisites
This work should be done on a branch in a git worktree. Before starting any work, verify you are in the worktree directory and on the correct branch:
pwd # should be the worktree path
git branch # should show the feature branch, not mainWorkflow Order
Always work in this order. Each step is a separate commit.
1. Add zizmor CI job using the standard template 2. Configure dependabot to batch github-actions updates weekly 3. Add local workflow linting to bin/setup and bin/ci (see below). Skip if these scripts don't exist in the project. 4. Pin actions with pinact run 5. Address zizmor warnings by severity (high → medium → low → informational). 6. Ensure all permissions are job-level. Check every workflow file for top-level permissions: blocks. Replace with permissions: {} and add per-job permissions. This goes beyond what zizmor flags — zizmor misses single-job workflows. Commit. 7. Run actionlint and fix any findings. Commit.
Running pinact
Run pinact run --min-age 10 from the repository root. This pins all actions in .github/workflows/ to SHA hashes, skipping any versions published less than 10 days ago.
Running zizmor
Always run zizmor with a GitHub token so that online audits (like ref-version-mismatch and impostor-commit) can resolve SHAs against the GitHub API. Without a token, these audits are silently skipped and findings will only surface in CI.
GITHUB_TOKEN=$(gh auth token) zizmor .Filter severity by passing the flag --min-severity=<level> where level can be high, medium, or low. Informational warnings may be emitted by omitting this flag entirely.
Auto-fix workflow
For each severity level (high, then medium, then low, then informational):
1. Run zizmor --fix=all --min-severity=<level> . to auto-correct fixable findings (--fix alone uses safe mode which silently holds back some fixes; use --fix=all and rely on diff review as the safety net) 2. STOP and review the diff. Check each auto-fix against the Decision Guide below.
cache-poisoningfixes will disable caching — almost always revert these and suppress insteadartipackedfixes addpersist-credentials: false— revert if the workflow needsgit pushsuperfluous-actionsfixes replace actions with inline code — always revert these and suppress insteadbot-conditionsauto-fix replacesgithub.actorwithuser.login— revert and apply the dual check instead (see rule file)template-injectionfixes are generally correct
3. Revert any incorrect fixes 4. For reverted fixes, apply the correct resolution manually (e.g., suppress with a reason) 5. Manually fix anything --fix didn't handle. For `excessive-permissions`: you MUST research each action's permissions. Do not guess. See the permission research process below. 6. Run zizmor --min-severity=<level> . to verify a clean check at this severity level 7. Commit
After completing all default severity levels, run a pedantic pass:
1. Run zizmor --persona=pedantic --min-severity=high . 2. Address findings the same way as above — the most common pedantic finding is excessive-permissions on single-job workflows where zizmor's default persona doesn't flag it. Apply the same fix: permissions: {} at workflow level, scoped per job. 3. Run zizmor --persona=pedantic --min-severity=high . to verify clean 4. Commit
Decision Guide by Rule
When you encounter a zizmor finding, read the corresponding rule file in references/ for full decision guidance, suppression checklists, and examples. Only read the rules you need.
| Rule | File | Action |
|---|---|---|
artipacked | references/rule-artipacked.md | Fix (add persist-credentials: false); suppress only if job does git push |
template-injection | references/rule-template-injection.md | Always fix (move expressions to env: vars) |
excessive-permissions | references/rule-excessive-permissions.md | Always fix (set permissions: {} at workflow level, scope per job) |
dangerous-triggers | references/rule-dangerous-triggers.md | Fix or suppress with 5-point checklist |
secrets-outside-env | references/rule-secrets-outside-env.md | Fix (add environment:) or suppress with 3-point checklist |
bot-conditions | references/rule-bot-conditions.md | Always fix (dual check: actor + user.login); revert auto-fix |
superfluous-actions | references/rule-superfluous-actions.md | Always suppress (never replace with inline code) |
cache-poisoning | references/rule-cache-poisoning.md | Suppress (default); revert auto-fixes; only escalate if custom cache keys |
unpinned-images | references/rule-unpinned-images.md | Suppress (default); digest pinning is nontrivial |
dependabot-execution | references/rule-dependabot-execution.md | Fix or suppress with 3-point checklist |
dependabot-cooldown | references/rule-dependabot-cooldown.md | Always fix (add cooldown: default-days: 10 to all ecosystems) |
Permission mappings for excessive-permissions are in references/permission-mappings.md.
For findings not covered in this skill, consult https://docs.zizmor.sh/audits/ for detailed explanations and resolution guidance.
Suppression Format
Always use inline comments with the rule name and a reason:
# zizmor: ignore[rule-name] -- reason why suppression is necessaryThe -- separator before the reason is a convention for readability. Never suppress without a reason. If you can't articulate why the fix would break something, apply the fix instead.
Standard Zizmor CI Job
Add this job to the repository's main CI workflow file (often ci.yml or ci-checks.yml).
Placement matters. Before inserting, find the existing lint job (rubocop, eslint, golangci-lint, etc.) in the workflow and place lint-actions immediately after it. If there is no lint job, place it immediately before the first test job. Never append it to the end of the file — it is a linting concern, not a test or deployment step:
lint-actions:
name: GitHub Actions audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run actionlint
uses: rhysd/actionlint@v1.7.11
- name: Run zizmor
uses: zizmorcore/zizmor-action@v0.5.2
with:
advanced-security: falseUse version tags, not SHA hashes — run pinact run --min-age 10 immediately after adding this job to pin them. This ensures the SHAs match what pinact produces for the rest of the workflow.
Before adding this job, check if the workflow already has a standalone `actionlint` job. If it does, remove it — lint-actions replaces it. Do not create duplicate actionlint runs.
Local Workflow Linting
If the project has a bin/ci script (or equivalent like config/ci.rb), add workflow linting so developers catch issues locally before pushing. If bin/setup also exists, add tool installation there too. Skip this section entirely if there is no local CI script.
bin/setup — tool installation
Check if actionlint, shellcheck, and zizmor are already installed. If not, install them using the platform's package manager. Read the existing bin/setup script to understand its conventions before adding to it.
shellcheck is required — actionlint uses it to lint shell scripts in run: blocks. Without shellcheck, actionlint silently skips script checks and local results won't match CI.
Install all three tools using the same pattern:
for tool in actionlint shellcheck zizmor; do
if ! command -v "$tool" &> /dev/null; then
if command -v brew &> /dev/null; then
brew install "$tool"
elif command -v pacman &> /dev/null; then
sudo pacman -S --noconfirm "$tool"
else
echo "Error: install $tool manually" >&2
exit 1
fi
fi
doneAdapt this to match the script's existing style (e.g., if it uses functions, conditionals, or a different error pattern, follow that convention).
bin/ci — running the linters
Add actionlint and zizmor as separate steps. Read the existing bin/ci script to understand its conventions before adding to it.
# Lint GitHub Actions workflows
actionlint
zizmor .Each tool should be a separate command so failures are clearly attributable. Place these near other linting steps if the script has them.
Examples
- bin/setup + config/ci.rb: lexxy#882
- Makefile: basecamp-sdk@aa1f2d50
Dependabot Configuration
GitHub Actions entry
Ensure .github/dependabot.yml includes a github-actions entry with batching. The schedule must be weekly — not daily.
- package-ecosystem: github-actions
directory: "/"
groups:
github-actions:
patterns:
- "*"
schedule:
interval: weekly
cooldown:
default-days: 7The groups block batches all action updates into a single PR instead of one PR per action.
Cooldown on all ecosystems
Add cooldown to every ecosystem entry in dependabot.yml. Use semver-granular cooldowns for real package ecosystems so low-risk patches flow faster while major bumps get more soak time:
# For package ecosystems (bundler, npm, gomod, gradle, pip, etc.)
cooldown:
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2
default-days: 7
# For github-actions (semver-granular keys are NOT supported)
cooldown:
default-days: 7If an ecosystem entry is missing the cooldown block, add it. If an existing cooldown block has different values, override them with the values above — these are the standard.
Common Mistakes
| Mistake | Correction |
|---|---|
| Guessing what permissions an action needs | Read the action's README. If it's not in the permission mappings table, research it before proceeding. |
Accepting cache-poisoning auto-fixes without review | --fix=all disables caching; almost always revert and suppress instead |
| Suppressing without a reason | Always explain WHY the fix can't be applied |
Suppressing template-injection | This should always be fixed, never suppressed |
Adding persist-credentials: false to a workflow that does git push | Suppress artipacked with a comment instead |
| Fixing permissions by removing the block entirely | Move to job-level, don't remove — implicit permissions may be too broad |
Using --fix instead of --fix=all | Safe mode silently holds back fixes; use --fix=all and review the diff |
| Committing without verifying clean zizmor output | Always re-run zizmor --min-severity=<level> . before committing |
| Analyzing all findings up front before starting work | Follow the workflow order step by step — CI job, dependabot, local linting, pin, then fix by severity |
| Adding the zizmor CI job at the end of the workflow file | Place it near existing lint jobs — it's a linting concern, not a test |
Replacing an action with inline code for superfluous-actions | Always suppress — actions are more maintainable and receive upstream fixes |
| Not specifying permissions on reusable workflow caller jobs | Caller jobs must declare permissions; reusable workflows inherit from the caller |
| Adding tools to bin/setup when there's no bin/ci | Only add local linting if a local CI script exists to run the tools |
| Running commands in the main repo instead of the worktree | Verify pwd and git branch before starting |
Common PR Feedback (Incorrect or Misleading)
Automated reviewers (Copilot, cubic, etc.) frequently flag these. They are wrong or misleading — dismiss them.
| Feedback | Why it's wrong |
|---|---|
ruby/setup-ruby with bundler-cache: true needs actions: write | No. Bundler cache works with contents: read. The cache API uses the implicit GITHUB_TOKEN. Do not add actions: write. |
persist-credentials: false will break git fetch / git worktree | Only true for private repos. All our target repos are public — unauthenticated HTTPS fetch works fine. |
cooldown is not a valid Dependabot configuration key | It is valid. GitHub added cooldown to Dependabot v2 config in late 2025. Copilot's training data predates this feature. |
| Checkout version inconsistency (v3 in existing jobs vs v6 in lint-actions) | The skill pins existing versions as-is; upgrading is dependabot's job after merge. The lint-actions job template uses v6 independently. |
GitHub Actions Permission Mappings
GITHUB_TOKEN permission requirements for common GitHub Actions. Used by the harden-github-actions skill when resolving excessive-permissions findings.
If an action is not in this table, you MUST fetch its README on GitHub and read the documented permission requirements before proceeding. Then add it to this table.
Actions
| Action | Permissions | Notes |
|---|---|---|
actions/ai-inference | models: read | + contents: read recommended; PAT required for MCP server feature |
actions/attest-build-provenance | id-token: write, attestations: write, contents: read | + packages: write when push-to-registry: true for container images |
actions/cache | none | Uses Actions cache service via implicit runner credentials, not GITHUB_TOKEN |
actions/checkout | contents: read | |
actions/configure-pages | none | Reads Pages config only; enablement: true needs a PAT with pages:write |
actions/create-github-app-token | none | Authenticates via GitHub App credentials, not GITHUB_TOKEN; generates a token with its own permissions |
actions/create-release | contents: write | Archived/unmaintained; consider softprops/action-gh-release instead |
actions/dependency-review-action | contents: read | + pull-requests: write if comment-summary-in-pr is always or on-failure |
actions/deploy-pages | pages: write, id-token: write | |
actions/download-artifact | none | actions: read only when using github-token input to download from other repos/workflow runs |
actions/github-script | depends on script | No fixed permissions; required permissions depend on which GitHub API calls the script makes. Key distinction: PR comments (gh pr comment, updateIssueComment on PR objects) need pull-requests: write; applying labels via the Issues API needs issues: write. Read the script to determine. |
actions/labeler | contents: read, pull-requests: write | + issues: write only if the action needs to create labels that don't already exist |
actions/setup-go | contents: read | |
actions/setup-java | contents: read | |
actions/setup-node | contents: read | |
actions/setup-ruby | contents: read | |
actions/stale | issues: write, pull-requests: write | + contents: write when delete-branch: true |
actions/upload-artifact | none | Uses Actions artifact storage via implicit runner credentials |
actions/upload-pages-artifact | none | Only creates a tar archive |
actions/upload-release-asset | contents: write | Archived/unmaintained; consider softprops/action-gh-release instead |
anchore/sbom-action | contents: write | + actions: read when attaching release assets (implicit for public repos) |
aquasecurity/trivy-action | contents: read | + security-events: write when uploading SARIF; + contents: write when submitting SBOMs to Dependency Graph |
basecamp/sdk (sub-actions) | varies | Contains 5 composite actions under actions/. Only release-orchestrate needs a token: actions: read, contents: write. The others (conformance-run, rubric-check, service-drift, smithy-verify) need none. |
cachix/install-nix-action | none | Uses github.token only to avoid API rate limits when downloading Nix |
dependabot/fetch-metadata | pull-requests: read | + pull-requests: write for auto-approving; + contents: write for auto-merging; PAT required for alert-lookup or compat-lookup |
dev-build-deploy/commit-me | pull-requests: read | + pull-requests: write when update-labels: true; + contents: write for automatic rebase-merge detection |
devcontainers/ci | none | When pushing images to ghcr.io, the workflow must log in separately (e.g., docker/login-action), which requires packages: write |
docker/build-push-action | contents: read | Registry auth (e.g., packages: write for GHCR) is handled by docker/login-action, not this action |
docker/login-action | none | The action itself needs no permissions; pass secrets.GITHUB_TOKEN as the password input. The token needs packages: write for GHCR push, packages: read for pull-only. |
docker/metadata-action | contents: read | Reads repo context (tags, branches, commits) only |
docker/setup-buildx-action | none | Only configures the Docker Buildx builder environment |
docker/setup-qemu-action | none | Installs QEMU static binaries for multi-platform builds |
dorny/paths-filter | pull-requests: read | Only on pull_request events; on push events uses git commands directly and needs no permissions |
elastic/docs | not a standard action | Elastic's internal documentation build tooling. Contains a composite action at .github/actions/docs-preview needing issues: write for PR comments, but not intended for external use. Check the repo source if encountered. |
github/codeql-action (/init, /analyze, /upload-sarif) | security-events: write, contents: read | security-events: write for all advanced setup workflows; contents: read additionally required for private repos |
golangci/golangci-lint-action | contents: read | + pull-requests: read if using only-new-issues; + checks: write for inline PR annotations |
google-github-actions/release-please-action | contents: write, pull-requests: write | May also need "Allow GitHub Actions to create and approve pull requests" enabled in repo settings |
goreleaser/goreleaser-action | contents: write | Token passed via env: GITHUB_TOKEN; PAT with repo scope needed for cross-repo operations (e.g., Homebrew taps) |
gradle/actions (sub-actions) | varies | setup-gradle: none for basic use; contents: write for dependency graph submission; pull-requests: write for PR comment summaries. dependency-submission: contents: write. wrapper-validation: none. |
ko-build/setup-ko | none | + packages: write when pushing container images to ghcr.io; no permissions needed if only building |
necko-actions/setup-smithy | none | Installs Smithy CLI and adds to PATH |
ossf/scorecard-action | security-events: write, id-token: write, contents: read | For public repos with publish_results: true; private repos also need issues: read, pull-requests: read, checks: read |
reviewdog/action-rubocop | contents: read, pull-requests: write | For github-pr-review and github-pr-check reporters; checks: write may also be needed for github-check reporter |
rhysd/actionlint | none | Not a standard action (no action.yml); used by downloading the binary via shell script or Docker. Does not use GITHUB_TOKEN. |
securego/gosec | none | Runs gosec in Docker; no token input. If uploading SARIF via github/codeql-action/upload-sarif, that action needs security-events: write. |
sigstore/cosign-installer | none | Only installs cosign. Subsequent cosign commands need id-token: write for OIDC keyless signing. |
softprops/action-gh-release | contents: write | + discussions: write if using discussion_category_name |
rubygems/configure-rubygems-credentials | id-token: write | + contents: write only if using bundle exec rake release for git push |
zizmorcore/zizmor-action | none | With advanced-security: false (recommended in this skill). With advanced-security: true: security-events: write; private repos also need contents: read, actions: read. |
zzak/action-discord | contents: read | Reads commit metadata for Discord webhook notifications |
Common patterns (not specific actions)
| Pattern | Permissions | Notes |
|---|---|---|
npm publish --provenance | id-token: write | OIDC for npm provenance attestation |
| Push to GHCR | packages: write | |
| Sigstore/cosign signing | id-token: write | |
| Attestation actions | attestations: write |
artipacked — Credential Persistence After Checkout
actions/checkout persists git credentials by default, which later steps can exfiltrate.
Fix (default): Add persist-credentials: false to every actions/checkout step.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: falseSuppress only if ALL of these are true: 1. A later step in the SAME job runs git push, git tag, or similar write operation 2. The push target is the same repository (not a fork or external repo requiring a PAT) 3. No other authentication mechanism (e.g., deploy key, PAT) is used for the push
If you cannot confirm all three, apply the fix. If fixing would break the workflow and you cannot confirm all three, stop and report the finding — do not suppress.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 # zizmor: ignore[artipacked] -- credentials needed for git pushbot-conditions — Spoofable Bot Actor Check
Using github.actor == 'dependabot[bot]' alone in job conditions is spoofable.
Fix (always): Use a dual check: github.actor validates who triggered the current event, github.event.pull_request.user.login validates who owns the PR. Both together prevent a human-triggered event from re-entering the approve/merge path. Suppress the zizmor finding with a comment explaining the dual check is intentional.
# BEFORE (spoofable)
if: github.actor == 'dependabot[bot]'
# AFTER (dual check)
if: github.actor == 'dependabot[bot]' && github.event.pull_request.user.login == 'dependabot[bot]' # zizmor: ignore[bot-conditions] -- dual check: actor validates current trigger, user.login validates PR originNote: --fix=all will replace github.actor with github.event.pull_request.user.login. Revert the auto-fix and apply the dual check manually instead.
cache-poisoning — Cache Poisoning in Release Workflows
zizmor warns when setup actions (e.g., setup-go, setup-ruby, setup-node) enable caching in workflows triggered by tags or workflow_dispatch, since a poisoned cache from a PR could theoretically affect the release build.
WARNING: --fix=all will disable caching for these findings. Almost always revert these auto-fixes and suppress instead.
Suppress (default). GitHub Actions caches are isolated by branch. A pull_request workflow from a fork cannot write to the cache used by a tag-push or workflow_dispatch release workflow. The cache-poisoning attack requires an actor who already has push access to the default branch — and if they have that, they can modify the release workflow directly.
This applies even when the cached dependencies are used to build the release artifact, because the cache isolation makes the poisoning vector impractical.
Suppress only if:
- The workflow uses the default cache keys from setup actions (e.g.,
setup-go,setup-ruby,
setup-node), which are keyed by lockfile hash and branch. This is the common case.
Do NOT suppress if:
- The workflow uses
actions/cachewith a customkey:that is shared across branches or
includes attacker-controllable values (e.g., PR number, branch name from a fork). In this case, stop and report the finding.
The suppression comment should explain why it's safe in this specific case. Examples:
# Cache used for testing, not release artifact generation:
- uses: ruby/setup-ruby@... # zizmor: ignore[cache-poisoning] -- cached deps are for testing, not release artifact generation
# Cache used for release build, but branch-isolated so fork PRs can't poison it:
- uses: actions/setup-go@... # zizmor: ignore[cache-poisoning] -- cache is branch-isolated; fork PRs cannot write to this cachedangerous-triggers — Dangerous Workflow Triggers
zizmor flags pull_request_target and workflow_run triggers. Both execute in the target repository's context (with write permissions and access to secrets) while remaining triggerable by external forks. Even workflows that don't explicitly check out PR code can be vulnerable to indirect execution vectors (argument injection, environment injection via LD_PRELOAD, GITHUB_ENV manipulation, etc.).
Fix (preferred):
- Replace
workflow_runwithworkflow_call(convert to a reusable workflow) - Replace
pull_request_targetwithpull_requestunless write permissions are required - If
pull_request_targetis necessary, never check out or run PR-controlled code
Suppress only if ALL of these are true: 1. The workflow requires write access to the PR (e.g., labeling, commenting, gating) or needs to run after another workflow completes 2. The workflow does NOT check out the PR's head ref (actions/checkout with ref: ${{ github.event.pull_request.head.sha }} or similar) 3. The workflow does NOT run any code from the PR (no run: steps that execute checked-out files, no build/test steps) 4. The workflow does NOT pass attacker-controllable values into GITHUB_ENV, GITHUB_PATH, or GITHUB_OUTPUT 5. For workflow_run: you have confirmed it cannot be replaced with workflow_call
If you cannot confirm all applicable criteria, do NOT suppress. If fixing would break the workflow and you cannot confirm all criteria, stop and report the finding — do not suppress or fix.
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- required for write access to PRs from forks; workflow only runs trusted actions, no PR code is checked out or executeddependabot-cooldown — Missing Cooldown on Dependabot Ecosystems
zizmor warns when dependabot ecosystem entries lack a cooldown period.
Fix (always): Add cooldown to every ecosystem entry in .github/dependabot.yml.
For real package ecosystems (bundler, npm, gomod, gradle, pip, etc.), use semver-granular cooldowns so low-risk patches flow faster while major bumps get more soak time:
cooldown:
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 2For github-actions, semver-granular keys are not supported. Use the default cooldown:
cooldown:
default-days: 7dependabot-execution — Insecure External Code Execution
Dependabot's insecure-external-code-execution: allow lets package managers run arbitrary code during dependency resolution.
Fix (if possible): Remove insecure-external-code-execution: allow.
Suppress only if ALL of these are true: 1. The package ecosystem requires code execution to resolve dependencies (e.g., Bundler with private gem sources that use custom source blocks in the Gemfile) 2. You have confirmed that removing allow causes dependency resolution to fail 3. The private registry is trusted (e.g., an internal GitHub Packages registry)
If you cannot confirm all three, apply the fix. If fixing would break dependency resolution and you cannot confirm all three, stop and report the finding — do not suppress.
insecure-external-code-execution: allow # zizmor: ignore[dependabot-execution] -- required for Bundler to resolve gems from the private github-basecamp registryexcessive-permissions — Overly Broad Permissions
Top-level permissions: grants those permissions to ALL jobs, violating least privilege.
Fix (always): Replace the workflow-level permissions: block with permissions: {} (deny all) and move permissions down to each individual job. The permissions: {} ensures every job starts with zero permissions, so any job that forgets to declare its own permissions will fail safe rather than inheriting broad defaults.
# BEFORE (too broad)
permissions:
contents: read
packages: write
id-token: write
attestations: write
jobs:
build:
...
merge:
...
# AFTER (deny-all at workflow level, scoped per job)
permissions: {}
jobs:
build:
permissions:
contents: read
packages: write
id-token: write
attestations: write
...
merge:
permissions:
packages: write
id-token: write
...How to determine which permissions each job needs — DO NOT GUESS:
1. Start from the existing top-level permissions: block — that's the universe of permissions to distribute 2. For each job, identify which actions it uses 3. For each action, check the permission mappings in `references/permission-mappings.md`. If the action is not in that file, you MUST fetch its README on GitHub and read the documented permission requirements before proceeding. 4. Assign only the permissions each job actually needs 5. Don't invent permissions that weren't in the original block
After researching a new action's permissions, add it to `references/permission-mappings.md`.
Reusable workflows (`uses: ./.github/workflows/...`): Caller jobs that invoke reusable workflows must specify permissions explicitly. Reusable workflows inherit the caller job's permissions, and permissions can only be maintained or reduced through the chain — never elevated. When you set permissions: {} at the workflow level, every job (including reusable workflow calls) starts with zero permissions and must declare what it needs.
secrets-outside-env — Secrets Used Without GitHub Environment
zizmor flags jobs that use secrets.* (other than secrets.GITHUB_TOKEN) without an environment: declaration. Org/repo-level secrets are available to any workflow run, including from forks. Environment-scoped secrets are only available to jobs targeting that environment, providing an additional access control layer.
Fix (preferred): Add an environment: declaration to the job and move the secrets into that environment's secret store (removing them from repo/org-wide secrets).
# BEFORE (flagged)
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }}
# AFTER (fixed)
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- run: ./deploy.sh
env:
API_KEY: ${{ secrets.API_KEY }}Suppress only if ALL of these are true: 1. The job genuinely needs a non-GITHUB_TOKEN secret (not just a misconfiguration) 2. Adding an environment: declaration would break the workflow (e.g., environment protection rules would block PR-triggered runs that need the secret) 3. The secret is not exposed to untrusted code (e.g., not passed to a step that runs PR-submitted code)
If you cannot confirm all three, apply the fix. If the situation is ambiguous, stop and report the finding — do not suppress or fix.
jobs:
test:
runs-on: ubuntu-latest # zizmor: ignore[secrets-outside-env] -- API key needed in PR test runs; environment protection would block CIsuperfluous-actions — Action Can Be Replaced With Inline Code
zizmor suggests replacing certain actions with equivalent inline shell commands.
Suppress (always): Never replace an existing action with inline code. Actions are more readable, maintainable, and receive upstream security fixes automatically. Suppress with a note to consider removal in the future.
- uses: softprops/action-gh-release@... # zizmor: ignore[superfluous-actions] -- consider removaltemplate-injection — Expression Injection in run: Blocks
Using ${{ }} directly in run: scripts allows attackers to inject shell commands via crafted PR titles, branch names, etc.
Fix (always): Pass expressions through env: vars. Never use ${{ }} directly in run:.
# BEFORE (vulnerable)
- run: |
tags="${{ steps.meta.outputs.tags }}"
echo "$tags"
# AFTER (safe)
- env:
TAGS: ${{ steps.meta.outputs.tags }}
run: |
tags="$TAGS"
echo "$tags"This applies to ALL expressions in run: blocks, including github.event.pull_request.title, steps.*.outputs.*, inputs.*, etc. There is no valid reason to suppress this rule.
unpinned-images — Container Images Not Pinned to Digest
zizmor flags container images in services: or container: blocks that use a tag without a SHA256 digest. Tags are mutable — a compromised registry could serve different content under the same tag.
Suppress (default). Determining the correct digest for a container image is nontrivial (multi-arch manifests, registry-specific behavior, digest instability across rebuilds). Suppress with a reason.
image: myorg/myimage:1.0.0 # zizmor: ignore[unpinned-images] -- version tag is fine for service containers