mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
🤖 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>
832 lines
24 KiB
JavaScript
832 lines
24 KiB
JavaScript
"use strict";
|
|
|
|
const MARKER = "<!-- codex-security-review -->";
|
|
const STALE_MARKER = "<!-- codex-security-review-stale -->";
|
|
const REVIEW_COMMAND = "@buzz-security-review";
|
|
const CURRENT_REVIEW_LABEL = "codex-security-review-current";
|
|
const RECONCILIATION_BATCH_SIZE = 32;
|
|
const MAX_RECONCILIATION_PASSES = 2;
|
|
const GITHUB_RETRY_ATTEMPTS = 3;
|
|
const RISKS = ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"];
|
|
const SEVERITIES = new Set(RISKS.slice(1));
|
|
const CATEGORIES = new Set([
|
|
"Isolation",
|
|
"Auth",
|
|
"Event Integrity",
|
|
"Cryptography",
|
|
"Injection",
|
|
"Agent/Workflow",
|
|
"Desktop/Mobile",
|
|
"Concurrency",
|
|
"Reliability",
|
|
"Supply Chain",
|
|
"Other",
|
|
]);
|
|
|
|
const completedMarker = (baseSha, headSha) =>
|
|
`<!-- codex-security-review-range:${baseSha}...${headSha} -->`;
|
|
|
|
const reviewCommand = (headSha) => `${REVIEW_COMMAND} ${headSha}`;
|
|
|
|
// 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(
|
|
(label) =>
|
|
(typeof label === "string" ? label : label?.name) ===
|
|
CURRENT_REVIEW_LABEL,
|
|
) ?? false;
|
|
|
|
const isObject = (value) =>
|
|
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
function requireKeys(value, expected, label) {
|
|
if (!isObject(value)) {
|
|
throw new Error(`${label} must be an object.`);
|
|
}
|
|
const actual = Object.keys(value).sort();
|
|
const wanted = [...expected].sort();
|
|
if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
|
|
throw new Error(`${label} has unexpected or missing properties.`);
|
|
}
|
|
}
|
|
|
|
function requireString(value, maxLength, label) {
|
|
if (
|
|
typeof value !== "string" ||
|
|
value.length === 0 ||
|
|
value.length > maxLength
|
|
) {
|
|
throw new Error(
|
|
`${label} must be a non-empty string of at most ${maxLength} characters.`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function safeCodeText(value, maxLength, label) {
|
|
const input = requireString(value, maxLength, label)
|
|
.replace(
|
|
/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g,
|
|
" ",
|
|
)
|
|
.trim();
|
|
if (!input) {
|
|
throw new Error(`${label} is empty after removing control characters.`);
|
|
}
|
|
|
|
const longestBacktickRun = Math.max(
|
|
0,
|
|
...(input.match(/`+/g) || []).map((run) => run.length),
|
|
);
|
|
const fence = "`".repeat(longestBacktickRun + 1);
|
|
return `${fence} ${input} ${fence}`;
|
|
}
|
|
|
|
function validPath(value) {
|
|
return (
|
|
typeof value === "string" &&
|
|
value.length > 0 &&
|
|
value.length <= 500 &&
|
|
!value.startsWith("/") &&
|
|
!value.includes("\\") &&
|
|
!/[\u0000-\u001f\u007f]/.test(value) &&
|
|
!value.split("/").includes("..")
|
|
);
|
|
}
|
|
|
|
const encodeUrlComponent = (value) =>
|
|
encodeURIComponent(value).replace(
|
|
/[!'()*]/g,
|
|
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
|
);
|
|
|
|
const encodePath = (value) =>
|
|
value.split("/").map(encodeUrlComponent).join("/");
|
|
|
|
const githubErrorStatus = (error) =>
|
|
Number(error?.status ?? error?.response?.status);
|
|
|
|
function isRetryableGithubError(error) {
|
|
const status = githubErrorStatus(error);
|
|
if (status === 429 || (status >= 500 && status <= 504)) {
|
|
return true;
|
|
}
|
|
if (status !== 403) {
|
|
return false;
|
|
}
|
|
|
|
const headers = error?.response?.headers || {};
|
|
const message = `${error?.message || ""} ${error?.response?.data?.message || ""}`;
|
|
return (
|
|
headers["retry-after"] !== undefined ||
|
|
headers["x-ratelimit-remaining"] === "0" ||
|
|
message.toLowerCase().includes("rate limit")
|
|
);
|
|
}
|
|
|
|
function githubRetryDelayMs(error, attempt) {
|
|
const headers = error?.response?.headers || {};
|
|
const retryAfterSeconds = Number(headers["retry-after"]);
|
|
if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) {
|
|
return Math.min(Math.max(retryAfterSeconds * 1000, 1000), 120000);
|
|
}
|
|
|
|
const resetSeconds = Number(headers["x-ratelimit-reset"]);
|
|
if (
|
|
headers["x-ratelimit-remaining"] === "0" &&
|
|
Number.isFinite(resetSeconds)
|
|
) {
|
|
return Math.min(
|
|
Math.max(resetSeconds * 1000 - Date.now() + 1000, 1000),
|
|
120000,
|
|
);
|
|
}
|
|
|
|
const status = githubErrorStatus(error);
|
|
const baseDelay = status === 403 || status === 429 ? 60000 : 2000;
|
|
return Math.min(baseDelay * 2 ** (attempt - 1), 120000);
|
|
}
|
|
|
|
async function withGithubRetry(
|
|
operation,
|
|
{
|
|
core,
|
|
sleep = (milliseconds) =>
|
|
new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
},
|
|
) {
|
|
for (let attempt = 1; attempt <= GITHUB_RETRY_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
return await operation();
|
|
} catch (error) {
|
|
if (
|
|
attempt === GITHUB_RETRY_ATTEMPTS ||
|
|
!isRetryableGithubError(error)
|
|
) {
|
|
throw error;
|
|
}
|
|
const delay = githubRetryDelayMs(error, attempt);
|
|
core.warning(
|
|
`GitHub API request failed with status ${githubErrorStatus(error)}; ` +
|
|
`retrying in ${Math.ceil(delay / 1000)} seconds ` +
|
|
`(attempt ${attempt + 1} of ${GITHUB_RETRY_ATTEMPTS}).`,
|
|
);
|
|
await sleep(delay);
|
|
}
|
|
}
|
|
|
|
throw new Error("GitHub API retry loop ended unexpectedly.");
|
|
}
|
|
|
|
async function findReviewComment({ github, context, prNumber }) {
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
per_page: 100,
|
|
});
|
|
return comments.find(
|
|
(comment) =>
|
|
comment.user?.login === "github-actions[bot]" &&
|
|
comment.user?.type === "Bot" &&
|
|
comment.body?.startsWith(`${MARKER}\n`),
|
|
);
|
|
}
|
|
|
|
async function upsertReviewComment({ github, context, core, prNumber, body }) {
|
|
const existing = await findReviewComment({ github, context, prNumber });
|
|
if (existing) {
|
|
await github.rest.issues.updateComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: existing.id,
|
|
body,
|
|
});
|
|
core.info(`Updated Codex security review comment #${existing.id}.`);
|
|
return;
|
|
}
|
|
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body,
|
|
});
|
|
core.info(`Posted Codex security review on PR #${prNumber}.`);
|
|
}
|
|
|
|
async function getPullRequest({ github, context, prNumber }) {
|
|
const { data: pullRequest } = await github.rest.pulls.get({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: prNumber,
|
|
});
|
|
if (
|
|
pullRequest.state !== "open" ||
|
|
pullRequest.base.repo.full_name !==
|
|
`${context.repo.owner}/${context.repo.repo}` ||
|
|
pullRequest.base.ref !== "main"
|
|
) {
|
|
return null;
|
|
}
|
|
return pullRequest;
|
|
}
|
|
|
|
async function getLiveMainSha({ github, context }) {
|
|
const { data: mainRef } = await github.rest.git.getRef({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
ref: "heads/main",
|
|
});
|
|
const sha = mainRef.object?.sha || "";
|
|
if (mainRef.object?.type !== "commit" || !/^[0-9a-f]{40,64}$/.test(sha)) {
|
|
throw new Error("refs/heads/main did not resolve to a commit SHA.");
|
|
}
|
|
return sha;
|
|
}
|
|
|
|
async function ensureCurrentReviewLabel({ github, context }) {
|
|
try {
|
|
await github.rest.issues.getLabel({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
name: CURRENT_REVIEW_LABEL,
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
if (error?.status !== 404) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await github.rest.issues.createLabel({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
name: CURRENT_REVIEW_LABEL,
|
|
color: "1d76db",
|
|
description: "The posted Codex security review matches its recorded range.",
|
|
});
|
|
} catch (error) {
|
|
// Another posting job may create the repository label concurrently.
|
|
if (error?.status !== 422) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function markReviewCurrent({ github, context, prNumber }) {
|
|
await ensureCurrentReviewLabel({ github, context });
|
|
await github.rest.issues.addLabels({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
labels: [CURRENT_REVIEW_LABEL],
|
|
});
|
|
}
|
|
|
|
async function clearCurrentReview({ github, context, prNumber }) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
name: CURRENT_REVIEW_LABEL,
|
|
});
|
|
} catch (error) {
|
|
if (error?.status !== 404) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function reviewRangeIsCurrent({
|
|
github,
|
|
context,
|
|
prNumber,
|
|
baseSha,
|
|
headSha,
|
|
headRepo,
|
|
}) {
|
|
const [pullRequest, liveMainSha] = await Promise.all([
|
|
getPullRequest({ github, context, prNumber }),
|
|
getLiveMainSha({ github, context }),
|
|
]);
|
|
return (
|
|
pullRequest !== null &&
|
|
liveMainSha === baseSha &&
|
|
pullRequest.head.sha === headSha &&
|
|
pullRequest.head.repo?.full_name === headRepo
|
|
);
|
|
}
|
|
|
|
async function prepare({ github, context, core }) {
|
|
let prNumber;
|
|
let requestedHeadSha;
|
|
if (context.eventName === "pull_request_target") {
|
|
prNumber = Number(context.payload.pull_request?.number);
|
|
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) {
|
|
core.setFailed(
|
|
`Review commands must be exactly "${REVIEW_COMMAND} <full-head-sha>".`,
|
|
);
|
|
return;
|
|
}
|
|
requestedHeadSha = match[1];
|
|
} else {
|
|
core.setFailed(`Unsupported review trigger: ${context.eventName}.`);
|
|
return;
|
|
}
|
|
|
|
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
|
|
core.setFailed("Invalid pull request number for security review.");
|
|
return;
|
|
}
|
|
|
|
const pullRequest = await getPullRequest({ github, context, prNumber });
|
|
if (!pullRequest) {
|
|
core.setFailed(
|
|
`Pull request #${prNumber} is not an open PR targeting main.`,
|
|
);
|
|
return;
|
|
}
|
|
if (!pullRequest.head.repo?.full_name) {
|
|
core.setFailed(
|
|
`Pull request #${prNumber} has no available head repository.`,
|
|
);
|
|
return;
|
|
}
|
|
if (
|
|
context.eventName === "pull_request_target" &&
|
|
!(await hasWriteAccess({
|
|
github,
|
|
context,
|
|
core,
|
|
username: pullRequest.user?.login,
|
|
}))
|
|
) {
|
|
core.info(
|
|
`Pull request #${prNumber} requires authorization from a user with write access.`,
|
|
);
|
|
return;
|
|
}
|
|
if (pullRequest.head.sha !== requestedHeadSha) {
|
|
core.setFailed(
|
|
`Pull request #${prNumber} moved after this review was authorized. ` +
|
|
`Use "${reviewCommand(pullRequest.head.sha)}" to review the current head.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const baseSha = await getLiveMainSha({ github, context });
|
|
const commitRange = `${baseSha}...${pullRequest.head.sha}`;
|
|
core.setOutput("authorized", "true");
|
|
core.setOutput("pr_number", String(prNumber));
|
|
core.setOutput("trigger_actor", context.actor);
|
|
core.setOutput("base_sha", baseSha);
|
|
core.setOutput("head_sha", pullRequest.head.sha);
|
|
core.setOutput("head_repo", pullRequest.head.repo.full_name);
|
|
core.setOutput("commit_range", commitRange);
|
|
}
|
|
|
|
function setReconciliationOutputs(
|
|
core,
|
|
{
|
|
prNumbers,
|
|
mainSha,
|
|
shouldContinue = false,
|
|
nextAfter = 0,
|
|
nextPass = 1,
|
|
},
|
|
) {
|
|
core.setOutput(
|
|
"pr_numbers",
|
|
JSON.stringify(prNumbers.length > 0 ? prNumbers : [0]),
|
|
);
|
|
core.setOutput("main_sha", mainSha);
|
|
core.setOutput("should_continue", String(shouldContinue));
|
|
core.setOutput("next_after", String(nextAfter));
|
|
core.setOutput("next_pass", String(nextPass));
|
|
}
|
|
|
|
async function prepareBaseReconciliation({ github, context, core }) {
|
|
const reconciliation = context.payload.client_payload || {};
|
|
const afterPrNumber = Number(reconciliation.after_pr || 0);
|
|
if (!Number.isSafeInteger(afterPrNumber) || afterPrNumber < 0) {
|
|
throw new Error("Invalid reconciliation cursor.");
|
|
}
|
|
const pass = Number(reconciliation.pass || 1);
|
|
if (
|
|
!Number.isSafeInteger(pass) ||
|
|
pass < 1 ||
|
|
pass > MAX_RECONCILIATION_PASSES
|
|
) {
|
|
throw new Error("Invalid reconciliation pass.");
|
|
}
|
|
|
|
const requestedMainSha =
|
|
context.eventName === "push"
|
|
? context.sha
|
|
: reconciliation.main_sha || "";
|
|
if (requestedMainSha && !/^[0-9a-f]{40,64}$/.test(requestedMainSha)) {
|
|
throw new Error("Invalid reconciliation main SHA.");
|
|
}
|
|
const liveMainSha = await getLiveMainSha({ github, context });
|
|
const mainSha = requestedMainSha || liveMainSha;
|
|
if (mainSha !== liveMainSha) {
|
|
core.info(
|
|
`Skipping reconciliation for superseded main commit ${mainSha}.`,
|
|
);
|
|
setReconciliationOutputs(core, { prNumbers: [], mainSha, nextPass: pass });
|
|
return;
|
|
}
|
|
|
|
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
state: "open",
|
|
labels: CURRENT_REVIEW_LABEL,
|
|
per_page: 100,
|
|
});
|
|
const prNumbers = [
|
|
...new Set(
|
|
issues
|
|
.filter((issue) => issue.pull_request)
|
|
.map((issue) => issue.number)
|
|
.filter((prNumber) => Number.isSafeInteger(prNumber) && prNumber > 0),
|
|
),
|
|
]
|
|
.sort((left, right) => left - right)
|
|
.filter((prNumber) => prNumber > afterPrNumber);
|
|
const batch = prNumbers.slice(0, RECONCILIATION_BATCH_SIZE);
|
|
const hasMore = prNumbers.length > RECONCILIATION_BATCH_SIZE;
|
|
const startRetryPass =
|
|
(batch.length > 0 || afterPrNumber > 0) &&
|
|
!hasMore &&
|
|
pass < MAX_RECONCILIATION_PASSES;
|
|
setReconciliationOutputs(core, {
|
|
prNumbers: batch,
|
|
mainSha,
|
|
shouldContinue: hasMore || startRetryPass,
|
|
nextAfter: hasMore ? batch.at(-1) : 0,
|
|
nextPass: startRetryPass ? pass + 1 : pass,
|
|
});
|
|
}
|
|
|
|
async function invalidatePullRequestUpdate({ github, context, core }) {
|
|
await invalidate({
|
|
github,
|
|
context,
|
|
core,
|
|
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,
|
|
existingOnlyForTrustedAuthors = false,
|
|
}) {
|
|
const prNumber = Number(
|
|
requestedPrNumber ?? context.payload.pull_request?.number,
|
|
);
|
|
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
|
|
throw new Error("Invalid pull request number for review invalidation.");
|
|
}
|
|
|
|
const pullRequest = await getPullRequest({ github, context, prNumber });
|
|
if (!pullRequest) {
|
|
await clearCurrentReview({ github, context, prNumber });
|
|
core.notice(`Skipping review invalidation for ineligible PR #${prNumber}.`);
|
|
return;
|
|
}
|
|
|
|
const existing = await findReviewComment({ github, context, prNumber });
|
|
if (
|
|
!existing &&
|
|
(existingOnly ||
|
|
(existingOnlyForTrustedAuthors &&
|
|
(await isTrustedForInvalidation({ github, context, core, pullRequest }))))
|
|
) {
|
|
if (existingOnly || hasCurrentReviewLabel(pullRequest)) {
|
|
await clearCurrentReview({ github, context, prNumber });
|
|
}
|
|
core.info(`PR #${prNumber} has no Codex security review to invalidate.`);
|
|
return;
|
|
}
|
|
|
|
const liveMainSha = await getLiveMainSha({ github, context });
|
|
const currentPrefix =
|
|
`${MARKER}\n` +
|
|
`${completedMarker(liveMainSha, pullRequest.head.sha)}\n`;
|
|
if (existing?.body?.startsWith(currentPrefix)) {
|
|
core.info(`PR #${prNumber} already has a review for the current range.`);
|
|
return;
|
|
}
|
|
|
|
const body = `${MARKER}
|
|
${STALE_MARKER}
|
|
## 🔐 Codex Security Review
|
|
|
|
> **Status: review required for the current range.**
|
|
>
|
|
> The current range is \`${liveMainSha}...${pullRequest.head.sha}\`.
|
|
> A new review must complete for this exact range. When manual authorization
|
|
> 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.
|
|
`;
|
|
|
|
if (existing?.body === body) {
|
|
core.info(`PR #${prNumber} already has the current stale-review notice.`);
|
|
await clearCurrentReview({ github, context, prNumber });
|
|
return;
|
|
}
|
|
|
|
await upsertReviewComment({ github, context, core, prNumber, body });
|
|
await clearCurrentReview({ github, context, prNumber });
|
|
}
|
|
|
|
async function post({ github, context, core }) {
|
|
const rawReview = process.env.REVIEW_JSON || "";
|
|
if (rawReview.length === 0 || rawReview.length > 120000) {
|
|
throw new Error("Codex output is empty or exceeds the renderer limit.");
|
|
}
|
|
|
|
const review = JSON.parse(rawReview);
|
|
requireKeys(review, ["overall_risk", "summary", "findings", "notes"], "review");
|
|
if (!RISKS.includes(review.overall_risk)) {
|
|
throw new Error("Review has an invalid overall risk.");
|
|
}
|
|
if (!Array.isArray(review.findings) || review.findings.length > 10) {
|
|
throw new Error("Review findings must be an array with at most 10 entries.");
|
|
}
|
|
if (!Array.isArray(review.notes) || review.notes.length > 5) {
|
|
throw new Error("Review notes must be an array with at most 5 entries.");
|
|
}
|
|
|
|
const prNumber = Number(process.env.REVIEW_PR_NUMBER);
|
|
if (!Number.isSafeInteger(prNumber) || prNumber <= 0) {
|
|
throw new Error("Invalid reviewed pull request number.");
|
|
}
|
|
const baseSha = process.env.REVIEW_BASE_SHA || "";
|
|
const headSha = process.env.REVIEW_HEAD_SHA || "";
|
|
const headRepo = process.env.REVIEW_HEAD_REPO || "";
|
|
const commitRange = process.env.REVIEW_COMMIT_RANGE || "";
|
|
if (!/^[0-9a-f]{40,64}$/.test(baseSha) || !/^[0-9a-f]{40,64}$/.test(headSha)) {
|
|
throw new Error("Invalid reviewed commit SHA.");
|
|
}
|
|
if (commitRange !== `${baseSha}...${headSha}`) {
|
|
throw new Error("Invalid reviewed commit range.");
|
|
}
|
|
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(headRepo)) {
|
|
throw new Error("Invalid reviewed head repository.");
|
|
}
|
|
|
|
const existingReview = {
|
|
github,
|
|
context,
|
|
core,
|
|
prNumber,
|
|
existingOnly: true,
|
|
};
|
|
const reviewedRange = {
|
|
github,
|
|
context,
|
|
prNumber,
|
|
baseSha,
|
|
headSha,
|
|
headRepo,
|
|
};
|
|
|
|
const [pullRequest, liveMainSha] = await Promise.all([
|
|
getPullRequest({ github, context, prNumber }),
|
|
getLiveMainSha({ github, context }),
|
|
]);
|
|
if (
|
|
!pullRequest ||
|
|
liveMainSha !== baseSha ||
|
|
pullRequest.head.sha !== headSha ||
|
|
pullRequest.head.repo?.full_name !== headRepo
|
|
) {
|
|
core.notice(`Skipping stale review for ${commitRange} on PR #${prNumber}.`);
|
|
await invalidate(existingReview);
|
|
return;
|
|
}
|
|
|
|
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: prNumber,
|
|
per_page: 100,
|
|
});
|
|
if (files.length !== pullRequest.changed_files) {
|
|
throw new Error(
|
|
`Expected ${pullRequest.changed_files} changed files, but GitHub returned ${files.length}.`,
|
|
);
|
|
}
|
|
const changedFiles = new Set(files.map((file) => file.filename));
|
|
|
|
const findingKeys = [
|
|
"severity",
|
|
"category",
|
|
"title",
|
|
"path",
|
|
"line",
|
|
"description",
|
|
"impact",
|
|
"recommendation",
|
|
];
|
|
const [headOwner, headName] = headRepo.split("/");
|
|
const renderedFindings = review.findings.map((finding, index) => {
|
|
const label = `finding ${index + 1}`;
|
|
requireKeys(finding, findingKeys, label);
|
|
if (!SEVERITIES.has(finding.severity)) {
|
|
throw new Error(`${label} has an invalid severity.`);
|
|
}
|
|
if (!CATEGORIES.has(finding.category)) {
|
|
throw new Error(`${label} has an invalid category.`);
|
|
}
|
|
if (!validPath(finding.path) || !changedFiles.has(finding.path)) {
|
|
throw new Error(`${label} does not reference a changed file.`);
|
|
}
|
|
if (
|
|
!Number.isSafeInteger(finding.line) ||
|
|
finding.line < 1 ||
|
|
finding.line > 10000000
|
|
) {
|
|
throw new Error(`${label} has an invalid line number.`);
|
|
}
|
|
|
|
const location =
|
|
`https://github.com/${encodeUrlComponent(headOwner)}/${encodeUrlComponent(headName)}` +
|
|
`/blob/${headSha}/${encodePath(finding.path)}#L${finding.line}`;
|
|
const pathLabel = safeCodeText(
|
|
`${finding.path}:${finding.line}`,
|
|
520,
|
|
`${label} location`,
|
|
);
|
|
return [
|
|
`#### [${finding.severity}] ${safeCodeText(finding.title, 200, `${label} title`)}`,
|
|
`- **Category**: ${finding.category}`,
|
|
`- **Location**: ${pathLabel} ([source](${location}))`,
|
|
`- **Description**: ${safeCodeText(finding.description, 1500, `${label} description`)}`,
|
|
`- **Impact**: ${safeCodeText(finding.impact, 1500, `${label} impact`)}`,
|
|
`- **Recommendation**: ${safeCodeText(finding.recommendation, 1500, `${label} recommendation`)}`,
|
|
].join("\n");
|
|
});
|
|
|
|
const highestFindingRisk = review.findings.reduce(
|
|
(highest, finding) => Math.max(highest, RISKS.indexOf(finding.severity)),
|
|
0,
|
|
);
|
|
const overallRisk = RISKS[
|
|
Math.max(RISKS.indexOf(review.overall_risk), highestFindingRisk)
|
|
];
|
|
const findingsMarkdown = renderedFindings.length
|
|
? renderedFindings.join("\n\n")
|
|
: "No concrete security, correctness, or reliability findings were identified.";
|
|
const notesMarkdown = review.notes.length
|
|
? review.notes
|
|
.map((note, index) => `- ${safeCodeText(note, 1000, `note ${index + 1}`)}`)
|
|
.join("\n")
|
|
: "- No additional limitations were reported.";
|
|
|
|
const triggerActor = process.env.REVIEW_TRIGGER_ACTOR || "";
|
|
if (!/^[A-Za-z0-9-]{1,39}$/.test(triggerActor)) {
|
|
throw new Error("Invalid review trigger actor.");
|
|
}
|
|
const model = process.env.CODEX_MODEL || "";
|
|
if (!/^[A-Za-z0-9._-]{1,100}$/.test(model)) {
|
|
throw new Error("Invalid review model name.");
|
|
}
|
|
const workflowRun =
|
|
`${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}` +
|
|
`/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
const body = `${MARKER}
|
|
${completedMarker(baseSha, headSha)}
|
|
## 🔐 Codex Security Review
|
|
|
|
> **Note**: This is an automated, security-focused review generated by Codex.
|
|
> Use it as a supplement to human review; false positives are possible.
|
|
>
|
|
> **Scope**
|
|
> - Exact PR diff: \`${commitRange}\`
|
|
> - Model: ${model}
|
|
>
|
|
> 💡 *Click "edited" above to see earlier reviews for this PR.*
|
|
|
|
---
|
|
|
|
## Review Summary
|
|
|
|
**Overall Risk**: ${overallRisk}
|
|
|
|
${safeCodeText(review.summary, 2000, "review summary")}
|
|
|
|
### Findings
|
|
|
|
${findingsMarkdown}
|
|
|
|
### Notes
|
|
|
|
${notesMarkdown}
|
|
|
|
---
|
|
|
|
<sub>Generated by [Codex Security Review](https://github.com/openai/codex-action) |
|
|
Requested by: \`@${triggerActor}\` |
|
|
[Workflow run](${workflowRun})</sub>`;
|
|
|
|
if (body.length > 60000) {
|
|
throw new Error("Rendered security review exceeds the GitHub comment limit.");
|
|
}
|
|
|
|
// Register the pending write before the final freshness check. If main moves
|
|
// now, either this check clears the label or the main-push reconciler sees it.
|
|
await markReviewCurrent({ github, context, prNumber });
|
|
if (!(await reviewRangeIsCurrent(reviewedRange))) {
|
|
core.notice(
|
|
`Skipping review because ${commitRange} moved while PR #${prNumber} was rendering.`,
|
|
);
|
|
await invalidate(existingReview);
|
|
return;
|
|
}
|
|
|
|
await upsertReviewComment({ github, context, core, prNumber, body });
|
|
|
|
if (!(await reviewRangeIsCurrent(reviewedRange))) {
|
|
core.notice(
|
|
`Review range ${commitRange} moved while posting on PR #${prNumber}; marking it stale.`,
|
|
);
|
|
await invalidate(existingReview);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
invalidate,
|
|
invalidatePullRequestUpdate,
|
|
post,
|
|
prepare,
|
|
prepareBaseReconciliation,
|
|
withGithubRetry,
|
|
};
|