CVE case study
CVE-2026-55675: Fork PR Code Ran in a Privileged GitHub Actions Job
Untrusted pull-request code reached a CI job holding deployment secrets. Separating build from deploy removed that path.
- Weakness
- CWE-94 · CWE-829 · CWE-1357
- Affected
- Workflow state at and before d1ee8f2f90ad0143138cc69edf85679780e3154e
- Remediation state
- Workflow redesigned in commit 649c9ae; no package release applies
- Advisory published
- 15 Jun 2026
Official vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H
Why it matters
Fork pull-request jobs do not receive base-repository secrets. The later workflow_run job did, and it trusted the pull-request commit SHA.
After checkout, the privileged job ran pip install -r requirements.txt. Setup.py or PEP 517 hooks could execute with deployment secrets and a write-capable token available.
How I found it
I checked whether a workflow triggered after an external pull request regained secrets and then executed material selected by that pull request. The repository used two workflows, with an artifact carrying data between them.
The first workflow stored the pull request number and head revision. The workflow_run consumer used that revision as its checkout target, then ran pip install -r requirements.txt. Python package installation can execute setup.py and PEP 517 backend hooks, so the pull-request revision selected code that ran inside the privileged job.
Because fork previews were intentional, the fix was to stop executing fork-controlled source in the secret-bearing job.
Root cause
The artifact carried the pull-request commit SHA into the privileged job. That SHA chose the code the job checked out and executed.
Fork previews were intentional. The fix had to keep fork-controlled code out of any job holding secrets or a write-capable token.
Source-to-sink trace
- 01External input
.github/workflows/trigger-pr-build.ymlThe pull-request head revision is written into the
pr_infoworkflow artifact. - 02Boundary crossing
.github/workflows/build-pr.yml · workflow_runThe follow-on workflow runs in the base repository context and downloads the earlier artifact.
- 03Source selection
actions/checkout · ref from pr_infoThe artifact-controlled revision becomes the source tree used by the privileged job.
- 04Execution sink
pip install -r requirements.txtPython build hooks from that tree execute while deployment credentials and a write-capable token are available.
Safe proof of concept
Prerequisites
- A disposable public GitHub repository and a fork or second account that you control.
- Two lab workflows mirroring the public advisory's pull request, artifact, then
workflow_runshape. - A repository secret
DEMO_CANARY=owned-demo-onlywith no value outside the lab.
Step-by-step reproduction
- Create the unprivileged pull-request workflow. Have it save the PR number and head revision to an artifact; do not give this workflow secrets.
- Create the follow-on
workflow_runjob. For the vulnerable control, let it consume that revision, check out the PR tree, and installrequirements.txtwhile exposing onlyDEMO_CANARY. - In the controlled fork, add a local editable requirement that points to
./canary_pkg. The package hook below checks only whether the exact synthetic value is visible. - Open a pull request to the disposable base repository and wait for both workflows. The vulnerable run prints the marker from the package-build process.
- Apply the architectural fix: build the site in the unprivileged PR job, upload static output, and make the privileged job deploy only that inert artifact. Repeat the pull request.
Disposable pull-request artifact workflow
name: Trigger PR Build (disposable lab)
on: pull_request
permissions:
contents: read
jobs:
capture:
runs-on: ubuntu-latest
steps:
- name: Record controlled PR identity
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_SHA: ${{ github.event.pull_request.head.sha }}
run: |
mkdir pr_info
printf '%s' "$PR_NUMBER" > pr_info/PR_NUMBER
printf '%s' "$PR_SHA" > pr_info/PR_SHA
- uses: actions/upload-artifact@v4
with:
name: pr_info
path: pr_info/Intentionally vulnerable follow-on workflow
name: Privileged Canary Demo
on:
workflow_run:
workflows: ['Trigger PR Build (disposable lab)']
types: [completed]
permissions:
contents: read
actions: read
jobs:
demonstrate:
runs-on: ubuntu-latest
env:
DEMO_CANARY: ${{ secrets.DEMO_CANARY }}
steps:
- uses: actions/download-artifact@v4
with:
name: pr_info
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
path: pr_info
- id: pr
run: echo "sha=$(cat pr_info/PR_SHA)" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
with:
ref: ${{ steps.pr.outputs.sha }}
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install -v -r requirements.txtControlled fork content
# requirements.txt
-e ./canary_pkg
# canary_pkg/setup.py
import os
from setuptools import setup
visible = os.getenv("DEMO_CANARY") == "owned-demo-only"
print("CYBERKAREEM_POC_CANARY_VISIBLE=" + str(visible).lower())
setup(name="owned-canary-proof", version="0.0.1")Fixed pull-request build workflow
name: Safe PR Build
on: pull_request
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install -v -r requirements.txt
- run: mkdir -p site && printf '<h1>owned preview</h1>' > site/index.html
- uses: actions/upload-artifact@v4
with:
name: built-site
path: site/Fixed inert-artifact deploy workflow
name: Safe Artifact Deploy
on:
workflow_run:
workflows: ['Safe PR Build']
types: [completed]
permissions:
contents: read
actions: read
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
env:
DEMO_CANARY: ${{ secrets.DEMO_CANARY }}
steps:
- uses: actions/download-artifact@v4
with:
name: built-site
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
path: site
- run: test -f site/index.html
# Add the owned deployment action here.
# No PR checkout, package manager, interpreter, or build hook runs in this job.Evidence check after the lab run
gh run view LAB_RUN_ID --repo OWNER/DISPOSABLE_REPO --log \
| rg 'CYBERKAREEM_POC_CANARY_VISIBLE'
# Vulnerable control:
# CYBERKAREEM_POC_CANARY_VISIBLE=true
# Redesigned build/deploy split:
# marker absent; no fork-controlled package hook runs in the deploy jobExpected evidence
- The unprivileged pull-request job has no canary and cannot print a positive marker.
- The intentionally vulnerable follow-on job prints
CYBERKAREEM_POC_CANARY_VISIBLE=true, proving attacker-selected build code ran after the trust-boundary crossing. - No real secret is logged, transmitted, or retained by the PoC.
Run the same package in the pull-request workflow before any privileged hand-off. The marker must be false. This separates ordinary untrusted code execution from execution in the privileged environment.
After moving package installation and site generation to the unprivileged job, the privileged deploy job should contain no checkout of the PR revision and no package/build command. Re-running the PR must produce only the prebuilt static artifact; the canary marker must be absent from the deploy logs.
Impact
The advisory scores this 10.0: no repository access or user interaction was required, and fork-controlled code ran in the deployment job.
An attacker could steal deployment credentials or use the repository token to alter infrastructure or published content.
Fix and retest
The patch moved dependency installation and site generation into the unprivileged pull-request workflow. The privileged follow-on job now receives only a prebuilt static artifact and deploys it; it does not check out or execute fork-controlled source.
Retest the workflow: no privileged job should check out a pull-request SHA or run a package manager, build hook, script, or interpreter over pull-request-controlled files.
Engineering lesson
Pass only built static files into the deploy job. Do not run fork-controlled code there.