openclaw/scripts/github/security-review-policy.mjs
Josh Avant c2b84506a9
feat(ci): split security review into maintainer and SecOps tiers (#152415)
* feat(ci): split security review into two tiers

* refactor(ci): move security review paths into YAML

* fix(ci): require explicit maintainer approval commands

* feat(ci): integrate automatic security review with CI

* fix(ci): filter security review comment events

* test(ci): include security review workflow routes
2026-09-18 23:39:46 -05:00

139 lines
5 KiB
JavaScript

import { readFileSync } from "node:fs";
import { Minimatch } from "minimatch";
import { parseDocument } from "yaml";
const policyPath = new URL("../../.github/security-review-policy.yml", import.meta.url);
/** @type {{
* rolloutPullRequest: number | undefined,
* isDependencyManifest: (filename: string) => boolean,
* isPackageLockfile: (filename: string) => boolean,
* isDependencyFile: (filename: string) => boolean,
* collectSecuritySensitiveChanges: (
* files: Array<string | { filename?: string, previous_filename?: string }>
* ) => Array<{ path: string, reason: string }>,
* } | undefined} */
let cachedPolicy;
function invalid(message) {
throw new Error(`Invalid security-review-policy.yml: ${message}`);
}
function mapping(value, location, fields) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
invalid(`${location} must be a mapping`);
}
if (fields) {
for (const key of Object.keys(value)) {
if (!fields.includes(key)) {
invalid(`${location}.${key} is not a supported field`);
}
}
}
return value;
}
function text(value, location) {
if (typeof value !== "string" || !value.trim()) {
invalid(`${location} must be nonempty text`);
}
return value;
}
function paths(value, location, nocase = false) {
if (!Array.isArray(value) || value.length === 0) {
invalid(`${location} must be a nonempty list of glob patterns`);
}
const patterns = value.map((entry, index) => {
const pattern = text(entry, `${location}[${index}]`);
if (/^[!#/]|\\|(?:^|\/)\.{1,2}(?:\/|$)/u.test(pattern)) {
invalid(`${location}[${index}] must be a repository-relative glob without negation`);
}
return new Minimatch(pattern, {
dot: true,
nocase,
nonegate: true,
nocomment: true,
noext: true,
platform: "linux",
});
});
return (filename) => patterns.some((pattern) => pattern.match(filename));
}
// The trusted checkout is immutable per run. Callers must fail the current
// revision if this policy cannot be loaded.
export function loadSecurityReviewPolicy() {
if (cachedPolicy) {
return cachedPolicy;
}
const document = parseDocument(readFileSync(policyPath, "utf8"));
const problem = document.errors[0] ?? document.warnings[0];
if (problem) {
invalid(problem.message);
}
const policy = mapping(document.toJS({ maxAliasCount: 0 }), "policy", [
"exclude",
"categories",
"dependencies",
"rollout",
]);
let rolloutPullRequest;
if (policy.rollout !== undefined) {
const rollout = mapping(policy.rollout, "rollout", ["pull-request"]);
if (!Number.isSafeInteger(rollout["pull-request"]) || rollout["pull-request"] <= 0) {
invalid("rollout.pull-request must be a positive safe integer");
}
rolloutPullRequest = rollout["pull-request"];
}
const excluded = Object.entries(mapping(policy.exclude, "exclude")).map(([name, value]) => {
const rule = mapping(value, `exclude.${name}`, ["paths", "case-insensitive"]);
if (rule["case-insensitive"] !== undefined && typeof rule["case-insensitive"] !== "boolean") {
invalid(`exclude.${name}.case-insensitive must be a boolean`);
}
return paths(rule.paths, `exclude.${name}.paths`, rule["case-insensitive"] === true);
});
const categories = Object.entries(mapping(policy.categories, "categories")).map(
([name, value]) => {
const rule = mapping(value, `categories.${name}`, ["description", "review", "paths"]);
return {
reason: `${text(rule.description, `categories.${name}.description`)} ${text(rule.review, `categories.${name}.review`)}`,
matches: paths(rule.paths, `categories.${name}.paths`),
};
},
);
if (categories.length === 0) {
invalid("categories must not be empty");
}
const dependencies = mapping(policy.dependencies, "dependencies", [
"manifests",
"lockfiles",
"other",
]);
const isDependencyManifest = paths(dependencies.manifests, "dependencies.manifests");
const isPackageLockfile = paths(dependencies.lockfiles, "dependencies.lockfiles");
const isOtherDependencyFile = paths(dependencies.other, "dependencies.other");
cachedPolicy = {
rolloutPullRequest,
isDependencyManifest,
isPackageLockfile,
isDependencyFile: (filename) => isPackageLockfile(filename) || isOtherDependencyFile(filename),
collectSecuritySensitiveChanges(files) {
const changes = new Map();
for (const file of files) {
const filenames =
typeof file === "string" ? [file] : [file?.filename, file?.previous_filename];
for (const filename of filenames) {
if (typeof filename !== "string" || excluded.some((matches) => matches(filename))) {
continue;
}
const rule = categories.find((category) => category.matches(filename));
if (rule) {
changes.set(filename, { path: filename, reason: rule.reason });
}
}
}
return [...changes.values()].toSorted((left, right) => left.path.localeCompare(right.path));
},
};
return cachedPolicy;
}