fix(ci): gate security review on repo write access (#7986)

🤖 The Codex Security Review gate trusted GitHub's `author_association`
(`MEMBER`/`OWNER`), but GitHub only reports `MEMBER` to viewers who can
see the membership. The workflow `GITHUB_TOKEN` cannot see private
`block` org membership, which is GitHub's default, so most Block
employees read as `CONTRIBUTOR` and their PRs never reviewed
automatically. The same applied to the comment path, where private
members could not authorize a review with `@buzz-security-review <sha>`.

Trust is now repo write access, read from `GET
/repos/{owner}/{repo}/collaborators/{user}/permission` via
`getCollaboratorPermissionLevel`, which is independent of membership
visibility and works with the authorize job's existing `contents: read`
and `pull-requests: read` permissions. A user is trusted only when
`permission` is `write` (which includes `maintain`) or `admin`. A
successful lookup is not enough: on this public repository, an account
with no relationship to it returns `200` with `read`, not 404. Outside
contributors can only push to forks and come back `read`, so they still
need an explicit command from a trusted user.

The check covers the PR author on `pull_request_target`, the commenter
on `issue_comment` (the job-level `author_association` filter is removed
so the trusted script makes that decision), and the stale-marking path
on PR updates. Transient API errors are retried. Authorization fails
closed: any lookup error other than 404 fails the job instead of
authorizing. Stale-marking is conservative: an existing review is always
marked stale without consulting permissions, and a failed lookup counts
as untrusted, so a previous range's review can never appear current for
a new head. A commenter without write access is logged and ignored
instead of failing the run.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-09-30 12:19:56 -04:00
committed by GitHub
parent 95b018c871
commit 0ee6093790
3 changed files with 230 additions and 50 deletions

View File

@@ -28,8 +28,30 @@ const completedMarker = (baseSha, headSha) =>
const reviewCommand = (headSha) => `${REVIEW_COMMAND} ${headSha}`;
const isOrganizationMember = (association) =>
association === "MEMBER" || association === "OWNER";
// Org membership is invisible to the workflow token when a member keeps it
// private, so trust is repo write access. `maintain` reports as `write`.
async function hasWriteAccess({ github, context, core, username }) {
if (!username) {
return false;
}
try {
const { data } = await withGithubRetry(
() =>
github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
}),
{ core },
);
return data.permission === "admin" || data.permission === "write";
} catch (error) {
if (error?.status === 404) {
return false;
}
throw error;
}
}
const hasCurrentReviewLabel = (pullRequest) =>
pullRequest.labels?.some(
@@ -330,6 +352,15 @@ async function prepare({ github, context, core }) {
requestedHeadSha = context.payload.pull_request?.head?.sha || "";
} else if (context.eventName === "issue_comment") {
prNumber = Number(context.payload.issue?.number);
const commenter = context.payload.comment?.user?.login;
if (
!(await hasWriteAccess({ github, context, core, username: commenter }))
) {
core.info(
`Review commands require write access to ${context.repo.owner}/${context.repo.repo}.`,
);
return;
}
const command = context.payload.comment?.body || "";
const match = /^@buzz-security-review ([0-9a-f]{40})$/.exec(command);
if (!match) {
@@ -364,10 +395,15 @@ async function prepare({ github, context, core }) {
}
if (
context.eventName === "pull_request_target" &&
!isOrganizationMember(pullRequest.author_association)
!(await hasWriteAccess({
github,
context,
core,
username: pullRequest.user?.login,
}))
) {
core.info(
`Pull request #${prNumber} requires authorization from a Block organization member.`,
`Pull request #${prNumber} requires authorization from a user with write access.`,
);
return;
}
@@ -479,17 +515,35 @@ async function invalidatePullRequestUpdate({ github, context, core }) {
github,
context,
core,
existingOnlyForOrganizationMembers: true,
existingOnlyForTrustedAuthors: true,
});
}
// A failed lookup must not skip stale-marking, so errors count as untrusted
// and the conservative stale notice is posted.
async function isTrustedForInvalidation({ github, context, core, pullRequest }) {
try {
return await hasWriteAccess({
github,
context,
core,
username: pullRequest.user?.login,
});
} catch (error) {
core.warning(
`Could not read PR author permission (status ${githubErrorStatus(error)}); treating the author as untrusted.`,
);
return false;
}
}
async function invalidate({
github,
context,
core,
prNumber: requestedPrNumber,
existingOnly = false,
existingOnlyForOrganizationMembers = false,
existingOnlyForTrustedAuthors = false,
}) {
const prNumber = Number(
requestedPrNumber ?? context.payload.pull_request?.number,
@@ -506,11 +560,12 @@ async function invalidate({
}
const existing = await findReviewComment({ github, context, prNumber });
const shouldOnlyUpdateExisting =
existingOnly ||
(existingOnlyForOrganizationMembers &&
isOrganizationMember(pullRequest.author_association));
if (!existing && shouldOnlyUpdateExisting) {
if (
!existing &&
(existingOnly ||
(existingOnlyForTrustedAuthors &&
(await isTrustedForInvalidation({ github, context, core, pullRequest }))))
) {
if (existingOnly || hasCurrentReviewLabel(pullRequest)) {
await clearCurrentReview({ github, context, prNumber });
}
@@ -535,7 +590,7 @@ ${STALE_MARKER}
>
> The current range is \`${liveMainSha}...${pullRequest.head.sha}\`.
> A new review must complete for this exact range. When manual authorization
> is required, a Block organization member must comment exactly
> is required, a user with write access must comment exactly
> \`${reviewCommand(pullRequest.head.sha)}\` to authorize a new review.
> Any previous review applies only to its recorded range.
`;