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.
When to Use This Skill
Use this skill when the request involves:
- Reviewing, auditing, or hardening any file under
.github/workflows/
- Authoring a new workflow and wanting it secure by default
- A workflow that uses
pull_request_target, workflow_run, or issue_comment triggers
- Questions about
GITHUB_TOKEN permissions or the permissions: key
- Pinning actions to commit SHAs vs tags vs branches
- Handling untrusted input (issue titles, PR bodies, branch names, commit messages) in
run: steps
- OIDC / cloud authentication from Actions, or secret handling in CI
- Self-hosted runners on public repositories
- Any request like "is this workflow safe?", "secure my CI", or "review this GitHub Action"
The Core Insight
In 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.
Execution Workflow
Follow these steps in order for every workflow reviewed.
Step 1 — Map the Triggers and Trust Level
Read every on: trigger and classify the workflow's privilege:
push, pull_request (from same repo) → runs with the contributor's own trust
pull_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 the
base 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.
Step 2 — Hunt for Script Injection
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.body
github.event.pull_request.title, github.event.pull_request.body, .head.ref, .head.label
github.event.comment.body, github.event.review.body
github.event.pages.*.page_name, github.event.commits.*.message, github.event.head_commit.*
github.head_ref and any github.event.* field a fork author can set
Read references/injection.md for the complete sink list and the safe-pattern fixes.
Step 3 — Check Privileged Triggers Don't Execute Untrusted Code
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.
Step 4 — Audit permissions:
- If there is no
permissions: block, the workflow inherits the repository default, which may
be read/write to everything. Flag it.
- Recommend a top-level
permissions: {} (deny-all) or contents: read, then grant the minimum
per job (e.g. pull-requests: write only on the job that comments).
- Flag any
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.
Step 5 — Audit Action References (Supply Chain)
For every uses::
- Third-party actions (not
actions/* or github/*) MUST be pinned to a full 40-character
commit 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.
- First-party
actions/* are lower risk but SHA-pinning is still the hardened recommendation.
- Flag
@main, @master, or any branch reference as HIGH — that is "latest" and can change under
you at any time.
- Note the human-readable version in a trailing comment:
uses: foo/bar@<sha> # v2.1.0.
Read references/supply-chain.md for pinning, Dependabot for actions, and artifact/cache risks.
Step 6 — Check Secret and Output Handling
- No secrets echoed, printed, or written to logs; no
set -x / bash -x in steps that touch
secrets.
- Secrets must not be passed to steps that run untrusted code or to untrusted third-party actions.
- Untrusted multiline data written to
$GITHUB_ENV or $GITHUB_OUTPUT can inject environment
variables 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 the
job later runs untrusted code.
Step 7 — Produce the Report
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 Guide
| 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 |
Output Rules
- Always show a findings summary table (counts by severity) first.
- Group by issue type, not by file.
- Be exact — quote the offending line and give the line location.
- Always pair every CRITICAL/HIGH with a concrete corrected YAML snippet.
- Never claim a fork
pull_request is dangerous just because it runs untrusted code — it has
no secrets and a read-only token. Reserve CRITICAL for the privileged triggers.
- If the workflow is already hardened, say so and list what was checked.
Reference Files
Load these as needed:
references/triggers-and-privilege.md — Trust matrix for every trigger, why pull_request_target
and 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 the
env:-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/after
remediation blocks.
- Search patterns:
report, format, finding, summary, remediation, before, after
Source: github/awesome-copilot → skills/github-actions-hardening/SKILL.md
1---2name: github-actions-hardening3description: 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...4---5# GitHub Actions Hardening
6
7A focused security reviewer for GitHub Actions workflows. It reasons about the *Actions-specific*
8threat model — where trust boundaries live in trigger types, token scopes, and string
9interpolation — rather than the application-code vulnerabilities a general security scanner looks
10for. Most workflow risks are invisible to language linters because the dangerous code is the YAML
11itself and the way GitHub expands `${{ }}` expressions into a shell before your script runs.
12
13## When to Use This Skill
14
15Use this skill when the request involves:
16
17* Reviewing, auditing, or hardening any file under `.github/workflows/`
18* Authoring a new workflow and wanting it secure by default
19* A workflow that uses `pull_request_target`, `workflow_run`, or `issue_comment` triggers
20* Questions about `GITHUB_TOKEN` permissions or the `permissions:` key
21* Pinning actions to commit SHAs vs tags vs branches
22* Handling untrusted input (issue titles, PR bodies, branch names, commit messages) in `run:` steps
23* OIDC / cloud authentication from Actions, or secret handling in CI
24* Self-hosted runners on public repositories
25* Any request like "is this workflow safe?", "secure my CI", or "review this GitHub Action"
26
27## The Core Insight
28
29In a workflow, **`${{ <expr> }}` is expanded by the runner into the script *before* the shell
30executes it.** So a step like:
31
32```yaml
33- run: echo "Title: ${{ github.event.issue.title }}"
34```
35
36is not passing a variable — it is *pasting attacker-controlled text directly into your shell
37command*. An issue titled `"; <attacker-command> #` is concatenated into the script and executed.
38This single mechanism is the most common real-world Actions vulnerability, and models routinely
39generate it. Treat every
40`${{ }}` that contains data an outside contributor can influence as a code-injection sink.
41
42## Execution Workflow
43
44Follow these steps **in order** for every workflow reviewed.
45
46### Step 1 — Map the Triggers and Trust Level
47
48Read every `on:` trigger and classify the workflow's privilege:
49
50* `push`, `pull_request` (from same repo) → runs with the contributor's own trust
51* `pull_request` from a **fork** → runs with a **read-only** token, **no secrets** (safe by design)
52* `pull_request_target`, `workflow_run`, `issue_comment`, `issues` → run in the context of the
53 **base repository** with a **read/write token and full access to secrets**, but can be
54 **triggered by outside contributors**. These are the dangerous triggers.
55
56Read `references/triggers-and-privilege.md` for the full trust matrix.
57
58### Step 2 — Hunt for Script Injection
59
60For every `run:` block, every `script:` in `actions/github-script`, and every input to a custom
61action, list the `${{ }}` expressions and check whether any resolve to attacker-controllable data.
62High-risk contexts include:
63
64* `github.event.issue.title`, `github.event.issue.body`
65* `github.event.pull_request.title`, `github.event.pull_request.body`, `.head.ref`, `.head.label`
66* `github.event.comment.body`, `github.event.review.body`
67* `github.event.pages.*.page_name`, `github.event.commits.*.message`, `github.event.head_commit.*`
68* `github.head_ref` and any `github.event.*` field a fork author can set
69
70Read `references/injection.md` for the complete sink list and the safe-pattern fixes.
71
72### Step 3 — Check Privileged Triggers Don't Execute Untrusted Code
73
74If a `pull_request_target` or `workflow_run` workflow checks out PR/fork code
75(`ref: ${{ github.event.pull_request.head.sha }}`) **and then runs it** (build, test, install
76scripts, `npm install` with lifecycle scripts, etc.), that is remote code execution against a
77privileged token. Flag it as CRITICAL. The safe pattern is to split into two workflows: an
78unprivileged `pull_request` workflow that runs the untrusted code, and a privileged
79`workflow_run` workflow that only consumes its results.
80
81### Step 4 — Audit `permissions:`
82
83* If there is **no** `permissions:` block, the workflow inherits the repository default, which may
84 be read/write to everything. Flag it.
85* Recommend a top-level `permissions: {}` (deny-all) or `contents: read`, then grant the minimum
86 per job (e.g. `pull-requests: write` only on the job that comments).
87* Flag any `permissions: write-all` or broad `write` scopes that the steps don't actually need.
88
89Read `references/permissions-and-tokens.md` for the per-scope guidance and OIDC setup.
90
91### Step 5 — Audit Action References (Supply Chain)
92
93For every `uses:`:
94
95* **Third-party actions** (not `actions/*` or `github/*`) MUST be pinned to a full 40-character
96 commit SHA, not a tag or branch. Tags and branches are mutable; a compromised upstream action
97 can rewrite `v1` to malicious code that runs with your token and secrets.
98* First-party `actions/*` are lower risk but SHA-pinning is still the hardened recommendation.
99* Flag `@main`, `@master`, or any branch reference as HIGH — that is "latest" and can change under
100 you at any time.
101* Note the human-readable version in a trailing comment: `uses: foo/bar@<sha> # v2.1.0`.
102
103Read `references/supply-chain.md` for pinning, Dependabot for actions, and artifact/cache risks.
104
105### Step 6 — Check Secret and Output Handling
106
107* No secrets echoed, printed, or written to logs; no `set -x` / `bash -x` in steps that touch
108 secrets.
109* Secrets must not be passed to steps that run untrusted code or to untrusted third-party actions.
110* Untrusted multiline data written to `$GITHUB_ENV` or `$GITHUB_OUTPUT` can inject environment
111 variables or step outputs — use the random-delimiter heredoc form and never write raw user input.
112* `actions/checkout` leaves a token on disk by default; set `persist-credentials: false` when the
113 job later runs untrusted code.
114
115### Step 7 — Produce the Report
116
117Output findings using the format in `references/report-format.md`: a severity summary table first,
118then grouped findings with file, the exact offending YAML, the risk in plain English, and a
119concrete before/after fix. Never auto-apply changes — present them for review.
120
121## Severity Guide
122
123| Severity | Meaning | Example |
124| --- | --- | --- |
125| 🔴 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 |
126| 🟠 HIGH | Exploitable supply-chain or scope problem | Third-party action on a mutable tag/branch; `write-all` permissions; injection sink on `issue_comment` |
127| 🟡 MEDIUM | Risk under conditions or chaining | Missing `permissions:` block; secret reachable by a non-fork PR author |
128| 🔵 LOW | Hardening gap, low direct risk | First-party action not SHA-pinned; `persist-credentials` left default on a non-privileged job |
129| ⚪ INFO | Observation, not a vulnerability | Version comment missing next to a pinned SHA |
130
131## Output Rules
132
133* **Always** show a findings summary table (counts by severity) first.
134* **Group by issue type**, not by file.
135* **Be exact** — quote the offending line and give the line location.
136* **Always** pair every CRITICAL/HIGH with a concrete corrected YAML snippet.
137* **Never** claim a fork `pull_request` is dangerous just because it runs untrusted code — it has
138 no secrets and a read-only token. Reserve CRITICAL for the privileged triggers.
139* If the workflow is already hardened, say so and list what was checked.
140
141## Reference Files
142
143Load these as needed:
144
145* `references/triggers-and-privilege.md` — Trust matrix for every trigger, why `pull_request_target`
146 and `workflow_run` are privileged, and the two-workflow safe pattern.
147 + Search patterns: `pull_request_target`, `workflow_run`, `issue_comment`, `fork`, `secrets`, `read-only token`, `trust boundary`
148* `references/injection.md` — Full list of attacker-controllable `${{ }}` contexts and the
149 `env:`-variable safe pattern for each sink (`run`, `github-script`, action inputs).
150 + Search patterns: `script injection`, `github.event`, `head_ref`, `issue title`, `env`, `intermediate variable`, `actions/github-script`
151* `references/permissions-and-tokens.md` — `GITHUB_TOKEN` scopes, least-privilege `permissions:`
152 recipes per job type, and OIDC for cloud auth instead of long-lived secrets.
153 + Search patterns: `permissions`, `GITHUB_TOKEN`, `write-all`, `contents: read`, `id-token`, `OIDC`, `least privilege`
154* `references/supply-chain.md` — SHA-pinning third-party actions, Dependabot for `github-actions`,
155 artifact and cache poisoning across `workflow_run`, and self-hosted runner exposure.
156 + Search patterns: `SHA pin`, `uses`, `mutable tag`, `Dependabot`, `download-artifact`, `cache`, `self-hosted runner`
157* `references/report-format.md` — Output template: summary table, finding cards, and before/after
158 remediation blocks.
159 + Search patterns: `report`, `format`, `finding`, `summary`, `remediation`, `before`, `after`
160
161---
162
163**Source:** [`github/awesome-copilot`](https://github.com/github/awesome-copilot) → `skills/github-actions-hardening/SKILL.md`