osec Workflow risk check cover: is your GitHub repo one pull request away from a poisoned release?

Why bother

• Ultralytics, 4 Dec 2024: a branch name in a stranger's pull request ran as a shell command in a pull_request_target job. By 7 December four poisoned releases had shipped to PyPI (case analysis).
• spotbugs, late 2024 → tj-actions, 14 Mar 2025: a pull_request_target job checked out pull request code; a maintainer's token leaked; months later every tag of tj-actions/changed-files was moved, about 23,000 repositories ran it (case analysis).
• Nx, Aug 2025: a PR-title check added on 21 August pasted the title into run: under pull_request_target. On 26 August malicious nx versions were on npm for about four hours.
• All three were YAML you can read. No exploit needed to find them.

Run it in five steps

1. Open osec.one/tools/workflow-check.
2. Paste a public repo (owner/repo or its GitHub URL), or a link to one workflow file or action.yml. A link with a commit SHA in it reads that exact version.
3. Click Check workflows.
4. Read the header: files checked, to fix now (red), to tighten (amber).
5. Fix red first. Each finding has the line number and a Fix box with a Copy button.

• Every screenshot below is a real result, run on 22 Oct 2026 against the public, already-fixed versions of the files involved in the incidents above.
• Free, no account. Nothing runs: the check only reads the YAML.

Red: outsider-controlled text in run:

Workflow risk check result for nrwl/nx pr-title-validation.yml at the August 2025 commit: 2 to fix now, 1 to tighten; github.event.pull_request.body pasted into the shell script, line 32, with an env: fix to copy
Nx's PR-title workflow as added on 21 August 2025.

• What it means: ${{ github.event.pull_request.title }} (or body, branch name, commit message, issue title) is pasted into the script before the shell runs it. A title like a";curl ...|sh;" becomes a command.
• Red when the trigger gets secrets (pull_request_target, issue_comment, issues, workflow_run...). Amber on plain pull_request.
• Also: lists more fields in the same step.

The fix (one per step):

yaml
- name: Validate PR title
  env:
    TITLE: ${{ github.event.pull_request.title }}
  run: echo "Validating PR title: $TITLE"   # quoted, never ${{ }} inside run:

• Same rule for actions/github-script: read process.env.TITLE, don't paste ${{ }} into script:.

Red: pull_request_target checks out the pull request's code

Workflow risk check result for spotbugs/sonar-findbugs sonarqube.yml at the December 2024 commit: pull_request_target checks out the pull request's code, line 45, the ref comes from steps.condval.outputs which is built from the pull request
spotbugs' sonarqube.yml, December 2024: the ref went through a step output. The check follows it.

• What it means: the job runs with your secrets and a write token, then checks out the stranger's code (head.sha, refs/pull/N/merge, head_ref). Any build, test or npm install after that runs their code with your rights.
• Also caught: the ref handed over through a step output or an env: variable, like spotbugs did.
• Green version of the same trigger: shown as a grey info line, Uses pull_request_target. Fine as long as nothing checks out PR code.

The fix: split it in two.

yaml
# 1) pr-build.yml: runs the PR's code, no secrets, read-only token
on: pull_request
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha> # v5
      - run: make test > results.txt
      - uses: actions/upload-artifact@<sha> # v4
        with: { name: results, path: results.txt }

# 2) pr-report.yml: has secrets, never checks out PR code, reads the artifact as data
on:
  workflow_run:
    workflows: [pr-build]
    types: [completed]

Red: the same, inside a composite action

Workflow risk check result for ultralytics/actions action.yml at the November 2024 commit: github.head_ref pasted into run: of a composite action, line 96, with an env: REF fix
ultralytics/actions' action.yml before December 2024: the branch name that poisoned the release.

• What it means: your workflow can be clean and still call an action that pastes github.head_ref into a shell. The check reads every action.yml in the repo (up to 15).
• Amber variant: Action input pasted into run: ${{ inputs.x }} in a script. Your callers may pass a PR title or branch name as that input.
• The fix: map it to env: first, exactly as in the Copy box (REF: ${{ github.head_ref || github.ref }}, then "$REF").

Amber: release job restores the Actions cache

Workflow risk check result for ultralytics/ultralytics publish.yml at the November 2024 commit: release job restores the Actions cache, line 61, fix cache-mode: none
Ultralytics' publish.yml as it was before the December 2024 releases.

• What it means: the job publishes (PyPI, npm, gh release, docker push...) and restores cached files first. Whoever can write that cache gets files into your release.
• Red variant: cache-mode: write on a trigger outsiders can start. Since June 2026 GitHub makes those caches read-only; declaring write undoes it.
• The fix:

yaml
jobs:
  publish:
    cache-mode: none          # this job never restores or saves the Actions cache
    # and drop cache: from setup-python / setup-node in this job

Amber: no permissions: block, or write-all

Workflow risk check finding: No permissions: block, jobs without their own block: validate-pr-title, with a permissions: contents: read fix to copy
Without a block the token gets the repository default.

• No permissions: block (amber): repos created before February 2023, and organisations that never changed it, give the token read and write on everything.
• permissions: write-all (red): same, on purpose.
• The fix: permissions: contents: read at the top of the file. Add more per job only where needed (pull-requests: write for a labeler, id-token: write for trusted publishing).

Amber: actions on a tag or branch

• What it means: uses: owner/action@v4 follows whatever the owner points v4 at. That's how tj-actions reached 23,000 repos.
• Amber if any third-party action is unpinned; grey if only GitHub's own (actions/*, github/*).
• The fix: click Pin them with the Action pin lookup. The lines arrive prefilled; you get @<sha> # v4.2.2 back (guide).

Also flagged

• Self-hosted runner on a pull request trigger (amber): a stranger's PR runs on your machine, which keeps files and credentials between jobs.
• ACTIONS_ALLOW_UNSECURE_COMMANDS (amber): re-enables set-env, so printed text can set variables for later steps.
• JavaScript and Docker actions are listed as skipped: the check reads YAML, not their code.

The 10-minute repo settings pass

Also worth a minute box under each Workflow risk check result: set Workflow permissions to read-only, require approval for workflow runs from outside contributors, map action inputs to env variables
The box under every result.

1. Settings → Actions → General → Workflow permissions: Read repository contents and packages permissions. Untick Allow GitHub Actions to create and approve pull requests.
2. Same page, Fork pull request workflows from outside collaborators: Require approval for all external contributors.
3. Same page, Actions permissions: tick Require actions to be pinned to a full-length commit SHA (available since August 2025).
4. Settings → Environments → New environment release: add yourself as a required reviewer; put the publish job on environment: release.
5. PyPI / npm: switch to trusted publishing, name that environment in the publisher settings, then delete every old API token (Ultralytics' 7 December releases came from one nobody revoked).
6. Add .github/ to CODEOWNERS and require code-owner review on the default branch, so workflow changes get a second look.
7. Run the check again. Header should read 0 to fix now.

Good to know

• Public repos only. For a private repo, nothing to paste yet; the patterns above are what to grep for (pull_request_target, ${{ github.event., head_ref, cache-mode).
• Up to 25 workflow files and 15 composite actions per repo. A single file link always works.
• Share link on every result (?id=): send it to whoever owns the repo.
• Sign up (email code, Google or Apple) to keep your results.

Paste a public GitHub repository and get each workflow's risky patterns with the fix to paste: injected PR text, pull_request_target checkouts, release jobs that restore the cache, missing permissions, unpinned actions. Free, no account.

Check your workflows

Sources

• GitHub Docs, security hardening for GitHub Actions (script injection, pull_request_target): https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions
• GitHub Security Lab, Keeping your GitHub Actions and workflows secure: preventing pwn requests: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
• William Woodruff, Ultralytics, zizmor and template injection: https://blog.yossarian.net/2024/12/06/zizmor-ultralytics-injection
• Palo Alto Unit 42, the tj-actions/changed-files incident and its spotbugs start: https://unit42.paloaltonetworks.com/github-actions-supply-chain-attack/
• Nx, s1ngularity postmortem: https://nx.dev/blog/s1ngularity-postmortem
• GitHub changelog, SHA pinning in the allowed actions policy (Aug 2025): https://github.blog/changelog/2025-08-15-github-actions-policy-now-supports-blocking-and-sha-pinning-actions/
• Files checked (public history): nrwl/nx@c916349 .github/workflows/pr-title-validation.yml, spotbugs/sonar-findbugs@eb3982e .github/workflows/sonarqube.yml, ultralytics/actions@edb4e5e action.yml, ultralytics/ultralytics@df82ce4 .github/workflows/publish.yml.