references/permissions-and-tokens.md
# Permissions and Tokens
Every workflow run gets an automatic `GITHUB_TOKEN`. Its scope is the blast radius if a step is
compromised, so scope it to the minimum.
## The Default Is Too Broad
If a workflow has no `permissions:` block, it inherits the repository/organization default. On
older or permissive repos that default is **read/write to most scopes**. A single injected command
or malicious dependency then runs with the ability to push code, publish releases, or approve PRs.
## Least-Privilege Recipe
Set a restrictive default at the top level, then elevate per job only where needed.
```yaml
# Deny by default
permissions: {}
jobs:
build:
permissions:
contents: read # checkout only
runs-on: ubuntu-latest
steps: [...]
comment:
permissions:
contents: read
pull-requests: write # this job posts a comment; nothing else
runs-on: ubuntu-latest
steps: [...]
```
Common scopes: `contents`, `pull-requests`, `issues`, `actions`, `packages`, `id-token`,
`deployments`, `checks`, `statuses`. Each is `read`, `write`, or `none`.
## Findings to Flag
* No `permissions:` block anywhere → MEDIUM (inherits possibly-broad default).
* `permissions: write-all` → HIGH.
* A `write` scope the job's steps never use → HIGH (drop it).
* Top-level `write` that should live on one job → MEDIUM (move it down).
## OIDC Instead of Long-Lived Cloud Secrets
Storing static cloud keys (`AWS_ACCESS_KEY_ID`, etc.) as repo secrets means a leak is permanent
until manually rotated. Prefer OpenID Connect: the workflow requests a short-lived token the cloud
provider trusts, scoped to that repo/branch, expiring in minutes.
```yaml
permissions:
id-token: write # required to request the OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@<sha>
with:
role-to-assume: arn:aws:iam::123456789012:role/my-ci-role
aws-region: us-east-1
# no AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY secrets needed
```
The same pattern exists for Azure (`azure/login`), GCP (`google-github-actions/auth`), HashiCorp
Vault, and others. On the cloud side, scope the trust policy to the specific repo and ideally a
specific branch/environment so a fork or another repo cannot assume the role.
## Secret Hygiene
* Reference secrets only in the jobs that need them.
* Never `echo` a secret or enable shell tracing (`set -x`) in a step that handles one.
* Don't pass secrets into third-party actions you haven't pinned and reviewed.
* Remember fork `pull_request` runs get no secrets — don't try to "fix" that by switching to
`pull_request_target` (see `triggers-and-privilege.md`).
references/triggers-and-privilege.md
# Triggers and Privilege
The single most important question for workflow security is: **can an outside contributor trigger
this workflow, and if so, what token and secrets does it get?** GitHub answers this differently per
trigger.
## Trust Matrix
| Trigger | Who can fire it | `GITHUB_TOKEN` | Secrets available | Risk |
| --- | --- | --- | --- | --- |
| `push` | Repo collaborators | read/write | yes | Low — trusted authors |
| `pull_request` (same-repo branch) | Collaborators | read/write | yes | Low |
| `pull_request` (from a fork) | **Anyone** | **read-only** | **no** | Low by design — even malicious code can't steal anything |
| `pull_request_target` | **Anyone with a fork** | **read/write** | **yes** | **High** — runs in base-repo context |
| `workflow_run` | Fires after another workflow | **read/write** | **yes** | **High** |
| `issue_comment`, `issues` | **Anyone** | **read/write** | **yes** | **High** |
The trap: `pull_request` from a fork is *safe* because GitHub deliberately strips the token down
and withholds secrets. Maintainers who find that "the secrets don't work on fork PRs" often switch
to `pull_request_target` to get them back — and in doing so hand a write token and every secret to
arbitrary contributors.
## Why `pull_request_target` Is Dangerous
`pull_request_target` checks out the **base** repository's workflow definition (so a fork can't
change what runs), but it runs with full privileges. The danger is when the workflow then
explicitly checks out the **fork's** code and executes it:
```yaml
# DANGEROUS — RCE with a write token + secrets
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.event.pull_request.head.sha }} # fork's code
- run: npm install && npm test # runs the fork's code + scripts
```
`npm install` alone runs arbitrary lifecycle scripts from the PR. With `pull_request_target` those
scripts can read `secrets.*` and push commits with the write token.
## The Safe Two-Workflow Pattern
Split responsibilities. An **unprivileged** workflow runs the untrusted code; a **privileged**
workflow consumes only the trusted *output*.
```yaml
# 1) Unprivileged: runs untrusted code, no secrets, read-only token
name: PR Build
on: pull_request
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
- run: npm ci && npm run build
- uses: actions/upload-artifact@<sha>
with: { name: pr, path: dist/ }
```
```yaml
# 2) Privileged: triggered by the first, never runs fork code
name: PR Comment
on:
workflow_run:
workflows: ["PR Build"]
types: [completed]
permissions:
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@<sha> # data only, not executed
# post results, using the trusted token — but never execute the artifact
```
## Rules
* Treat `pull_request_target`, `workflow_run`, `issue_comment`, and `issues` as privileged.
* In a privileged workflow, **never** check out and execute PR/fork code.
* If you only need to label, comment, or triage based on metadata, that is fine — just don't run
the contributor's code.
* Prefer `pull_request` (with its safe read-only/no-secrets defaults) whenever possible.
SKILL.md
---
name: github-actions-hardening
description: 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.
---
# 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:
```yaml
- 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`