Skip to content

fix(ci): harden PR→issue linking gate against shell injection and missed refs#35761

Open
nollymar wants to merge 11 commits into
mainfrom
fix/pr-issue-linking-gate-hardening
Open

fix(ci): harden PR→issue linking gate against shell injection and missed refs#35761
nollymar wants to merge 11 commits into
mainfrom
fix/pr-issue-linking-gate-hardening

Conversation

@nollymar
Copy link
Copy Markdown
Member

@nollymar nollymar commented May 19, 2026

Fixes #35794

Summary

Hardens the PR-to-issue linking gate (.github/workflows/issue_open-pr.ymlissue_comp_link-issue-to-pr.yml) and adds a second gate on the linked issue's metadata.

Linking-gate fixes (the original three)

  • Shell injection / template interpolation. inputs.pr_body, pr_title, pr_author, pr_url, pr_branch, pr_merged were template-substituted by GitHub Actions directly into the bash run: scripts. PRs whose body contained backticks, $var(...), or unbalanced parens (e.g. feat(tiptap): convert Story Block content to Markdown (#35727) #35728's $markdownTool.blockToMarkdown(json)) caused a bash syntax error on the first Debug workflow inputs step → entire job exited code 2 → Add failure comment to PR was skipped (its condition needs failure_detected=true, never set) → PR appeared unchecked. Hoisted all inputs into a job-level env: block and reference them as $ENV_VAR in shell, so user-supplied values are treated as data, not code.
  • Missed markdown-link refs. The regex (close[ds]?|fix(e[ds])?|resolve[ds]?)(:)?\s+#([0-9]+) requires a literal # immediately after the keyword and misses GitHub's other valid form fixes [#123](url) (as in feat(dotAI): Dot AI LangChain4J - Amazon Bedrock #35242). Added a GraphQL closingIssuesReferences lookup as the 4th and final fallback in Determine final issue number.
  • Stale-on-open. The workflow only triggered on pull_request: [opened], so editing the body or pushing new commits never re-evaluated the gate. A once-broken PR stayed broken even after the author fixed the link. Broadened triggers to [opened, edited, synchronize, reopened]. Existing idempotency (grep -q "#$issue_number" before body patch, sort -u on PR-list comments, Remove failure comment step) handles the extra runs; the failure-comment step is now dedup-guarded.

New second gate — linked issue must have a team label

After the link is resolved, the workflow validates that the linked issue carries a Team : * label and fails with a distinct ❌ Linked Issue Needs Team Label PR comment if it doesn't. The validation step runs before the side-effect steps (PR-list comment on the issue, PR body PATCH), so a PR whose linked issue is missing a team label does not mutate any remote state before failing.

Review-driven follow-ups (commit 312ced3e)

  • Step order moved so gates run before side effects (Link PR to issue and the issue's PR-list comment no longer run when validation fails).
  • gh issue view errors in the team-label step are surfaced distinctly instead of being collapsed into "no team label."
  • $GITHUB_OUTPUT heredocs use a random ghadelimiter_<random> instead of literal EOF to prevent delimiter collision with user-controlled content.
  • Markdown link characters (\, [, ]) in PR_TITLE are escaped before being embedded in the bot's linked-issue comment.
  • URL-presence check on the existing PR list switched from grep -q to grep -qF.

Operational notes

  • Fork PRs are skipped via if: github.event.pull_request.head.repo.full_name == github.repository because the pull_request GITHUB_TOKEN is read-only on forks. If this gate ever becomes a required check, fork contributors will be unchecked by design — flag for follow-up at that point.
  • Explicit permissions: { pull-requests: write, issues: write } declared on the reusable workflow.
  • The check remains advisory (not in branch-protection required checks on main).

Test plan

  • Confirm no shell error in Debug workflow inputs when the PR body contains backticks / $var(...) (this PR's own description is the test case).
  • GraphQL fallback runs only when neither body regex nor branch-name extraction succeeds.
  • synchronize re-fires the check on a new push (empty commit 2e6b0ed0 validated this).
  • Linked-issue gate fails with ❌ Linked Issue Needs Team Label when the linked issue has no Team : * label.
  • Linked-issue gate passes when Team : * is restored on the issue.
  • Side-effect steps (Create new comment, Link PR to issue) are skipped on a run where team-label validation fails.

🤖 Generated with Claude Code

…sed refs

The "PR opened" check silently failed on PRs whose body contained shell
metacharacters (backticks, `$var(`, etc.), exiting before the linkage logic
ran. Move `inputs.*` into a job-level `env:` block so they are no longer
template-interpolated into bash sources. Add a GraphQL
`closingIssuesReferences` fallback for refs the body regex misses (e.g.
markdown-linked `fixes [#123](url)`). Re-evaluate the check on `edited`,
`synchronize`, and `reopened` so a once-broken PR can recover after a body
edit or new push instead of being stuck on the stale `opened` run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nollymar nollymar requested a review from a team as a code owner May 19, 2026 22:45
@github-actions github-actions Bot added the Area : CI/CD PR changes GitHub Actions/workflows label May 19, 2026
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 19, 2026

Claude finished @nollymar's task in 2m 54s —— View job


PR Review

Scope: .github/workflows/issue_open-pr.yml (+11/-1) and issue_comp_link-issue-to-pr.yml (+249/-81). I read both files end-to-end and walked through the success / failure paths for the linking gate and the new team-label gate.

The hardening is sound on the main axes — env-var hoisting, GraphQL fallback, broader triggers, dedup-guarded failure comments, gates-before-side-effects. The findings below are smaller things worth a look.

Findings

1. Stale failure comments on regression: linked → unlinked issue_comp_link-issue-to-pr.yml:188-216
The "Remove issue-linking failure comment" step runs after determine_issue succeeds. If a PR was previously linked-but-no-team-label (so it has a ❌ Linked Issue Needs Team Label comment), and the author then unlinks the issue, determine_issue exits 1 and the "Remove issue-validation failure comment" step is skipped (default success()). Net result: the PR ends up with both the stale team-label comment and a fresh ❌ Issue Linking Required comment. Minor UX papercut — the team-label comment now points at a non-existent link. Consider running the validation-comment cleanup unconditionally, or guarding the comment removal on if: always() and a positive existence check.

2. gh issue view failure produces no PR comment, just a red X issue_comp_link-issue-to-pr.yml:230-235
Errors from gh issue view distinctively fail the step (good — addresses prior review), but unlike the "no team label" path they don't set validation_failed=true, so no PR comment is posted. The author only sees a failing check with no explanation. If the linked issue was deleted/typo'd, the author has to dig into Actions logs. Could be improved by posting a distinct ❌ Could not read linked issue #N comment, but not a blocker.

3. echo -e on existing comment content issue_comp_link-issue-to-pr.yml:340

all_pr_lines=$(echo -e "$existing_pr_lines\n$new_pr_line" | sort -u)

existing_pr_lines is parsed from a previously-stored comment body, where PR titles were already sed-escaped (\[, \], \\). echo -e will interpret backslash escapes — \\ collapses to \, leading to gradual unescaping across rewrites. Safer:

all_pr_lines=$(printf '%s\n%s\n' "$existing_pr_lines" "$new_pr_line" | sort -u)

4. Markdown-char escaping is partial issue_comp_link-issue-to-pr.yml:297
The sed escapes \, [, ]. That blocks the Fix bug](https://evil) link-redirect vector cited in the PR description. But the title is also interpolated into a markdown link's text; unescaped ` lets a title close-then-reopen formatting (Fix \code` bugis fine,Fix bug`` ``something injected) and \n would not normally exist in a PR title via the API but `\r` is possible from some clients. Probably fine in practice since GitHub strips control chars on title, but worth a note in the comment near line 297 explaining the threat model — "we escape link-syntax chars, not formatting chars."

5. ${{ env.GH_TOKEN }} and ${{ github.repository }} inside run: blocks multiple sites (e.g. :64, :149 vs :230)
The whole point of the env-hoist hardening was "no template interpolation into bash." GH_TOKEN is already exposed as $GH_TOKEN, and GITHUB_REPOSITORY is a built-in env var (you already use it at line 149). Yet ${{ env.GH_TOKEN }} and ${{ github.repository }} still appear in many run: blocks. These values are not user-controlled so it's not a security regression, but it's inconsistent with the stated principle and a future grep for "no template interpolation in bash" will be noisier than it needs to be. Cheap one-pass cleanup.

6. cancel-in-progress: false + serial events on PATCH self-trigger issue_open-pr.yml:9-13
The PATCH at issue_comp_link-issue-to-pr.yml:404-409 fires a pull_request: edited event, which queues another run. The grep at line 395 prevents the PATCH from happening twice, but the whole job still runs end-to-end on every self-trigger (timeline fetch, GraphQL, label fetch, comment fetch — ~8 API calls). With cancel-in-progress: false, every author edit produces N+1 runs (their edit + our PATCH-edit), which doubles API consumption on each interaction. Not a correctness issue, but worth filing as a follow-up to short-circuit when no link mutation happened.

7. gh api graphql -F num="$pr_number" issue_comp_link-issue-to-pr.yml:155
-F does numeric-string coercion to integers in gh, so this works, but it's a bit subtle. If you wanted to be explicit, -F num="$pr_number" paired with the Int! in the query is OK; alternatively --raw-field for strings vs -F for typed. Leaving as-is is fine.

8. team_label regex requires exact Team : prefix issue_comp_link-issue-to-pr.yml:238

grep -E '^Team : '

Matches your stated convention exactly. If someone ever lowercases or omits the spaces (Team: Scout, team : Scout), the gate fails opaquely with "no team label." Either tighten the docs in the failure-comment text to make the casing/spacing explicit, or relax the regex (^[Tt]eam ?: ). Pick one — current behavior is bug-prone for humans applying labels by hand.

9. Test-plan checkbox is unchecked

[ ] Side-effect steps (Create new comment, Link PR to issue) are skipped on a run where team-label validation fails.

This is the most important behavior added in this PR and the box is still unchecked in the description. Looking at step ordering and step if: defaults (success()), the property does hold mechanically — but the checkbox should be ticked from a real run before this merges.

Nothing else flagged

The bigger pieces (env-var hoisting, closingIssuesReferences GraphQL fallback ordering, random heredoc delimiter, grep -qF on the URL match, gates-before-side-effects ordering, dedup-guarded failure-comment steps, fork-PR skip with explicit rationale) all check out.

The widened triggers (synchronize/edited) caused the "Add failure comment
to PR" step to repost the ❌ Issue Linking Required comment on every push
to an unlinked PR. Mirror the lookup used by the Remove step and skip the
POST if a matching bot comment already exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Skip the linking job on fork-sourced PRs. The pull_request token is
  read-only for forks, so the PATCH/POST/DELETE calls would 403 on every
  run — now amplified by synchronize/edited triggers.
- Declare explicit pull-requests/issues write permissions on the reusable
  workflow so the dependency is visible and survives future default
  permission tightening.
- Surface GraphQL fallback errors via ::warning:: instead of silently
  swallowing stderr with 2>/dev/null; users debugging a misleading "no
  issue found" failure can now see the underlying call's error.
- Guard the fallback against an empty pr_number (malformed PR_URL) so it
  skips the GraphQL call with a clear warning instead of issuing an
  invalid Int query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty commit to exercise the synchronize trigger after the linking-gate
hardening changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a second gate after the issue-linking check: if the resolved issue
has no 'Team : *' label, the workflow fails and posts a distinct
'❌ Linked Issue Needs Team Label' comment on the PR. The comment is
dedup-guarded and auto-removed on the next successful run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the linked-issue comment already lists the current PR, the "keeping
existing list" branch built the GITHUB_OUTPUT heredoc with printf and no
trailing newline, so the closing EOF was appended to the last PR line
instead of being on its own. The runner then errored with "Matching
delimiter not found 'EOF'" and skipped the rest of the job. Only exposed
now that synchronize/edited triggers cause the workflow to re-run against
issues whose PR-list comment already exists. Add the missing trailing \n.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty commit to test the new "Linked Issue Needs Team Label" gate after
removing the Team : Scout label from issue #35794.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move the issue-link-success cleanup, team-label validation, and the
  validation-cleanup steps to run before the side-effect steps (issue
  PR-list comment, PR body PATCH). A PR whose linked issue is missing a
  team label no longer mutates remote state before failing.
- Surface gh CLI errors in the team-label step distinctly from "no team
  label" so transient API failures, deleted issues, or scope problems do
  not masquerade as missing-label and start a fix cycle that never works.
- Replace the literal `EOF` heredoc delimiter on $GITHUB_OUTPUT multiline
  values with a random `ghadelimiter_<random>` so user-supplied PR titles
  containing a literal "EOF" line cannot terminate the output block early
  or leak subsequent shell content into the value.
- Escape `\`, `[`, and `]` in PR titles before embedding them in the
  markdown link of the linked-issue PR-list comment, so titles like
  'Fix bug](https://evil)' cannot redirect the bot-rendered link.
- Switch the URL-presence check on the existing PR list from `grep -q`
  to `grep -qF` so `.` and `/` in the URL are not interpreted as regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Hoist all step-output values into per-step env: blocks instead of
  inlining them as ${{ steps.X.outputs.Y }} into run: scripts. Today
  these values are all numeric or trusted-shape, but the env-hoist
  invariant means a future widening (a label name, a title) cannot
  silently re-introduce the bash injection the PR was created to close.
- Add concurrency: { group: link-issue-<pr_number>, cancel-in-progress:
  false } to issue_open-pr.yml so synchronize + the edited fired by the
  workflow's own body PATCH cannot race on the PATCH/POST/DELETE calls.
- Anchor the issue-reference dedup in Link PR to issue with a
  non-digit-boundary regex so the body containing "closes #100" is no
  longer considered to already reference #10.
- Tighten the PR-list URL dedup in Check if comment already exists to
  match against the literal markdown-link terminator "($pr_url)" so a
  URL ending in /pull/10 cannot substring-match an existing entry for
  /pull/100.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty commit to exercise the synchronize trigger against the env-hoisted,
concurrency-guarded, anchor-matched version of the linking workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A PR body containing 'fixes [#123](url)' was losing to a branch named
'456-some-feature' because the GraphQL closingIssuesReferences lookup
ran only AFTER branch-name extraction. The body was effectively
unreadable for markdown-linked refs — the exact case the PR claimed to
fix. Reorder priority so author intent in the PR body (whether plain or
markdown-linked) outranks the branch-name heuristic:

  1) check_existing_issues: timeline / plain-keyword regex
  2) closingIssuesReferences GraphQL: catches markdown-linked refs
  3) Branch name extraction: last-resort heuristic

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : CI/CD PR changes GitHub Actions/workflows

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

harden PR→issue linking gate against shell injection and missed refs

1 participant