-
Notifications
You must be signed in to change notification settings - Fork 0
ci: separate trusted policy from repository verification #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 7 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
4522494
fix: admit research benchmark layout safely
openboa a61d41c
ci: separate trusted policy from isolated repository verification
openboa 2b9e474
fix: close ownership review bypasses and run PR regressions
openboa ae10f86
fix: recognize routine Dependabot version updates
openboa 49f7037
fix: preserve raw Git paths when validating verification snapshots
openboa f18eef7
ci: lint both supported workflow extensions
openboa 00d7a0e
ci: remove candidate-owned central orchestration
openboa 8b48ca8
ci: verify owner approval and reject effective merge queues
openboa 72b8782
ci: keep target wrapper triggers fixed to pull requests
openboa fc9efaf
docs: clarify PR-only target and central main CI boundaries
openboa 23c840d
ci: require root security policy protection
openboa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { pathToFileURL } from "node:url"; | ||
|
|
||
| export function auditSettings(repository, { branchRules, rulesets, environment, actions }) { | ||
| const issues = []; | ||
| // GitHub resolves include/exclude patterns, default-branch aliases and inherited | ||
| // rules. Never infer effective protection from a ruleset's declarations. | ||
| const rules = Array.isArray(branchRules) ? branchRules : []; | ||
| if (!Array.isArray(branchRules)) issues.push("Effective main rules could not be verified."); | ||
| if (!rules.length) issues.push("No active ruleset protects main."); | ||
| const ids = [...new Set(rules.map((rule) => rule.ruleset_id))]; | ||
| const active = ids.map((id) => { | ||
| const set = rulesets.find((entry) => entry.id === id && entry.enforcement === "active"); | ||
| if (!set || !Array.isArray(set.bypass_actors)) issues.push("Applicable ruleset details could not be verified: " + id); | ||
| return set; | ||
| }).filter(Boolean); | ||
| for (const type of ["deletion", "non_fast_forward", "required_linear_history"]) { | ||
| if (!rules.some((rule) => rule.type === type)) issues.push("Missing protection: " + type); | ||
| } | ||
| const pulls = rules.filter((rule) => rule.type === "pull_request").map((rule) => rule.parameters); | ||
| if (!pulls.some((pull) => pull?.require_code_owner_review)) issues.push("Independent CODEOWNERS review is not required."); | ||
| if (!pulls.some((pull) => pull?.dismiss_stale_reviews_on_push)) issues.push("Stale approvals are not dismissed."); | ||
| if (!pulls.some((pull) => pull?.required_review_thread_resolution)) issues.push("Review thread resolution is not required."); | ||
| const checks = rules.filter((rule) => rule.type === "required_status_checks").map((rule) => rule.parameters); | ||
| const context = repository === ".github" ? "Organization controls verification" : "OpenBoa Coffee trusted required / OpenBoa Coffee trusted required"; | ||
| if (!checks.some((check) => check?.strict_required_status_checks_policy && check.required_status_checks?.length)) issues.push("Required checks do not require an up-to-date branch."); | ||
| if (!checks.some((set) => set?.required_status_checks?.some((check) => check.context === context && check.integration_id === 15368))) issues.push("Required check/source is missing: " + context); | ||
|
openboa marked this conversation as resolved.
|
||
| if (active.some((rule) => rule.bypass_actors?.length)) issues.push("Ruleset has bypass actors; explicit exception review is needed."); | ||
| if (repository !== ".github") { | ||
| const reviewers = environment?.protection_rules?.find((rule) => rule.type === "required_reviewers")?.reviewers ?? []; | ||
| if (!reviewers.length) issues.push("coffee-security has no required reviewers."); | ||
|
openboa marked this conversation as resolved.
|
||
| } | ||
| if (actions.default_workflow_permissions !== "read") issues.push("Default workflow token is not read-only."); | ||
| if (actions.can_approve_pull_request_reviews !== false) issues.push("Actions are allowed to approve pull requests."); | ||
| return { repository, issues, preservedAdditionalRules: rules.filter((rule) => /coverage|code_quality/u.test(rule.type)).map((rule) => rule.type) }; | ||
| } | ||
|
|
||
| async function main() { | ||
| const repositories = process.argv.slice(2); | ||
| if (!repositories.length) repositories.push(".github", "coffee-chat", "coffee-chat-roastery", "coffee-chat-eval", "coffee-chat-bench"); | ||
| const api = (path, paginated = false) => { | ||
| const data = JSON.parse(execFileSync("gh", ["api", ...(paginated ? ["--paginate", "--slurp"] : []), path], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); | ||
| return paginated ? data.flat() : data; | ||
| }; | ||
| const reports = []; | ||
| for (const repository of repositories) { | ||
| if (![".github", "coffee-chat", "coffee-chat-roastery", "coffee-chat-eval", "coffee-chat-bench"].includes(repository)) throw Error("Unsupported repository"); | ||
| const prefix = "repos/openboa-ai/" + repository; | ||
| try { | ||
| const branchRules = api(prefix + "/rules/branches/main?per_page=100", true); | ||
| const ids = [...new Set(branchRules.map((rule) => rule.ruleset_id))]; | ||
| const rulesets = ids.map((id) => api(prefix + "/rulesets/" + id)); | ||
| const environment = repository === ".github" ? null : api(prefix + "/environments/coffee-security"); | ||
| const actions = api(prefix + "/actions/permissions/workflow"); | ||
| reports.push(auditSettings(repository, { branchRules, rulesets, environment, actions })); | ||
| } catch { reports.push({ repository, issues: ["Live settings could not be fully read; not verified."] }); } | ||
| } | ||
| console.log(JSON.stringify(reports, null, 2)); | ||
| if (reports.some((report) => report.issues.length)) process.exitCode = 1; | ||
| } | ||
| if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { console.error(error.message); process.exitCode = 1; }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { execFileSync } from "node:child_process"; | ||
| import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; | ||
| import { resolve } from "node:path"; | ||
| import { pathToFileURL } from "node:url"; | ||
| import { validateCandidateWorkflowDelegation } from "./classify-candidate.mjs"; | ||
|
|
||
| export function readJson(root, path) { | ||
| const file = resolve(root, path); | ||
| assert.ok(lstatSync(file).isFile(), `${path}: regular file required`); | ||
| const bytes = readFileSync(file); | ||
| assert.ok(bytes.length <= 8 * 1024 * 1024, `${path}: oversized control data`); | ||
| return JSON.parse(bytes.toString("utf8")); | ||
| } | ||
|
|
||
| export function validatePackage(root) { | ||
| const pkg = readJson(root, "package.json"); | ||
| const lock = readJson(root, "package-lock.json"); | ||
| assert.equal(pkg.private, true, "verification packages must remain private"); | ||
| assert.equal(typeof pkg.scripts?.verify, "string", "npm run verify is required"); | ||
| assert.ok(pkg.scripts.verify.trim(), "verify must not be empty"); | ||
| for (const script of ["preinstall", "install", "postinstall", "prepare", "preverify", "postverify"]) { | ||
| assert.equal(pkg.scripts[script], undefined, `${script}: implicit execution is not allowed`); | ||
| } | ||
| assert.equal(pkg.workspaces, undefined, "workspace installation requires a reviewed execution contract"); | ||
| assert.equal(lock.lockfileVersion, 3, "lockfile v3 is required"); | ||
| assert.equal(lock.name, pkg.name, "lockfile name mismatch"); | ||
| assert.equal(lock.version, pkg.version, "lockfile version mismatch"); | ||
| assert.ok(lock.packages?.[""], "lockfile root is required"); | ||
| for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) { | ||
| assert.deepEqual(lock.packages[""][field] ?? {}, pkg[field] ?? {}, `${field}: package/lock mismatch`); | ||
| for (const version of Object.values(pkg[field] ?? {})) { | ||
| assert.match(version, /^\d+\.\d+\.\d+$/, "direct dependencies must use exact registry versions"); | ||
| } | ||
| } | ||
| for (const [path, dependency] of Object.entries(lock.packages)) { | ||
| if (path === "") continue; | ||
| assert.ok(path.startsWith("node_modules/") && !path.split("/").includes(".."), "invalid dependency path"); | ||
| assert.equal(dependency.link, undefined, "linked dependencies are not allowed"); | ||
| assert.equal(dependency.hasInstallScript, undefined, "dependency installation scripts are not allowed"); | ||
| const url = new URL(dependency.resolved); | ||
| assert.equal(url.origin, "https://registry.npmjs.org", "dependencies must come from the npm registry"); | ||
| assert.equal(url.username + url.password + url.search + url.hash, "", "dependency URL authority override"); | ||
| assert.match(dependency.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/, "dependency integrity is required"); | ||
| } | ||
| return { pkg, lock }; | ||
| } | ||
|
|
||
| export function validateMergePolicy(policy) { | ||
| assert.equal(policy.merge_method, "squash"); | ||
| assert.equal(policy.merge_queue, false); | ||
| assert.deepEqual(policy.eligible_author_associations, ["OWNER", "MEMBER"]); | ||
| assert.deepEqual(policy.eligible_bot_logins, ["dependabot[bot]"]); | ||
| assert.ok(policy.required_checks?.some((check) => | ||
| check.context === "OpenBoa Coffee trusted required / OpenBoa Coffee trusted required" && | ||
| check.integration_id === 15368), "trusted required check must remain enforced"); | ||
| assert.equal(policy.sensitive_review?.enforcement, "github_environment"); | ||
| assert.equal(policy.sensitive_review.environment, "coffee-security"); | ||
| assert.equal(policy.sensitive_review.required_approvals, 1); | ||
| assert.ok(Array.isArray(policy.protected_paths) && policy.protected_paths.length > 0); | ||
| for (const path of policy.protected_paths) { | ||
| assert.equal(typeof path, "string"); | ||
| assert.ok(path.length > 0 && !path.startsWith("!") && !/[\r\n\0]/u.test(path), "invalid protected path"); | ||
| } | ||
| const normalized = policy.protected_paths.map((path) => path.replace(/^\//u, "")); | ||
| for (const path of [".github/**", "AGENTS.md", "CODEOWNERS", "package.json", "package-lock.json"]) { | ||
| assert.ok(normalized.includes(path), `protected control missing: ${path}`); | ||
|
openboa marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
|
|
||
| function readOwnership(root) { | ||
| const locations = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"].filter((path) => existsSync(resolve(root, path))); | ||
| assert.equal(locations.length, 1, "exactly one CODEOWNERS location is required; competing authority is forbidden"); | ||
| const path = locations[0]; | ||
| assert.ok(lstatSync(resolve(root, path)).isFile(), "CODEOWNERS must be a regular file"); | ||
| const bytes = readFileSync(resolve(root, path)); | ||
| assert.ok(bytes.length < 3_000_000, "CODEOWNERS must remain below GitHub's file-size limit"); | ||
| const text = bytes.toString("utf8"); | ||
| assert.ok(!/[\0-\x08\x0b\x0c\x0e-\x1f\x7f]/u.test(text), "invalid CODEOWNERS control character"); | ||
| const routes = text.split(/\r?\n/u).map((line) => line.split("#", 1)[0].trim()).filter(Boolean).map((line) => line.split(/[ \t]+/u)); | ||
| assert.ok(routes.length, "CODEOWNERS must retain review routes"); | ||
| for (const [, ...owners] of routes) for (const owner of owners) { | ||
| // These repositories use GitHub handles, not email aliases. Invalid added | ||
| // syntax can make GitHub discard the entire line, including retained owners. | ||
| assert.match(owner, /^@[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}(?:\/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)?$/u, "CODEOWNERS owners must use valid user/team handles"); | ||
| } | ||
| return { path, routes }; | ||
| } | ||
|
|
||
| export function validateCandidatePolicy(baseRoot, candidateRoot) { | ||
| // No repository code, YAML constructors, package imports or base bootstrap is executed here. | ||
| const records = execFileSync("git", ["-C", candidateRoot, "ls-files", "--stage", "-z"], { encoding: "utf8" }).split("\0").filter(Boolean); | ||
| for (const record of records) { | ||
| const [metadata, path] = record.split("\t"); | ||
| assert.match(metadata, /^100(?:644|755) [0-9a-f]{40} 0$/, `${path}: only regular tracked files are allowed`); | ||
| assert.ok(lstatSync(resolve(candidateRoot, path)).isFile(), `${path}: checkout must be regular`); | ||
| assert.ok(!/(^|\/)(?:\.npmrc|npm-shrinkwrap\.json|\.gitleaks\.toml)$/u.test(path), `${path}: alternate authority is forbidden`); | ||
| } | ||
| const base = readJson(baseRoot, ".github/merge-policy.json"); | ||
| const candidate = readJson(candidateRoot, ".github/merge-policy.json"); | ||
| validateMergePolicy(base); | ||
| validateMergePolicy(candidate); | ||
| const normalized = new Set(candidate.protected_paths.map((path) => path.replace(/^\//u, ""))); | ||
| for (const path of base.protected_paths) { | ||
| assert.ok(normalized.has(path.replace(/^\//u, "")), `protected path removal requires a scoped exception: ${path}`); | ||
| } | ||
| for (const check of base.required_checks) { | ||
| assert.ok(candidate.required_checks.some((entry) => entry.context === check.context && entry.integration_id === check.integration_id), "required check removal is forbidden"); | ||
| } | ||
| assert.ok((candidate.required_approvals ?? 0) >= (base.required_approvals ?? 0), "review requirement cannot decrease"); | ||
| assert.ok((candidate.required_code_owner_reviews ?? 0) >= (base.required_code_owner_reviews ?? 0), "code-owner review cannot decrease"); | ||
| if (base.sensitive_review.prevent_self_review === true) assert.equal(candidate.sensitive_review.prevent_self_review, true); | ||
| assert.deepEqual(readdirSync(resolve(candidateRoot, ".github/workflows")).sort(), ["trusted.yml"], "only the inert wrapper is allowed"); | ||
| validateCandidateWorkflowDelegation(readFileSync(resolve(candidateRoot, ".github/workflows/trusted.yml"), "utf8")); | ||
| // Preserve installed local safeguards while policy implementation moves to the center. | ||
| for (const path of [".githooks/pre-commit", ".github/dependabot.yml"]) { | ||
| assert.equal(readFileSync(resolve(candidateRoot, path), "utf8"), readFileSync(resolve(baseRoot, path), "utf8"), `${path}: control evolution requires a separately reviewed central contract`); | ||
| } | ||
| assert.ok(lstatSync(resolve(candidateRoot, ".githooks/pre-commit")).mode & 0o111, "secret hook must remain executable"); | ||
| const ignore = (root) => readFileSync(resolve(root, ".gitignore"), "utf8").split("\n").filter((line) => line && !line.startsWith("#")); | ||
| const baseIgnore = ignore(baseRoot); | ||
| const candidateIgnore = ignore(candidateRoot); | ||
| for (const pattern of baseIgnore) assert.ok(candidateIgnore.includes(pattern), `.gitignore removed protection: ${pattern}`); | ||
| for (const pattern of candidateIgnore.filter((line) => line.startsWith("!"))) assert.ok(baseIgnore.includes(pattern), ".gitignore cannot add exclusion overrides"); | ||
| const baseOwners = readOwnership(baseRoot), candidateOwners = readOwnership(candidateRoot); | ||
| assert.equal(candidateOwners.path, baseOwners.path, "CODEOWNERS authority cannot move"); | ||
| // GitHub uses the last matching route. New routes may precede the existing | ||
| // ordered suffix, so they cannot shadow ownership of current or future paths. | ||
| // No approximation of GitHub's glob grammar is needed. | ||
| const retainedRoutes = candidateOwners.routes.slice(-baseOwners.routes.length); | ||
| assert.deepEqual(retainedRoutes.map(([pattern]) => pattern), baseOwners.routes.map(([pattern]) => pattern), "existing ownership routes must remain the ordered suffix; add new routes before them"); | ||
| for (const [index, [pattern, ...reviewers]] of baseOwners.routes.entries()) { | ||
| for (const reviewer of reviewers) assert.ok(retainedRoutes[index].slice(1).includes(reviewer), `owner removed for ${pattern}`); | ||
| } | ||
| for (const path of ["AGENTS.md", "SECURITY.md"]) assert.ok(readFileSync(resolve(candidateRoot, path), "utf8").trim(), `${path} must remain nonempty`); | ||
| validatePackage(candidateRoot); | ||
| return { status: "policy-passed" }; | ||
| } | ||
|
|
||
| if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { | ||
| try { | ||
| console.log(JSON.stringify(validateCandidatePolicy(process.argv[2], process.argv[3]))); | ||
| } catch (error) { | ||
| console.error(`Policy violation or invalid control data: ${error.message}`); | ||
| process.exitCode = 1; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import assert from "node:assert/strict"; | ||
| import { pathToFileURL } from "node:url"; | ||
|
|
||
| export function checkRequiredResults(needs) { | ||
| for (const job of ["authorize", "dependency-review", "codeql", "quality"]) { | ||
| assert.equal(needs[job]?.result, "success", `${job}: required success is missing`); | ||
| } | ||
| const sensitive = needs.authorize.outputs?.sensitive; | ||
| assert.ok(sensitive === "true" || sensitive === "false", "classification must be explicit"); | ||
| assert.equal(needs["sensitive-review"]?.result, sensitive === "true" ? "success" : "skipped", | ||
| "sensitive approval must match the exact run classification"); | ||
| return { status: "required-checks-passed" }; | ||
| } | ||
|
|
||
| if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { | ||
| try { console.log(JSON.stringify(checkRequiredResults(JSON.parse(process.env.NEEDS_JSON)))); } | ||
| catch (error) { console.error(error.message); process.exitCode = 1; } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
| version=1.7.12 | ||
| case "$(uname -s)-$(uname -m)" in | ||
| Linux-x86_64) platform=linux_amd64; digest=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ;; | ||
| Darwin-arm64) platform=darwin_arm64; digest=aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f ;; | ||
| *) echo 'Unsupported actionlint platform' >&2; exit 1 ;; | ||
| esac | ||
| install_dir="${RUNNER_TEMP:?}/actionlint-$version" | ||
| mkdir -p "$install_dir" | ||
| archive="$install_dir/archive.tar.gz" | ||
| curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ | ||
| "https://github.com/rhysd/actionlint/releases/download/v$version/actionlint_${version}_${platform}.tar.gz" --output "$archive" | ||
| printf '%s %s\n' "$digest" "$archive" | shasum -a 256 --check | ||
| tar -xzf "$archive" -C "$install_dir" actionlint | ||
| if test -n "${GITHUB_PATH:-}"; then printf '%s\n' "$install_dir" >> "$GITHUB_PATH"; fi | ||
| printf '%s\n' "$install_dir/actionlint" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.