# Harden Github Actions

> Whenever you create or substantially edit a GitHub Actions workflow (.github/workflows/*.yml), run `uvx zizmor --persona pedantic` on it and fix every finding before considering the file done. Use this any time you add a new workflow, paste a template workflow (e.g. the Claude GitHub App, a marketplace action, a generated CI/CD file), or change `uses:`, `permissions:`, or trigger blocks — even if the user didn't mention security or zizmor. A workflow that hasn't been run through the pedantic audit is not finished.

- Skill: `alexanderop/harden-github-actions` (Agent Skill)
- Install (CLI): `npx skillmds@latest add alexanderop/harden-github-actions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/alexanderop/harden-github-actions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: alexanderop (https://skillmd.com/u/alexanderop)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/alexanderop/harden-github-actions

---


# Harden GitHub Actions with zizmor

`zizmor` is a static analyzer for GitHub Actions workflows. New workflow files —
especially ones pasted from templates or generated by an app — almost always ship
with insecure defaults (unpinned actions, no permissions boundary, credential
persistence). If the repo runs zizmor in CI, those defaults fail the pipeline; even
if it doesn't, they're real supply-chain and token-leak risks. So treat the audit as
part of *writing* the workflow, not a separate review step that happens later.

## When this applies

Run the audit whenever you have:

- Added a new file under `.github/workflows/`.
- Pasted or generated a workflow from a template (Claude GitHub App, an action's
  README, a scaffolding tool).
- Edited an existing workflow's `uses:`, `permissions:`, `on:`, or `concurrency:`.

You don't need the user to ask for "security" or mention "zizmor" — hardening is the
default expectation for any workflow you touch.

## The loop

1. **Match the repo's existing conventions first.** Before inventing hardening, read a
   workflow in the same repo that already passes (often `ci.yml` or `codeql.yml`). Copy
   its action pins, its permission-comment style, its concurrency group naming. A
   consistent repo is easier to review than a "correct" but idiosyncratic file. Use
   `grep -nE "uses:|permissions:|persist-credentials|concurrency:" .github/workflows/*.yml`
   to see what's already established.

2. **Run the audit** on the file(s) you created or changed:

   ```bash
   uvx zizmor --persona pedantic .github/workflows/<file>.yml
   ```

   `--persona pedantic` is the strictest mode and is what hardened repos tend to enforce
   in CI, so matching it locally avoids a surprise pipeline failure. Pass every file you
   touched. Exit code `0` with "No findings to report" means done.

3. **Fix every finding**, not just the error-level ones. The pedantic persona reports
   informational findings too, and a CI gate with no `min-severity` set fails on any
   finding. The common ones and their fixes are below.

4. **Re-run until clean** (exit 0). Then you're done.

## Common findings → fixes

These are the recurring ones. Apply the fix that matches how the rest of the repo does it.

| Finding | Why it matters | Fix |
|---|---|---|
| **unpinned action reference** (error) | A mutable tag like `@v4` can be repointed at malicious code; pinning to a commit SHA is immutable. | Pin to the full commit SHA with the tag in a trailing comment: `uses: actions/checkout@34e1148… # v4`. Resolve the SHA for an action (see below). |
| **overly broad permissions** | Default `GITHUB_TOKEN` permissions are write-all; an exploited step can push code or open releases. | Add top-level `permissions: {}` and grant each job only what it needs. |
| **permissions without explanatory comments** | Reviewers can't tell why a scope was granted. | Add an inline comment per permission line: `contents: read # checkout`. |
| **credential persistence** | `actions/checkout` leaves the git token on disk by default, readable by later steps. | Add `persist-credentials: false # don't leave the git token on disk` to the checkout `with:` block. |
| **insufficient job-level concurrency limits** | Stacked runs on the same ref waste minutes and can race. | Add a top-level `concurrency:` block with a `group:` keyed on the ref/PR and `cancel-in-progress: true`. |
| **anonymous-definition** (informational) | An unnamed job shows up with an opaque check name. | Give each job a `name:`. |

### Resolving a commit SHA for an action

Lightweight tags resolve directly; annotated tags (common for `@v1`-style major tags)
need one extra dereference step. Use the GitHub API:

```bash
# Step 1: resolve the tag ref
gh api repos/<owner>/<repo>/git/refs/tags/<tag>
# If "object.type" is "commit", that SHA is your pin.
# If it's "tag" (annotated), dereference it:
gh api repos/<owner>/<repo>/git/tags/<tag-object-sha>
# The resulting "object.sha" (type "commit") is your pin.
```

Pin to that commit SHA and keep the human-readable tag in a `# v1` comment so the file
stays reviewable.

## Notes

- `uvx` runs zizmor in an ephemeral environment, so nothing needs to be installed
  globally. If `uvx` isn't available, fall back to `pipx run zizmor`, `docker run --rm
  -v "$PWD":/work -w /work ghcr.io/zizmorcore/zizmor:latest`, or a local `zizmor`.
- Some findings have legitimate exceptions. If a finding genuinely doesn't apply, prefer
  a scoped `# zizmor: ignore[<rule>]` comment on the offending line (with a reason) over
  silencing the whole audit — that keeps the rest of the file covered and documents the
  decision.
- Online audits (e.g. checking whether a pinned SHA is stale) need network access and a
  token; the local run above covers the static rules, which are what gate CI.

