diff --git a/docs/main-governance-audit.md b/docs/main-governance-audit.md index bce46b7a3..063d2d892 100644 --- a/docs/main-governance-audit.md +++ b/docs/main-governance-audit.md @@ -60,6 +60,10 @@ The integration requirement prevents a similarly named status from an arbitrary A failed governance audit stops all write actions but still uploads `main-governance-audit` evidence. +## API execution boundary + +The GitHub CLI subprocess is shell-free, output-bounded, pinned to `github.com`, and limited to 20 seconds per request. It receives only `PATH`, the scoped `GH_TOKEN`, and the pinned `GH_HOST`; unrelated runner environment variables and proxy overrides are not inherited. Missing credentials, process timeout, malformed pagination, nonzero CLI exit, empty response, or invalid JSON produce a bounded `governance_collection_failed` report and a failing exit code. + ## Permissions GitHub documents the active branch-rules endpoint as requiring only repository `Metadata: read` for a fine-grained or GitHub App installation token. The maintainer App therefore does **not** receive repository administration permission. diff --git a/scripts/lib/maintainer-app-readiness.mjs b/scripts/lib/maintainer-app-readiness.mjs new file mode 100644 index 000000000..7cca08f60 --- /dev/null +++ b/scripts/lib/maintainer-app-readiness.mjs @@ -0,0 +1,306 @@ +export const REQUIRED_API_PROBES = Object.freeze([ + "actions_read", + "checks_read", + "statuses_read", + "pull_requests_read", + "contents_read", +]); + +const MAX_DETAIL_CHARS = 800; +const expectedRepository = "ContextualWisdomLab/noema"; +const appSlugPattern = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/; +const botLoginPattern = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,98}[A-Za-z0-9])?\[bot\]$/; + +function normalized(value) { + return String(value ?? "").trim(); +} + +function objectValue(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function safeDetail(value) { + const text = String(value ?? "") + .replace(/[\u0000-\u001f\u007f]/g, "") + .trim(); + return text.length <= MAX_DETAIL_CHARS + ? text + : `${text.slice(0, MAX_DETAIL_CHARS - 1)}…`; +} + +function addCheck(checks, failures, code, pass, detail) { + const retainedDetail = safeDetail(detail); + const check = { code, pass, detail: retainedDetail }; + checks.push(check); + if (!pass) failures.push({ code, detail: retainedDetail }); +} + +function validateIdentity(evidence, checks, failures) { + const repository = normalized(evidence.repository); + const installationId = evidence.installationId; + const appSlug = normalized(evidence.appSlug); + const maintainerAccount = objectValue(evidence.maintainerAccount); + const reviewerAppSlug = normalized(evidence.reviewerAppSlug); + const reviewerInstallationId = evidence.reviewerInstallationId; + const reviewerLogin = normalized(evidence.reviewerLogin); + const reviewerAccount = objectValue(evidence.reviewerAccount); + const expectedMaintainerLogin = appSlug ? `${appSlug}[bot]` : ""; + const expectedReviewerLogin = reviewerAppSlug ? `${reviewerAppSlug}[bot]` : ""; + const maintainerLogin = normalized(maintainerAccount.login); + const observedReviewerLogin = normalized(reviewerAccount.login); + + addCheck( + checks, + failures, + "repository_mismatch", + repository === expectedRepository, + repository === expectedRepository + ? `Evidence is bound to ${expectedRepository}.` + : `Evidence repository ${repository || "missing"} does not match ${expectedRepository}.`, + ); + addCheck( + checks, + failures, + "maintenance_already_enabled", + evidence.maintenanceEnabled === false, + evidence.maintenanceEnabled === false + ? "Automated maintenance remains disabled during pre-activation audit." + : "NOEMA_MAINTENANCE_ENABLED must remain disabled until pre-activation evidence and independent approval pass.", + ); + addCheck( + checks, + failures, + "installation_id_invalid", + Number.isSafeInteger(installationId) && installationId > 0, + Number.isSafeInteger(installationId) && installationId > 0 + ? `Installation id ${installationId} is a positive integer.` + : "Installation id must be a positive safe integer.", + ); + addCheck( + checks, + failures, + "app_slug_invalid", + appSlugPattern.test(appSlug), + appSlugPattern.test(appSlug) + ? `Maintainer App slug ${appSlug} is valid.` + : "Maintainer App slug is missing or malformed.", + ); + addCheck( + checks, + failures, + "maintainer_login_mismatch", + Boolean(expectedMaintainerLogin) && maintainerLogin === expectedMaintainerLogin, + maintainerLogin === expectedMaintainerLogin && expectedMaintainerLogin + ? `Maintainer bot login matches ${expectedMaintainerLogin}.` + : `Maintainer bot login ${maintainerLogin || "missing"} does not match ${expectedMaintainerLogin || "the App slug"}.`, + ); + addCheck( + checks, + failures, + "maintainer_type_invalid", + normalized(maintainerAccount.type) === "Bot", + normalized(maintainerAccount.type) === "Bot" + ? "Maintainer identity is a GitHub Bot account." + : `Maintainer identity type is ${normalized(maintainerAccount.type) || "missing"}, not Bot.`, + ); + addCheck( + checks, + failures, + "reviewer_installation_id_invalid", + Number.isSafeInteger(reviewerInstallationId) && reviewerInstallationId > 0, + Number.isSafeInteger(reviewerInstallationId) && reviewerInstallationId > 0 + ? `Reviewer installation id ${reviewerInstallationId} is a positive integer.` + : "Reviewer installation id must be a positive safe integer.", + ); + addCheck( + checks, + failures, + "reviewer_app_slug_invalid", + appSlugPattern.test(reviewerAppSlug), + appSlugPattern.test(reviewerAppSlug) + ? `Reviewer App slug ${reviewerAppSlug} is valid.` + : "Reviewer App slug is missing or malformed.", + ); + addCheck( + checks, + failures, + "reviewer_app_login_mismatch", + Boolean(expectedReviewerLogin) && reviewerLogin === expectedReviewerLogin, + reviewerLogin === expectedReviewerLogin && expectedReviewerLogin + ? `Configured reviewer login is bound to authenticated Reviewer App ${reviewerAppSlug}.` + : `Configured reviewer login ${reviewerLogin || "missing"} does not match ${expectedReviewerLogin || "the authenticated Reviewer App slug"}.`, + ); + addCheck( + checks, + failures, + "reviewer_login_invalid", + botLoginPattern.test(reviewerLogin), + botLoginPattern.test(reviewerLogin) + ? `Configured reviewer login ${reviewerLogin} is an exact bot login.` + : "Configured reviewer login must end in [bot] and contain only supported GitHub login characters.", + ); + addCheck( + checks, + failures, + "reviewer_login_mismatch", + Boolean(reviewerLogin) && observedReviewerLogin === reviewerLogin, + observedReviewerLogin === reviewerLogin && reviewerLogin + ? `Reviewer API identity matches ${reviewerLogin}.` + : `Reviewer API identity ${observedReviewerLogin || "missing"} does not match ${reviewerLogin || "the configured reviewer"}.`, + ); + addCheck( + checks, + failures, + "reviewer_type_invalid", + normalized(reviewerAccount.type) === "Bot", + normalized(reviewerAccount.type) === "Bot" + ? "Reviewer identity is a GitHub Bot account." + : `Reviewer identity type is ${normalized(reviewerAccount.type) || "missing"}, not Bot.`, + ); + addCheck( + checks, + failures, + "app_identity_not_separated", + Boolean(maintainerLogin && reviewerLogin) && maintainerLogin !== reviewerLogin, + maintainerLogin && reviewerLogin && maintainerLogin !== reviewerLogin + ? "Maintainer and reviewer bot identities are distinct." + : "Maintainer and reviewer bot identities must be distinct.", + ); +} + +function validateRepositoryScope(evidence, checks, failures) { + const accessibleRepositories = Array.isArray(evidence.accessibleRepositories) + ? evidence.accessibleRepositories + : []; + const repositoryNames = accessibleRepositories.map((item) => normalized(item?.full_name)); + const exactScope = repositoryNames.length === 1 && repositoryNames[0] === expectedRepository; + addCheck( + checks, + failures, + "repository_scope_invalid", + exactScope, + exactScope + ? `Effective token is scoped only to ${expectedRepository}.` + : `Effective token reports ${repositoryNames.length} accessible repositories; expected exactly one repository scoped to ${expectedRepository}.`, + ); + + const permissions = objectValue(evidence.repositoryPermissions); + addCheck( + checks, + failures, + "repository_pull_missing", + permissions.pull === true, + permissions.pull === true + ? "Effective token reports repository read access." + : "Effective token does not report repository read access.", + ); + addCheck( + checks, + failures, + "repository_push_missing", + permissions.push === true, + permissions.push === true + ? "Effective token reports the scoped write access required by the maintainer loop." + : "Effective token does not report the scoped write access required by the maintainer loop.", + ); + const adminStateKnown = typeof permissions.admin === "boolean"; + addCheck( + checks, + failures, + "repository_admin_state_invalid", + adminStateKnown, + adminStateKnown + ? "Repository administrator permission state is explicitly reported." + : "Repository administrator permission state is missing or non-boolean.", + ); + addCheck( + checks, + failures, + "repository_admin_present", + permissions.admin === false, + permissions.admin === false + ? "Effective token does not have repository administrator access." + : permissions.admin === true + ? "Effective token has repository administrator access." + : "Administrator absence cannot be established from unknown permission evidence.", + ); +} + +function validateApiProbes(evidence, checks, failures) { + const probes = objectValue(evidence.apiProbes); + for (const probe of REQUIRED_API_PROBES) { + const pass = probes[probe] === true; + addCheck( + checks, + failures, + `api_probe_${probe}`, + pass, + pass + ? `Required GitHub API probe ${probe} passed.` + : `Required GitHub API probe ${probe} did not pass.`, + ); + } +} + +function validateGovernance(evidence, checks, failures) { + const governance = evidence.governanceReport; + const valid = governance && typeof governance === "object" && !Array.isArray(governance); + addCheck( + checks, + failures, + "governance_report_invalid", + Boolean(valid), + valid ? "Main governance audit report is present." : "Main governance audit report is missing or malformed.", + ); + if (!valid) return; + + addCheck( + checks, + failures, + "governance_repository_mismatch", + normalized(governance.repository) === expectedRepository, + normalized(governance.repository) === expectedRepository + ? `Governance evidence is bound to ${expectedRepository}.` + : `Governance evidence repository ${normalized(governance.repository) || "missing"} does not match ${expectedRepository}.`, + ); + addCheck( + checks, + failures, + "governance_branch_mismatch", + normalized(governance.branch) === "main", + normalized(governance.branch) === "main" + ? "Governance evidence is bound to main." + : `Governance evidence branch is ${normalized(governance.branch) || "missing"}, not main.`, + ); + const status = normalized(governance.status).toUpperCase(); + addCheck( + checks, + failures, + "governance_status_not_pass", + status === "PASS", + status === "PASS" + ? "Live main governance audit passed." + : `Live main governance audit status is ${status || "missing"}, not PASS.`, + ); +} + +/** + * Evaluate bounded, already-collected evidence for the Maintainer GitHub App. + * The function is pure so tests and buyers can reproduce the decision without + * network, filesystem, environment, or clock dependencies. Public GitHub user + * responses are used only for exact login and account-type identity checks; + * installation suspension is outside that endpoint's documented schema. + */ +export function evaluateMaintainerAppReadiness(evidence = {}) { + const checks = []; + const failures = []; + validateIdentity(evidence, checks, failures); + validateRepositoryScope(evidence, checks, failures); + validateApiProbes(evidence, checks, failures); + validateGovernance(evidence, checks, failures); + return { + status: failures.length === 0 ? "PASS" : "FAIL", + checks, + failures, + }; +} diff --git a/scripts/main-governance-audit.mjs b/scripts/main-governance-audit.mjs index 2bf27ba9c..aa6907e0e 100644 --- a/scripts/main-governance-audit.mjs +++ b/scripts/main-governance-audit.mjs @@ -7,24 +7,37 @@ import { evaluateMainGovernanceRules } from "./lib/main-governance-audit.mjs"; const MAX_ERROR_CHARS = 4_000; const MAX_GH_OUTPUT_BYTES = 4 * 1024 * 1024; +const MAX_GH_REQUEST_MILLISECONDS = 20_000; const repositoryPattern = /^ContextualWisdomLab\/[A-Za-z0-9_.-]+$/; const defaultReportPath = "artifacts/governance/main-governance-audit.json"; +const githubApiHeaders = [ + "-H", + "Accept: application/vnd.github+json", + "-H", + "X-GitHub-Api-Version: 2022-11-28", +]; function bound(value, limit = MAX_ERROR_CHARS) { const text = String(value ?? "") - .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "") + .replace(/[\u0000-\u001f\u007f]/g, "") .trim(); return text.length <= limit ? text : `${text.slice(0, limit)}…`; } function runGh(args) { - const completed = spawnSync("gh", args, { + const completed = spawnSync("gh", ["api", ...githubApiHeaders, ...args], { encoding: "utf8", maxBuffer: MAX_GH_OUTPUT_BYTES, + timeout: MAX_GH_REQUEST_MILLISECONDS, shell: false, + env: { + PATH: process.env.PATH, + GH_TOKEN: process.env.GH_TOKEN, + GH_HOST: "github.com", + }, }); if (completed.error) { - throw new Error(`GitHub CLI could not start: ${bound(completed.error.message)}`); + throw new Error(`GitHub CLI could not complete: ${bound(completed.error.message)}`); } if (completed.status !== 0) { const detail = completed.stderr || completed.stdout || `exit ${completed.status}`; @@ -145,8 +158,11 @@ export function main() { if (!repositoryPattern.test(repository)) { throw new Error("GITHUB_REPOSITORY must identify a ContextualWisdomLab repository."); } + if (!process.env.GH_TOKEN) { + throw new Error("GH_TOKEN is required for the governance audit."); + } const endpoint = `repos/${repository}/rules/branches/main?per_page=100`; - const pages = runGhJson(["api", "--paginate", "--slurp", endpoint]); + const pages = runGhJson(["--paginate", "--slurp", endpoint]); const rules = flattenRulePages(pages); report = buildReport(repository, rules, evaluateMainGovernanceRules(rules)); } catch (error) { diff --git a/test/main-governance-audit-script.test.ts b/test/main-governance-audit-script.test.ts index f8c685d0e..2c897b0d1 100644 --- a/test/main-governance-audit-script.test.ts +++ b/test/main-governance-audit-script.test.ts @@ -23,17 +23,24 @@ describe("main governance audit GitHub adapter", () => { ); }); - it("uses shell-free complete pagination for the live main rules endpoint", () => { + it("uses version-pinned bounded shell-free pagination for the live main rules endpoint", () => { const script = readFileSync("scripts/main-governance-audit.mjs", "utf8"); - expect(script).toContain('spawnSync("gh"'); + expect(script).toContain('spawnSync("gh", ["api", ...githubApiHeaders, ...args]'); expect(script).toContain("shell: false"); - expect(script).toContain('["api", "--paginate", "--slurp", endpoint]'); + expect(script).toContain("MAX_GH_OUTPUT_BYTES"); + expect(script).toContain("MAX_GH_REQUEST_MILLISECONDS"); + expect(script).toContain("timeout: MAX_GH_REQUEST_MILLISECONDS"); + expect(script).toContain('Accept: application/vnd.github+json'); + expect(script).toContain('X-GitHub-Api-Version: 2022-11-28'); + expect(script).toContain('GH_HOST: "github.com"'); + expect(script).toContain("GH_TOKEN: process.env.GH_TOKEN"); + expect(script).toContain('["--paginate", "--slurp", endpoint]'); expect(script).toContain("rules/branches/main?per_page=100"); expect(script).toContain("evaluateMainGovernanceRules"); }); - it("writes bounded evidence, outputs, and a workflow summary", () => { + it("writes single-line bounded evidence, outputs, and a workflow summary without leaking the token", () => { const script = readFileSync("scripts/main-governance-audit.mjs", "utf8"); expect(script).toContain("artifacts/governance/main-governance-audit.json"); @@ -43,13 +50,15 @@ describe("main governance audit GitHub adapter", () => { expect(script).toContain('appendOutput("governance_status", report.status)'); expect(script).toContain('appendOutput("governance_report_path", absoluteReportPath)'); expect(script).toContain("MAX_ERROR_CHARS"); - expect(script).not.toContain("GITHUB_TOKEN"); - expect(script).not.toContain("GH_TOKEN"); + expect(script).toContain('.replace(/[\\u0000-\\u001f\\u007f]/g, "")'); + expect(script).not.toContain("console.log(process.env.GH_TOKEN"); + expect(script).not.toContain("JSON.stringify(process.env"); }); - it("fails closed when the audit or collection does not pass", () => { + it("fails closed when credentials, the audit, or collection do not pass", () => { const script = readFileSync("scripts/main-governance-audit.mjs", "utf8"); + expect(script).toContain("GH_TOKEN is required for the governance audit."); expect(script).toContain('if (report.status !== "PASS")'); expect(script).toContain("process.exitCode = 1"); expect(script).toContain('status: "FAIL"'); diff --git a/test/maintainer-app-readiness.test.ts b/test/maintainer-app-readiness.test.ts new file mode 100644 index 000000000..e0e08c3f1 --- /dev/null +++ b/test/maintainer-app-readiness.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { + REQUIRED_API_PROBES, + evaluateMaintainerAppReadiness, +} from "../scripts/lib/maintainer-app-readiness.mjs"; + +const repository = "ContextualWisdomLab/noema"; + +function passingEvidence() { + return { + repository, + maintenanceEnabled: false, + installationId: 123456, + appSlug: "noema-maintainer", + maintainerAccount: { + login: "noema-maintainer[bot]", + type: "Bot", + }, + reviewerAppSlug: "noema-reviewer", + reviewerInstallationId: 654321, + reviewerLogin: "noema-reviewer[bot]", + reviewerAccount: { + login: "noema-reviewer[bot]", + type: "Bot", + }, + accessibleRepositories: [{ full_name: repository }], + repositoryPermissions: { + pull: true, + push: true, + admin: false, + maintain: false, + triage: false, + }, + apiProbes: Object.fromEntries(REQUIRED_API_PROBES.map((name) => [name, true])), + governanceReport: { + repository, + branch: "main", + status: "PASS", + }, + }; +} + +function reasonCodes(result: ReturnType) { + return result.failures.map((failure: { code: string }) => failure.code); +} + +describe("maintainer App readiness evaluation", () => { + it("passes exact identity, disabled activation, scope, probes, and governance evidence", () => { + const result = evaluateMaintainerAppReadiness(passingEvidence()); + + expect(result.status).toBe("PASS"); + expect(result.failures).toEqual([]); + expect(result.checks.every((check: { pass: boolean }) => check.pass)).toBe(true); + }); + + it.each([ + ["wrong repository", { repository: "ContextualWisdomLab/other" }, "repository_mismatch"], + ["enabled maintenance", { maintenanceEnabled: true }, "maintenance_already_enabled"], + ["missing activation evidence", { maintenanceEnabled: undefined }, "maintenance_already_enabled"], + ["missing installation", { installationId: null }, "installation_id_invalid"], + ["unsafe installation", { installationId: Number.MAX_SAFE_INTEGER + 1 }, "installation_id_invalid"], + ["invalid App slug", { appSlug: "Noema Maintainer" }, "app_slug_invalid"], + ])("fails closed for %s", (_label, patch, expectedCode) => { + const result = evaluateMaintainerAppReadiness({ ...passingEvidence(), ...patch }); + + expect(result.status).toBe("FAIL"); + expect(reasonCodes(result)).toContain(expectedCode); + }); + + it.each([ + ["wrong login", { login: "other[bot]" }, "maintainer_login_mismatch"], + ["non-bot type", { type: "User" }, "maintainer_type_invalid"], + ])("rejects maintainer identity with %s", (_label, patch, expectedCode) => { + const evidence = passingEvidence(); + evidence.maintainerAccount = { ...evidence.maintainerAccount, ...patch }; + + const result = evaluateMaintainerAppReadiness(evidence); + + expect(result.status).toBe("FAIL"); + expect(reasonCodes(result)).toContain(expectedCode); + }); + + it.each([ + ["missing bot suffix", { reviewerLogin: "noema-reviewer" }, "reviewer_login_invalid"], + [ + "API login mismatch", + { reviewerAccount: { login: "other[bot]", type: "Bot" } }, + "reviewer_login_mismatch", + ], + [ + "non-bot type", + { reviewerAccount: { login: "noema-reviewer[bot]", type: "User" } }, + "reviewer_type_invalid", + ], + ])("rejects reviewer identity with %s", (_label, patch, expectedCode) => { + const result = evaluateMaintainerAppReadiness({ ...passingEvidence(), ...patch }); + + expect(result.status).toBe("FAIL"); + expect(reasonCodes(result)).toContain(expectedCode); + }); + + it("does not infer installation suspension from public user-profile fields", () => { + const evidence = passingEvidence(); + evidence.maintainerAccount = { + ...evidence.maintainerAccount, + suspended: true, + suspended_at: "2026-08-04T00:00:00Z", + } as typeof evidence.maintainerAccount; + evidence.reviewerAccount = { + ...evidence.reviewerAccount, + suspended: true, + suspended_at: "2026-08-04T00:00:00Z", + } as typeof evidence.reviewerAccount; + + const result = evaluateMaintainerAppReadiness(evidence); + + expect(result.status).toBe("PASS"); + expect(result.checks.map((check: { code: string }) => check.code)).not.toEqual( + expect.arrayContaining(["maintainer_account_suspended", "reviewer_account_suspended"]), + ); + }); + + it("requires distinct maintainer and reviewer identities", () => { + const evidence = passingEvidence(); + evidence.reviewerLogin = evidence.maintainerAccount.login; + evidence.reviewerAccount = { ...evidence.maintainerAccount }; + + const result = evaluateMaintainerAppReadiness(evidence); + + expect(reasonCodes(result)).toContain("app_identity_not_separated"); + }); + + it.each([ + ["no repository", []], + ["wrong repository", [{ full_name: "ContextualWisdomLab/other" }]], + ["extra repository", [{ full_name: repository }, { full_name: "ContextualWisdomLab/other" }]], + ])("rejects effective scope with %s", (_label, accessibleRepositories) => { + const result = evaluateMaintainerAppReadiness({ + ...passingEvidence(), + accessibleRepositories, + }); + + expect(reasonCodes(result)).toContain("repository_scope_invalid"); + }); + + it.each([ + ["missing pull", { pull: false }, "repository_pull_missing"], + ["missing push", { push: false }, "repository_push_missing"], + ["administrator access", { admin: true }, "repository_admin_present"], + ])("rejects effective permissions with %s", (_label, patch, expectedCode) => { + const evidence = passingEvidence(); + evidence.repositoryPermissions = { ...evidence.repositoryPermissions, ...patch }; + + const result = evaluateMaintainerAppReadiness(evidence); + + expect(reasonCodes(result)).toContain(expectedCode); + }); + + it("rejects unavailable administrator permission evidence", () => { + const evidence = passingEvidence(); + const result = evaluateMaintainerAppReadiness({ + ...evidence, + repositoryPermissions: { + ...evidence.repositoryPermissions, + admin: null, + }, + }); + + expect(result.status).toBe("FAIL"); + expect(reasonCodes(result)).toEqual(expect.arrayContaining([ + "repository_admin_state_invalid", + "repository_admin_present", + ])); + }); + + it.each(REQUIRED_API_PROBES)("fails when %s does not pass", (probe) => { + const evidence = passingEvidence(); + evidence.apiProbes[probe] = false; + + const result = evaluateMaintainerAppReadiness(evidence); + + expect(reasonCodes(result)).toContain(`api_probe_${probe}`); + }); + + it.each([ + ["missing report", null, "governance_report_invalid"], + [ + "wrong repository", + { repository: "ContextualWisdomLab/other", branch: "main", status: "PASS" }, + "governance_repository_mismatch", + ], + [ + "wrong branch", + { repository, branch: "release", status: "PASS" }, + "governance_branch_mismatch", + ], + [ + "failed status", + { repository, branch: "main", status: "FAIL" }, + "governance_status_not_pass", + ], + ])("rejects governance evidence with %s", (_label, governanceReport, expectedCode) => { + const result = evaluateMaintainerAppReadiness({ ...passingEvidence(), governanceReport }); + + expect(reasonCodes(result)).toContain(expectedCode); + }); + + it("accumulates independent failures for a complete audit trail", () => { + const evidence = passingEvidence(); + evidence.maintenanceEnabled = true; + evidence.installationId = -1; + evidence.accessibleRepositories = []; + evidence.repositoryPermissions.admin = true; + evidence.apiProbes.actions_read = false; + evidence.governanceReport.status = "FAIL"; + + const result = evaluateMaintainerAppReadiness(evidence); + + expect(result.status).toBe("FAIL"); + expect(reasonCodes(result)).toEqual(expect.arrayContaining([ + "maintenance_already_enabled", + "installation_id_invalid", + "repository_scope_invalid", + "repository_admin_present", + "api_probe_actions_read", + "governance_status_not_pass", + ])); + }); +}); diff --git a/test/maintainer-app-scope-redaction.test.ts b/test/maintainer-app-scope-redaction.test.ts new file mode 100644 index 000000000..e7efccfb7 --- /dev/null +++ b/test/maintainer-app-scope-redaction.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + REQUIRED_API_PROBES, + evaluateMaintainerAppReadiness, +} from "../scripts/lib/maintainer-app-readiness.mjs"; + +const repository = "ContextualWisdomLab/noema"; + +function evidenceWithRepositories(accessibleRepositories: Array<{ full_name: string }>) { + return { + repository, + maintenanceEnabled: false, + installationId: 123456, + appSlug: "noema-maintainer", + maintainerAccount: { login: "noema-maintainer[bot]", type: "Bot" }, + reviewerAppSlug: "noema-reviewer", + reviewerInstallationId: 654321, + reviewerLogin: "noema-reviewer[bot]", + reviewerAccount: { login: "noema-reviewer[bot]", type: "Bot" }, + accessibleRepositories, + repositoryPermissions: { + pull: true, + push: true, + admin: false, + maintain: false, + triage: false, + }, + apiProbes: Object.fromEntries(REQUIRED_API_PROBES.map((name) => [name, true])), + governanceReport: { repository, branch: "main", status: "PASS" }, + }; +} + +describe("Maintainer App scope failure privacy", () => { + it("reports an unexpected repository count without persisting repository names", () => { + const unexpectedRepository = "ContextualWisdomLab/private-acquisition-target"; + + const result = evaluateMaintainerAppReadiness( + evidenceWithRepositories([ + { full_name: repository }, + { full_name: unexpectedRepository }, + ]), + ); + + const scopeFailure = result.failures.find( + (failure: { code: string }) => failure.code === "repository_scope_invalid", + ); + expect(scopeFailure?.detail).toContain("2 accessible repositories"); + expect(JSON.stringify(result)).not.toContain(unexpectedRepository); + }); + + it("single-lines and bounds every retained policy diagnostic", () => { + const hostileLogin = `attacker\n::error::forged-${"x".repeat(2_000)}[bot]`; + const evidence = evidenceWithRepositories([{ full_name: repository }]); + evidence.reviewerLogin = hostileLogin; + + const result = evaluateMaintainerAppReadiness(evidence); + const details = result.checks.map((check: { detail: string }) => check.detail); + + expect(result.status).toBe("FAIL"); + expect(details.every((detail: string) => detail.length <= 800)).toBe(true); + expect(details.every((detail: string) => !/[\u0000-\u001f\u007f]/.test(detail))).toBe(true); + expect(JSON.stringify(result)).not.toContain("x".repeat(1_000)); + }); +}); diff --git a/test/reviewer-app-identity-binding.test.ts b/test/reviewer-app-identity-binding.test.ts new file mode 100644 index 000000000..95e13b5d0 --- /dev/null +++ b/test/reviewer-app-identity-binding.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + REQUIRED_API_PROBES, + evaluateMaintainerAppReadiness, +} from "../scripts/lib/maintainer-app-readiness.mjs"; + +const repository = "ContextualWisdomLab/noema"; + +function reviewerBoundEvidence() { + return { + repository, + maintenanceEnabled: false, + installationId: 123456, + appSlug: "noema-maintainer", + maintainerAccount: { + login: "noema-maintainer[bot]", + type: "Bot", + }, + reviewerAppSlug: "noema-reviewer", + reviewerInstallationId: 654321, + reviewerLogin: "noema-reviewer[bot]", + reviewerAccount: { + login: "noema-reviewer[bot]", + type: "Bot", + }, + accessibleRepositories: [{ full_name: repository }], + repositoryPermissions: { + pull: true, + push: true, + admin: false, + maintain: false, + triage: false, + }, + apiProbes: Object.fromEntries(REQUIRED_API_PROBES.map((name) => [name, true])), + governanceReport: { + repository, + branch: "main", + status: "PASS", + }, + }; +} + +function reasonCodes(result: ReturnType) { + return result.failures.map((failure: { code: string }) => failure.code); +} + +describe("reviewer App identity binding", () => { + it("rejects a configured reviewer bot that is not bound to the authenticated reviewer App", () => { + expect(evaluateMaintainerAppReadiness(reviewerBoundEvidence()).status).toBe("PASS"); + + const result = evaluateMaintainerAppReadiness({ + ...reviewerBoundEvidence(), + reviewerAppSlug: "different-reviewer", + }); + + expect(result.status).toBe("FAIL"); + expect(reasonCodes(result)).toContain("reviewer_app_login_mismatch"); + }); + + it.each([null, -1, Number.MAX_SAFE_INTEGER + 1])( + "rejects invalid reviewer App installation identifier %s", + (reviewerInstallationId) => { + const result = evaluateMaintainerAppReadiness({ + ...reviewerBoundEvidence(), + reviewerInstallationId, + }); + + expect(result.status).toBe("FAIL"); + expect(reasonCodes(result)).toContain("reviewer_installation_id_invalid"); + }, + ); + + it.each(["", "Noema Reviewer", "-reviewer", "reviewer-"])( + "rejects malformed reviewer App slug %j", + (reviewerAppSlug) => { + const result = evaluateMaintainerAppReadiness({ + ...reviewerBoundEvidence(), + reviewerAppSlug, + }); + + expect(result.status).toBe("FAIL"); + expect(reasonCodes(result)).toContain("reviewer_app_slug_invalid"); + }, + ); +});