Skip to content

GitHub Actions

Never Combine pull_request_target with PR Head Checkout — Critical

pull_request_target runs in the base repo context. When triggered by an external fork, untrusted code can potentially repo gain write permissions and access repo secrets.

Unsafe
on: pull_request_target
steps:
  - uses: actions/checkout@v4
    with:
      ref: ${{ github.event.pull_request.head.sha }}
  - run: npm install && npm test  # Untrusted code executed in privileged context
                                  # Can write and access secrets, etc.
Less Risky: Split Workflow
# Unprivileged: runs untrusted code, no secrets
# Repo token is read-only
name: Receive PR
on: pull_request
steps:
  - uses: actions/checkout@v4
  - run: npm test
  - uses: actions/upload-artifact@v4
    with: { name: results, path: test-output/ }
# Privileged: consumes artifact, never touches untrusted PR code
# Repo token is read-write
name: Comment on PR
on:
  workflow_run:
    workflows: ["Receive PR"]
    types: [completed]
permissions:
  pull-requests: write
steps:
  - name: "Download artifact"
    uses: actions/download-artifact@v4
  - name: "Do the thing..."

Ref: GitHub Security Lab — Preventing Pwn Requests (2021)

Pin All Actions to Full Commit SHAs — Critical

Branch and tag refs (@main, @v3) are mutable. A compromised upstream action is indistinguishable from the legitimate one at runtime.

Unsafe
uses: actions/checkout@v4
uses: some-org/some-action@main
Less Risky
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

Hash pinning does add some management overhead. Use Dependabot or Renovate to automate this process.

Ref: GitHub Docs — Security hardening: using third-party actions

Scope permissions: Minimally on Every Workflow — High

Without an explicit block, workflows inherit the repo's default token permissions. The TanStack OIDC token extraction worked because id-token: write was globally scoped on the release workflow.

permissions: {}           # deny all at workflow level

jobs:
  publish:
    permissions:
      id-token: write     # only what this job needs
      contents: read

Ref: GitHub Docs — Workflow syntax: permissions

Never Interpolate Untrusted Input into run: Steps — High

Issue bodies, PR titles, commit messages, and branch names are attacker-controlled. Direct interpolation into shell is command injection.

Unsafe
- run: echo "${{ github.event.pull_request.title }}"
Less Risky
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "$PR_TITLE"

Applies to issue.body, head.ref, actor, and all other user-supplied context values.

Ref: GitHub Security Lab Part 2 — Untrusted Input

Use OIDC Trusted Publishing — Medium

OIDC eliminates long-lived static publishing tokens, but OIDC bindings still get abused if an attacker can execute code within the bound workflow job (as TanStack demonstrated). Layer with GitHub Environments and required reviewers:

jobs:
  publish:
    environment: npm-production  # requires human approval
    permissions:
      id-token: write

Scope OIDC trust conditions as tightly as the registry permits: workflow file, ref, and environment.

Unsafe
# Broadly scoped
repo: org/myrepo
Less Risky
# Tightly scoped
repo: org/myrepo
workflow: .github/workflows/publish.yml
ref: refs/tags/v*
environment: release