feat(review-pr): authorize review_requested via the requester for external contributors#16
Conversation
…ernal contributors Add a second authorization path so a maintainer can pull an external (non-org-member) contributor's PR into the review pipeline by requesting docker-agent as a reviewer, while auto-run stays limited to org-member PRs. check-org-membership now decides via evaluateMembership: - auto-run (pull_request opened/ready/synchronize): authorize the PR author - review_requested: authorize the requester (a maintainer), not the author The requester is only taken from trusted sources, never from the fork-written trigger artifact: github.event.sender.login on the direct same-repo path, or re-derived from the PR timeline (listEventsForTimeline) on the fork/workflow_run path. The timeline lookup is bound to the same pr_number that drives the checkout and the result is re-validated against real org membership, so a forged requester cannot escalate onto attacker-controlled code. The timeline call runs only when the author is not already a member. This also fixes a latent issue where same-repo direct pull_request auto-run resolved an empty username (it now resolves the PR author). review-pr.yml passes EVENT_NAME, EVENT_ACTION and REQUESTER to the membership step. Docs (review-pr README, onboarding skill, AGENTS.md) updated.
688b3dd to
6ef9831
Compare
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟡 NEEDS ATTENTION
One LIKELY finding in the new / resolveTrustedRequester authorization logic. The core security model is sound — the workflow routing enforces the fork/same-repo separation — but the TypeScript code relies on that workflow-layer invariant without any in-code assertion, creating a fragile defense-in-depth posture.
| ): Promise<string> { | ||
| const { prSource, eventName, eventAction, trustedRequester } = inputs; | ||
| if (prSource === 'event' && eventName === 'pull_request' && eventAction === 'review_requested') { | ||
| return trustedRequester; |
There was a problem hiding this comment.
[LIKELY / MEDIUM] resolveTrustedRequester trusts REQUESTER env var without an in-code same-repo guard
On the prSource === 'event' + eventName === 'pull_request' + eventAction === 'review_requested' branch, the function returns inputs.trustedRequester — which comes directly from process.env.REQUESTER (set to github.event.sender.login in the workflow). There is no in-code assertion that this event path is exclusive to same-repo (non-fork) triggers; the trust guarantee is enforced only at the workflow layer (source=trigger is set for fork workflow_run paths, source=event for direct events).
This is safe as long as the workflow routing invariant holds. However, the TypeScript code itself has no internal check, so any future misconfiguration of the workflow (e.g., a consumer that passes PR_SOURCE=event on a fork path) would silently trust an attacker-controlled REQUESTER value and could grant review authorization to a non-member.
Suggested hardening: add an explicit guard in resolveTrustedRequester to make the safety assumption explicit and self-documenting — e.g., throw (or return '') if trustedRequester is empty, or add a comment tying the invariant to the workflow routing. A stronger fix would be to always use resolveReviewRequester (the timeline-based, server-side method) even on the prSource==='event' path, removing any reliance on the env-var value entirely.
…variant The trusted requester (REQUESTER = github.event.sender.login) is only authoritative on a direct same-repo pull_request:review_requested event; the fork/workflow_run path is resolved server-side from the PR timeline. Make that boundary explicit in code and add a regression test asserting the env-supplied requester is ignored on any other path.
derekmisler
left a comment
There was a problem hiding this comment.
one real security issue to fix before merging: resolveReviewRequester doesn't track review_request_removed events, so a retracted review request can still authorize a fork PR. fix is small (see inline). one minor suggestion about denial logging subject. the overall approach (trusted triple gate, timeline re-derivation on fork path, fail-closed on API errors) is solid.
|
|
||
| let requester = ''; | ||
| for (const ev of events) { | ||
| if (ev.event === 'review_requested' && ev.requested_reviewer?.login === reviewerLogin) { |
There was a problem hiding this comment.
issue: resolveReviewRequester only accumulates review_requested events but never checks for a later review_request_removed event on the same reviewer. if a maintainer requests docker-agent then removes that request, requester still holds the stale login and path 2 in evaluateMembership will grant authorization anyway, on the fork/workflow_run path especially, that's a real access control bypass.
fix: handle both event types in the same pass (timeline is ascending-chronological, so this handles re-request-after-removal correctly too):
let requester = '';
for (const ev of events) {
if (ev.event === 'review_requested' && ev.requested_reviewer?.login === reviewerLogin) {
requester = ev.review_requester?.login ?? ev.actor?.login ?? '';
} else if (ev.event === 'review_request_removed' && ev.requested_reviewer?.login === reviewerLogin) {
requester = ''; // revoked
}
}also worth adding review_request_removed as a possible event value in TimelineReviewRequestedEvent so future contributors see this case at the type level.
There was a problem hiding this comment.
Fixed in 6949610.
resolveReviewRequester now replays both event types for the reviewer in one pass: review_requested sets the requester, review_request_removed clears it, so a retracted request no longer authorizes the PR. review_request_removed is added to the (renamed) TimelineReviewEvent type.
One addition beyond the suggested fix: the replay now sorts by created_at rather than relying on the timeline's array order. That order is ascending in practice but not contractually guaranteed, and an out-of-order request/removal pair would otherwise leave a retraction stale, which the org-membership re-check cannot catch because the login is a real member.
New tests:
- removal clears a prior request
- re-request after removal wins
- removal targeting a different reviewer is ignored
- out-of-order replay still lets the retraction win
- fork/trigger integration: a retracted request denies an external PR
| return { isMember: true, subject: requester, via: 'requester' }; | ||
| } | ||
|
|
||
| return { isMember: false, subject: author, via: 'none' }; |
There was a problem hiding this comment.
suggestion (non-blocking): when path 2 fires and the requester check fails, this always returns subject: author. but the party that actually failed the membership check was requester, not author (who correctly failed path 1 first). the operator log says ⏭️ Not authorized to review (subject: @ext-author) which points at the wrong person.
return { isMember: false, subject: requester || author, via: 'none' };small change, but audit logs for auth failures should identify who actually failed.
There was a problem hiding this comment.
Applied in 6949610.
evaluateMembership now returns subject: requester || author on denial, so an authorization failure on path 2 names the requester that actually failed rather than the author who already failed path 1.
Tests updated to assert the corrected subject:
- direct
review_requesteddenial reports the requester (outsider) - fork/trigger denial reports the timeline-derived requester (
not-a-member)
- CONTRIBUTING.md: org-member PRs are not auto-reviewed on open or ready-for-review. The self-review trigger fires only on review_requested, so org members also request a review explicitly; the only difference from external contributors is approving the workflow run. - review-pr/README.md: add a visible note that the requester-authorized path depends on the check-org-membership update from PR #16; without it, membership is checked against the PR author and the review is silently skipped. - review-pr/README.md: caveat the check run with "if checks: write is granted" to match the rest of the doc (the Create check run step uses continue-on-error).
…orized resolveReviewRequester replayed only review_requested timeline events, so a review request a maintainer later removed left a stale requester that still authorized a fork/workflow_run PR (access control bypass raised in review). Replay review_requested and review_request_removed for the reviewer in chronological order, sorting by created_at instead of relying on the timeline endpoint's non-guaranteed ordering, so the latest still-effective state wins. On denial, evaluateMembership now reports the requester that failed path 2 rather than the PR author.
derekmisler
left a comment
There was a problem hiding this comment.
fix is correct. the review_request_removed handling is clean, the created_at sort to avoid relying on undocumented array ordering is a nice proactive addition, and the denial subject now points at the right party. good work addressing this quickly.
> [!IMPORTANT] > **Merge #16 first.** This PR documents the requester-authorized review flow for external and fork contributor PRs, which only works once #16 (`feat(review-pr): authorize review_requested via the requester for external contributors`) lands. Merging this ahead of #16 would describe a flow that does not yet function and would briefly contradict the on-main defense-in-depth text that #16 updates. ## Summary Documents the UX for org members handling external and fork contributor PRs across three docs. The flow, with no special commands or workflow inputs: 1. An org member approves the workflow run (GitHub gates Actions on PRs from first-time and external contributors). 2. An org member requests a review from `docker-agent` via the native review-request UI (PR sidebar, Reviewers). | File | Change | | --- | --- | | `review-pr/README.md` | New `External and fork contributor PRs` section with the full two-step flow | | `CONTRIBUTING.md` | New `Automated PR Review` section (org members vs external and fork contributors) | | `README.md` | One-line pointer and link in the PR Review Workflow section | ## Alignment with open PRs | PR | Relationship | | --- | --- | | #16 | Source of the documented behavior. The review is authorized by the requesting org member, not the PR author. This PR's wording matches that model. | | #13 | Compatible. The "only triage or write access can request a reviewer" phrasing matches #13's GitHub-native enforcement framing. | ## Conflict avoidance Changes are additive. The new section in `review-pr/README.md` is inserted before `Customizing`, outside the regions #16 and #13 edit (the "What you get" trigger table and the defense-in-depth blockquote), so it merges cleanly against both. ## Validation | Check | Result | | --- | --- | | Merges cleanly against #16 and #13 (additive, non-overlapping regions) | yes | | Em-dash, double-hyphen, or emoji in added prose | none | | Cross-file anchor links resolve (`#external-and-fork-contributor-prs`) | yes | | Markdown lists, links, and backticks valid | yes |
Reconcile the abuse safeguards with the requester-based authorization rework that landed on main (PRs #16, #17, #18). check-org-membership: take main's evaluateMembership model. It subsumes this branch's resolveUsername helper (the directly wired pull_request auto-review path now verifies the PR author via a live API lookup) and adds the review_requested requester path so an org member can pull an external contributor's PR into review. review-pr.yml: provide EVENT_NAME, EVENT_ACTION and REQUESTER to the membership step; drop the PR_AUTHOR env that main's code never reads. tsup.config.ts: keep both the rate-limit (this branch) and score-confidence (main) entry points. review-pr/README.md: keep main's requester-model wording and preserve this branch's audit, throttle and force-push safeguards. SECURITY.md: update section 1 to the requester-based model and remove the stale resolveUsername and PR_AUTHOR references.
Summary
Adds the second authorization path the issue describes, so the review workflow handles both triggers feeding the one review pipeline.
pull_requestopened/ready/synchronizedocker-agentrequested as reviewerRouting already converged both onto the single
reviewjob; the missing piece was the authorization, now handled incheck-org-membership.Issue expectations mapped
evaluateMembershipauthor pathevaluateMembershiprequester pathreview-pr.ymlreview job (iflines 161-164)Security
The requester is never trusted from the trigger artifact (a fork PR controls its own trigger workflow and could otherwise forge a member login):
github.event.sender.login(populated by GitHub).workflow_runpath: requester is re-derived server-side from the PR timeline (listEventsForTimeline) using the repo-scoped token.Safeguards:
pr_numberthat drives the checkout, so forgingpr_numberonly redirects to a real PR's own code, never attacker-controlled code.Side fix
Same-repo direct
pull_requestauto-run previously resolved an empty username (COMMENT_AUTHORis empty on apull_requestevent), so it now resolves the PR author.Validation
Rollout note
The self-review workflow runs
check-org-membershipfrom the pinned released action (DOCKER_AGENT_ACTION_ROOT) anddist/is gitignored, so this takes effect on the next release plussetup-credentialspin bump. No trigger-workflow or consumer template change is required, because the requester is derived from the timeline rather than the artifact.