Skip to main content
ISSUE-802CriticalMediumCLIWorkflow triggers and permissions

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 carries the base repository's secrets while being fireable by unprivileged users (workflow_run, issue_comment, pull_request_review, pull_request_review_comment, discussion, discussion_comment, gollum, fork) and checks out fork-controlled code: untrusted code then executes with the base repo's secrets and GITHUB_TOKEN. The trigger alone is never flagged, and a recognised job-level if: guard (same-repository check, workflow_run gated to an upstream push, trusted author_association allowlist) silences the finding. The pull_request_target case is owned by ISSUE-804.

⚠️ Impact

The same exploit class as the March 2025 tj-actions/changed-files compromise (CVE-2025-30066). Once fork code runs with the base repo's secrets, every shell step is an exfiltration path (npm install scripts, pytest conftest.py, even attacker-supplied file 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.

✗ BeforeThe fork-controlled head SHA from the chained workflow runs with the secret-bearing GITHUB_TOKEN, the same exploit class as the tj-actions / CVE-2025-30066 vector.
# .github/workflows/preview.yml: ❌ workflow_run chains off a fork-influenceable workflow
on:
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
✓ AfterThe `if:` blocks fork-originated runs entirely; only same-repository commits ever reach the checkout.
# .github/workflows/preview.yml: ✅ same-repository guard: fork code never runs
on:
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.yaml
github:
controls:
workflowMustNotUseDangerousTriggers:
enabled: true

💡 Tips

  • The pull_request_target exploit 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); a workflow_run restricted to an upstream push (github.event.workflow_run.event == 'push'); and a trusted author_association allowlist (== 'OWNER' | 'MEMBER' | 'COLLABORATOR', or contains(fromJSON('[…]'), …author_association)). A negated check like author_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: true

See the CLI documentation for the full configuration reference.