Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/scripts/audit-ci-settings.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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"]) {
Comment thread
openboa marked this conversation as resolved.
if (!rules.some((rule) => rule.type === type)) issues.push("Missing protection: " + type);
}
if (rules.some((rule) => rule.type === "merge_queue")) issues.push("Merge queue is enabled for main.");
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);
Comment thread
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 protectionRules = Array.isArray(environment?.protection_rules) ? environment.protection_rules : [];
const required = protectionRules.filter((rule) => rule?.type === "required_reviewers");
const reviewers = required.length === 1 && Array.isArray(required[0].reviewers) ? required[0].reviewers : [];
if (!reviewers.length) issues.push("coffee-security has no required reviewers.");
Comment thread
openboa marked this conversation as resolved.
const login = reviewers[0]?.reviewer?.login;
// GitHub accepts approval by any listed principal, so an additional user or
// team would make the documented owner's confirmation optional.
if (required.length !== 1 || reviewers.length !== 1 || reviewers[0]?.type !== "User" || typeof login !== "string" || login.toLowerCase() !== "sonsangjoon") {
issues.push("coffee-security must require only User SonSangjoon.");
}
if (environment?.can_admins_bypass === true) issues.push("coffee-security allows administrator bypass.");
else if (environment?.can_admins_bypass !== false) issues.push("coffee-security administrator bypass setting could not be verified.");
}
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; });
147 changes: 147 additions & 0 deletions .github/scripts/check-candidate-policy.mjs
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}`);
Comment thread
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;
}
}
18 changes: 18 additions & 0 deletions .github/scripts/check-required-results.mjs
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; }
}
33 changes: 24 additions & 9 deletions .github/scripts/classify-candidate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function changedPaths(candidateRoot, baseSha, headSha) {
return paths;
}

function trustedWrapper(controlSha) {
export function trustedWrapper(controlSha) {
return `name: OpenBoa Coffee trusted gate

on:
Expand Down Expand Up @@ -97,12 +97,31 @@ function requireTrustedCandidateWorkflow(candidateRoot) {
);
}

function safePackageUpdate(trustedRoot, candidateRoot) {
const read = (root) => JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"));
const base = read(trustedRoot);
const candidate = read(candidateRoot);
for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
const before = base[field] ?? {};
const after = candidate[field] ?? {};
if (JSON.stringify(Object.keys(before).sort()) !== JSON.stringify(Object.keys(after).sort())) return false;
for (const name of Object.keys(before)) {
if (!/^\d+\.\d+\.\d+$/u.test(before[name]) || !/^\d+\.\d+\.\d+$/u.test(after[name])) return false;
const a = before[name].split(".").map(Number);
const b = after[name].split(".").map(Number);
if (a[0] !== b[0] || b[1] < a[1] || (a[1] === b[1] && b[2] < a[2])) return false;
}
delete base[field];
delete candidate[field];
}
return JSON.stringify(base) === JSON.stringify(candidate);
}

export function classifyCandidate({
actor,
baseRepository,
baseSha,
candidateRoot,
exactPolicyOutcome,
headRepository,
headSha,
prAuthor,
Expand All @@ -123,22 +142,19 @@ export function classifyCandidate({
});
const paths = changedPaths(candidateRoot, baseSha, headSha);
const protectedChanges = paths.filter((path) =>
matchers.some((matcher) => matcher.test(path)),
matchers.some((matcher) => matcher.test(path)) || [".gitignore", ".gitattributes"].includes(path),
);
const dependabotPackageOnly =
exactPolicyOutcome === "success" &&
actor === "dependabot[bot]" &&
prAuthor === "dependabot[bot]" &&
typeof baseRepository === "string" &&
baseRepository.length > 0 &&
headRepository === baseRepository &&
protectedChanges.length > 0 &&
protectedChanges.every(
safePackageUpdate(trustedRoot, candidateRoot) && protectedChanges.every(
(path) => path === "package.json" || path === "package-lock.json",
);
const sensitive =
exactPolicyOutcome !== "success" ||
(protectedChanges.length > 0 && !dependabotPackageOnly);
const sensitive = protectedChanges.length > 0 && !dependabotPackageOnly;
return Object.freeze({
sensitive,
protectedChanges: Object.freeze(protectedChanges),
Expand All @@ -152,7 +168,6 @@ function main() {
baseRepository: process.env.BASE_REPOSITORY,
baseSha: process.env.BASE_SHA,
candidateRoot: process.env.CANDIDATE_ROOT,
exactPolicyOutcome: process.env.EXACT_POLICY_OUTCOME,
headRepository: process.env.HEAD_REPOSITORY,
headSha: process.env.HEAD_SHA,
prAuthor: process.env.PR_AUTHOR,
Expand Down
Loading