Dangerous trigger runs fork-controlled code with base-repo secrets
Control: Workflow must not use dangerous triggers · Config key: workflowMustNotUseDangerousTriggers
📋 What is this?
A job runs under a trigger that combines attacker-controlled input with the base repository's secrets — workflow_run, issue_comment, pull_request_review, pull_request_review_comment, discussion, discussion_comment, gollum, fork — **and checks out fork-controlled code** (an actions/checkout whose ref: is the workflow_run head or a PR-controlled ref). Untrusted code then executes with the base repo's secrets and GITHUB_TOKEN. The pull_request_target case is owned by [ISSUE-804](./ISSUE-804) (pullRequestTargetMustNotCheckoutHead), so this rule never fires on it — same exploit class, dedicated rule, no double-firing. Subscribing to such a trigger is **not** flagged on its own: labelling, milestone, comment and notification workflows legitimately need them and are safe without an untrusted checkout. The finding fires only on the exploitable combination, and abstains when a job-level if: neutralises the risk — a same-repository pull-request guard, a workflow_run gated to an upstream push (github.event.workflow_run.event == 'push'), or a trusted author_association allowlist.
⚠️ Impact
This is the same exploit class as the March 2025 tj-actions/changed-files vector (CVE-2025-30066) that exfiltrated secrets from hundreds of projects including aquasecurity/trivy. Once fork code runs with the base repo's secrets, every shell step is a direct exfiltration path — npm install (runs package scripts), pytest (loads conftest.py), even cat README.md (via attacker-supplied content). The blast radius is the union of the job's permissions and every secret the workflow can read.
🔧 How to fix
Three options, from strongest to most permissive: (1) drop the head checkout and let the job only operate on the base branch; (2) move the work to a plain pull_request trigger that runs without secret access; or (3) keep the head checkout but add a same-repository if: guard so fork-controlled runs never reach the job. Option 3 is the right answer for trusted-maintainer-only preview/build flows.
# .github/workflows/preview.yml — ❌ workflow_run chains off a fork-influenceable workflowon: workflow_run: workflows: ["lint"] types: [completed]jobs: preview: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.workflow_run.head_sha }} # ← fork-controlled - run: npm install && npm test # ← runs with base secrets# .github/workflows/preview.yml — ✅ same-repository guard: fork code never runson: workflow_run: workflows: ["lint"] types: [completed]jobs: preview: if: github.event.workflow_run.head_repository.full_name == github.repository runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.workflow_run.head_sha }} - run: npm install && npm test
# .plumber.yamlgithub: controls: workflowMustNotUseDangerousTriggers: enabled: true💡 Tips
- The
pull_request_targetexploit pattern is owned by ISSUE-804 (pullRequestTargetMustNotCheckoutHead) — this rule excludes it to avoid double-firing on the same job. - Recognised guards: same-repository (
head.repo.full_name == github.repository,head.repo.fork == false,head.repo.fork != true,!github.event.pull_request.head.repo.fork); aworkflow_runrestricted to an upstream push (github.event.workflow_run.event == 'push'); and a trustedauthor_associationallowlist (== 'OWNER' | 'MEMBER' | 'COLLABORATOR', orcontains(fromJSON('[…]'), …author_association)). A negated check likeauthor_association != 'OWNER'is a denylist, not a guard, and still fires. - Recognised untrusted refs:
github.event.pull_request.head.sha,head.ref,github.head_ref,github.event.workflow_run.head_sha,head_branch. - Metadata-only jobs (labelling, milestone, comment reactions) under these triggers are safe and intentionally not flagged.
- Pair with ISSUE-305 for the environment-gate side of the same risk surface.
⚙️ Configuration
This control is configured in .plumber.yaml under the github section:
github:
controls:
workflowMustNotUseDangerousTriggers:
enabled: trueSee the CLI documentation for the full configuration reference.