GitHub Actions hardening
Review workflow YAML through the GitHub Actions threat model: trigger privilege, expression interpolation, token scopes, action supply chain, secret handling, and runner exposure.
When to invoke
- "Review my GitHub Actions workflow for security."
- "Is this workflow safe?"
- "Why is pull_request_target dangerous here?"
- "Pin my actions to SHAs."
- "Lock down GITHUB_TOKEN permissions."
Core threat model
${{ <expr> }} is expanded by the runner into the script before the shell executes it. In a step like run: echo "Title: ${{ github.event.issue.title }}", attacker-controlled issue text is pasted directly into the shell command. Treat every ${{ }} expression containing outside-contributor-controlled data as a code-injection sink unless it is passed through a safe intermediate environment variable or equivalent safe pattern.
Procedure
- Map every
on:trigger and classify the workflow privilege usingreferences/triggers-and-privilege.md. - Inspect every
run:block,actions/github-scriptscript:, and custom action input for${{ }}expressions containing attacker-controlled fields. - Check that
pull_request_targetandworkflow_runworkflows do not check out PR or fork code withref: ${{ github.event.pull_request.head.sha }}and then run build, test, install scripts,npm installlifecycle scripts, or other untrusted code under privileged tokens. - Audit
permissions:for least privilege. Missing permissions inherit repository defaults; prefer top-levelpermissions: {}orcontents: read, then grant minimum scopes per job such aspull-requests: writeonly where comments are posted. - Audit every
uses:reference for mutable tags or branches. Third-party actions must be pinned to a full 40-character commit SHA, with a trailing comment for the human-readable version such asuses: foo/bar@<sha> # v2.1.0. - Check secrets,
$GITHUB_ENV,$GITHUB_OUTPUT,set -x,bash -x,actions/checkoutpersist-credentials, OIDC setup, artifact/cache poisoning, and self-hosted runner exposure. - Report findings using
references/report-format.md; never auto-apply changes.
Trigger and privilege criteria
| Trigger | Trust level | Review rule |
|---|---|---|
push |
Repository-controlled | Still check token scopes, secrets, and supply chain. |
pull_request from same repo |
Contributor has repository trust | Check standard injection and permissions risks. |
pull_request from a fork |
Read-only token and no secrets | Do not call this dangerous merely because it runs untrusted code. |
pull_request_target |
Base repo context with read/write token and secrets, triggerable by outsiders | CRITICAL if it checks out and runs fork code. |
workflow_run |
Privileged follow-up context | Must consume artifacts safely and never trust unvalidated upstream output. |
issue_comment, issues |
Outside contributors can trigger text-controlled workflows | High-risk for ${{ github.event.* }} injection. |
High-risk expression contexts
Check 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.
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; privileged ${{ github.event.* }} in run: |
| HIGH | Exploitable supply-chain or scope issue | Third-party action on mutable tag/branch; write-all; injection sink on issue_comment |
| MEDIUM | Risk under conditions or chaining | Missing permissions:; secret reachable by non-fork PR author |
| LOW | Hardening gap with low direct risk | First-party action not SHA-pinned; persist-credentials default on non-privileged job |
| INFO | Observation, not a vulnerability | Version comment missing next to pinned SHA |
Output rules
- Show a findings summary table first, with counts by severity.
- Group by issue type, not by file.
- Quote the offending YAML and line location.
- Pair every CRITICAL and HIGH finding with corrected YAML.
- If the workflow is hardened, say so and list triggers, permissions, action refs, expression sinks, and secrets handling checked.
Progressive disclosure and bundled resources
references/triggers-and-privilege.md: trust matrix,pull_request_target,workflow_run,issue_comment, fork secrets, read-only token, and trust boundary.references/injection.md: script injection,github.event,head_ref, issue title,env, intermediate variable,run,github-script, and action input safe patterns.references/permissions-and-tokens.md:GITHUB_TOKEN,permissions,write-all,contents: read,id-token,OIDC, and least privilege.references/supply-chain.md: SHA pinning,uses, mutable tag, Dependabot,download-artifact, cache, and self-hosted runner guidance.references/report-format.md: report, finding, summary, remediation, before, and after format.
Output template
## GitHub Actions hardening — <workflow path>
| Severity | Count |
| --- | ---: |
| CRITICAL | <count> |
| HIGH | <count> |
| MEDIUM | <count> |
| LOW | <count> |
| INFO | <count> |
### Findings
#### <issue type>
**Severity:** CRITICAL | HIGH | MEDIUM | LOW | INFO
**Location:** `<workflow>:<line>`
**Evidence:**
```yaml
<offending YAML>
Risk: Fix:
<corrected YAML>
## Quality gate
- [ ] Every workflow trigger was classified by trust level.
- [ ] Every `run:`, `actions/github-script` `script:`, and custom action input was checked for unsafe `${{ }}` interpolation.
- [ ] Privileged triggers do not run untrusted fork or PR code.
- [ ] `permissions:` is least-privilege and no unjustified `write-all` remains.
- [ ] Third-party `uses:` references are pinned to full 40-character SHAs or reported.
- [ ] `$GITHUB_ENV`, `$GITHUB_OUTPUT`, secrets, OIDC, artifacts, caches, and self-hosted runners were reviewed.
- [ ] Every CRITICAL or HIGH finding has corrected YAML and no change was applied automatically.