diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 962929e4..574f0769 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -49,19 +49,50 @@ jobs: actions: read id-token: write steps: - # CodeQL actions/untrusted-checkout-toctou: refs/pull/N/head is mutable, an - # attacker could push a different commit after the trusted comment fires this - # job but before checkout runs. Resolve the SHA once, right after the trust - # gate above, and pin checkout to it instead of the branch ref. + # The trust gate above vouches for the commenter, not for the PR head: a fork + # author can push a new commit after a maintainer comments and before this job + # starts, and the run would review code nobody with write access has seen + # (CodeQL actions/untrusted-checkout/high and untrusted-checkout-toctou). + # + # So the SHA is resolved once and checkout is pinned to it, and on a fork PR + # the run stops when the fork was pushed at or after the comment. The fork's + # pushed_at is set by GitHub on push, unlike commit dates, which the author + # controls. It is read after the SHA, so a push in between also stops the run. + # Same-repo PRs skip the date check: their pushers already have write access. + # The automatic pull_request review has no comment and only runs on same-repo + # PRs (see the job's if:). # https://github.com/AdnaneKhan/ActionsTOCTOU # https://docs.github.com/en/actions/reference/security/secure-use - name: Resolve trusted PR head SHA id: resolve-sha + shell: bash env: GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} + COMMENTED_AT: ${{ github.event.comment.created_at || github.event.review.submitted_at }} run: | - PR_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}" - SHA=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json headRefOid --jq .headRefOid) + set -euo pipefail + PR_FIELDS=$(gh api "repos/$REPO/pulls/$PR_NUMBER" \ + --jq '[.head.sha, (.head.repo.full_name != .base.repo.full_name), (.head.repo.full_name // "")] | @tsv') + read -r SHA IS_FORK HEAD_REPO <<< "$PR_FIELDS" + if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Could not resolve the head SHA of PR #$PR_NUMBER" + exit 1 + fi + + if [[ -n "$COMMENTED_AT" && "$IS_FORK" == "true" ]]; then + if [[ -z "$HEAD_REPO" ]]; then + echo "::error::The fork behind PR #$PR_NUMBER no longer exists; refusing to check out its head" + exit 1 + fi + HEAD_PUSHED_AT=$(gh api "repos/$HEAD_REPO" --jq .pushed_at) + if [[ $(date -d "$HEAD_PUSHED_AT" +%s) -ge $(date -d "$COMMENTED_AT" +%s) ]]; then + echo "::error::$HEAD_REPO was pushed at $HEAD_PUSHED_AT, at or after the triggering comment ($COMMENTED_AT). Review the latest push and mention @claude again." + exit 1 + fi + fi + echo "sha=$SHA" >> "$GITHUB_OUTPUT" - name: Checkout PR head @@ -70,6 +101,7 @@ jobs: # Agent mode performs no branch setup of its own (unlike tag mode), and # a comment event points at the default branch — without an explicit ref # every file Claude reads would be master's copy, not the change's. + # Only the SHA vetted by the step above: never the mutable PR ref. ref: ${{ steps.resolve-sha.outputs.sha }} fetch-depth: 1