
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:

• 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):
• 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

• 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.
Red: the same, inside a composite action

• 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

• 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:
Amber: no permissions: block, or write-all

• 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

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 workflowsSources
• 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.