From 9aa6d4afb08a07d2082ef8c2689c5780893c22d0 Mon Sep 17 00:00:00 2001 From: Jacek Date: Sat, 13 Jun 2026 22:08:26 -0500 Subject: [PATCH 1/2] ci(repo): replace labeler pull_request_target with workflow_run handshake The repo's only pull_request_target lived in labeler.yml. Split labeling into an untrusted trigger (pull_request, read-only, no secrets) that records the PR number to an artifact, and a privileged labeler-apply.yml (workflow_run) that validates the number, checks out only the trusted base .github/labeler.yml, and applies labels via actions/labeler driven by pr-number. Fork-PR labeling is preserved without the pull_request_target footgun. Refs SDK-80. --- .changeset/sdk-80-labeler-workflow.md | 2 + .github/workflows/labeler-apply.yml | 55 +++++++++++++++++++++++++++ .github/workflows/labeler.yml | 27 ++++++++++--- 3 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 .changeset/sdk-80-labeler-workflow.md create mode 100644 .github/workflows/labeler-apply.yml diff --git a/.changeset/sdk-80-labeler-workflow.md b/.changeset/sdk-80-labeler-workflow.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/sdk-80-labeler-workflow.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/workflows/labeler-apply.yml b/.github/workflows/labeler-apply.yml new file mode 100644 index 00000000000..7cbe8b6948a --- /dev/null +++ b/.github/workflows/labeler-apply.yml @@ -0,0 +1,55 @@ +name: Labeler (apply) + +# Privileged half of the labeler. Triggered by completion of the "Labeler" workflow, +# so it always runs the base-branch copy of this file (a PR cannot modify it) with +# write access. It reads the PR number from the trigger run's artifact, validates it, +# checks out only the trusted base .github/labeler.yml, and applies labels via +# actions/labeler driven by pr-number. Replaces the previous pull_request_target +# trigger while preserving fork-PR labeling (SDK-80). + +on: + workflow_run: + workflows: [Labeler] + types: + - completed + +permissions: {} + +jobs: + apply: + name: Apply labels + if: >- + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + timeout-minutes: ${{ vars.TIMEOUT_MINUTES_SHORT && fromJSON(vars.TIMEOUT_MINUTES_SHORT) || 3 }} + runs-on: ${{ vars.RUNNER_NORMAL || 'ubuntu-latest' }} + permissions: + actions: read # download the artifact from the triggering run + contents: read # checkout the trusted base labeler config + pull-requests: write # apply labels + steps: + - name: Download PR number + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: pr-number + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Read and validate PR number + id: pr + run: | + number="$(cat number)" + if ! [[ "$number" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid PR number from artifact: '$number'" + exit 1 + fi + echo "number=$number" >> "$GITHUB_OUTPUT" + - name: Check out trusted labeler config + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + sparse-checkout: .github/labeler.yml + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6 + with: + pr-number: ${{ steps.pr.outputs.number }} + configuration-path: .github/labeler.yml diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index d27fdec4e32..1c68d4b93f7 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,14 +1,29 @@ name: Labeler +# Untrusted trigger half of the labeler. Runs in the PR (incl. fork) context with a +# read-only token and no secrets. It only records the PR number to an artifact; the +# privileged labeling happens in labeler-apply.yml on workflow_run. This avoids +# pull_request_target (see SDK-80 / Monorepo Supply-Chain Hardening). + on: - - pull_request_target + - pull_request + +permissions: {} jobs: - triage: + collect: + name: Collect PR metadata timeout-minutes: ${{ vars.TIMEOUT_MINUTES_SHORT && fromJSON(vars.TIMEOUT_MINUTES_SHORT) || 3 }} - permissions: - contents: read - pull-requests: write runs-on: ${{ vars.RUNNER_NORMAL || 'ubuntu-latest' }} steps: - - uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6 + - name: Save PR number + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + mkdir -p ./pr + echo "$PR_NUMBER" > ./pr/number + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pr-number + path: ./pr/ + retention-days: 1 From fbb0b6b0859b34d2f156c6eadb823a4adc883701 Mon Sep 17 00:00:00 2001 From: Jacek Date: Sun, 14 Jun 2026 13:19:21 -0500 Subject: [PATCH 2/2] ci(repo): bind labeler artifact PR number to the triggering run The PR number comes from the untrusted pull_request leg, so a malicious PR could upload another open PR's number and redirect the privileged labeler at it. Before labeling, verify via API that the claimed PR's head SHA and head repository match github.event.workflow_run (head_sha / head_repository), and fail closed otherwise. This preserves fork-PR labeling (workflow_run.pull_requests is empty for forks, so it can't be used for the binding). Refs SDK-80. --- .github/workflows/labeler-apply.yml | 36 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/labeler-apply.yml b/.github/workflows/labeler-apply.yml index 7cbe8b6948a..9ace7dbc33d 100644 --- a/.github/workflows/labeler-apply.yml +++ b/.github/workflows/labeler-apply.yml @@ -34,15 +34,35 @@ jobs: name: pr-number run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Read and validate PR number + - name: Resolve and verify PR number id: pr - run: | - number="$(cat number)" - if ! [[ "$number" =~ ^[0-9]+$ ]]; then - echo "::error::Invalid PR number from artifact: '$number'" - exit 1 - fi - echo "number=$number" >> "$GITHUB_OUTPUT" + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const fs = require('fs'); + const raw = fs.readFileSync('number', 'utf8').trim(); + if (!/^[0-9]+$/.test(raw)) { + return core.setFailed(`Invalid PR number from artifact: '${raw}'`); + } + // The artifact comes from the untrusted pull_request leg, so its number + // could point at any PR. Bind it to the run that actually triggered this + // workflow_run before handing it to the privileged labeler: the claimed + // PR's head commit and head repository must match the trigger run. + const run = context.payload.workflow_run; + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(raw), + }); + if (pr.head.sha !== run.head_sha) { + return core.setFailed(`PR #${raw} head ${pr.head.sha} does not match triggering run head ${run.head_sha}`); + } + const prHeadRepo = pr.head.repo ? pr.head.repo.full_name : null; + const runHeadRepo = run.head_repository ? run.head_repository.full_name : null; + if (prHeadRepo !== runHeadRepo) { + return core.setFailed(`PR #${raw} head repo ${prHeadRepo} does not match triggering run head repo ${runHeadRepo}`); + } + core.setOutput('number', raw); - name: Check out trusted labeler config uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: