Mert Özen Further With Every Line
DevOps GitHub Actions — #13

GitHub Actions Security: SHA Pinning, Lockfiles, pull_request_target and Preventing Script Injection

Mert Özen Nov 8, 2026 12 min 4 views
GitHub Actions Security: SHA Pinning, Lockfiles, pull_request_target and Preventing Script Injection

Pin actions to full commit SHAs and keep them current with Dependabot and lockfiles, pass untrusted values through env: instead of run:, and never run PR code in pull_request_target. The tj-actions case, zizmor and actionlint findings, gh actions-lock, real script injection output under act, checkout@v7 and the December 2, 2026 change, a safe workflow_run pattern and read-only cache tokens.

Version note: The examples in this post were run with act 0.2.97, actionlint 1.7.12, zizmor 1.32.0, Docker 29.2 and .NET 10. Lockfile, policy and pull_request_target behavior were not run on GitHub; dates were checked against the GitHub changelog, the documentation and CISA (Cybersecurity and Infrastructure Security Agency) alerts as of November 2026.

Guess First

Bookstore has a step that checks the title of incoming pull requests: run: echo "Checking title: ${{ github.event.pull_request.title }}". Someone opens a PR (pull request) with this title: Fix typo"; echo "INJECTED: this ran as a separate command. What do you see in the log: one odd-looking title line, or the output of two separate commands?

Second question: your workflow says uses: some-org/changed-files@v45. v45 is a tag; you read its code once and found it safe. Will the same line run the same code tomorrow?

Threat Model: Who Can Get Code into Your Workflow?

A workflow is a program with access to the repository's write token, its secrets and often a cloud account. Every door through which outside input reaches it is an attack surface. Let's count those doors for Bookstore. First, the code of the third-party actions we use: a uses: line runs code from someone else's repository with our privileges. Second, the content of a PR: code from a fork, tests that run during the build, MSBuild targets, npm scripts. Third, the PR's metadata: title, description, branch name, commit message; all strings written by the attacker. Fourth, data carried between jobs: caches and artifacts. Fifth, the runner itself: self-hosted runners in particular can carry traces from one job to the next.

The defenses follow those doors: pin the code you run, treat the attacker's strings as data rather than code, separate privileged contexts from untrusted code, narrow the token's permissions. The general framework (least privilege, short-lived credentials, supply chain) is in the pipeline security post of the CI/CD Fundamentals series, which covers CI (Continuous Integration) and CD (Continuous Delivery), and in the A03 "Software Supply Chain Failures" post of the OWASP (Open Worldwide Application Security Project) series; here we look at the mechanics specific to GitHub Actions. This post is defensive; we don't go into the details of attack techniques.

The Case: tj-actions/changed-files (March 2025)

In March 2025, several version tags of the tj-actions/changed-files action, used by more than 23,000 repositories, were rewritten to point to a malicious commit. That commit read secrets from the memory of the runner process, encoded them with base64 and printed them to the workflow log. The incident was recorded in the CVE (Common Vulnerabilities and Exposures) system as CVE-2025-30066; the exposure window given by CISA is March 12, 2025 00:00 UTC (Coordinated Universal Time) to March 15 12:00 UTC. According to CISA, the attack may have been enabled by the compromise of reviewdog/action-setup@v1 shortly before (CVE-2025-30154). Public repositories with public logs were at the highest risk: the secrets were in front of anyone who could read the log.

The lesson of the case is the answer to the second question: a Git tag is just a name, not an immutable reference. Anyone with write access to the repository can move v45 to any commit, and your workflow runs different code on its next run. When you write @v45, what you trust is not the code but the account security of that repository.

SHA Instead of Tags: Pinning and Dependabot

The fix is to pin the action to a full-length commit SHA (Secure Hash Algorithm). A commit hash is derived from the content itself; even if a tag moves, that SHA points to the same code. You find the SHA for a tag like this:

$ git ls-remote https://github.com/actions/checkout refs/tags/v7.0.1
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1
$ git ls-remote https://github.com/actions/setup-dotnet refs/tags/v6.1.0
0f4a354dd5547a28c70b7853dbd95d5b67886e73	refs/tags/v6.1.0

In the workflow you write the SHA and keep the version as a trailing comment for readability:

name: ci-pinned

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
        with:
          global-json-file: global.json
      - run: dotnet test --project api/tests/Bookstore.Api.Tests -c Release

I scanned the tagged version of the same workflow, without a permissions: block, with zizmor. zizmor is a static analysis tool written for GitHub Actions workflows; I ran it in --offline mode, which needs no network access:

error[unpinned-uses]: unpinned action reference
  --> .github/workflows/ci-tags.yml:11:15
   |
11 |       - uses: actions/checkout@v7
   |               ^^^^^^^^^^^^^^^^^^^ action is not pinned to a hash (required by blanket policy)
...
warning[excessive-permissions]: overly broad permissions
  --> .github/workflows/ci-tags.yml:8:3
...
help[artipacked]: credential persistence through GitHub Actions artifacts
  --> .github/workflows/ci-tags.yml:11:9
   |
11 |       - uses: actions/checkout@v7
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^ does not set persist-credentials: false
...
7 findings (3 suppressed, 1 unsafe fixes): 0 informational, 1 low, 1 medium, 2 high

For the pinned version the output is one line: No findings to report. Good job! (2 suppressed). When I scanned the workflows I wrote in posts 09–11, there were 20 unpinned-uses and 5 artipacked findings; we used tags for readability, but in a production repository every one of these lines should be a SHA.

The cost of a SHA is that updating becomes manual. You hand that to Dependabot; the github-actions ecosystem updates the SHA together with the version comment next to it:

version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    groups:
      actions:
        patterns: ["*"]

Before merging a Dependabot PR, check that the new SHA really corresponds to a published release of the action; automated updates must not become unreviewed updates.

Workflow Lockfiles and Policies

The blind spot of SHA pinning is transitive dependencies: if the composite action you pinned contains uses: other-org/tool@v2, that line stays a tag. Workflow lockfiles, which entered public preview in October 2026 and became GA (General Availability) on October 28, 2026, solve this. The .github/workflows/actions.lock file records the verified commit of every action your workflows use and of their transitive dependencies; in a workflow onboarded to the lockfile, Actions runs the recorded commit even if a tag or branch has moved. The file is managed by the gh actions-lock extension of the GitHub CLI (Command Line Interface):

gh extension install github/gh-actions-lock
gh actions-lock
gh actions-lock --verify
gh actions-lock --verify-local
gh actions-lock --relock

In order: installation, creating or fixing the lockfile, verifying in CI without changes, an offline, coverage-only pre-commit check, and advancing moved refs (v4, main) to their current commit. When Dependabot updates an action, it regenerates the lockfile entry in the same PR. The known limits are spelled out in the README: reusable workflows called from another repository are not locked, container images are not locked, and local ./ actions are not followed (the tool offers an option to rewrite them to the $/ syntax from post 12). Since there is no authenticated gh on this machine and I did not use a token, I did not run these commands; the behavior is from the changelog and the README.

Allowed Actions and Required SHAs

The allowed actions policy in organization or repository settings limits which actions may run. Since August 2025 you can block a specific action or version with !-prefixed entries and turn on the "full-length commit SHA" requirement; when it is on, a workflow that calls an action by tag fails. Since February 2026 these settings are available on Free, Team and Enterprise plans alike. When an action is compromised, the first response is to block it across the organization; then move pins and lockfiles to a clean commit and rotate every secret within the exposure window.

Script Injection: Don't Put Expressions Inside run:

I tried the first question with act. The workflow has two steps, one unsafe and one safe:

name: pr-title

on: pull_request

permissions:
  contents: read

jobs:
  check-title:
    runs-on: ubuntu-latest
    steps:
      - name: Unsafe
        run: |
          echo "Checking title: ${{ github.event.pull_request.title }}"
      - name: Safe
        env:
          TITLE: ${{ github.event.pull_request.title }}
        run: |
          echo "Checking title: $TITLE"

The title in the event file is this text, carrying a harmless echo: Fix typo"; echo "INJECTED: this ran as a separate command. Output:

[pr-title/check-title] ⭐ Run Main Unsafe
[pr-title/check-title]   | Checking title: Fix typo
[pr-title/check-title]   | INJECTED: this ran as a separate command
[pr-title/check-title] ⭐ Run Main Safe
[pr-title/check-title]   | Checking title: Fix typo"; echo "INJECTED: this ran as a separate command

In the unsafe step the title was split into two commands and the second one ran. The reason is that the ${{ }} expression is pasted into the script as plain text before the shell runs: the shell sees a script that closes the quote and starts a new command. In place of an echo, someone could have put a command that sends the token elsewhere. In the safe step the value is an environment variable; the shell expands it as a quoted variable and its content stays data no matter what it is.

Both tools catch this. actionlint: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. zizmor: error[template-injection]: code injection via template expansion. The rule is simple: never put any value an attacker can control (titles, bodies and branch names under github.event.*, github.head_ref, workflow_dispatch inputs) into run: as an expression; pass it through env: and use it quoted. The same applies to the script: field of actions/github-script.

pull_request_target, workflow_run and Checkout

The pull_request event runs restricted for PRs from forks: no secrets, a read-only GITHUB_TOKEN. pull_request_target runs in the base repository's context: secrets and a write-capable token are there. It was designed for jobs that don't run code, such as labeling a PR or posting a comment. The danger starts when you check out and build the PR's code in that privileged workflow; at that moment the attacker's code runs with your secrets. workflow_run belongs to the same class: an untrusted workflow's result triggers a privileged workflow.

Three changes in 2026 narrowed this door. actions/checkout@v7, released in June 2026, refuses by default to check out a fork PR's code under pull_request_target and under workflow_run triggered by a pull request; to enable it you have to write allow-unsafe-pr-checkout: true. On July 20, 2026 the same behavior was added to v6.1.0, v5.1.0, v4.4.0, v3.7.0 and v2.8.0; those following a major tag got it automatically, those pinned to a SHA or an exact version have to update. Second, with workflow execution protections, GA since September 2026, organizations can restrict with rulesets which events and which people may trigger workflows. Third, and harshest: from December 2, 2026, pull_request_target will be blocked by default in public repositories unless an Actions event policy explicitly allows it. Brownouts are scheduled ahead of the switch on November 9 and 16, 2026, 16:00–18:00 UTC; during those hours blocked runs fail with an error annotation. Private and internal repositories and GitHub Enterprise Server are not affected.

If you have work that needs privileges (for example posting the test result as a PR comment), the safe layout is to split it in two. Untrusted code runs in a secret-free pull_request workflow and leaves its result as an artifact:

name: pr-test

on: pull_request

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
        with:
          global-json-file: global.json
      - name: Test and count
        run: |
          dotnet test --project api/tests/Bookstore.Api.Tests -c Release --report-xunit-trx --results-directory results
          grep -o 'passed="[0-9]*"' results/*.trx | head -1 | tr -dc '0-9' > results/passed.txt
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: test-summary
          path: results/passed.txt

The privileged second workflow runs no code; it reads the artifact as data and validates it first:

name: pr-comment

on:
  workflow_run: # zizmor: ignore[dangerous-triggers] artifact is validated as data
    workflows: [pr-test]
    types: [completed]

permissions:
  contents: read

jobs:
  comment:
    if: github.event.workflow_run.event == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      actions: read
      pull-requests: write
    steps:
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: test-summary
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
      - name: Comment with the result
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
        run: |
          passed=$(cat passed.txt)
          if ! [[ "$passed" =~ ^[0-9]+$ ]]; then
            echo "Unexpected artifact content, refusing to use it"
            exit 1
          fi
          gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "Unit tests passed: $passed"

The artifact is a file that passed through the attacker's hands; if its content is not in the expected shape, it is not used. I ran the test and counting commands locally (6 tests, 6 in passed.txt); because the chain of two workflows needs the GitHub API (Application Programming Interface) and gh, it was not run under act and was validated with actionlint and zizmor. Without the comment, zizmor still flags workflow_run (error[dangerous-triggers]: use of fundamentally insecure workflow trigger); that is the tool's way of saying "explain why you use it". The trailing ignore comment records that decision.

persist-credentials: false is part of this section too. By default actions/checkout keeps the token for later Git commands; since v6 it is stored in a separate file under $RUNNER_TEMP instead of .git/config, but it is still there for the job's later steps. In a job that uploads the workspace as an artifact or runs untrusted code, turn it off if you don't need the token.

Cache Poisoning and Read-Only Cache Tokens

A cache is data carried between jobs and even workflows; a cache written in the default branch scope is restored in later privileged runs. If an untrusted run can write a cache into the default branch scope, a poisoned NuGet or npm folder can leak into a release build. Since June 26, 2026, untrusted events such as pull_request_target, issue_comment and workflow_run from fork PRs get a read-only cache token in the default branch scope: restores work, saves are blocked with a warning. A new cache-mode field (read, write, write-only, none) also arrived at workflow and job level; actionlint 1.7.12 does not know it yet (unexpected key "cache-mode" for "job" section), so I did not use it in the examples. Do not trust content restored from cache in release builds; that is another reason restores driven by lock files (--locked-mode, npm ci) matter.

In Production

Make GITHUB_TOKEN read-only by default. Select "Read repository contents permission" in repository and organization settings; every job that needs write access should ask for it with permissions:. This is the least privilege we discussed in post 08, applied.

Scan workflows in CI. actionlint on every PR; zizmor and the Pinned-Dependencies, Token-Permissions and Dangerous-Workflow checks of OpenSSF (Open Source Security Foundation) Scorecard weekly. zizmor's output can be uploaded to code scanning as SARIF (Static Analysis Results Interchange Format).

When you hear that an action was compromised, work through the timeline: block the action in allowed actions, find the runs inside the exposure window, look for base64-encoded blocks in their logs, rotate every secret those runs could reach. In a public repository the logs are public; "maybe nobody saw it" is not an option.

Do not attach a self-hosted runner to a public repository. Anyone can open a PR, and there is no guarantee the runner is cleaned between jobs; more in post 14.

Common Pitfalls

Trusting tags. @v4 is a name; you are trusting its owner's account, not the content.

Pinning a SHA and forgetting to update it. A pinned but two-year-old action is an action frozen with its known vulnerabilities. Pinning without Dependabot is half the job.

Pinning with a short SHA. Only a full-length (40-character) SHA guarantees immutability; the required-SHA policy also demands full length.

Putting untrusted values into run: or script: as expressions. Title, branch name, commit message, input: all through env:.

Checking out the PR head in pull_request_target. checkout@v7 now refuses by default; writing allow-unsafe-pr-checkout: true means switching that protection off deliberately.

Treating the artifact in workflow_run as trusted. Untrusted code produced it; don't execute it, hand it to a shell or use it as a path without validation.

A Small Experiment

★☆☆ Scan a workflow from the series with zizmor in --offline mode, convert every uses: line to the SHA you find with git ls-remote and scan again. How many findings remain?

★★☆ Repeat the unsafe step of the pr-title workflow with the branch name (github.head_ref) and change the branch name in the event file you give act. Which of actionlint and zizmor catches it?

★★★ In the pr-comment workflow, use a JSON (JavaScript Object Notation) artifact instead of passed.txt and validate its content against a schema with jq: only the expected fields, only numbers. Prepare a workflow_run event file for act with an artifact that fails validation and see how the step rejects it.

In this post we took the runner as a given: ubuntu-latest and the jobs running on it. Yet ubuntu-latest is shifting under our feet right now. In the next post we look at hosted images, the Ubuntu 26.04 migration, arm64 and larger runners, and when self-hosted runners are the right choice.