github/github-actions-hardening
Security hardening reviewer for GitHub Actions workflow files (.github/workflows/*.yml). Reasons about the Actions threat model that pattern matchers and general code linters miss — untrusted-input script injection, privileged triggers running fork code, mutable action references, and over-scoped tokens. Use this skill when asked to review, audit, harden, or secure a GitHub Actions workflow, when writing a new workflow, or for any request like "is this workflow safe?", "review my CI for security issues", "why is pull_request_target dangerous here?", "pin my actions", or "lock down GITHUB_TOKEN permissions". Covers script injection via ${{ }} interpolation, pull_request_target / workflow_run privilege escalation, SHA-pinning of third-party actions, least-privilege permissions, GITHUB_ENV/GITHUB_OUTPUT injection, secret exposure, OIDC over long-lived credentials, and self-hosted runner exposure on public repositories.
npx skills add https://github.com/github/awesome-copilot --skill github-actions-hardening
A focused security reviewer for GitHub Actions workflows. It reasons about the *Actions-specific*
threat model — where trust boundaries live in trigger types, token scopes, and string
interpolation — rather than the application-code vulnerabilities a general security scanner looks
for. Most workflow risks are invisible to language linters because the dangerous code is the YAML
itself and the way GitHub expands ${{ }} expressions into a shell before your script runs.
Use this skill when the request involves:
.github/workflows/pull_request_target, workflow_run, or issue_comment triggersGITHUB_TOKEN permissions or the permissions: keyrun: stepsIn a workflow, **${{ <expr> }} is expanded by the runner into the script *before* the shell
executes it.** So a step like:
- run: echo "Title: ${{ github.event.issue.title }}"
is not passing a variable — it is *pasting attacker-controlled text directly into your shell
command*. An issue titled "; <attacker-command> # is concatenated into the script and executed.
This single mechanism is the most common real-world Actions vulnerability, and models routinely
generate it. Treat every
${{ }} that contains data an outside contributor can influence as a code-injection sink.
Follow these steps in order for every workflow reviewed.
Read every on: trigger and classify the workflow's privilege:
push, pull_request (from same repo) → runs with the contributor's own trustpull_request from a fork → runs with a read-only token, no secrets (safe by design)pull_request_target, workflow_run, issue_comment, issues → run in the context of thebase repository with a read/write token and full access to secrets, but can be
triggered by outside contributors. These are the dangerous triggers.
Read references/triggers-and-privilege.md for the full trust matrix.
For every run: block, every script: in actions/github-script, and every input to a custom
action, list the ${{ }} expressions and check whether any resolve to attacker-controllable data.
High-risk contexts include:
github.event.issue.title, github.event.issue.bodygithub.event.pull_request.title, github.event.pull_request.body, .head.ref, .head.labelgithub.event.comment.body, github.event.review.bodygithub.event.pages.*.page_name, github.event.commits.*.message, github.event.head_commit.*github.head_ref and any github.event.* field a fork author can setRead references/injection.md for the complete sink list and the safe-pattern fixes.
If a pull_request_target or workflow_run workflow checks out PR/fork code
(ref: ${{ github.event.pull_request.head.sha }}) and then runs it (build, test, install
scripts, npm install with lifecycle scripts, etc.), that is remote code execution against a
privileged token. Flag it as CRITICAL. The safe pattern is to split into two workflows: an
unprivileged pull_request workflow that runs the untrusted code, and a privileged
workflow_run workflow that only consumes its results.
permissions:permissions: block, the workflow inherits the repository default, which maybe read/write to everything. Flag it.
permissions: {} (deny-all) or contents: read, then grant the minimumper job (e.g. pull-requests: write only on the job that comments).
permissions: write-all or broad write scopes that the steps don't actually need.Read references/permissions-and-tokens.md for the per-scope guidance and OIDC setup.
For every uses::
actions/* or github/*) MUST be pinned to a full 40-charactercommit SHA, not a tag or branch. Tags and branches are mutable; a compromised upstream action
can rewrite v1 to malicious code that runs with your token and secrets.
actions/* are lower risk but SHA-pinning is still the hardened recommendation.@main, @master, or any branch reference as HIGH — that is "latest" and can change underyou at any time.
uses: foo/bar@<sha> # v2.1.0.Read references/supply-chain.md for pinning, Dependabot for actions, and artifact/cache risks.
set -x / bash -x in steps that touchsecrets.
$GITHUB_ENV or $GITHUB_OUTPUT can inject environmentvariables or step outputs — use the random-delimiter heredoc form and never write raw user input.
actions/checkout leaves a token on disk by default; set persist-credentials: false when thejob later runs untrusted code.
Output findings using the format in references/report-format.md: a severity summary table first,
then grouped findings with file, the exact offending YAML, the risk in plain English, and a
concrete before/after fix. Never auto-apply changes — present them for review.
| Severity | Meaning | Example |
| --- | --- | --- |
| 🔴 CRITICAL | Token/secret theft or RCE reachable by an outside contributor | pull_request_target checking out and running fork code; ${{ github.event.* }} in a run: on a privileged trigger |
| 🟠 HIGH | Exploitable supply-chain or scope problem | Third-party action on a mutable tag/branch; write-all permissions; injection sink on issue_comment |
| 🟡 MEDIUM | Risk under conditions or chaining | Missing permissions: block; secret reachable by a non-fork PR author |
| 🔵 LOW | Hardening gap, low direct risk | First-party action not SHA-pinned; persist-credentials left default on a non-privileged job |
| ⚪ INFO | Observation, not a vulnerability | Version comment missing next to a pinned SHA |
pull_request is dangerous just because it runs untrusted code — it hasno secrets and a read-only token. Reserve CRITICAL for the privileged triggers.
Load these as needed:
references/triggers-and-privilege.md — Trust matrix for every trigger, why pull_request_targetand workflow_run are privileged, and the two-workflow safe pattern.
+ Search patterns: pull_request_target, workflow_run, issue_comment, fork, secrets, read-only token, trust boundary
references/injection.md — Full list of attacker-controllable ${{ }} contexts and theenv:-variable safe pattern for each sink (run, github-script, action inputs).
+ Search patterns: script injection, github.event, head_ref, issue title, env, intermediate variable, actions/github-script
references/permissions-and-tokens.md — GITHUB_TOKEN scopes, least-privilege permissions:recipes per job type, and OIDC for cloud auth instead of long-lived secrets.
+ Search patterns: permissions, GITHUB_TOKEN, write-all, contents: read, id-token, OIDC, least privilege
references/supply-chain.md — SHA-pinning third-party actions, Dependabot for github-actions,artifact and cache poisoning across workflow_run, and self-hosted runner exposure.
+ Search patterns: SHA pin, uses, mutable tag, Dependabot, download-artifact, cache, self-hosted runner
references/report-format.md — Output template: summary table, finding cards, and before/afterremediation blocks.
+ Search patterns: report, format, finding, summary, remediation, before, after
Take github/github-actions-hardening from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npm.
Without those the skill loads but fails at the first command.