* chore(#2331): trigger PR-policy workflows on pull_request_target
Three PR-policy workflows (pr-title-validator, pr-target-validator,
require-issue-link) triggered on plain `pull_request`, so a fork PR's
GITHUB_TOKEN was downgraded to read-only regardless of the declared
`permissions:`. Each one comments on the PR and THEN emits its verdict, so the
createComment 403 killed the github-script step before core.setFailed ran: the
contributor saw an API stack trace instead of the instructions the comment
exists to deliver. Confirmed on PR #2084 (job 86573823878), whose title has
been non-compliant since 2026-07-08 while the explanatory comment 403'd on
every run.
Switches all three to pull_request_target (base-repo context, write-capable
token), matching the three siblings that already do this correctly
(pr-template-format, close-draft-prs, auto-close-unsolicited-prs). Safe: the
only checkouts are BASE-branch with persist-credentials: false, and every
PR-controlled input is read as data — no head code executes. Also wraps each
comment in try/catch so a comment failure can never again suppress the verdict.
Extends tests/workflow-maintainer-skip.test.cjs with the trigger lock already
applied to close-draft-prs.yml (:32-42) for this same defect class.
Closes#2331
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2331): strip backticks before echoing untrusted text into bot comments
Found by the orthogonal security review of this change.
pr-title-validator and pr-target-validator echo attacker-controlled text (the PR
title; the fork's branch name) into an inline-code span in a comment posted by
github-actions[bot]. A single backtick closes the span early and the remainder
renders as live Markdown — GFM autolinks a bare URL — so a fork author could
make our own bot post an arbitrary clickable link into a PR thread, borrowing
the bot's credibility for phishing.
This interpolation is unchanged from next, but it was NOT previously reachable
from forks: the createComment call 403'd and the comment was never posted. The
trigger switch in the parent commit is what makes it reachable by untrusted
authors for the first time, using the write token it grants — so it is in scope
here and fixed here rather than deferred.
A PR title has no charset restriction, so that vector is fully exploitable. The
branch-name vector is weaker (check-ref-format forbids space, ':', '[' and '*',
so no bare URL, link or emphasis is expressible) but is the same class and is
stripped identically rather than left to the charset to police. Stripping the
backtick is complete: it is the only character that can break out of an
inline-code span. Only the rendered body needs this — core.warning/setFailed go
to the job log, where @actions/core already escapes workflow commands.
Also strengthens the try/catch test to assert core.setFailed sits AFTER the
catch block rather than merely existing, so moving the verdict inside the try
(the exact inversion #2331 fixes) fails the test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(#2331): assert verdict ordering on code, not on comment prose
The first cut of the verdict-ordering guard failed against correct code. It used
indexOf('core.setFailed') on raw source, and these workflows name core.setFailed
in their own comments while explaining the bug — at lines 29/118/147, 16 and 6,
all BEFORE the catch block. So the assertion compared a comment to the call and
reported the inversion it was written to catch. gsd-test caught it: 4 unique
failures across linux-node22/24.
The code was right; the test was measuring the wrong text. Fixes:
- readWorkflowCode() strips whole-line YAML/JS comments so positional
assertions see only executable text.
- The ordering check is extracted to verdictSurvivesCommentFailure() and
exercised against BOTH a good and an inverted sample, so the guard is proven
non-vacuous rather than merely passing.
- A test pins the trap itself: raw source really does mention core.setFailed
before the catch, while the stripped view puts the real call after it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(#2331): drop the unnecessary permission widening; the trigger was the whole bug
Both orthogonal review passes flagged the permissions block, from opposite
directions — one said issues:write was dead surface on require-issue-link, the
other said it was the load-bearing scope the two validators lacked. Neither is
right, and the repo's own history settles it:
- pr-title-validator declares pull-requests:write ONLY, and its sticky comment
has posted 26 times.
- pr-target-validator declares pull-requests:write ONLY — posted 8 times.
- require-issue-link declares issues:write ONLY — posted on same-repo PRs
#106, #164, #232, #259.
So GitHub accepts EITHER scope for issues.createComment when the target is a
PR, and all three files already declared a sufficient one. The 403 was purely
the fork token downgrade. My added scopes fixed nothing and widened privilege
on precisely the workflows now running as pull_request_target — the context
where surplus scope matters most. Reverted: permissions are byte-identical to
next, and the diff is now trigger + try/catch + sanitizer only.
The permission test previously used an (issues|pull-requests) alternation, so
it passed on the pre-fix tree and would not have caught removal of the scope
that matters. It now asserts each file's SPECIFIC scope and, more usefully,
asserts the absence of the other — locking the least-privilege property against
a future 'add it to be safe' regression. It is a forward lock, not a #2331
fails-first test; the trigger assertion is the fails-first one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>