Fix pwn request vulnerability in PR quality checks workflow

The pull_request_target workflow checked out and executed Go scripts from
the PR head, allowing attackers to inject arbitrary code via init()
functions with access to a write-scoped GITHUB_TOKEN. This was confirmed
exploited in the wild (ref: StepSecurity blog).

Checkout now targets the base branch so only trusted scripts execute.
PR head SHA is fetched as data-only for diffing via a new PR_HEAD_SHA
env var. Write operations (comments, labels) are isolated in a separate
report job that never checks out code. All job permissions follow least
privilege — quality runs read-only, report holds the write token.

fixed: #6083

Signed-off-by: Avelino <31996+avelino@users.noreply.github.com>
Co-Authored-By: Thierry Abalea <thierry.abalea@shipfox.io>
This commit is contained in:
Avelino 2026-03-02 11:51:11 -03:00
parent 470fa15543
commit 24273bf86b
No known key found for this signature in database
3 changed files with 62 additions and 17 deletions

View File

@ -207,11 +207,15 @@ func getDiff() string {
if base == "" {
base = "main"
}
out, err := exec.Command("git", "diff", "origin/"+base+"...HEAD", "--", "README.md").Output()
head := os.Getenv("PR_HEAD_SHA")
if head == "" {
head = "HEAD"
}
out, err := exec.Command("git", "diff", "origin/"+base+"..."+head, "--", "README.md").Output()
if err == nil && len(out) > 0 {
return string(out)
}
out, err = exec.Command("git", "diff", "HEAD~1", "--", "README.md").Output()
out, err = exec.Command("git", "diff", head+"~1", "--", "README.md").Output()
if err == nil {
return string(out)
}
@ -223,11 +227,15 @@ func getChangedFiles() []string {
if base == "" {
base = "main"
}
out, err := exec.Command("git", "diff", "--name-only", "origin/"+base+"...HEAD").Output()
head := os.Getenv("PR_HEAD_SHA")
if head == "" {
head = "HEAD"
}
out, err := exec.Command("git", "diff", "--name-only", "origin/"+base+"..."+head).Output()
if err == nil && len(out) > 0 {
return splitLines(string(out))
}
out, err = exec.Command("git", "diff", "--name-only", "HEAD~1").Output()
out, err = exec.Command("git", "diff", "--name-only", head+"~1").Output()
if err == nil {
return splitLines(string(out))
}
@ -291,7 +299,14 @@ func extractRepoName(rawURL string) string {
// --- README parsing ---
func getCategoryItemCount(readmePath, entryURL string) (category string, count int) {
data, err := os.ReadFile(readmePath)
var data []byte
var err error
head := os.Getenv("PR_HEAD_SHA")
if head != "" {
data, err = exec.Command("git", "show", head+":README.md").Output()
} else {
data, err = os.ReadFile(readmePath)
}
if err != nil {
return "unknown", -1
}

View File

@ -5,8 +5,8 @@ on:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: write
contents: write
contents: read
pull-requests: read
jobs:
detect:
@ -36,16 +36,30 @@ jobs:
runs-on: ubuntu-latest
environment: action
container: golang:latest
permissions:
contents: read
pull-requests: read
outputs:
comment: ${{ steps.quality.outputs.comment }}
labels: ${{ steps.quality.outputs.labels }}
fail: ${{ steps.quality.outputs.fail }}
diff_comment: ${{ steps.diff.outputs.diff_comment }}
diff_fail: ${{ steps.diff.outputs.diff_fail }}
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
fetch-depth: 0
- name: Fetch base branch
- name: Fetch base branch and PR head
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git fetch origin ${{ github.base_ref }}
AUTH="$(printf '%s' "x-access-token:${GITHUB_TOKEN}" | base64 -w0)"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${AUTH}" fetch origin "${{ github.base_ref }}"
git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${AUTH}" fetch origin "+refs/pull/${{ github.event.pull_request.number }}/head"
- name: Run quality checks
id: quality
@ -59,28 +73,38 @@ jobs:
continue-on-error: true
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: go run ./.github/scripts/check-pr-diff/
report:
name: Post quality report
needs: [detect, quality]
if: always() && needs.detect.outputs.is_package_pr == 'true' && needs.quality.result != 'cancelled'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: write
steps:
- name: Post quality report comment
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-quality-check
message: |
${{ steps.quality.outputs.comment }}
${{ needs.quality.outputs.comment }}
---
${{ steps.diff.outputs.diff_comment }}
${{ needs.quality.outputs.diff_comment }}
- name: Sync labels
if: needs.quality.outputs.labels != ''
uses: actions-ecosystem/action-add-labels@v1
if: ${{ steps.quality.outputs.labels != '' }}
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
labels: ${{ join(fromJson(steps.quality.outputs.labels), '\n') }}
labels: ${{ join(fromJson(needs.quality.outputs.labels), '\n') }}
- name: Fail if critical checks failed
if: ${{ steps.quality.outputs.fail == 'true' || steps.diff.outputs.diff_fail == 'true' }}
if: needs.quality.outputs.fail == 'true' || needs.quality.outputs.diff_fail == 'true'
run: |
echo "Quality or diff checks failed."
exit 1
@ -90,6 +114,8 @@ jobs:
needs: detect
if: needs.detect.outputs.is_package_pr == 'false'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Post skip notice
uses: marocchino/sticky-pull-request-comment@v2
@ -104,9 +130,12 @@ jobs:
auto-merge:
name: Enable auto-merge
needs: quality
if: always() && needs.quality.result == 'success'
needs: [quality, report]
if: always() && needs.quality.result == 'success' && needs.report.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Enable auto-merge via squash
env:

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
out/
awesome-go
.cache/
check-*
# Folders
.idea