Files
openclaw/scripts/github/security-review.mjs
T

282 lines
9.6 KiB
JavaScript

#!/usr/bin/env node
import { appendFile } from "node:fs/promises";
import { reviewDependencyChanges } from "./dependency-guard.mjs";
import {
SupersededReviewError,
assertGuardUnchanged,
findMaintainerApproval,
readGuardReview,
securityReviewContracts,
} from "./guard-review.mjs";
import {
GitHubDiffDataError,
GitHubRateLimitError,
GitHubReadTimeoutError,
GitHubStatusPublicationError,
publishGuardStatus,
withSecurityReviewRecovery,
} from "./guard-shared.mjs";
import { securityReviewRollout } from "./security-review-rollout.mjs";
import { reviewSecuritySensitiveChanges } from "./security-sensitive-guard.mjs";
function ciRunState(run) {
if (
!Number.isSafeInteger(run.id) ||
run.id <= 0 ||
!Number.isSafeInteger(run.run_attempt) ||
run.run_attempt <= 0
) {
throw new Error("CI returned an invalid run identity.");
}
switch (run.status) {
case "completed":
return "completed";
case "queued":
case "in_progress":
case "waiting":
case "requested":
case "pending":
return "pending";
default:
throw new Error("CI returned an invalid run status.");
}
}
async function ciState(review) {
const { api, owner, repo, pullRequest } = review;
const root = `/repos/${owner}/${repo}/actions`;
const response = await api.request(
`${root}/workflows/ci.yml/runs?head_sha=${pullRequest.head.sha}&per_page=100`,
);
if (!Array.isArray(response.workflow_runs) || response.total_count > 100) {
throw new Error("Cannot identify the current CI run.");
}
// A same-named status alone must never stand in for absent or unfinished CI.
// Use the latest real CI run, not arbitrary checks posted under its job name.
const candidates = response.workflow_runs.filter(
(candidate) =>
(candidate.event === "pull_request" ||
(candidate.event === "workflow_dispatch" &&
candidate.display_title === `CI release gate ${pullRequest.head.sha}`)) &&
candidate.path === ".github/workflows/ci.yml" &&
candidate.head_sha === pullRequest.head.sha &&
candidate.head_branch === pullRequest.head.ref &&
candidate.repository?.id === pullRequest.base.repo.id,
);
// Invalid IDs make latest-run ordering untrustworthy, even if an older run passed.
for (const candidate of candidates) {
ciRunState(candidate);
}
// Delayed draft events can create wholly skipped PR runs after runnable CI.
let run;
for (const candidate of candidates.toSorted((left, right) => right.id - left.id)) {
if (
candidate.event !== "pull_request" ||
candidate.status !== "completed" ||
candidate.conclusion !== "skipped"
) {
run = candidate;
break;
}
// Reruns retain their ID; a skipped list entry can already have a new attempt.
const current = await api.request(`${root}/runs/${candidate.id}`);
const currentState = ciRunState(current);
if (current.id !== candidate.id || current.head_sha !== candidate.head_sha) {
throw new Error("The CI run identity changed during security review.");
}
if (currentState !== "completed" || current.conclusion !== "skipped") {
run = current;
break;
}
}
if (!run || run.status !== "completed") {
return "pending";
}
const jobs = [];
for (let page = 1; ; page += 1) {
const result = await api.request(
`${root}/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100&page=${page}`,
);
if (!Array.isArray(result.jobs) || !Number.isSafeInteger(result.total_count)) {
throw new Error("CI returned an invalid job list.");
}
jobs.push(...result.jobs);
if (jobs.length >= result.total_count) {
break;
}
if (result.jobs.length === 0 || page >= 100) {
throw new Error("CI did not return the complete job list.");
}
}
const current = await api.request(`${root}/runs/${run.id}`);
const currentState = ciRunState(current);
if (current.id !== run.id || current.head_sha !== run.head_sha) {
throw new Error("The CI run identity changed during security review.");
}
if (currentState === "pending") {
return "pending";
}
if (current.run_attempt !== run.run_attempt) {
throw new Error("The completed CI attempt changed during security review; rerun this review.");
}
const gates = jobs.filter((job) => job.name === "openclaw/ci-gate");
return gates.length === 1 && gates[0].status === "completed" && gates[0].conclusion === "success"
? "success"
: "failure";
}
let diffRecoveryReview;
let currentReview;
async function main() {
const mode = process.env.OPENCLAW_SECURITY_REVIEW_MODE ?? "enforce";
if (!["detect", "autoscrub", "enforce"].includes(mode)) {
throw new Error(`Unknown security review mode: ${mode}`);
}
const review = await readGuardReview(diffRecoveryReview);
currentReview = review;
if (!review) {
return;
}
review.context = securityReviewContracts.combined.context;
review.guards = [];
await publishGuardStatus(review, "pending", "CI and security review have not completed");
try {
review.rollout = await securityReviewRollout(review);
if (review.rollout.mode === "enforced") {
if (mode !== "enforce") {
await reviewDependencyChanges(review, mode);
return;
}
// Each guard must publish its own notice even when its sibling rejects a PR.
const errors = [];
let allowed = true;
for (const guard of [reviewDependencyChanges, reviewSecuritySensitiveChanges]) {
try {
if (!(await guard(review))) {
allowed = false;
}
} catch (error) {
if (
error instanceof GitHubRateLimitError ||
((error instanceof GitHubStatusPublicationError ||
error instanceof GitHubReadTimeoutError ||
error instanceof GitHubDiffDataError ||
error instanceof SupersededReviewError) &&
errors.length === 0)
) {
throw error;
}
errors.push(error instanceof Error ? error.message : String(error));
}
}
if (errors.length > 0) {
throw new Error(errors.join("\n"));
}
if (!allowed) {
await publishGuardStatus(
review,
"failure",
"A maintainer must approve the current PR revision",
);
console.log("Security review is awaiting maintainer approval.");
return;
}
} else {
const summary = `Security review: ${review.rollout.mode}; standalone review statuses are not published.`;
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);
} else {
console.log(summary);
}
if (mode !== "enforce") {
return;
}
}
const ci = await ciState(review);
if (ci === "pending") {
await assertGuardUnchanged(review);
await publishGuardStatus(review, "pending", securityReviewContracts.combined.waiting);
console.log("Waiting for CI. CI completion will automatically reevaluate security review.");
return;
}
if (ci === "failure") {
await publishGuardStatus(review, "failure", securityReviewContracts.combined.failure);
console.log("The current CI gate did not pass. Review the CI workflow failures.");
return;
}
// Authority can be removed while CI metadata and the other guard are read.
// Revalidate both decisions immediately before publishing their combined result.
for (const guard of review.guards) {
if (guard.requiresApproval && !(await findMaintainerApproval(guard))) {
await publishGuardStatus(
guard,
"failure",
"A maintainer must approve the current PR revision",
);
await publishGuardStatus(
review,
"failure",
"Maintainer approval changed during security review",
);
console.log("Maintainer approval changed during security review.");
return;
}
}
await assertGuardUnchanged(review);
await publishGuardStatus(review, "success", securityReviewContracts.combined.success);
} catch (error) {
if (error instanceof GitHubDiffDataError) {
diffRecoveryReview = review;
}
if (
error instanceof GitHubRateLimitError ||
error instanceof GitHubStatusPublicationError ||
error instanceof GitHubReadTimeoutError ||
error instanceof SupersededReviewError
) {
throw error;
}
await publishGuardStatus(
review,
"failure",
"CI or security review failed; see workflow details",
).catch(
/** @param {unknown} publicationError */ (publicationError) => {
if (
error instanceof GitHubDiffDataError &&
(publicationError instanceof GitHubRateLimitError ||
publicationError instanceof GitHubStatusPublicationError)
) {
// Keep publication timing and the diff's original PR identity together.
throw publicationError;
}
console.error(
publicationError instanceof Error ? publicationError.message : String(publicationError),
);
},
);
throw error;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
withSecurityReviewRecovery(main, {
checkCurrent: async () => {
if (currentReview) {
await assertGuardUnchanged(currentReview, { allowFileCountChange: true });
}
},
}).catch(
/** @param {unknown} error */ (error) => {
if (error instanceof SupersededReviewError) {
console.log(error.message);
return;
}
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
},
);
}