Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
50 changes: 50 additions & 0 deletions .github/scripts/audit-ci-settings.mjs
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"));
Comment thread
openboa marked this conversation as resolved.
Outdated
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"]) {
Comment thread
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.");
Comment thread
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; });
126 changes: 126 additions & 0 deletions .github/scripts/check-candidate-policy.mjs
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}`);
Comment thread
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);
Comment thread
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;
}
}
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; }
}
37 changes: 26 additions & 11 deletions .github/scripts/classify-candidate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ function changedPaths(candidateRoot, baseSha, headSha) {
return paths;
}

function trustedWrapper(controlSha) {
export function trustedWrapper(controlSha, postMerge = false) {
return `name: OpenBoa Coffee trusted gate

on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review]

${postMerge ? " push:\n branches: [main]\n" : ""}
permissions: {}

jobs:
Expand All @@ -77,7 +77,7 @@ export function validateCandidateWorkflowDelegation(source) {
/uses: openboa-ai\/\.github\/\.github\/workflows\/coffee-trusted-gate\.yml@([0-9a-f]{40})/u,
);
const controlSha = match?.[1];
if (controlSha === undefined || source !== trustedWrapper(controlSha)) {
if (controlSha === undefined || (source !== trustedWrapper(controlSha) && source !== trustedWrapper(controlSha, true))) {
Comment thread
openboa marked this conversation as resolved.
Outdated
throw new Error("target repository must retain the exact trusted wrapper");
}
return controlSha;
Expand All @@ -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;
Comment thread
openboa marked this conversation as resolved.
Outdated
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
17 changes: 17 additions & 0 deletions .github/scripts/install-actionlint.sh
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"
4 changes: 4 additions & 0 deletions .github/scripts/reject-candidate-authorities.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ if awk '$1 == "120000" { found = 1 } END { exit !found }' "$index_entries"; then
echo 'candidate symlinks are not allowed across the trusted data boundary' >&2
exit 1
fi
if awk '$1 == "160000" { found = 1 } END { exit !found }' "$index_entries"; then
echo 'candidate gitlinks are not allowed across the trusted data boundary' >&2
exit 1
fi

test ! -e "$candidate_root/.npmrc"
test ! -e "$candidate_root/.github/policy-parser/.npmrc"
Expand Down
Loading