Skip to content

Commit 631d626

Browse files
krlmlrclaude
andcommitted
ci: Harden workflow_run workflows against untrusted pull requests (#106)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2abc6b4 commit 631d626

5 files changed

Lines changed: 186 additions & 89 deletions

File tree

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Workflow to update the status of a commit for the R-CMD-check workflow
22
# Necessary because remote PRs cannot update the status of the commit
3+
#
4+
# SECURITY -- `workflow_run` runs from the default branch of the BASE
5+
# repository and this job holds `statuses: write`, so it can mark any commit
6+
# in this repository green. The run it reacts to may be a pull request from a
7+
# fork, which means both the run's metadata and its artifacts are
8+
# attacker-controlled. Two rules follow, and both are load-bearing:
9+
#
10+
# 1. The `rcc-smoke-sha` artifact is only read when the triggering run came
11+
# from this repository. For a `pull_request` event GitHub uses the
12+
# workflow file *from the pull request head*, so a fork can rewrite `rcc`
13+
# to upload any artifact it likes; trusting it would let anyone set an
14+
# arbitrary `rcc` status on an arbitrary commit and so satisfy a required
15+
# status check. Even then the value must be a real 40-hex commit.
16+
# 2. No event field is interpolated with `${{ }}` into the script. Values
17+
# reach the shell through the environment, where they stay inert data.
318
on:
419
workflow_run:
520
workflows:
@@ -18,68 +33,81 @@ jobs:
1833

1934
name: "Update commit status"
2035

36+
# Only run if triggered by rcc workflow
37+
if: github.event.workflow_run.name == 'rcc'
38+
2139
permissions:
2240
# Required to list and download the triggering run's artifacts.
2341
# The workflow did not previously request this, so the `rcc-smoke-sha`
2442
# lookup below could not have succeeded.
2543
actions: read
44+
# Also used to verify that the SHA read from the artifact really exists
2645
contents: read
2746
statuses: write
2847

2948
steps:
3049
- name: "Update commit status"
31-
# Only run if triggered by rcc workflow
32-
if: github.event.workflow_run.name == 'rcc'
3350
env:
3451
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
52+
REPO: ${{ github.repository }}
53+
RUN_ID: ${{ github.event.workflow_run.id }}
54+
RUN_STATUS: ${{ github.event.workflow_run.status }}
55+
RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
56+
RUN_URL: ${{ github.event.workflow_run.html_url }}
57+
RUN_NAME: ${{ github.event.workflow_run.name }}
58+
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
59+
HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }}
3560
run: |
36-
set -x
61+
set -euo pipefail
62+
63+
sha=""
3764
38-
if [ "${{ github.event.workflow_run.status }}" == "completed" ]; then
39-
if [ "${{ github.event.workflow_run.conclusion }}" == "success" ]; then
65+
if [ "${RUN_STATUS}" = "completed" ]; then
66+
if [ "${RUN_CONCLUSION}" = "success" ]; then
4067
state="success"
4168
else
4269
state="failure"
4370
fi
4471
45-
# Read artifact ID
46-
artifact_id=$(gh api \
47-
-H "Accept: application/vnd.github+json" \
48-
-H "X-GitHub-Api-Version: 2022-11-28" \
49-
repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/artifacts | jq -r '.artifacts[] | select(.name == "rcc-smoke-sha") | .id')
50-
51-
if [ -n "${artifact_id}" ]; then
52-
# Download artifact
53-
curl -L -o rcc-smoke-sha.zip \
54-
-H "Accept: application/vnd.github+json" \
55-
-H "Authorization: Bearer ${GH_TOKEN}" \
56-
-H "X-GitHub-Api-Version: 2022-11-28" \
57-
https://api.github.com/repos/${{ github.repository }}/actions/artifacts/${artifact_id}/zip
72+
# See rule 1 in the security note at the top of this file.
73+
if [ "${HEAD_REPO}" = "${REPO}" ]; then
74+
artifact_id=$(
75+
gh api "repos/${REPO}/actions/runs/${RUN_ID}/artifacts" \
76+
--jq '[.artifacts[] | select(.name == "rcc-smoke-sha") | .id][0] // empty'
77+
) || artifact_id=""
5878
59-
# Unzip artifact
60-
unzip rcc-smoke-sha.zip
79+
if [ -n "${artifact_id}" ]; then
80+
workdir=$(mktemp -d)
81+
gh api "repos/${REPO}/actions/artifacts/${artifact_id}/zip" > "${workdir}/artifact.zip"
82+
# -j flattens stored paths, so a crafted archive cannot write
83+
# outside the temporary directory.
84+
unzip -j -o "${workdir}/artifact.zip" -d "${workdir}" > /dev/null
85+
candidate=$(tr -d '[:space:]' < "${workdir}/rcc-smoke-sha.txt") || candidate=""
86+
rm -rf "${workdir}"
6187
62-
# Read artifact
63-
sha=$(cat rcc-smoke-sha.txt)
64-
65-
# Clean up
66-
rm rcc-smoke-sha.zip rcc-smoke-sha.txt
88+
if printf '%s' "${candidate}" | grep -qE '^[0-9a-f]{40}$' &&
89+
gh api "repos/${REPO}/commits/${candidate}" --jq .sha > /dev/null 2>&1; then
90+
sha="${candidate}"
91+
elif [ -n "${candidate}" ]; then
92+
echo "::warning::Ignoring unusable rcc-smoke-sha artifact contents"
93+
fi
94+
fi
6795
fi
6896
else
6997
state="pending"
7098
fi
7199
72100
if [ -z "${sha}" ]; then
73-
sha=${{ github.event.workflow_run.head_sha }}
101+
sha="${HEAD_SHA}"
74102
fi
75103
76-
html_url=${{ github.event.workflow_run.html_url }}
77-
description=${{ github.event.workflow_run.name }}
78-
79104
gh api \
80105
--method POST \
81106
-H "Accept: application/vnd.github+json" \
82107
-H "X-GitHub-Api-Version: 2022-11-28" \
83-
repos/${{ github.repository }}/statuses/${sha} \
84-
-f "state=${state}" -f "target_url=${html_url}" -f "description=${description}" -f "context=rcc"
108+
"repos/${REPO}/statuses/${sha}" \
109+
-f "state=${state}" \
110+
-f "target_url=${RUN_URL}" \
111+
-f "description=${RUN_NAME}" \
112+
-f "context=rcc"
85113
shell: bash

.github/workflows/R-CMD-check.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ on:
3838
description: "Run rcc-suggests job"
3939
type: boolean
4040
default: false
41+
# SECURITY: a merge-queue run executes the queued pull request's code with
42+
# the base repository's token and secrets, unlike `pull_request`, where a
43+
# fork only ever gets a read-only token and no secrets. Adding a fork's pull
44+
# request to the queue is therefore as much of a trust decision as merging
45+
# it; keep "Require merge queue" paired with a branch rule that only lets
46+
# maintainers enqueue.
4147
merge_group:
4248
types:
4349
- checks_requested

.github/workflows/commit-suggest.yaml

Lines changed: 116 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,29 @@
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/
127
name: commit-suggest.yaml
228

329
on:
@@ -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-
[![Apply patch](https://img.shields.io/badge/Apply%20patch-%2Fapply--patch-2ea44f?style=for-the-badge&logo=github)](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 '[![Apply patch](https://img.shields.io/badge/Apply%%20patch-%%2Fapply--patch-2ea44f?style=for-the-badge&logo=github)]'
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 }}

.github/workflows/git-identity/action.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ runs:
55
steps:
66
- name: Configure Git identity
77
run: |
8-
env | sort
98
git config --local user.name "$GITHUB_ACTOR"
109
git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com"
1110
shell: bash

.github/workflows/install/action.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ runs:
7575
shell: bash
7676

7777
- name: Review environment variables
78+
# Deliberately limited to the R-related variables this action sets.
79+
# A blanket `env | sort` also prints anything a caller happens to export;
80+
# log masking only covers values GitHub knows are secret, so a token that
81+
# was derived, decoded or assembled from parts would be printed in clear.
7882
run: |
79-
set -x
80-
env | sort
83+
env | grep -E '^(_?R_|RGL_|CCACHE_|PKG_BUILD_)' | sort
8184
shell: bash
8285

8386
- name: Update apt

0 commit comments

Comments
 (0)