-
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 2 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
Some comments aren't visible on the classic Files Changed page.
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,50 @@ | ||
| import { execFileSync } from "node:child_process"; | ||
| import { pathToFileURL } from "node:url"; | ||
|
|
||
| export function auditSettings(repository, { rulesets, environment, actions }) { | ||
| const issues = []; | ||
| const active = rulesets.filter((rule) => rule.enforcement === "active" && | ||
| rule.conditions?.ref_name?.include?.some((ref) => ref === "~DEFAULT_BRANCH" || ref === "refs/heads/main")); | ||
| if (!active.length) issues.push("No active ruleset protects main."); | ||
| const rules = active.flatMap((set) => set.rules ?? []); | ||
| for (const type of ["deletion", "non_fast_forward", "required_linear_history"]) { | ||
|
openboa marked this conversation as resolved.
|
||
| if (!rules.some((rule) => rule.type === type)) issues.push("Missing protection: " + type); | ||
| } | ||
| const pull = rules.find((rule) => rule.type === "pull_request")?.parameters; | ||
| if (!pull?.require_code_owner_review) issues.push("Independent CODEOWNERS review is not required."); | ||
| if (!pull?.dismiss_stale_reviews_on_push) issues.push("Stale approvals are not dismissed."); | ||
| if (!pull?.required_review_thread_resolution) issues.push("Review thread resolution is not required."); | ||
| const checks = rules.find((rule) => rule.type === "required_status_checks")?.parameters; | ||
| const context = repository === ".github" ? "Organization controls verification" : "OpenBoa Coffee trusted required / OpenBoa Coffee trusted required"; | ||
| if (!checks?.strict_required_status_checks_policy) issues.push("Required checks do not require an up-to-date branch."); | ||
| if (!checks?.required_status_checks?.some((check) => check.context === context && check.integration_id === 15368)) issues.push("Required check/source is missing: " + context); | ||
| 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) => JSON.parse(execFileSync("gh", ["api", path], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); | ||
| 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 entries = api(prefix + "/rulesets?includes_parents=true"); | ||
| const rulesets = entries.map((entry) => api(prefix + "/rulesets/" + entry.id)); | ||
| const environment = repository === ".github" ? null : api(prefix + "/environments/coffee-security"); | ||
| const actions = api(prefix + "/actions/permissions/workflow"); | ||
| reports.push(auditSettings(repository, { 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,126 @@ | ||
| 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
|
||
| } | ||
| } | ||
|
|
||
| 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 ownerPath = existsSync(resolve(baseRoot, ".github/CODEOWNERS")) ? ".github/CODEOWNERS" : "CODEOWNERS"; | ||
| const owners = (root) => readFileSync(resolve(root, ownerPath), "utf8").split("\n").filter((line) => line.trim() && !line.startsWith("#")).map((line) => line.trim().split(/\s+/u)); | ||
| const candidateOwners = owners(candidateRoot); | ||
| for (const [pattern, ...reviewers] of owners(baseRoot)) { | ||
| const matches = candidateOwners.filter(([path]) => path === pattern); | ||
|
openboa marked this conversation as resolved.
Outdated
|
||
| assert.equal(matches.length, 1, "ownership routes cannot be missing or shadowed"); | ||
| for (const reviewer of reviewers) assert.ok(matches[0].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" |
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
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.