diff --git a/.github/workflows/pr-labels-ci.yml b/.github/workflows/pr-labels-ci.yml index e1d2ee0..d6411b8 100644 --- a/.github/workflows/pr-labels-ci.yml +++ b/.github/workflows/pr-labels-ci.yml @@ -49,20 +49,22 @@ jobs: - name: Promote to Ready for QA env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | - REPO=${{ github.repository }} - # Look up the associated PR. # The API can 404 after a force-push (orphaned run). # Capture output only on success so 404 body doesn't leak into $PR. PR="" - API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \ + API_OUT=$(gh api "repos/$REPO/actions/runs/$RUN_ID/pull_requests" \ --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true # Fallback: pull_requests array is empty for dependabot PRs. - # Search by head branch instead. + # Search by head branch instead. HEAD_BRANCH comes via env + # (not a direct GHA expression) because fork PR branch names + # are contributor-controlled and allow shell metacharacters. if [ -z "$PR" ]; then - HEAD_BRANCH=${{ github.event.workflow_run.head_branch }} PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \ --json number --jq '.[0].number // empty' 2>/dev/null) || true fi @@ -103,15 +105,17 @@ jobs: - name: Set CI Failed env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | - REPO=${{ github.repository }} - PR="" - API_OUT=$(gh api "repos/$REPO/actions/runs/${{ github.event.workflow_run.id }}/pull_requests" \ + API_OUT=$(gh api "repos/$REPO/actions/runs/$RUN_ID/pull_requests" \ --jq '.[0].number // empty' 2>&1) && PR="$API_OUT" || true + # HEAD_BRANCH comes via env (not a direct GHA expression) + # because fork PR branch names are contributor-controlled. if [ -z "$PR" ]; then - HEAD_BRANCH=${{ github.event.workflow_run.head_branch }} PR=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open \ --json number --jq '.[0].number // empty' 2>/dev/null) || true fi diff --git a/CHANGELOG.md b/CHANGELOG.md index cbb5f15..ea7d45d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security +- **Harden `pr-labels-ci.yml` against shell injection via fork-PR branch names.** Contributor-controlled values (`github.event.workflow_run.head_branch`, `.id`, `github.repository`) previously inlined directly into `run:` blocks as `${{ ... }}` expressions. Git refnames permit shell metacharacters (`$`, backtick, `;`, `&`, `|`), so a malicious fork branch name would render as executed shell after GHA's queue-time substitution. All such values now route through step-level `env:` and are referenced as shell variables (`"$HEAD_BRANCH"`, `"$REPO"`, `"$RUN_ID"`). Cascaded from [cmeans/mcp-clipboard#88](https://github.com/cmeans/mcp-clipboard/pull/88) hardening. Closes [#332](https://github.com/cmeans/mcp-awareness/issues/332). +- **Pre-empt the "literal `${{ }}` in shell comments" parser trap** that would have surfaced the moment the hardening above landed with placeholder comments. GHA's queue-time parser substitutes `${{ ... }}` everywhere inside `run:` blocks — including inside shell `#` comments — and rejects empty expressions with `An expression was expected` on `workflow_dispatch` / fresh-repo registration. The hardening cascade uses comment wording that avoids literal `${{ }}` entirely. Root cause diagnosed in [cmeans/yt-dont-recommend#28](https://github.com/cmeans/yt-dont-recommend/issues/28); cascade source is [cmeans/mcp-clipboard#92](https://github.com/cmeans/mcp-clipboard/pull/92). + ## [0.18.0] - 2026-04-16 ### Added