#!/usr/bin/env node import { appendFile } from "node:fs/promises"; import { ObsoleteReviewError, finishGuard, openGuard, securityReviewContracts, withApprovalRequest, } from "./guard-review.mjs"; import { createIssueMutationHelpers, sanitizeGuardDisplayValue } from "./guard-shared.mjs"; import { loadSecurityReviewPolicy } from "./security-review-policy.mjs"; const marker = securityReviewContracts.sensitive.commentMarker; const changedLabel = "security-sensitive-changed"; const reviewLabel = "security-review-required"; function code(value) { return `\`${sanitizeGuardDisplayValue(value).replaceAll("`", "\\`")}\``; } function renderComment({ changes, pullRequest, approval }) { if (changes.length > 0 && approval?.kind === "comment") { return [ marker, "", "### ✅ Maintainer security changes approved", "", "A maintainer approved this revision with an explicit security approval comment.", "", `- Current SHA: ${code(approval.sha)}`, `- Maintainer: @${sanitizeGuardDisplayValue(approval.login)}`, `- Repository role: ${code(approval.role)}`, `- Approval comment: ${approval.url}`, "", "A later push requires a fresh approval comment for an external contributor's PR.", ].join("\n"); } const heading = changes.length === 0 ? "Security-sensitive guard cleared" : approval?.kind === "author" ? "⚠️ Security sensitive changes" : "⚠️ Maintainer security review required"; const lines = [marker, "", `### ${heading}`, ""]; if (changes.length > 0 && approval?.kind === "author") { lines.push( "This maintainer PR changes sensitive security components. This comment is informational because the PR author has repository Maintain or Admin access.", "", `- Current SHA: ${code(pullRequest.head.sha)}`, `- Maintainer: @${sanitizeGuardDisplayValue(approval.login)}`, `- Repository role: ${code(approval.role)}`, ); } else if (changes.length > 0 && !approval) { lines.push( "This external contributor PR changes sensitive security components. A maintainer must review these changes before merging.", "", `Current SHA: ${code(pullRequest.head.sha)}`, ); } else { lines.push(`Current revision: ${code(pullRequest.head.sha)}`); } if (changes.length === 0) { lines.push("", "This PR no longer changes files in the maintainer security-review tier."); } else { lines.push( "", approval?.kind === "author" ? "These security sensitive changes were made:" : "These sensitive security changes were made:", "", ); for (const change of changes.slice(0, 25)) { lines.push(`- ${code(change.path)}: ${change.reason}`); } if (changes.length > 25) { lines.push(`- ${changes.length - 25} additional sensitive files; see the workflow summary.`); } lines.push(""); if (approval?.kind === "author") { lines.push("Carefully review these changes before merging."); } else { lines.push( "After reviewing the changes, post a new PR comment containing only approval commands, each on its own line:", "", "```text", "/allow-security-sensitive-change", "```", "", "A later push requires a fresh approval comment.", ); } } if (changes.length === 0) { lines.push( "", "Separate CODEOWNERS requirements still apply to security policy and enforcement files.", ); } return lines.join("\n"); } export async function reviewSecuritySensitiveChanges(prepared) { const guard = await openGuard(securityReviewContracts.sensitive, prepared); if (!guard) { return true; } const { api, owner, repo, issuePath, files, pullRequest } = guard; const { collectSecuritySensitiveChanges } = loadSecurityReviewPolicy(); const changes = collectSecuritySensitiveChanges(files); const comments = await api.paginate(`${issuePath}/comments`); const labels = await api.paginate(`${issuePath}/labels`); const existing = comments.find( (comment) => comment.user?.login === "github-actions[bot]" && comment.body?.startsWith(marker), ); const { addLabelIfMissing, removeLabelIfPresent, upsertComment } = createIssueMutationHelpers({ api, owner, repo, issuePath, labelNames: new Set(labels.map((label) => label.name)), }); const allowed = await finishGuard( guard, securityReviewContracts.sensitive.success[changes.length > 0 ? "approved" : "clear"], ); if (changes.length > 0) { await addLabelIfMissing(changedLabel); } else { await removeLabelIfPresent(changedLabel); } if (allowed) { await removeLabelIfPresent(reviewLabel); } else { await addLabelIfMissing(reviewLabel); } const body = withApprovalRequest( guard, renderComment({ changes, pullRequest, approval: guard.approval }), ); if (changes.length > 0 || existing) { await upsertComment(existing, body); } const summary = [ body, "", ...changes.slice(25).map((change) => `- ${code(change.path)}: ${change.reason}`), ].join("\n"); if (process.env.GITHUB_STEP_SUMMARY) { await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); } else { console.log(summary); } return allowed; } if (import.meta.url === `file://${process.argv[1]}`) { reviewSecuritySensitiveChanges().catch( /** @param {unknown} error */ (error) => { if (error instanceof ObsoleteReviewError) { console.log(error.message); return; } console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }, ); }