| name | github-actions-security |
| description | Apply battle-tested GitHub Actions security hardening to CI/CD workflows. Use this skill whenever a user asks about securing GitHub Actions, writing or reviewing workflow YAML files, setting up CI/CD pipelines, hardening release processes, managing workflow permissions or secrets, pinning actions, or anything related to supply chain security in GitHub-based projects. Also trigger when the user shares a workflow file and asks for a review, or mentions tools like zizmor or pinact.
|
| allowed-tools | Read Write Edit WebFetch Bash(gh *) Bash(curl *) Bash(jq *) |
GitHub Actions Security
Guidance for hardening GitHub Actions workflows, drawn from Astral's production security practices
(the team behind Ruff and uv). Apply these checks when writing, reviewing, or auditing workflow
files.
1. Forbid Dangerous Triggers
Never use pull_request_target or workflow_run. These triggers run with write permissions
in the context of the base repository, even when initiated by a fork — making them almost
impossible to use safely. Real-world attacks on Ultralytics, tj-actions, and Nx all exploited this.
What to do instead:
- Always use
pull_request for contributor-triggered workflows. It runs in a sandboxed,
unprivileged context with no access to secrets.
- Never post comments on PRs or issues from a workflow. This is explicitly forbidden — it is
the most common reason projects reach for
pull_request_target, but it is never worth the risk.
Instead, surface results via job summaries or workflow logs.
on:
pull_request_target:
workflow_run:
on:
pull_request:
Job summary example — write structured output that appears in the Actions UI without any
write permission to the repository:
- name: Report results
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "| Suite | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Unit | ✅ Passed (142/142) |" >> $GITHUB_STEP_SUMMARY
echo "| Integration | ❌ Failed (3/10) |" >> $GITHUB_STEP_SUMMARY
2. Pin All Actions to Full Commit SHAs
Never reference actions by a mutable tag or branch (e.g., uses: actions/checkout@v6).
Tags can be moved or deleted; branches can be force-pushed. An attacker who compromises an
upstream action can silently substitute malicious code.
Always pin to the full 40-character commit SHA:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
Always set persist-credentials: false on checkout actions. By default, actions/checkout
saves the authentication token in the local git config, where subsequent steps can read and
abuse it. Disabling this limits credential exposure if a later step is compromised.
Looking Up the Correct SHA — Never Guess
Never fabricate or guess a commit SHA, and never assume you know the latest version. Both
the version tag and the hash must always be looked up from the actual repository. Use the
GitHub CLI (gh) to find the latest release and resolve its tag to the full commit SHA:
gh release list -R actions/checkout -L 5
gh api repos/actions/checkout/commits/v6.0.2 --jq '.sha'
Always perform both steps. Step 1 tells you what the latest version actually is (not what
you think it might be). Step 2 gives you the immutable hash for that version. Using a real hash
for an outdated version is almost as bad as guessing — it means missing security fixes.
The /commits/{ref} endpoint handles both lightweight and annotated tags correctly — it
always resolves to the underlying commit, skipping the tag object indirection.
Fallback with curl and jq (when gh is unavailable):
curl -s https://api.github.com/repos/actions/checkout/releases/latest | jq -r '.tag_name'
curl -s https://api.github.com/repos/actions/checkout/commits/v6.0.2 | jq -r '.sha'
Note: unauthenticated GitHub API requests are rate-limited to 60/hour. Prefer gh when possible.
Tooling
- Run zizmor locally and in CI — its
unpinned-uses and impostor-commit audits
catch both missing pins and commits that don't correspond to any real released state.
- Use pinact to automatically convert tag-based references to SHA pins.
Important Caveat
Hash-pinning is necessary but not sufficient. An immutably-pinned action can still make
mutable decisions at runtime, such as downloading the latest binary from a GitHub release.
Manually review action dependencies for these "immutability gaps" and work with upstreams to
embed cryptographic hashes for any downloaded binaries.
Actions Without Tagged Releases
Strongly prefer not to use actions that have no versioned releases. An action that ships only
from a branch (e.g., main) signals that the maintainer is not treating releases as auditable
events. Look for an alternative action with proper release tags first; rolling your own inline
run: step is often the safer answer.
If you have already evaluated alternatives and still need to use such an action, pin a specific
commit SHA from the upstream repository directly — do not fork. Forking creates a parallel
history that diverges silently, hides upstream security fixes from your own dependency review,
and shifts the maintenance burden onto whoever inherits the workflow. A direct SHA pin against
the upstream is honest about what you depend on, and dependabot and zizmor can both reason
about it.
Resolve the upstream commit with gh and record both the SHA and a one-line justification in a
comment next to the uses: line so future reviewers understand why the action has no version
tag:
gh api repos/owner/action/commits/main --jq '.[0].sha'
- uses: owner/action@<full-40-char-sha>
3. Pin the Runner to a Specific Ubuntu Version
Always use runs-on: ubuntu-24.04, never ubuntu-latest. The latest label is a moving
pointer — when GitHub advances it to a new OS version, your workflow silently runs on a different
environment, which can break builds or introduce unexpected behaviour.
jobs:
build:
runs-on: ubuntu-latest
jobs:
build:
runs-on: ubuntu-24.04
This applies to every job, including test, lint, and release jobs.
The same principle applies to non-Ubuntu runners: pin macos-26, windows-2025, or whichever
specific image tag the workflow needs, and never use the -latest alias.
4. Apply Minimal Permissions
Default GitHub Actions permissions are far too broad. Always start from zero at the workflow level
and grant only what each individual job needs.
At the workflow level — always start from zero:
permissions: {}
At the job level — grant only what that job actually needs:
jobs:
build:
permissions:
contents: read
release:
permissions:
contents: write
id-token: write
5. Authenticate as Late as Possible
Any step that obtains credentials — OIDC tokens, cloud provider sessions, API keys, registry
logins — must appear immediately before the step that uses them, with no unrelated steps in
between.
Every step that runs while credentials are active is a step that can leak or abuse them. By
authenticating at the last possible moment, you minimise the number of steps in the blast radius
if any one of them is compromised.
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
- uses: aws-actions/configure-aws-credentials@...
- run: cargo build
- run: cargo test
- run: aws s3 cp target/release/app s3://my-bucket/
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
persist-credentials: false
- run: cargo build
- run: cargo test
- uses: aws-actions/configure-aws-credentials@...
- run: aws s3 cp target/release/app s3://my-bucket/
The review rule is simple: for every authentication step, check that the very next step is
the one that consumes those credentials. If there are unrelated steps between authentication and
usage, move the authentication step down.
6. Isolate Secrets with Deployment Environments
Never use repo-level secrets for sensitive operations. If any job in any workflow is
compromised, those secrets are exposed to every job.
Use GitHub deployment environments and environment-scoped secrets instead:
jobs:
test:
runs-on: ubuntu-24.04
publish:
runs-on: ubuntu-24.04
environment: release
steps:
- uses: pypa/gh-action-pypi-publish@...
This limits the blast radius: a compromised test or lint job cannot reach the secrets needed to
publish release artifacts.
For release environments specifically, layer these additional controls:
- Require manual approval from at least one other privileged team member before the
environment activates. This prevents a single rogue or compromised account from publishing.
- Do not use caching in release jobs — this prevents cache poisoning attacks.
7. Add a zizmor CI Workflow
Every repository should include a workflow that runs zizmor against proposed workflow changes.
This catches security regressions at review time, including unpinned-uses, impostor-commit,
template-injection, and dependabot-cooldown (see §8).
A complete reference workflow that implements every rule in this skill lives at
examples/zizmor.yml. Read that file only when you actually need to
scaffold a new workflow into a target repo — it is a copy-paste artifact, not narrative content.
Before copying, look up the current SHAs for actions/checkout and zizmorcore/zizmor-action
using the procedure in §2; the pins recorded in the example will go stale.
Set the path trigger to .github/**, not just .github/workflows/**. This ensures changes
to dependabot.yml and the zizmor config (see §8) are also gated by the workflow:
on:
pull_request:
paths:
- '.github/**'
push:
branches: [main]
paths:
- '.github/**'
8. Set a Dependabot Cooldown (and align zizmor)
Configure a Dependabot cooldown to delay adopting brand-new releases. Without a cooldown,
Dependabot opens a PR the moment a release is published — giving you no time to observe whether
the upstream was compromised (as happened with tj-actions and Ultralytics). Even a 1-day delay
allows the community to catch most compromised or broken releases before you merge them.
Add a cooldown block to every Dependabot ecosystem entry in .github/dependabot.yml:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
cooldown:
default-days: 1
labels:
- "dependencies"
- "actions"
Note: cooldown applies to version updates only — security updates are never delayed.
Align zizmor's dependabot-cooldown audit
zizmor's dependabot-cooldown audit defaults to requiring 7 days and flags anything lower
as a finding. If your project deliberately uses a shorter cooldown (e.g. 1 day), configure
zizmor to accept it via .github/zizmor.yml (auto-discovered by zizmor in the .github/
directory, and by the zizmorcore/zizmor-action in CI):
rules:
dependabot-cooldown:
config:
days: 1
Keep rules.dependabot-cooldown.config.days equal to cooldown.default-days in
dependabot.yml so the policy and the check always agree. If you raise the Dependabot cooldown
later, raise the zizmor threshold to match.
9. Recommended Tools
| Tool | Purpose |
|---|
| zizmor | Static analysis for GitHub Actions — catches unpinned-uses, impostor-commit, template-injection, dependabot-cooldown, and more. Run locally and in CI. |
| pinact | Automatically converts tag/branch action references to full SHA pins. |
10. Quick Review Checklist
When reviewing a workflow file, verify each of the following: