ghtier

GitHub Actions

How a workflow run works, what an attacker gets when one goes wrong, and how to lock it down, with most of the attention on version pinning. The Best Practices page has the baseline hardening examples (least-privilege permissions, safe pull_request_target, script injection, OIDC). This page goes further on why those matter and where they fall short.

How a run works

Every workflow run is the same chain. Something fires an event, GitHub picks a workflow file from some ref, hands a runner a token and the secrets the job asks for, and then executes every step in order. Nothing separates your own build script from a third-party action in step three: they share the filesystem, the environment, the network, and the process memory where secrets live.

  1. 1

    Event

    A push, PR, comment, schedule, or API call. Decides who is effectively running your CI.

  2. 2

    Workflow file

    Read from a ref the event decides. For a fork PR that can be the attacker's copy.

  3. 3

    Runner

    A fresh VM (hosted) or your own machine (self-hosted), given a token and the job's secrets.

  4. 4

    Steps

    Your scripts and every third-party action, all in the same process space with the same access.

  5. 5

    Side effects

    Pushes, releases, packages, cache entries, artifacts, cloud deploys. The real blast radius.

Security questions map onto that chain. Who can fire the event? Which copy of the workflow runs? What does the token let the job do? Which code, from whom, runs in the steps? And what can the job change afterwards? Almost every Actions incident on this site is one of those questions answered badly.

Triggers and who controls them

The trigger is the trust decision. It decides whether an outsider can start the run, whether their copy of the workflow is used, and whether the run gets secrets. The dangerous combination is always the same: a trigger that runs with secrets, plus a step that runs or reads something an outsider controls.

TriggerWho can fire itWorkflow file fromFork run getsRiskWatch for
pull_requestAnyone who can open a PR, including from a forkThe PR's merge commit, so the PR author controls the workflow fileRead-only GITHUB_TOKEN, no secretslowSafe by design for forks. The risk is a same-repo branch PR, which gets secrets, or a later privileged workflow that trusts this run's output.
pull_request_targetAnyone who can open a PR, including from a forkThe default branch (since Dec 2025)Secrets and the repo's default token permissionshighOnly dangerous once the job checks out or runs PR code. That is the classic pwn request. It also shares the default branch's cache scope.
workflow_runWhoever triggered the upstream workflow, which can be a fork PRThe default branchSecrets and write token, even when the upstream run came from a forkhighArtifacts, branch names, and PR titles from the upstream run are attacker-controlled. Treat anything downloaded from it as untrusted input.
issue_comment / issues / discussionAnyone who can comment, on public repos that is everyoneThe default branchSecrets and write tokenhighTitles, bodies, and comments go straight into expressions. ChatOps bots that check out a PR on a '/test' comment are pwn requests by another name.
pushAnyone with write access to the branchThe pushed commitNot applicable, forks can't pushmediumA write-access user can add a new workflow on any unprotected branch and read every secret it references. Write access is effectively secrets access unless deploy secrets live in protected environments.
workflow_dispatch / repository_dispatchWrite access, or any token with actions: write / contents: writeThe ref the caller choosesNot applicablemediumA stolen token with write access can start a release workflow on a branch it just pushed. Inputs are attacker-controlled strings.
scheduleNobody directly, it runs on a timerThe default branchNot applicablelowLow exposure, but these jobs often hold broad tokens for maintenance and quietly keep running long after anyone looks at them.

Two things that catch people out. First, a pull request from a branch in the same repository is not a fork PR, so it gets secrets. Anyone with write access can open one with a modified workflow. Second, the privileged triggers (pull_request_target, workflow_run, issue_comment) are fine on their own. They become a pwn request when a step checks out the PR, restores a cache the PR could have written, or puts a title or comment into a shell.

What an attacker gets inside a job

Once untrusted code runs in a step, whether through a compromised action, a malicious dependency, or script injection, it has everything the job has. Assume all of this is gone:

The GITHUB_TOKEN

With whatever permissions the job was given. On actions/checkout before v6 it was also written to .git/config, where any later step or uploaded artifact could pick it up (the ArtiPACKED finding).

Every secret the job references

The runner process holds them in memory in plain text. Log masking doesn't help: the tj-actions payload read them straight out of the Runner.Worker process memory.

An OIDC token, if id-token: write is set

Which can be traded for cloud credentials for as long as the job runs, from anywhere.

Credentials set up by earlier steps

Cloud CLI sessions, registry logins, npm/pypi tokens in config files. The environment is richer than the YAML suggests.

The Actions cache

A job can write entries that later, more privileged runs on the same branch scope restore. That is how Ultralytics was poisoned.

Outbound network

Hosted runners have unrestricted egress by default, so exfiltration is one curl away.

The runner host itself (self-hosted only)

A non-ephemeral runner keeps whatever the attacker leaves behind, for every future job that lands on it, and sits inside your network.

The takeaway is that the blast radius of a job is decided before it starts: by its token permissions, the secrets it references, its OIDC trust, and the runner it lands on. Reducing any of those helps even when every other control fails.

GITHUB_TOKEN scopes by tier

Each scope you add to a job's permissions: block is handed to every step in that job. These are the permissions a workflow can ask for, from the Permission Reference, on the same scale as everything else. The token only reaches the repository the workflow runs in and expires when the job ends (at most 24 hours), so none of them is Tier 0 on its own.

TierScopeWhat it allows
T1
actions: writeStarts, re-runs and cancels workflows and deletes caches and artifacts. Starting a privileged publish or deploy job by hand was the step that mattered in the Nx incident.
T1
id-token: writeLets the job request an OIDC token and trade it for cloud credentials. The cloud trust policy is the only check left; a wildcard subject turns this into cloud admin.
T1
packages: writePublishes packages. Whatever is published is pulled by everyone who depends on it.
T2
artifact-metadata: writeWrites artifact deployment records.
T2
attestations: writeUploads and deletes attestations. Deleting one makes a genuine artifact fail verification.
T2
checks: writeCreates and completes check runs, which can satisfy required checks named in branch rules.
T2
contents: writePushes commits, creates and moves branches and tags, and edits releases. Cannot change workflow files without Workflows: write. On a repository other projects consume, moving a tag changes what they run.
T2
deployments: writeCreates deployments, and approves or rejects pending ones where the caller is a required reviewer or the custom protection rule.
T2
discussions: writeCreates and edits discussions. GitHub Discussions has been used as a command channel for malware.
T2
issues: writeCreates and edits issues and comments. Text in issues is what prompt injection against AI agents uses.
T2
pages: writeBuilds and deploys GitHub Pages. Can publish content on the repository's Pages site.
T2
pull-requests: writeOpens, reviews, approves and merges pull requests. An approval can satisfy a review rule when the approver counts as a reviewer.
T2
security-events: writeDismisses code scanning alerts and uploads analyses, so findings can disappear without a fix.
T2
statuses: writeSets commit statuses. A required status check that does not pin its source can be marked green by anyone with this.
T3
actions: readReads workflow runs, logs, artifacts and caches. Logs and artifacts sometimes contain tokens that were not masked.
T3
artifact-metadata: readReads artifact deployment records.
T3
attestations: readReads build provenance attestations.
T3
checks: readReads check runs and suites.
T3
contents: readReads code, commits, branches, tags and releases. On a private repository this is the source code itself.
T3
deployments: readReads deployments and their statuses.
T3
discussions: readReads discussions.
T3
issues: readReads issues and comments.
T3
packages: readDownloads packages from GitHub Packages.
T3
pages: readReads the GitHub Pages configuration.
T3
pull-requests: readReads pull requests and reviews.
T3
security-events: readReads code scanning alerts and analyses. Shows where the code is weak.
T3
statuses: readReads commit statuses.

A workflow with no permissions: key falls back to the repository default, which on older orgs is often read/write on nearly everything. Setting the org default to read-only and writing an explicit block per job is the single cheapest change on this page.

Version pinning

A uses: line is a dependency on someone else's repository, fetched fresh on every run. What you pin to decides who can change the code your job runs, and whether you would notice.

ReferenceExampleWho can change what runsRisk
Branchactions/checkout@mainAnyone who can push to that branch in the action's repo, at any timehigh
Tagactions/checkout@v6Anyone with write access to the action's repo. Tags can be moved or recreated silently, which is what tj-actions and TeamPCP didhigh
Tag on an immutable releaseowner/action@v2.1.0 (release published as immutable)Nobody, the tag is locked to its commit and can't be deleted. Floating major tags like v2 are still mutablemedium
Full commit SHAactions/checkout@<40-char sha> # v6.0.1Nobody. Changing the code means a new SHA, which means a visible diff in your repolow

Pin to a SHA and keep the version as a comment

GitHub's own guidance calls a full-length SHA the only way to use an action as an immutable release. The trailing comment is what makes pins reviewable and lets Dependabot and Renovate update them.

steps:
  - uses: actions/checkout@<full 40-char sha> # v6.0.1
    with:
      persist-credentials: false
  - uses: actions/setup-node@<full 40-char sha> # v5.0.0

For the first pass across many repositories, a tool like pinact rewrites tag references to SHA plus comment in place. After that, let Dependabot keep them current:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"          # covers .github/workflows
    schedule:
      interval: "weekly"
    groups:
      actions:
        patterns: ["*"]     # one PR per week instead of one per action

Review those PRs like code, because they are code. A bump PR that is auto-merged pins a malicious release for you just as fast as a floating tag would pull it in. Look at the upstream diff for anything that touches network calls, credentials, or new nested uses: lines.

Enforce it, don't just recommend it

Since August 2025 the Actions policy at enterprise, org, and repo level has a checkbox that fails any workflow using an action not pinned to a full SHA. The same allow-list accepts ! entries to block a specific action or version outright, which is the fastest way to cut off a compromised action across an org while you work out who used it:

# Settings > Actions > General > Allow select actions and reusable workflows
your-org/*,
aws-actions/configure-aws-credentials@*,
docker/build-push-action@*,
!tj-actions/changed-files@*

Scan first, then enforce. Run a scanner across the org to find unpinned uses, fix them, then turn on the SHA requirement. Turning it on first breaks every unpinned workflow on its next run.

What a SHA pin does not cover

Nested actions inside composite actions

A composite action you pinned by SHA can itself call other actions by tag. You get whatever those tags point to today. When reviewdog/action-setup@v1 was backdoored in March 2025, other reviewdog actions that call it by tag passed the payload on, including to workflows that had pinned those actions by SHA.

Docker-based actions

An action whose action.yml says `image: docker://some/image:latest`, or whose Dockerfile starts `FROM some/image:tag`, pulls a mutable image on every run.

Things the action downloads at runtime

Setup actions that fetch 'the latest' binary, install scripts piped from curl, and unpinned package installs. The SHA pins the wrapper, not what it fetches.

Reusable workflows

`uses: org/repo/.github/workflows/build.yml@main` is a mutable reference to a whole job, secrets included if you pass `secrets: inherit`.

Impostor commits

GitHub resolves a SHA from any fork in the repository's network, so `owner/action@<sha>` can point at a commit that only exists in an attacker's fork. Check that a new SHA is reachable from a tag or branch in the real repo.

Auto-merged bump PRs

If Dependabot or Renovate PRs for actions merge without review, a malicious release gets pinned for you. Pinning buys review time only if someone uses it.

GitHub's 2026 roadmap targets the first of these directly: a dependencies: section in the workflow file that locks every direct and nested action to a SHA, so composite actions no longer hide what they pull in. Until that is available to you, prefer actions that pin their own dependencies (OpenSSF Scorecard's Pinned-Dependencies check shows this), and vendor small actions into your own org where you can.

If you publish actions yourself

  • Turn on immutable releases, so a published tag is locked to its commit and can't be deleted or moved.
  • Add a tag ruleset that blocks force-updates and deletion for everyone, including admins.
  • Pin every nested uses: and Docker image inside your action by SHA or digest, so your consumers' pins mean something.
  • Publish from a workflow that runs no untrusted code, with a token scoped to that repository only.

Hardening, layer by layer

Controls that are set once for the whole org beat controls that rely on every workflow author getting it right. Work from the top down.

1. Org and enterprise policy

  • Default GITHUB_TOKEN permission set to read-only.
  • "Allow GitHub Actions to create and approve pull requests" turned off.
  • Allowed actions restricted to your org, GitHub-verified creators, and a reviewed allow-list, with SHA pinning required.
  • Approval required for workflow runs from outside contributors on public repos.
  • Self-hosted runners limited by runner group to the repos that need them, and never available to public repos.
  • Workflow execution protections to limit who can run release and deploy workflows, and to disallow pull_request_target where nothing needs it. Start in evaluate mode.

2. The workflow file

  • permissions: {} at the top, then grant per job.
  • persist-credentials: false on checkout unless the job pushes.
  • No ${{ }} expressions with event data inside run:. Pass them through env:.
  • Deploy secrets in environments with required reviewers and a branch rule, not as repo or org secrets.
  • OIDC for cloud access, with the trust policy pinned to repo and environment.
  • No secrets: inherit into reusable workflows. Pass the ones they need by name.
  • Separate caches for release builds, or no cache at all in jobs that publish.
permissions: {}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@<sha> # v6.0.1
        with:
          persist-credentials: false
      - run: make test

  publish:
    needs: build
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: release        # required reviewers + main-only rule
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    steps:
      - uses: actions/checkout@<sha> # v6.0.1
        with:
          persist-credentials: false
      # build, push by digest, attest

3. The runner

  • Ephemeral or just-in-time self-hosted runners, one job per machine, rebuilt from an image.
  • Runners grouped by trust level. A runner that builds public PRs never shares a group with one that deploys.
  • Egress control. On hosted runners that means an agent such as Harden-Runner today, or GitHub's native egress firewall as it rolls out. Start by recording traffic, then block.

4. Detection and review

Scan workflow files on every pull request, the same way you scan code. These catch most of the patterns on this page:

The Tooling page goes through each of these and the rest of the open-source options, with how they work and how to run them in CI.

  • Require code owner review on .github/workflows/ and .github/actions/.
  • Stream the audit log out of GitHub and alert on workflow file changes, new self-hosted runners, secret changes, and deleted run logs.
  • Keep a list of which repos use which actions. When the next tj-actions happens, the first question is "where do we use it", and the answer should take minutes.

The Checklist tracks the org-level items above under "CI/CD & GitHub Actions".

What GitHub changed recently

Several defaults changed between 2025 and 2026. If your threat model or internal guidance predates these, parts of it are out of date. Roadmap items move, so check the linked changelog before relying on one.

  1. Apr 2025

    CodeQL analysis for Actions workflows is GA ↗

    Code scanning flags script injection, missing permissions, and untrusted checkout in workflow files.

  2. Aug 2025

    Actions policy can block actions and require SHA pinning ↗

    Prefix an allow-list entry with ! to block an action or version. A separate checkbox fails any workflow that uses an action not pinned to a full SHA. Available at enterprise, org, and repo level.

  3. Nov 2025

    actions/checkout v6 stops writing the token into .git/config ↗

    Credentials move to $RUNNER_TEMP, so an uploaded workspace no longer carries the token with it.

  4. Dec 2025

    pull_request_target always runs the default branch's workflow ↗

    Old, vulnerable copies of a workflow on other branches can no longer be triggered by targeting a PR at that branch. Environment branch rules now evaluate against the ref that actually runs.

  5. Mar 2026

    GitHub publishes its 2026 Actions security roadmap ↗

    Workflow dependency locking, scoped secrets, execution policies, an Actions data stream, and a native egress firewall for hosted runners. Most items were slated for preview within 3 to 9 months.

  6. Jun 2026

    actions/checkout v7 refuses fork PR code in privileged triggers ↗

    Checking out a fork PR in pull_request_target or workflow_run fails unless you set allow-unsafe-pr-checkout. Backported to all supported major versions on July 20, 2026.

  7. Sep 2026

    Workflow execution protections are GA ↗

    Allow-lists for who can trigger a workflow and which events may start it, scoped down to individual workflow files, with an evaluate mode and a REST API.

One side effect worth knowing: the checkout v7 fork guard was backported to older major tags, so workflows on actions/checkout@v4 got it automatically, while workflows pinned to an old SHA did not. Pinning means you also have to take security fixes deliberately.

Incidents on this site

Real cases behind the sections above, each with a step-by-step walkthrough and mitigations.

ApplicationMarch 2025

tj-actions/changed-files supply-chain compromise (CVE-2025-30066)

The attacker used a previously obtained GitHub token with write permissions to push a commit disguised as renovate[bot], slipped into a legitimate auto-merged PR, then retroactively repointed multiple version tags at the malicious commit, affecting over 23,000 repositories. The payload dumped runner memory to logs, exposing access keys, PATs, npm tokens, and private RSA keys. Upstream, reviewdog/action-setup (CVE-2025-30154) was backdoored first, and researchers traced the original target to Coinbase's agentkit repo. The permission abused, contents:write on an Action repo, implies tag-write, which implies control of every consumer pinning by tag.

Click to open →
OrganizationMarch – April 2026

TeamPCP tag force-overwrite campaign

The most complete demonstration of tag-write as a permission tier. On 19 March 2026 (CVE-2026-33634), continuing an attack that began in late February, the actors used credentials with tag-write privileges to publish Trivy v0.69.4 and force-overwrite 76 of 77 tags in aquasecurity/trivy-action and all seven in aquasecurity/setup-trivy, redirecting trusted versions to malicious commits with no visible change in release metadata. Checkmarx's KICS GitHub Action followed on 23 March, when all 35 of its tags were moved through a compromised service account, and on 24 March LiteLLM versions 1.82.7 and 1.82.8 were uploaded straight to PyPI with credentials taken from LiteLLM's own CI, where Trivy ran. Researchers traced at least seven waves through to @bitwarden/cli hijacked with stolen KICS npm tokens, and elementary-data via Actions script injection. Each compromised environment yielded the credentials for the next.

Click to open →
OrganizationOngoing

"Pwn requests": pull_request_target & workflow_run

The structural flaw behind most of the incidents on this page. pull_request deliberately withholds write permissions and secrets; pull_request_target was created to restore them, and becomes hazardous the moment maintainers treat it as a normal CI trigger. workflow_run carries the same risk and gets far less attention: the triggered workflow can access secrets and write tokens even when the workflow that triggered it could not. One industry scan found 38% of organizations have a workflow vulnerable to script injection or dangerous triggers. Since 8 December 2025, pull_request_target always runs the workflow file from the default branch and checks out the default branch, whatever branch the pull request targets. That closes attacks through an old, vulnerable workflow left on another branch. It does not help when the default branch workflow itself checks out and runs the pull request's code.

Click to open →
OrganizationDecember 2024

Ultralytics build cache poisoning

Template injection via a crafted branch name against a format.yml workflow using pull_request_target. Secrets including secrets._GITHUB_TOKEN were exfiltrated and the build cache poisoned to inject a cryptominer into v8.3.41 and v8.3.42. The read from researchers was that the attackers had write access but preferred extracting CI/CD secrets, then used them to poison the cache and ship malicious releases.

Click to open →
OrganizationOngoing

Actions cache poisoning (Angular, TanStack)

Actions caches are shared between PR workflows and release workflows. One researcher's angular/dev-infra chain was script injection via ${{ github.head_ref }} in a pull_request_target workflow, then cache filling with a purpose-built tool. TanStack saw 84 malicious versions across 42 @tanstack/* packages from the same class of chain on 11 May 2026: a pull_request_target workflow ran a fork's code, which poisoned the pnpm store in the shared cache, and the release workflow restored it and leaked its npm OIDC token. There is no CVE for any of this; the cache is behaving exactly as designed.

Click to open →
ApplicationAugust 2024

ArtiPACKED artifact token leakage

actions/checkout persists the GitHub token into .git/config by default; uploading the whole checkout directory as an artifact exposes it. Researchers observed leakage of both GITHUB_TOKEN and the undocumented ACTIONS_RUNTIME_TOKEN, which lives about six hours and can be used to swap an artifact for a malicious version. The token-theft path is a race condition: download the artifact mid-run and create a branch before expiry. It requires the workflow to hold contents: write.

Click to open →
OrganizationSeptember 2025, returned 2026

GhostAction

327 compromised GitHub user accounts were used to inject workflows disguised as "Add GitHub Actions Security workflow" across 817 repositories, stealing 3,325 secrets. The actors enumerated secret names from legitimate workflow files first, then hardcoded those names into their own workflow, harvesting PyPI, npm, DockerHub, GitHub, Cloudflare, and AWS credentials. It came back in 2026. GitGuardian found the malicious workflow in 772 public repositories belonging to 373 users and organizations between 31 August and 30 September 2026, aimed at 2,577 secrets; GitHub held most of the runs for approval, and by 5 October only 16% of the repositories had removed the workflow. From 7 October Socket saw more than 500 accounts committing it to tens of thousands of repositories.

Click to open →
OrganizationAugust 2025

Nx "s1ngularity"

The injected script extracted a read/write GitHub token, a consequence of the workflow permissions setting, then created a branch replacing the CI script, triggered the publish workflow against that branch via the API using workflow_dispatch, and deleted branches and workflow runs to clean up. When publish ran, it had NPM_TOKEN in scope and exfiltrated it. The PR targeted an outdated branch that still carried the vulnerable workflow after it had been reverted on master. In phase two, the stolen GitHub tokens were used to flip thousands of private repositories to public. Downstream, one threat-intel group rode the consequences to full AWS administrator access at a victim org in under 72 hours, via OIDC trust chains and a CloudFormation IAM capability chain. No zero-days were involved.

Click to open →
Organization2023–2024

Self-hosted runner takeovers (PyTorch and others)

A fork PR makes you a "previous contributor," after which workflows run on the self-hosted runner without approval; default runner setup is non-ephemeral, so access persists. On PyTorch, researchers confirmed root on the runners and pulled GITHUB_TOKENs with extensive write access out of .git/config during active workflows. Against GitHub's own actions/runner-images, the access reached internal macOS private cloud vCenter and Azure credentials, and could have poisoned every hosted runner base image. TensorFlow's fix was requiring approval for all fork PRs and setting GITHUB_TOKEN to read-only on self-hosted runners.

Click to open →
OrganizationFebruary 2026

hackerbot-claw autonomous agent RCE

An autonomous agent doing this at scale. It abused unsafe pull_request_target, unsanitized inputs, dynamic shell execution, and overprivileged GITHUB_TOKEN to get RCE on GitHub-hosted runners across at least six repositories, exfiltrating write-capable tokens in some cases. Targets included Trivy, avelino/awesome-go, microsoft/ai-discovery-agent, DataDog/datadog-iac-scanner, project-akri/akri, RustPython, and ambient-code/platform, where the vector was prompt injection via CLAUDE.md, which was blocked. On Trivy it stole a PAT through a pull_request_target workflow and used it to take over the repository.

Click to open →

Sources: GitHub Docs: Secure use reference, Workflow syntax: permissions, Keeping your actions up to date with Dependabot, and the GitHub changelog entries linked above.