1+ # Posts the formatting patch produced by the `rcc` workflow
2+ # as a comment on the pull request it came from.
3+ #
4+ # SECURITY -- `workflow_run` is a privileged trigger.
5+ # It runs from the default branch of the BASE repository
6+ # with a token that can write to it,
7+ # and it fires for `rcc` runs of pull requests from forks.
8+ # Everything reachable from `github.event.workflow_run` is therefore
9+ # attacker-controlled data, not trusted input:
10+ #
11+ # * `head_branch` is a fork branch name, and `git check-ref-format`
12+ # permits `"`, `` ` ``, `;` and `$(...)` in branch names.
13+ # * `head_commit.message`, repository descriptions and similar fields
14+ # are free text and may contain quotes.
15+ # * The `changes-patch` artifact was produced by a run
16+ # that executed the fork's code, so its contents are arbitrary.
17+ #
18+ # Consequently no field of the event is ever interpolated with `${{ }}`
19+ # into a shell script; values are passed through the environment
20+ # so the shell treats them as inert data.
21+ # The pull request head is deliberately NOT checked out:
22+ # this job only needs the artifact, and not checking out
23+ # avoids placing a credentialed `.git/config`
24+ # next to attacker-controlled files.
25+ #
26+ # https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
127name : commit-suggest.yaml
228
329on :
@@ -22,22 +48,12 @@ jobs:
2248 # The workflow did not previously request this, so the download could
2349 # only ever have failed -- silently, under `continue-on-error: true`.
2450 actions : read
25- # Required to check out the pull request head
26- contents : read
51+ # `contents: read` is deliberately absent: the pull request checkout is
52+ # gone, and nothing else in this job reads the repository.
2753 # Required to post the suggestion comment
2854 pull-requests : write
2955
3056 steps :
31- - name : Show event payload
32- run : |
33- echo '${{ toJson(github.event) }}' | jq .
34- shell : bash
35-
36- - name : Checkout PR
37- uses : actions/checkout@v6
38- with :
39- ref : ${{ github.event.workflow_run.head_sha }}
40-
4157 - name : Download artifact
4258 uses : actions/download-artifact@v6
4359 with :
@@ -59,59 +75,104 @@ jobs:
5975
6076 - name : Find PR number for branch from correct head repository
6177 id : find-pr
78+ if : steps.check-artifact.outputs.has_diff == 'true'
6279 env :
63- GITHUB_TOKEN : ${{ github.token }}
80+ GH_TOKEN : ${{ github.token }}
81+ HEAD_BRANCH : ${{ github.event.workflow_run.head_branch }}
82+ HEAD_OWNER : ${{ github.event.workflow_run.head_repository.owner.login }}
6483 run : |
65- PR_NUMBER=$(gh pr list --head ${{ github.event.workflow_run.head_branch }} --state open --json number,headRepositoryOwner --jq '.[] | select(.headRepositoryOwner.login == "${{ github.event.workflow_run.head_repository.owner.login }}") | .number' || echo "")
66- echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
84+ set -euo pipefail
85+
86+ # `--arg` keeps the owner login out of the jq program text,
87+ # and `"${HEAD_BRANCH}"` keeps the branch name out of the shell's
88+ # parsing -- see the security note at the top of this file.
89+ pr_number=$(
90+ gh pr list \
91+ --repo "${GITHUB_REPOSITORY}" \
92+ --head "${HEAD_BRANCH}" \
93+ --state open \
94+ --json number,headRepositoryOwner |
95+ jq -r --arg owner "${HEAD_OWNER}" \
96+ '[.[] | select(.headRepositoryOwner.login == $owner) | .number][0] // empty'
97+ ) || pr_number=""
98+
99+ # Belt and braces: only ever emit a plain integer downstream.
100+ if ! printf '%s' "${pr_number}" | grep -qE '^[0-9]+$'; then
101+ echo "No matching open pull request found"
102+ pr_number=""
103+ fi
104+
105+ echo "pr_number=${pr_number}" >> "${GITHUB_OUTPUT}"
67106 shell : bash
68107
69108 - name : Generate comment body
70- if : steps.check-artifact.outputs.has_diff == 'true'
71- id : comment-body
109+ if : steps.check-artifact.outputs.has_diff == 'true' && steps.find-pr.outputs.pr_number != ''
110+ env :
111+ RUN_ID : ${{ github.event.workflow_run.id }}
112+ REPO : ${{ github.repository }}
113+ PR_NUMBER : ${{ steps.find-pr.outputs.pr_number }}
72114 run : |
73- cat << 'EOF' > comment.md
74- ## Formatting suggestions available
75-
76- A patch file with formatting suggestions has been generated. You can apply it using one of these methods:
77-
78- ### Method 1: Apply via gh CLI
79-
80- ```bash
81- # Download and apply the patch directly
82- gh run download ${{ github.event.workflow_run.id }} --repo ${{ github.repository }} --name changes-patch && patch -p1 < changes.patch && rm changes.patch
83- ```
84-
85- Repo owners can also apply the patch automatically. Click the button to jump to the comment box, then post:
86-
87- ```
88- /apply-patch
89- ```
90-
91- [](https://github.com/${{ github.repository }}/pull/${{ steps.find-pr.outputs.pr_number }}#new_comment_field)
92-
93- ### Method 2: View the patch
94-
95- <details>
96- <summary>Click to see the patch contents</summary>
97-
98- ```diff
99- EOF
100-
101- cat changes.patch >> comment.md
102-
103- cat << 'EOF' >> comment.md
104- ```
105-
106- </details>
115+ set -euo pipefail
116+
117+ # A GitHub comment is capped at 65536 characters, and the patch is
118+ # attacker-controlled, so cap what we embed and say so when we do.
119+ max_bytes=40000
120+ truncated=false
121+ if [ "$(wc -c < changes.patch)" -gt "${max_bytes}" ]; then
122+ head -c "${max_bytes}" changes.patch > patch.txt
123+ truncated=true
124+ else
125+ cp changes.patch patch.txt
126+ fi
107127
108- ---
109- *This comment was automatically generated by the commit-suggester workflow.*
110- EOF
128+ # Pick a fence longer than the longest run of backticks in the patch.
129+ # Otherwise a crafted patch could close the code block early and
130+ # inject arbitrary Markdown into a comment authored by github-actions.
131+ longest=$(
132+ { grep -o '`\+' patch.txt || true; } |
133+ awk '{ if (length($0) > n) n = length($0) } END { print n + 0 }'
134+ )
135+ if [ "${longest}" -lt 3 ]; then
136+ fence_len=3
137+ else
138+ fence_len=$((longest + 1))
139+ fi
140+ fence=$(printf '`%.0s' $(seq 1 "${fence_len}"))
141+
142+ {
143+ printf '## Formatting suggestions available\n\n'
144+ printf 'A patch file with formatting suggestions has been generated. '
145+ printf 'You can apply it using one of these methods:\n\n'
146+ printf '### Method 1: Apply via gh CLI\n\n'
147+ printf '%s\n' '```bash'
148+ printf '# Download and apply the patch directly\n'
149+ printf 'gh run download %s --repo %s --name changes-patch && patch -p1 < changes.patch && rm changes.patch\n' \
150+ "${RUN_ID}" "${REPO}"
151+ printf '%s\n\n' '```'
152+ printf 'Repo owners can also apply the patch automatically. '
153+ printf 'Click the button to jump to the comment box, then post:\n\n'
154+ printf '%s\n' '```'
155+ printf '/apply-patch\n'
156+ printf '%s\n\n' '```'
157+ printf '[]'
158+ printf '(https://github.com/%s/pull/%s#new_comment_field)\n\n' "${REPO}" "${PR_NUMBER}"
159+ printf '### Method 2: View the patch\n\n'
160+ printf '<details>\n'
161+ printf '<summary>Click to see the patch contents</summary>\n\n'
162+ printf '%sdiff\n' "${fence}"
163+ cat patch.txt
164+ printf '\n%s\n\n' "${fence}"
165+ if [ "${truncated}" = "true" ]; then
166+ printf '_Patch truncated at %s bytes; download the artifact for the full diff._\n\n' "${max_bytes}"
167+ fi
168+ printf '</details>\n\n'
169+ printf -- '---\n'
170+ printf '*This comment was automatically generated by the commit-suggester workflow.*\n'
171+ } > comment.md
111172 shell : bash
112173
113174 - name : Post or update comment
114- if : steps.check-artifact.outputs.has_diff == 'true'
175+ if : steps.check-artifact.outputs.has_diff == 'true' && steps.find-pr.outputs.pr_number != ''
115176 uses : thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
116177 with :
117178 pr-number : ${{ steps.find-pr.outputs.pr_number }}
0 commit comments