A pull_request_target workflow runs with the base repository's write token and secrets, so one that checks out or executes pull request code hands those privileges to anyone who can open a pull request. This page is the search-and-fix runbook for that one problem: find every workflow in an organisation that uses the trigger, sort the safe ones from the exposed ones, fix the exposed ones and keep new ones from appearing. It is for platform, DevOps and application security engineers who own GitHub organisations. Why privileged triggers are dangerous, and the wider set of workflow attacks, is covered in the GitHub and GitHub Actions Security Guide and the OWASP CI/CD Top 10 CICD-SEC-4 Poisoned Pipeline Execution Guide; this page focuses on doing the audit.
This is an independent guide from NHI Mgmt Group, based on GitHub's public documentation as of November 2026. It is not sponsored or reviewed by GitHub. Product features change, so confirm settings against GitHub's current documentation.
Key takeaways
- The trigger alone is not the bug. GitHub's own guidance allows
pull_request_targetfor jobs that label or comment; the exposure is the trigger combined with checking out, building or interpolating pull request content. - Search first, then triage by hand. Code search finds candidates in minutes; zizmor, OpenSSF Scorecard and CodeQL rank them; a person still has to read each flagged job.
- The default fix is a split. Build and test untrusted code under
pull_requestwith a read-only token, then do any privileged follow-up in a separateworkflow_runjob that treats artifacts as data. - The token outlives the checkout step. GitHub Security Lab notes that
actions/checkoutpersists credentials by default, so later steps can read the token from disk; setpersist-credentials: false. - Prevent regressions in CI. Run a workflow linter on every change under
.github/workflows/and treat a new privileged trigger as a reviewed change, not a routine edit.
When you need this
- You own public repositories that accept fork pull requests. Anyone with a GitHub account can open a pull request, and GitHub's events reference states that
pull_request_targetworkflows run in the context of the base repository's default branch. - You inherited workflows nobody has reviewed. Bots that label, triage, deploy previews or post coverage are the usual places this trigger appears.
- A scanner flagged a "dangerous trigger" or "untrusted checkout". This page explains how to confirm whether the finding is real and how to fix it.
- Private and internal repositories still count. The contributor pool is smaller, but an insider or a compromised contributor account can use the same path to reach deployment secrets.
What makes a workflow exposed
GitHub's secure use reference describes pull_request_target and workflow_run as privileged triggers that share the main branch's cache and says workflows using them "must not explicitly check out untrusted code, including from pull request forks". The events reference warns that running untrusted code on this trigger can lead to cache poisoning and to unintended access to write privileges or secrets. Read each candidate against this table.
| Pattern in the workflow | Exposure | Why |
|---|---|---|
| Trigger only labels, comments or assigns using event metadata (PR number, author, changed file names via API) | Low | No pull request code runs. Still restrict permissions to what the job needs. |
actions/checkout with ref: ${{ github.event.pull_request.head.sha }}, head.ref or refs/pull/N/merge | Critical if any step then runs code | Install hooks, build scripts, test runners and Makefiles from the pull request execute with the privileged token and secrets. |
PR-controlled text (github.event.pull_request.title, .body, .head.ref) inside a run: block | Critical | Template expansion writes attacker text straight into the shell script, even without a checkout. |
Label gate such as if: contains(github.event.pull_request.labels.*.name, 'safe to test') before a checkout | High | GitHub Security Lab calls labels a temporary measure: new commits can be pushed after a maintainer applies the label. |
Writes to GITHUB_ENV or GITHUB_PATH from PR-derived values | High | Later steps inherit attacker-chosen environment variables or binaries. |
| Cache save or restore in the privileged job | High | Privileged triggers share the default branch cache, so a poisoned entry can reach release builds. |
workflow_run job that executes downloaded artifacts | Critical | GitHub Security Lab: artifacts produced from untrusted pull request data are themselves untrusted. |
Note the December 2025 platform change. GitHub's changelog of 7 November 2025 announced that from 8 December 2025 the workflow file and checkout commit for pull_request_target always come from the default branch, so outdated copies on other branches no longer run. That narrows where to look, but it does not make an exposed workflow on the default branch safe.
Before you start
- Access: read access to every repository in scope; organisation owner or security manager rights if you will enable code scanning or change Actions policies.
- A token for tooling: zizmor reads a token from
GH_TOKEN,GITHUB_TOKENorZIZMOR_GITHUB_TOKEN; per its docs, private repositories needcontents: read(Actions),reposcope (classic PAT) or a fine-grained token with repository access. Use a short-lived token for the audit and revoke it afterwards. - A tracking sheet: repository, workflow path, trigger, job, finding, exposure rating, owner, fix status. This becomes the evidence trail.
- Agreement on a freeze: owners of exposed workflows should be ready to disable them the same day if triage confirms a critical finding.
Step-by-step: find and triage
- Search the organisation with GitHub code search. Code search supports
org:,path:globs, boolean operators and regular expressions. Start broad, then narrow:org:YOUR-ORG path:.github/workflows pull_request_targetlists every candidate file.org:YOUR-ORG path:.github/workflows pull_request_target /head\.(sha|ref)/narrows to files that also reference the pull request head.org:YOUR-ORG path:.github/workflows workflow_run download-artifactfinds privileged follow-up jobs that consume artifacts.org:YOUR-ORG path:.github/workflows pull_request_target /github\.event\.pull_request\.(title|body|head\.ref)/finds likely injection sinks.
NOT is:archivedto skip archived repositories, and record them separately. Code search does not support partial or regex matching on organisation names, so run one query per organisation. - Run zizmor across the candidates. zizmor audits remote repositories by slug (for example
owner/repo, optionally with@ref). Itsdangerous-triggersaudit flagspull_request_target,workflow_runand, from v1.31.0,issue_comment;template-injectionflags${{ }}expansions in code contexts;artipackedflags persisted checkout credentials;cache-poisoningflags cache use that can reach release workflows. Use--format=sarifto load results into code scanning, and note that the default "regular" persona favours high-signal findings while--persona=auditorshows everything for manual review. - Cross-check with OpenSSF Scorecard. The Dangerous-Workflow check (risk Critical) looks for a
pull_request_targetorworkflow_runtrigger combined with an explicit checkout of pull request code, and for script injection with untrusted context variables. Scorecard's docs state it does not evaluate whether the checkout is guarded, for example by a label, so a Scorecard finding on a label-gated job still needs review. Its Token-Permissions check shows whether top-level permissions are read-only. - Turn on CodeQL for workflows. GitHub's secure use reference says CodeQL can scan and detect potentially vulnerable Actions workflows. The CodeQL query help for Actions includes untrusted checkout queries for privileged and non-privileged contexts, plus TOCTOU variants, artifact poisoning, cache poisoning, code injection and excessive secrets exposure. Check that Actions appears in the language list when you configure default setup.
- Triage each hit by reading the job. For every flagged workflow answer: Does the job check out pull request content? Does any step after that execute files from the checkout (install, build, test, lint with plugins, scripts)? Which secrets, environments and cloud roles can the job reach? What does the
permissionsblock grant? Rate it with the table above. - Contain confirmed critical findings first. Disable the workflow (Actions tab, or rename the trigger in a protected change) before redesigning it. Then check the repository's Actions run history for runs from forks or first-time contributors and treat any secret the job could reach as exposed until proven otherwise. The CI/CD Pipeline Compromise Playbook covers the response if a run looks malicious.
How to fix each pattern
| Finding | Fix | Notes |
|---|---|---|
Job builds or tests PR code under pull_request_target | Move it to pull_request; if it must comment or write status, add a separate workflow_run job that reads results from artifacts | The pattern GitHub Security Lab and the secure use reference recommend. |
| Job only labels or comments | Keep the trigger, remove any checkout, set job-level permissions to the minimum (for example pull-requests: write) | Use the API for changed files rather than checking out the branch. |
Untrusted text in run: | Pass the value through env: and reference it as a shell variable, or use an action that takes it as an argument | zizmor's docs note ${{ env.VAR }} is still template expansion; use ${VAR} in the shell. |
| Label-gated checkout | Replace with the split above; if a gate must stay for now, check out the exact SHA that was reviewed and require environment approval | Treat as temporary, with an expiry date on the exception. |
| Persisted checkout credentials | Set persist-credentials: false on every checkout that does not push | zizmor's artipacked audit tracks this. |
workflow_run consumes artifacts | Extract to a temporary directory outside the workspace, read only simple values, validate them (for example that a PR number is an integer), never execute them | Per GitHub Security Lab. |
| Cache use in privileged or release jobs | Remove cache restore from release workflows, or make it read-only where the action supports it | Confirm the exact input names in each action's documentation. |
Set the workflow-level default to permissions: contents: read (or read-all) and grant writes per job, as both the secure use reference and Scorecard's Token-Permissions check recommend. Pin third-party actions used in privileged jobs to full commit SHAs; see How to Pin GitHub Actions to Commit SHAs.
Verify it worked
- Re-run the same code search queries. Every remaining
pull_request_targethit should be on your "low exposure, reviewed" list. - Re-run zizmor and Scorecard. Dangerous-Workflow should pass on fixed repositories; any remaining dangerous-triggers finding should carry a documented reason.
- Test from a fork. Open a pull request from a test fork that changes a build script to print a marker. Confirm the marker appears only in the unprivileged
pull_requestrun and that the run shows a read-only token and no secrets. - Close or rebase stale pull requests. GitHub Security Lab advises that open pull requests created before a fix may still run the vulnerable version; the December 2025 default-branch change reduces this, but confirm on your own runs.
Rollback and common errors
- Rollback: revert the workflow change through a normal reviewed pull request. Never roll back to a version that checks out PR code in a privileged job; disable the automation instead and run it manually until fixed.
- Environment protection stopped matching: since 8 December 2025, environment branch rules for
pull_requestevents evaluate againstrefs/pull/N/mergeand forpull_request_targetagainst the default branch. Update filters rather than widening them to all branches. - Comment bot lost write access after the split: the
workflow_runjob needs its ownpermissionsblock; thepull_requestjob should stay read-only. - Annotations missing in CI: zizmor's docs note that a single step renders only 10 GitHub annotations; use SARIF upload for full results.
Prevent new exposures
- Lint on change: run zizmor (SARIF) or rely on CodeQL for every pull request that touches
.github/workflows/, and block merges on high findings. - Review ownership: add CODEOWNERS for
.github/workflows/so a platform or security reviewer approves trigger changes. - Fork run approvals: GitHub lets organisations require maintainer approval before workflows run on fork pull requests. Our recommendation (NHI Mgmt Group) is to require it for first-time and outside contributors, while not relying on it as the control for privileged triggers.
- Least privilege for what the job can reach: keep deployment secrets in environments with required reviewers, and prefer OIDC federation over stored cloud keys so an exposed job cannot exfiltrate a long-lived credential. See the CI/CD Pipeline Identity Security Guide.
Measurement and framework mapping
Track these metrics, ours rather than GitHub's, monthly:
- Workflows using
pull_request_targetorworkflow_run, and the share reviewed and rated. - Open critical or high findings (untrusted checkout, injection) and median days to fix.
- Repositories passing Scorecard Dangerous-Workflow and Token-Permissions.
- Share of checkouts with
persist-credentials: false. - Repositories with workflow linting enforced on pull requests.
| Framework | Reference | How this task supports it |
|---|---|---|
| MITRE ATT&CK v20 | T1195.002 Compromise Software Supply Chain | Removes a route from a pull request to release secrets and artefacts. |
| NIST CSF 2.0 | ID.RA-01, PR.PS-06 | Identifies and records pipeline weaknesses; integrates secure practices into the development life cycle. |
| NIST SP 800-53 Rev 5 | AC-6, CM-6, SA-11 | Least privilege for workflow tokens, configuration settings for workflows, developer testing including static analysis of pipeline code. |
| OWASP Top 10 CI/CD Security Risks | CICD-SEC-4 Poisoned Pipeline Execution | The risk class this audit addresses. |
Practitioner checklist
- Run the four code search queries for every organisation and record all hits, including archived repositories.
- Scan candidates with zizmor and Scorecard; enable CodeQL for Actions where available.
- Read every flagged job and rate it against the exposure table.
- Disable critical workflows the same day and review fork run history and reachable secrets.
- Move PR builds to
pull_requestand privileged follow-up toworkflow_runthat treats artifacts as data. - Route untrusted text through
env:, setpersist-credentials: false, and set read-only default permissions. - Remove cache use from release and privileged jobs.
- Verify from a test fork and re-run all scanners.
- Enforce workflow linting and CODEOWNERS on
.github/workflows/.
Standards and references
- GitHub Docs: Secure use reference
- GitHub Docs: Events that trigger workflows
- GitHub Changelog: pull_request_target and environment branch protections changes
- GitHub Security Lab: Preventing pwn requests
- GitHub Docs: Understanding GitHub code search syntax
- GitHub CodeQL: Actions query help
- OpenSSF Scorecard: Checks documentation
- zizmor: Audit rules
Related NHI Mgmt Group resources: GitHub and GitHub Actions Security Guide · CI/CD Security Guide · OWASP CI/CD Top 10 CICD-SEC-4 Poisoned Pipeline Execution Guide · Self-hosted CI/CD Runners and Build Agents