This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new b2742e529 ci: resolve the PR triage trigger from the API, not the
artifact (#4257)
b2742e529 is described below
commit b2742e529d318dc2be3aa525f139113167c61f40
Author: Justin Mclean <[email protected]>
AuthorDate: Mon Sep 28 21:40:13 2026 +1000
ci: resolve the PR triage trigger from the API, not the artifact (#4257)
---
.github/workflows/pr-triage-apply.yml | 233 +++++++++++++++++----
.github/workflows/pr-triage-collect.yml | 6 +-
.github/workflows/stale-prs-unmark-on-activity.yml | 101 +++++++--
3 files changed, 280 insertions(+), 60 deletions(-)
diff --git a/.github/workflows/pr-triage-apply.yml
b/.github/workflows/pr-triage-apply.yml
index d5840f4f3..468076197 100644
--- a/.github/workflows/pr-triage-apply.yml
+++ b/.github/workflows/pr-triage-apply.yml
@@ -76,9 +76,13 @@ name: PR Triage Apply
# - pull_request_target runs the base-repo workflow with a write token so
# fork-PR lifecycle labels can be applied. workflow_run also runs against
# base ref. Both are safe because we never run fork code: no checkout,
-# no exec of PR contents, no token export. The review/comment body
-# reaches us via the artifact uploaded by pr-triage-collect.yml; it is
-# treated as untrusted text, parsed only by the command regex. See
+# no exec of PR contents, no token export.
+# - The artifact from pr-triage-collect.yml is NOT trusted for identity. On
+# the pull_request_review path collect runs the PR head's own copy of
+# itself, so a fork can upload any payload. The resolve step reads only a
+# numeric id from it and re-fetches the author, association, PR number,
+# review state and body from the API, which a fork cannot forge. The body
+# stays in a file and is parsed only by the command regex. See
#
https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
on:
@@ -139,52 +143,191 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
path: payload/
- - name: Parse triage event payload
+ - name: Resolve the trigger from the API
+ id: resolve
if: github.event_name == 'workflow_run'
env:
UPSTREAM_EVENT: ${{ github.event.workflow_run.event }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ RUN_CREATED: ${{ github.event.workflow_run.created_at }}
+ REPO: ${{ github.repository }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
- # The artifact contains exactly one file: the JSON event payload
- # uploaded as `${{ github.event_path }}`. Its on-disk name is
- # whatever the runner used (e.g. event.json); resolve it dynamically.
- payload=( payload/*.json payload/event.json )
- f=""
- for candidate in "${payload[@]}"; do
- [[ -f "$candidate" ]] && f="$candidate" && break
- done
+ # The collect workflow runs the PR head's own copy of itself on the
+ # pull_request_review path, so a fork can upload any payload it
likes.
+ # Nothing in the artifact is trusted for identity. It is read only
for
+ # the numeric trigger id, and every field the gate depends on -
author,
+ # association, PR number, body, review state - is re-fetched from the
+ # API below, which a fork cannot forge. A forged id resolves to a
real
+ # object (whose real properties are used) or 404s; it cannot invent a
+ # committer command. The collect run's commit must also belong to the
+ # claimed PR and the review must be as new as the run, so an old
review
+ # cannot be replayed onto another PR or onto the fork's own.
+ # The case below routes on UPSTREAM_EVENT, which GitHub sets from the
+ # collect run itself, not from the artifact. GitHub only runs
+ # issue_comment workflows from the default branch, so a fork's copy
+ # of collect can never fire on that event: a fork's run always
+ # arrives as pull_request_review and takes the checked path, while
+ # the issue_comment path only ever sees an artifact written by the
+ # default branch's collect. Any other event is rejected.
+ payload=( payload/*.json )
+ f="${payload[0]:-}"
if [[ -z "$f" ]]; then
echo "triage payload not found under payload/" >&2
ls -la payload/ >&2 || true
exit 1
fi
- # Body is attacker-controlled. Route it via a file rather than
- # $GITHUB_ENV: heredoc terminators can be forged inside the body
- # to close the block early and inject arbitrary env vars (e.g.
- # NODE_OPTIONS, GITHUB_PATH) into the subsequent github-script
- # step, which holds a write GITHUB_TOKEN. The other fields below
- # are constrained single-line values (numeric ids, enum states,
- # GH login charset) and can stay in $GITHUB_ENV.
- jq -r '.comment.body // .review.body // ""' "$f" > payload/body.txt
+
+ # A forged newline in an artifact id must never reach a step output.
+ require_num() {
+ case "$1" in
+ ''|*[!0-9]*) echo "non-numeric ${2}: '${1}'" >&2; exit 1 ;;
+ esac
+ }
+
+ # Retry 5xx, 429 and network failures, as withRetry does for the
+ # script path. Anything else fails at once with gh's own message;
+ # a 404 returns 44 so a caller can tell it apart.
+ gh_api() {
+ local err out code="" delay
+ err="$(mktemp)"
+ for delay in 0.5 1.5 4 ""; do
+ if out="$(gh api "$@" 2>"$err")"; then
+ rm -f "$err"
+ printf '%s\n' "$out"
+ return 0
+ fi
+ code="$(grep -oE 'HTTP [0-9]{3}' "$err" | tail -n1 | cut -d' '
-f2 || true)"
+ case "$code" in
+ ''|429|5??) ;;
+ *) break ;;
+ esac
+ [[ -n "$delay" ]] || break
+ echo "gh api $1: transient failure (${code:-network}), retrying
in ${delay}s" >&2
+ sleep "$delay"
+ done
+ cat "$err" >&2
+ rm -f "$err"
+ if [[ "$code" == 404 ]]; then return 44; fi
+ return 1
+ }
+
+ author=""; assoc=""; state=""; pr=""; cid=""
+ : > payload/body.txt
+
+ case "$UPSTREAM_EVENT" in
+ issue_comment)
+ cid="$(jq -r '.comment.id // ""' "$f")"
+ require_num "$cid" comment_id
+ c="$(gh_api "repos/${REPO}/issues/comments/${cid}")"
+ author="$(jq -r '.user.login // ""' <<<"$c")"
+ assoc="$(jq -r '.author_association' <<<"$c")"
+ jq -r '.body // ""' <<<"$c" > payload/body.txt
+ # issue_url ends in the PR (issue) number.
+ pr="$(jq -r '.issue_url' <<<"$c" | grep -oE '[0-9]+$' || true)"
+ ;;
+ pull_request_review)
+ rid="$(jq -r '.review.id // ""' "$f")"
+ cpr="$(jq -r '.pull_request.number // ""' "$f")"
+ require_num "$rid" review_id
+ require_num "$cpr" pr_number
+ # Fetching the review under the claimed PR verifies the pair: a
+ # review id that does not belong to that PR 404s here, so the
+ # claimed PR number cannot be steered to another PR. Skip it
+ # rather than fail the run, as the PR lookup below does.
+ rc=0
+ r="$(gh_api "repos/${REPO}/pulls/${cpr}/reviews/${rid}")" ||
rc=$?
+ if (( rc == 44 )); then
+ echo "review ${rid} is not on PR ${cpr}, skipping"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ elif (( rc != 0 )); then
+ exit "$rc"
+ fi
+ # The pair is real but may be someone else's old review on any
+ # PR. HEAD_SHA is set by GitHub, not the fork, and is the head
+ # the collect run fired on. Requiring it to be one of the claimed
+ # PR's commits is what confines a forged payload to the fork's
+ # own PR: a forged run fires on the fork's own commit, which is
+ # in no other PR's commit list.
+ shas="$(gh_api --paginate "repos/${REPO}/pulls/${cpr}/commits"
--jq '.[].sha')"
+ if ! grep -qxF "$HEAD_SHA" <<<"$shas"; then
+ echo "collect ran on ${HEAD_SHA}, which is not a commit of PR
${cpr}" >&2
+ exit 1
+ fi
+ # That leaves a fork replaying an old review on its own PR, for
+ # example a committer's /pin. RUN_CREATED is set by GitHub, and a
+ # real run is created seconds after its review is submitted.
+ sub="$(jq -r '.submitted_at // ""' <<<"$r")"
+ if [[ -z "$sub" ]]; then
+ echo "review ${rid} has no submitted_at" >&2
+ exit 1
+ fi
+ age=$(( $(date -u -d "$RUN_CREATED" +%s) - $(date -u -d "$sub"
+%s) ))
+ if (( age < -60 || age > 300 )); then
+ echo "review ${rid} was submitted at ${sub} and the collect
run created at ${RUN_CREATED}, outside the allowed window" >&2
+ exit 1
+ fi
+ author="$(jq -r '.user.login // ""' <<<"$r")"
+ assoc="$(jq -r '.author_association' <<<"$r")"
+ state="$(jq -r '.state' <<<"$r" | tr '[:upper:]' '[:lower:]')"
+ jq -r '.body // ""' <<<"$r" > payload/body.txt
+ pr="$cpr"
+ # Reviews carry no issue-comment id, so reactions stay skipped
+ # for them, as before.
+ ;;
+ *)
+ echo "unexpected upstream event: ${UPSTREAM_EVENT}" >&2
+ exit 1
+ ;;
+ esac
+
+ require_num "$pr" resolved_pr
+ # Constrain the API-derived scalars before they leave this step. The
+ # API cannot return a newline in a login or an association; this is
+ # defence in depth on top of the source already being trusted.
+ # A bash match tests the whole string. grep tests each line, so a
+ # value with a newline in it would pass on any one matching line.
+ login_re='^[A-Za-z0-9-]+(\[bot\])?$'
+ if [[ ! "$author" =~ $login_re ]]; then
+ echo "unexpected login: '${author}'" >&2
+ exit 1
+ fi
+ case "$assoc" in
+
OWNER|MEMBER|COLLABORATOR|CONTRIBUTOR|FIRST_TIME_CONTRIBUTOR|FIRST_TIMER|MANNEQUIN|NONE)
;;
+ *) echo "unexpected association: '${assoc}'" >&2; exit 1 ;;
+ esac
+ # Empty on the issue_comment path. A newline here could add a second
+ # pr_number line to the outputs below.
+ case "$state" in
+ ''|approved|changes_requested|commented|dismissed|pending) ;;
+ *) echo "unexpected review state: '${state}'" >&2; exit 1 ;;
+ esac
+ # Step outputs, not $GITHUB_ENV: an output is read only through a
+ # steps.*.outputs.* expression and never becomes a process env var in
+ # a later step, so it cannot smuggle NODE_OPTIONS or BASH_ENV even if
+ # a value slipped the checks above. The values are single-line here.
{
- echo "COMMENT_AUTHOR=$(jq -r '.comment.user.login //
.review.user.login // ""' "$f")"
- echo "COMMENT_ASSOC=$(jq -r '.comment.author_association //
.review.author_association // ""' "$f")"
- echo "COMMENT_ID=$(jq -r '.comment.id // ""' "$f")"
- echo "PR_NUMBER=$(jq -r '.pull_request.number // .issue.number //
""' "$f")"
- echo "REVIEW_STATE=$(jq -r '.review.state // ""' "$f")"
- echo "TRIAGE_EVENT_NAME=${UPSTREAM_EVENT}"
- } >> "$GITHUB_ENV"
+ echo "comment_author=${author}"
+ echo "comment_assoc=${assoc}"
+ echo "comment_id=${cid}"
+ echo "pr_number=${pr}"
+ echo "review_state=${state}"
+ echo "triage_event_name=${UPSTREAM_EVENT}"
+ } >> "$GITHUB_OUTPUT"
- name: Dispatch
+ if: steps.resolve.outputs.skip != 'true'
uses: actions/github-script@v9
env:
- COMMENT_AUTHOR: ${{ env.COMMENT_AUTHOR }}
- COMMENT_ASSOC: ${{ env.COMMENT_ASSOC }}
- COMMENT_ID: ${{ env.COMMENT_ID }}
- PR_NUMBER: ${{ env.PR_NUMBER || github.event.pull_request.number }}
- REVIEW_STATE: ${{ env.REVIEW_STATE }}
- TRIAGE_EVENT_NAME: ${{ env.TRIAGE_EVENT_NAME || github.event_name }}
+ COMMENT_AUTHOR: ${{ steps.resolve.outputs.comment_author }}
+ COMMENT_ASSOC: ${{ steps.resolve.outputs.comment_assoc }}
+ COMMENT_ID: ${{ steps.resolve.outputs.comment_id }}
+ PR_NUMBER: ${{ steps.resolve.outputs.pr_number ||
github.event.pull_request.number }}
+ REVIEW_STATE: ${{ steps.resolve.outputs.review_state }}
+ TRIAGE_EVENT_NAME: ${{ steps.resolve.outputs.triage_event_name ||
github.event_name }}
with:
script: |
const LABEL_REVIEW = 'S-waiting-on-review';
@@ -212,11 +355,11 @@ jobs:
return;
}
- // Effective event name. On the workflow_run leg the artifact
- // payload was produced by issue_comment / pull_request_review,
- // and the upstream event name was preserved into the env in
- // the parse step. On the pull_request_target leg we read the
- // GH-provided context.eventName directly.
+ // Effective event name. On the workflow_run leg the trigger was
+ // issue_comment / pull_request_review, and the upstream event name
+ // was preserved into the env by the resolve step. On the
+ // pull_request_target leg we read the GH-provided
context.eventName
+ // directly.
const eventName = process.env.TRIAGE_EVENT_NAME ||
context.eventName;
// Welcome comment posted once when a non-draft PR is opened.
@@ -547,13 +690,13 @@ jobs:
// ----- issue_comment / pull_request_review commands -----
// Both event types feed the same path: COMMENT_AUTHOR/_ASSOC/
- // _ID, PR_NUMBER and REVIEW_STATE resolve from the artifact
- // uploaded by pr-triage-collect.yml via the parse step above.
- // body is routed through a file (payload/body.txt) rather
- // than $GITHUB_ENV -- see the parse step's comment for the
- // injection rationale. The file is absent on the
- // pull_request_target leg (no parse step), and lifecycle
- // returned above, so falling back to '' is safe.
+ // _ID, PR_NUMBER, REVIEW_STATE and body were re-fetched from the
+ // API by the resolve step above, not taken from the fork-supplied
+ // artifact. body stays in a file (payload/body.txt) rather than a
+ // step output: it is multi-line text its author controls, and a
+ // multi-line output needs a delimiter the body could forge. The
file
+ // is absent on the pull_request_target leg (no resolve step), and
+ // lifecycle returned above, so falling back to '' is safe.
const fs = require('fs');
const body = fs.existsSync('payload/body.txt')
? fs.readFileSync('payload/body.txt', 'utf8')
diff --git a/.github/workflows/pr-triage-collect.yml
b/.github/workflows/pr-triage-collect.yml
index 32316bd7a..dd439069a 100644
--- a/.github/workflows/pr-triage-collect.yml
+++ b/.github/workflows/pr-triage-collect.yml
@@ -29,8 +29,10 @@ name: PR Triage Collect
# pr-triage-apply.yml.
#
# SECURITY: no actions/checkout, no exec of PR contents, no token export.
-# The artifact contents come from the GitHub-runner-written event_path,
-# not from PR code.
+# The token here is read-only. The artifact is NOT trustworthy: on the
+# pull_request_review path GitHub runs the PR head's copy of this file, so a
+# fork can change it and upload any payload. The consumers take only a
+# numeric id from the artifact and re-fetch everything else from the API.
on:
issue_comment:
diff --git a/.github/workflows/stale-prs-unmark-on-activity.yml
b/.github/workflows/stale-prs-unmark-on-activity.yml
index 94de223ac..78db1b38c 100644
--- a/.github/workflows/stale-prs-unmark-on-activity.yml
+++ b/.github/workflows/stale-prs-unmark-on-activity.yml
@@ -29,8 +29,11 @@
# Push-driven unmark stays in stale-prs-unmark.yml because
# pull_request_target.synchronize gets a write token directly.
#
-# SECURITY: no actions/checkout, no exec of PR contents. The artifact
-# contents come from the runner-written event_path, not from PR code.
+# SECURITY: no actions/checkout, no exec of PR contents. The artifact is
+# not trusted for identity: on the pull_request_review path collect runs the
+# PR head's own copy, so a fork can forge the payload. Only a numeric id is
+# read from it, the PR number is resolved from the API, and the commit the
+# collect run fired on must belong to that PR.
name: Unmark Stale PRs on Activity
@@ -66,13 +69,24 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
path: payload/
- - name: Extract PR number
+ - name: Resolve PR number from the API
+ id: resolve
+ env:
+ UPSTREAM_EVENT: ${{ github.event.workflow_run.event }}
+ HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
+ REPO: ${{ github.repository }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
shopt -s nullglob
- # The artifact contains exactly one file: the JSON event payload
- # uploaded as ${{ github.event_path }}. Its on-disk name is
- # whatever the runner used; resolve it dynamically.
+ # collect runs the PR head's own copy on the pull_request_review
+ # path, so a fork can upload any payload. Take only a numeric id from
+ # the artifact and resolve the PR number from the API, so a forged
+ # value can neither reach a process env var nor point this at another
+ # PR. UPSTREAM_EVENT is set by GitHub from the collect run, not from
+ # the artifact, and issue_comment workflows only run from the default
+ # branch, so a fork's run always arrives as pull_request_review and
+ # takes the checked path.
payload=( payload/*.json payload/event.json )
f=""
for candidate in "${payload[@]}"; do
@@ -83,17 +97,78 @@ jobs:
ls -la payload/ >&2 || true
exit 1
fi
- pr=$(jq -r '.pull_request.number // .issue.number // ""' "$f")
- if [[ -z "$pr" ]]; then
- echo "no PR number in payload" >&2
- exit 1
- fi
- echo "PR_NUMBER=$pr" >> "$GITHUB_ENV"
+ require_num() {
+ case "$1" in
+ ''|*[!0-9]*) echo "non-numeric ${2}: '${1}'" >&2; exit 1 ;;
+ esac
+ }
+ # Retry 5xx, 429 and network failures, as withRetry does for the
+ # script path. Anything else fails at once with gh's own message;
+ # a 404 returns 44 so a caller can tell it apart.
+ gh_api() {
+ local err out code="" delay
+ err="$(mktemp)"
+ for delay in 0.5 1.5 4 ""; do
+ if out="$(gh api "$@" 2>"$err")"; then
+ rm -f "$err"
+ printf '%s\n' "$out"
+ return 0
+ fi
+ code="$(grep -oE 'HTTP [0-9]{3}' "$err" | tail -n1 | cut -d' '
-f2 || true)"
+ case "$code" in
+ ''|429|5??) ;;
+ *) break ;;
+ esac
+ [[ -n "$delay" ]] || break
+ echo "gh api $1: transient failure (${code:-network}), retrying
in ${delay}s" >&2
+ sleep "$delay"
+ done
+ cat "$err" >&2
+ rm -f "$err"
+ if [[ "$code" == 404 ]]; then return 44; fi
+ return 1
+ }
+ case "$UPSTREAM_EVENT" in
+ issue_comment)
+ cid="$(jq -r '.comment.id // ""' "$f")"
+ require_num "$cid" comment_id
+ pr="$(gh_api "repos/${REPO}/issues/comments/${cid}" \
+ --jq '.issue_url' | grep -oE '[0-9]+$' || true)"
+ ;;
+ pull_request_review)
+ rid="$(jq -r '.review.id // ""' "$f")"
+ cpr="$(jq -r '.pull_request.number // ""' "$f")"
+ require_num "$rid" review_id
+ require_num "$cpr" pr_number
+ # A review id that does not belong to the claimed PR 404s here.
+ gh_api "repos/${REPO}/pulls/${cpr}/reviews/${rid}" >/dev/null
+ # HEAD_SHA is set by GitHub, not the fork. A forged run fires on
+ # the fork's own commit, which is in no other PR's commit list,
+ # so a real review cannot be replayed onto another PR. The
+ # review's commit_id is not compared: GitHub moves it when the
+ # branch is updated.
+ shas="$(gh_api --paginate "repos/${REPO}/pulls/${cpr}/commits"
--jq '.[].sha')"
+ if ! grep -qxF "$HEAD_SHA" <<<"$shas"; then
+ echo "collect ran on ${HEAD_SHA}, which is not a commit of PR
${cpr}" >&2
+ exit 1
+ fi
+ pr="$cpr"
+ ;;
+ *)
+ echo "unexpected upstream event: ${UPSTREAM_EVENT}" >&2
+ exit 1
+ ;;
+ esac
+ require_num "$pr" resolved_pr
+ # A step output, not $GITHUB_ENV: it is read only through a
+ # steps.*.outputs.* expression and never becomes a process env var in
+ # the next step, so a value cannot smuggle in an env var like
BASH_ENV.
+ echo "pr_number=$pr" >> "$GITHUB_OUTPUT"
- name: Remove stale label if present
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PR_NUMBER: ${{ env.PR_NUMBER }}
+ PR_NUMBER: ${{ steps.resolve.outputs.pr_number }}
REPO: ${{ github.repository }}
# Pre-check via REST so we only invoke the GraphQL edit when the
# label is actually on the PR. Saves an API write on every drive-by