github-actions-security
Skill2 starsChanged 2 months agoMITView on GitHub
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.
4 other projects use a version of this.
Adopted by
Most recently taken up by
- konstruktoid/agent-instructions-skillstheir own version, first seen Aug 24, 2026
- konstruktoid/prescrybtheir own version, first seen Aug 15, 2026
- daedalus/skillstheir own version, first seen Jun 9, 2026
- GoldenWing-360/claude-security-skillstheir own version, first seen May 12, 2026
Other versions4 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 4
- GoldenWing-360/claude-security-skills★ 16same name
- konstruktoid/agent-instructions-skills★ 2same name
- daedalus/skills★ 1same name
- konstruktoid/prescryb★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository10 other files it gives its agents.
- ci-failure-triageSkill
- cloud-aws-failure-triageSkill
- dependabot-managerSkill
- doc-hygieneSkill
- jira-acliSkill
- linear-cliSkill
- pypi-version-lookupSkill
- ship-storySkill
- step-functions-jsonataSkill
- terraform-reviewSkill
The file
We split this skill into the rules it gives an agent
12 rules. 0 are common, 7 are distinctive, and 5 are very specific to this project.
0
Common
Most of the 4 edited versions we read keep these.
7
Distinctive
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 42
Always use pull_request for contributor-triggered workflows.
- Distinctive: line 46
Instead, surface results via job summaries or workflow logs.
- Very specific: line 184
Run zizmor locally and in CI — its unpinned-uses and impostor-commit audits catch both missing pins and commits that don't correspond to …
- Distinctive: line 186
Use pinact to automatically convert tag-based references to SHA pins.
- Distinctive: line 204
If you have already evaluated alternatives and still need to use such an action, pin a specific commit SHA from the upstream repository …
- Distinctive: line 254
Default GitHub Actions permissions are far too broad.
- Distinctive: line 282
Every step that runs while credentials are active is a step that can leak or abuse them.
- Distinctive: line 316
Never use repo-level secrets for sensitive operations: If any job in any workflow is compromised, those secrets are exposed to every job.
- Very specific: line 373
Before copying, look up the current SHAs for actions/checkout and zizmorcore/zizmor-action using the procedure in §2, and the current …
- Very specific: line 403
Run zizmor --persona=pedantic .github/workflows/ periodically even if CI stays on the default persona, and say which persona a clean result …
- Very specific: line 438
Add a cooldown block to every Dependabot ecosystem entry in .github/dependabot.yml:
- Distinctive: line 498
When reviewing a workflow file, verify each of the following:
“Common” is measured: kept by at least 6 in 10 of the 4 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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, Nx, and Trivy (see §11)
all exploited this.
The pattern is known as a "pwn request": a privileged trigger combined with a checkout of
attacker-controlled fork code, so that code from an unmerged pull request executes with the base
repository's token and secrets. As of 2026 actions/checkout blocks the most common form of this
by default (see §2), but that is a backstop, not a licence to use these triggers.
What to do instead:
- Always use
pull_requestfor 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.
# ❌ Avoid
on:
pull_request_target:
workflow_run:
# ✅ Prefer
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_SUMMARY2. 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:
# ❌ Mutable — tag can be overwritten
- uses: actions/checkout@v7
# ✅ Immutable — locked to an exact commit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: falseAlways 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.
actions/checkout Now Blocks Pwn Requests — Make Sure You Have That Version
Since v7.0.0 (June 2026), actions/checkout refuses by default to check out fork pull
request code under pull_request_target or workflow_run. It detects the telltale unsafe
inputs and fails rather than fetching attacker-controlled code into a privileged context:
# All of these are now blocked by default under pull_request_target / workflow_run
ref: refs/pull/${{ github.event.pull_request.number }}/merge
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}The fix was backported on 2026-07-16 to v2, v3.7.0, v4.4.0, v5.1.0 and v6.1.0, with
v7.0.1 following on 2026-07-17. Anything older does not have it.
This is the cost of pinning, and you have to pay it deliberately. Hash-pinning means upstream
security fixes do not reach you automatically — a workflow pinned to a pre-July-2026
actions/checkout commit is still running the old permissive behaviour today. Pinning buys you
protection against a compromised upstream at the price of owning your own update cadence. That
is the right trade, but only if something actually drives the updates: pair every pinned action
with Dependabot's github-actions ecosystem (§8), which updates the SHA and rewrites the
trailing version comment.
Treat allow-unsafe-pr-checkout: true as a review blocker. It is the opt-out input, named to
be greppable precisely so it shows up in review and static analysis. There is no configuration of
this repository's rules under which it is acceptable.
# ❌ Re-enables the pwn request the block exists to prevent
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
allow-unsafe-pr-checkout: trueKnow the limits of the backstop. It protects actions/checkout only. A workflow that fetches
fork code with raw git, with gh pr checkout, or through a third-party action reintroduces the
same vulnerability with none of the protection. §1 remains the actual rule; this is defence in
depth beneath it.
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:
# Step 1: Find the latest release tag — do not skip this step
gh release list -R actions/checkout -L 5# Step 2: Resolve that tag to the full commit SHA
gh api repos/actions/checkout/commits/v7.0.1 --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):
# Find latest release tag
curl -sf https://api.github.com/repos/actions/checkout/releases/latest | jq -r '.tag_name'
# Resolve tag to commit SHA
curl -sf https://api.github.com/repos/actions/checkout/commits/v7.0.1 | jq -r '.sha'Note: unauthenticated GitHub API requests are rate-limited to 60/hour. Prefer gh when possible.
No network access at all? git ls-remote needs no API token and no authentication, and it is
the authoritative source for what a tag actually points at:
git ls-remote --tags https://github.com/actions/checkout | grep -E 'v7\.0\.1'Watch for annotated tags here: git ls-remote shows the tag object on the refs/tags/x line
and the underlying commit on the refs/tags/x^{} line. Pin the commit, not the tag object.
Lightweight tags have no ^{} line and point straight at the commit.
Tooling
- Run zizmor locally and in CI — its
unpinned-usesandimpostor-commitaudits 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.
Note that
v4.0.0was a breaking release: version comments are now mandatory, the-reviewoption was removed, and-no-apiwas added for validating pins offline.
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'# No tagged releases upstream as of 2026-04-09; pinned directly to main HEAD.
# Re-evaluate alternatives during the next quarterly workflow audit.
- 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.
# ❌ Unpredictable — silently changes when GitHub updates the alias
jobs:
build:
runs-on: ubuntu-latest
# ✅ Consistent — locked to a known environment
jobs:
build:
runs-on: ubuntu-24.04This 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: {} # deny all by defaultAt the job level — grant only what that job actually needs:
jobs:
build:
permissions:
contents: read # only what's required
release:
permissions:
contents: write
id-token: write # for OIDC / Trusted Publishing5. 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.
# ❌ Credential obtained too early — two unrelated steps run with an active AWS session
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: aws-actions/configure-aws-credentials@... # authenticates here
- run: cargo build # unrelated — but has AWS creds
- run: cargo test # unrelated — but has AWS creds
- run: aws s3 cp target/release/app s3://my-bucket/ # actual usage
# ✅ Credential obtained immediately before use — no unrelated steps exposed
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: cargo build
- run: cargo test
- uses: aws-actions/configure-aws-credentials@... # authenticates here
- run: aws s3 cp target/release/app s3://my-bucket/ # immediately afterThe 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
# No environment — no access to release secrets
publish:
runs-on: ubuntu-24.04
environment: release # secrets scoped here only
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).
Use zizmor 1.28.0 or later. Version 1.27.0 shipped a logging defect that exposed
configured GitHub tokens in cleartext in zizmor's own logs when debug-level logging was enabled
— a single -v/--verbose flag, or a matching RUST_LOG directive, was enough to trigger it
(GHSA-f42p-wjw5-97qh). No other version was affected. The debug-logging precondition makes this
less severe than it first sounds, but CI runs are exactly where someone turns verbosity up to
debug a failure, so treat the floor as firm. If a repo pins zizmor, check the pin before anything
else.
Pin the zizmor version, not just the action. zizmorcore/zizmor-action takes a version:
input that defaults to latest, so SHA-pinning the action still leaves it downloading whatever
zizmor is current at run time. This is precisely the immutability gap described in §2 — an
immutably-pinned action making a mutable decision at runtime — and it means an unpinned version:
would have silently picked up 1.27.0 during the window it was vulnerable. Set it explicitly:
- uses: zizmorcore/zizmor-action@<sha> # vX.Y.Z
with:
version: "1.29.0"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, and the current zizmor release; 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/**'Audits That Do Not Fire by Default
zizmor sorts audits into personas, and a default run is --persona=regular. Several audits worth
having simply do not run unless you ask for them — a clean default report is not the same as a
clean pedantic one:
| Audit | Persona required | Why you want it |
stale-action-refs | pedantic | Flags SHA pins whose version comment no longer matches any release — the classic symptom of a pin that was never updated. |
self-hosted-runner | pedantic | Self-hosted runners on public repos are reachable by fork PRs. |
secrets-outside-env | auditor | Secrets referenced outside an env: block, widening exposure. |
Run zizmor --persona=pedantic .github/workflows/ periodically even if CI stays on the default
persona, and say which persona a clean result came from when reporting one.
Two Audits to Configure Deliberately
forbidden-uses— an allow or deny list foruses:references. This is the practical way to enforce an organisational action allowlist locally, without waiting on org-level policy:rules: forbidden-uses: config: allow: - actions/* - zizmorcore/*secrets-inherit— flagssecrets: inheriton reusable workflow calls, which hands the callee every secret the caller can see rather than the ones it needs. It ships an autofix that rewrites the call to forward secrets explicitly. Take the autofix.
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, Ultralytics and Trivy). A delay of a
few days allows the community to catch most compromised or broken releases before you merge them.
GitHub now applies a 3-day cooldown by default, even when cooldown is absent from the
config entirely. Set it explicitly at 3 days anyway: writing anything shorter produces a config
that looks stricter than the platform default while actually being weaker, and stating the value
gives you an obvious place to raise it.
Add a cooldown block to every Dependabot ecosystem entry in .github/dependabot.yml:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
cooldown:
default-days: 3 # matches the platform default; raise semver-major-days for stricter control
labels:
- "dependencies"
- "actions"Note:
cooldownapplies to version updates only — security updates are never delayed.
Keep the daily schedule. The cooldown runs from each release, not from a fixed point in the week, so on an active upstream there is always a sliding window of releases becoming eligible. Daily picks each one up the day it clears; weekly batches them into one large PR up to six days late for no security benefit.
Align zizmor's dependabot-cooldown audit
zizmor's dependabot-cooldown audit defaults to requiring 7 days and flags anything lower
as a finding. To run a 3-day cooldown without a standing finding, set the threshold explicitly
in .github/zizmor.yml (auto-discovered by zizmor in the .github/ directory, and by the
zizmorcore/zizmor-action in CI):
# zizmor config — https://docs.zizmor.sh/configuration/
# Keep this value in sync with cooldown.default-days in .github/dependabot.yml.
rules:
dependabot-cooldown:
config:
days: 3Keep 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.
Be honest about what this is: lowering a check's threshold to match your policy is only
legitimate when the policy is deliberate. Three days is a considered trade between vetting time
and patch latency, and it is the platform floor. Never lower this below 3 — at that point you
are suppressing the warning rather than answering it, and the config is weaker than having no
cooldown block at all.
9. Recommended Tools
| Tool | Purpose |
| zizmor | Static analysis for GitHub Actions — catches unpinned-uses, impostor-commit, template-injection, secrets-inherit, dependabot-cooldown, and ~35 more. Run locally and in CI. Requires ≥ 1.28.0 (see §7). |
| pinact | Automatically converts tag/branch action references to full SHA pins. v4.0.0+ requires version comments and drops -review. |
10. Quick Review Checklist
When reviewing a workflow file, verify each of the following:
- No
pull_request_targetorworkflow_runtriggers - No steps that post comments to PRs or issues — use job summaries or logs instead
- All
uses:references are pinned to a full 40-char SHA (looked up, never guessed) - All checkout actions use
persist-credentials: false actions/checkoutis pinned tov7.0.0or later, or to one of the 2026-07-16 backports (v2,v3.7.0,v4.4.0,v5.1.0,v6.1.0), so the pwn-request block is present- No
allow-unsafe-pr-checkout: trueanywhere - No fork PR code is fetched by raw
git,gh pr checkout, or a third-party action - No
secrets: inheriton reusable workflow calls — secrets are forwarded explicitly - All jobs use
runs-on: ubuntu-24.04(neverubuntu-latest) permissions: {}at the top of every workflow file- Per-job permissions grant only what is strictly necessary
- Authentication steps appear immediately before the step that uses the credentials
- Sensitive secrets are in deployment environments, not at repo level
- Release jobs require manual approval and do not use caching
- Dependabot config sets a
cooldown(default-days ≥ 3) on every ecosystem entry .github/zizmor.ymlsetsrules.dependabot-cooldown.config.daysto match the Dependabot cooldown, and never below 3- zizmor is ≥ 1.28.0 and passes cleanly (no
unpinned-uses,impostor-commit,template-injection,secrets-inherit, ordependabot-cooldownfindings) - A
--persona=pedanticrun has been done at least once (see §7) - A zizmor CI workflow exists with path trigger
.github/** - No
aquasecurity/trivy-action(see §11)
11. Case Studies — Why These Rules Exist
Real 2026 incidents that map directly onto the rules above live in
references/case-studies.md: the two-stage Trivy compromise (a
pwn request, then a credential rotation botched badly enough that the attacker force-pushed 76
of 77 version tags after disclosure), and the actions-cool/issues-helper tag hijack, which
cleanly split consumers into SHA-pinned (unaffected) and tag-pinned (compromised). Read that
file when a reviewer pushes back on the cost of a rule, or before relaxing one — it is the
receipts, not new rules.
One repository-level position does come out of them: do not use aquasecurity/trivy-action.
The vulnerability was ordinary and forgivable; the incident response was not. Where a
vulnerability scanner is genuinely needed, choose a different one and pin it by SHA like
everything else.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

