diff --git a/scripts/github/dependency-guard.mjs b/scripts/github/dependency-guard.mjs index 97f046208ddf..2434ff9856d2 100644 --- a/scripts/github/dependency-guard.mjs +++ b/scripts/github/dependency-guard.mjs @@ -3,16 +3,29 @@ // GitHub dependency-change guard: detects dependency files, manages override // comments/labels, and can autoscrub lockfile-only PR changes. import { appendFile, readFile } from "node:fs/promises"; -import { readBoundedResponseText } from "../lib/bounded-response.mjs"; +import { + GITHUB_API_REQUEST_TIMEOUT_MS, + GITHUB_ERROR_BODY_MAX_BYTES, + GITHUB_RESPONSE_BODY_MAX_BYTES, + createGitHubApi, + createGuardApproverChecks, + guardTrustedActorCandidates, + readBoundedGitHubErrorText, + readBoundedGitHubJson, +} from "./guard-shared.mjs"; /** Marker used to identify dependency guard comments. */ export const dependencyChangeMarker = ""; export const dependencyGraphGuardMarker = ""; export const dependencyChangedLabel = "dependencies-changed"; export const allowDependenciesCommand = "/allow-dependencies-change"; -export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024; -export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024; -export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000; +export { + GITHUB_API_REQUEST_TIMEOUT_MS, + GITHUB_ERROR_BODY_MAX_BYTES, + GITHUB_RESPONSE_BODY_MAX_BYTES, + readBoundedGitHubErrorText, + readBoundedGitHubJson, +}; const maxListedFiles = 25; const autoscrubCommitMessage = "chore: remove dependency lockfile change"; @@ -445,28 +458,7 @@ function renderAutoscrubStatusLines(status) { } export function dependencyGuardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) { - const eventHeadSha = event?.pull_request?.head?.sha; - const eventAfterSha = event?.after; - const eventMatchesCurrentHead = - Boolean(currentHeadSha) && - (eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha); - if (!eventMatchesCurrentHead) { - return []; - } - const candidates = []; - const seen = new Set(); - for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) { - if (typeof login !== "string" || login.length === 0) { - continue; - } - const normalizedLogin = login.toLowerCase(); - if (seen.has(normalizedLogin)) { - continue; - } - seen.add(normalizedLogin); - candidates.push({ login, source }); - } - return candidates; + return guardTrustedActorCandidates({ pullRequest, event, currentHeadSha }); } export async function findTrustedDependencyGuardActor({ candidates, isDependencyApprover }) { @@ -486,112 +478,12 @@ function renderManifestChangeLine(change) { return `- ${markdownCode(change.path)} changed ${change.fields.map(markdownCode).join(", ")}.`; } -function githubErrorBodyTooLarge(maxBytes) { - return new Error(`GitHub error response body exceeded ${maxBytes} bytes`); -} - -function githubResponseBodyTooLarge(maxBytes) { - return new Error(`GitHub response body exceeded ${maxBytes} bytes`); -} - -export async function readBoundedGitHubErrorText( - response, - maxBytes = GITHUB_ERROR_BODY_MAX_BYTES, - options = {}, -) { - return await readBoundedResponseText(response, "GitHub error", maxBytes, { - createTooLargeError: () => githubErrorBodyTooLarge(maxBytes), - ...options, - }); -} - -export async function readBoundedGitHubJson( - response, - maxBytes = GITHUB_RESPONSE_BODY_MAX_BYTES, - options = {}, -) { - const text = await readBoundedResponseText(response, "GitHub", maxBytes, { - createTooLargeError: () => githubResponseBodyTooLarge(maxBytes), - ...options, - }); - return JSON.parse(text); -} - -function timeoutError(path, method, timeoutMs) { - return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`); -} - -function combineAbortSignals(signals) { - const activeSignals = signals.filter(Boolean); - if (activeSignals.length === 0) { - return undefined; - } - if (activeSignals.length === 1) { - return activeSignals[0]; - } - return AbortSignal.any(activeSignals); -} - export function githubApi(token, options = {}) { - const fetchImpl = options.fetchImpl ?? fetch; - const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS; - const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES; - const baseHeaders = { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "user-agent": "openclaw-dependency-guard", - "x-github-api-version": "2022-11-28", - }; - const request = async (path, requestOptions = {}) => { - const method = requestOptions.method ?? "GET"; - const timeoutController = new AbortController(); - let timeout; - const timeoutPromise = new Promise((_, reject) => { - timeout = setTimeout(() => { - timeoutController.abort(); - reject(timeoutError(path, method, timeoutMs)); - }, timeoutMs); - timeout.unref?.(); - }); - const operationPromise = (async () => { - const response = await fetchImpl(`https://api.github.com${path}`, { - ...requestOptions, - signal: combineAbortSignals([requestOptions.signal, timeoutController.signal]), - headers: { ...baseHeaders, ...requestOptions.headers }, - }); - if (response.status === 204) { - return null; - } - if (!response.ok) { - let errorText; - try { - errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, { - signal: timeoutController.signal, - timeoutPromise, - }); - } catch (bodyError) { - errorText = bodyError instanceof Error ? bodyError.message : String(bodyError); - } - const error = new Error(`${response.status} ${response.statusText}: ${errorText}`); - error.status = response.status; - throw error; - } - return await readBoundedGitHubJson(response, responseMaxBodyBytes, { - signal: timeoutController.signal, - timeoutPromise, - }); - })(); - operationPromise.catch(() => {}); - try { - return await Promise.race([operationPromise, timeoutPromise]); - } finally { - clearTimeout(timeout); - } - }; + const api = createGitHubApi(token, { ...options, userAgent: "openclaw-dependency-guard" }); return { - request, + ...api, graphql: async (query, variables) => { - const result = await request("/graphql", { + const result = await api.request("/graphql", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query, variables }), @@ -609,7 +501,7 @@ export function githubApi(token, options = {}) { const items = []; for (let page = 1; ; page += 1) { const separator = path.includes("?") ? "&" : "?"; - const pageItems = await request(`${path}${separator}per_page=100&page=${page}`); + const pageItems = await api.request(`${path}${separator}per_page=100&page=${page}`); items.push(...pageItems); if (pageItems.length < 100) { return items; @@ -925,51 +817,13 @@ async function main() { return; } - const membershipCache = new Map(); - const permissionCache = new Map(); - const isSecurityMember = async (login) => { - const normalizedLogin = login.toLowerCase(); - if (explicitSecurityApprovers.has(normalizedLogin)) { - return true; - } - if (membershipCache.has(normalizedLogin)) { - return membershipCache.get(normalizedLogin); - } - try { - const membership = await api.request( - `/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`, - ); - const allowed = membership?.state === "active"; - membershipCache.set(normalizedLogin, allowed); - return allowed; - } catch (error) { - if (error?.status !== 404) { - console.warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`); - } - membershipCache.set(normalizedLogin, false); - return false; - } - }; - const isRepositoryAdmin = async (login) => { - const normalizedLogin = login.toLowerCase(); - if (permissionCache.has(normalizedLogin)) { - return permissionCache.get(normalizedLogin); - } - try { - const result = await api.request( - `/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`, - ); - const allowed = result?.permission === "admin"; - permissionCache.set(normalizedLogin, allowed); - return allowed; - } catch (error) { - if (error?.status !== 404) { - console.warn(`Could not verify repository permission for ${login}: ${error.message}`); - } - permissionCache.set(normalizedLogin, false); - return false; - } - }; + const { isSecurityMember, isRepositoryAdmin } = createGuardApproverChecks({ + api, + owner, + repo, + securityTeamSlug, + explicitSecurityApprovers, + }); const isDependencyApprover = async (login) => { if (await isSecurityMember(login)) { return securityTeamSlug; diff --git a/scripts/github/guard-shared.mjs b/scripts/github/guard-shared.mjs new file mode 100644 index 000000000000..2bd30370ee12 --- /dev/null +++ b/scripts/github/guard-shared.mjs @@ -0,0 +1,191 @@ +import { readBoundedResponseText } from "../lib/bounded-response.mjs"; + +export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024; +export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024; +export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000; + +export function guardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) { + const eventHeadSha = event?.pull_request?.head?.sha; + const eventAfterSha = event?.after; + const eventMatchesCurrentHead = + Boolean(currentHeadSha) && + (eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha); + if (!eventMatchesCurrentHead) { + return []; + } + const candidates = []; + const seen = new Set(); + for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) { + if (typeof login !== "string" || login.length === 0) { + continue; + } + const normalizedLogin = login.toLowerCase(); + if (seen.has(normalizedLogin)) { + continue; + } + seen.add(normalizedLogin); + candidates.push({ login, source }); + } + return candidates; +} + +export function createGuardApproverChecks({ + api, + owner, + repo, + securityTeamSlug, + explicitSecurityApprovers, + warn = console.warn, +}) { + const membershipCache = new Map(); + const permissionCache = new Map(); + const isSecurityMember = async (login) => { + const normalizedLogin = login.toLowerCase(); + if (explicitSecurityApprovers.has(normalizedLogin)) { + return true; + } + if (membershipCache.has(normalizedLogin)) { + return membershipCache.get(normalizedLogin); + } + try { + const membership = await api.request( + `/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`, + ); + const allowed = membership?.state === "active"; + membershipCache.set(normalizedLogin, allowed); + return allowed; + } catch (error) { + if (error?.status !== 404) { + warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`); + } + membershipCache.set(normalizedLogin, false); + return false; + } + }; + const isRepositoryAdmin = async (login) => { + const normalizedLogin = login.toLowerCase(); + if (permissionCache.has(normalizedLogin)) { + return permissionCache.get(normalizedLogin); + } + try { + const result = await api.request( + `/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`, + ); + const allowed = result?.permission === "admin"; + permissionCache.set(normalizedLogin, allowed); + return allowed; + } catch (error) { + if (error?.status !== 404) { + warn(`Could not verify repository permission for ${login}: ${error.message}`); + } + permissionCache.set(normalizedLogin, false); + return false; + } + }; + return { isSecurityMember, isRepositoryAdmin }; +} + +function githubErrorBodyTooLarge(maxBytes) { + return new Error(`GitHub error response body exceeded ${maxBytes} bytes`); +} + +function githubResponseBodyTooLarge(maxBytes) { + return new Error(`GitHub response body exceeded ${maxBytes} bytes`); +} + +export async function readBoundedGitHubErrorText( + response, + maxBytes = GITHUB_ERROR_BODY_MAX_BYTES, + options = {}, +) { + return await readBoundedResponseText(response, "GitHub error", maxBytes, { + createTooLargeError: () => githubErrorBodyTooLarge(maxBytes), + ...options, + }); +} + +export async function readBoundedGitHubJson( + response, + maxBytes = GITHUB_RESPONSE_BODY_MAX_BYTES, + options = {}, +) { + const text = await readBoundedResponseText(response, "GitHub", maxBytes, { + createTooLargeError: () => githubResponseBodyTooLarge(maxBytes), + ...options, + }); + return JSON.parse(text); +} + +function timeoutError(path, method, timeoutMs) { + return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`); +} + +function combineAbortSignals(signals) { + const activeSignals = signals.filter(Boolean); + if (activeSignals.length === 0) { + return undefined; + } + if (activeSignals.length === 1) { + return activeSignals[0]; + } + return AbortSignal.any(activeSignals); +} + +export function createGitHubApi(token, options = {}) { + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS; + const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES; + const baseHeaders = { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": options.userAgent, + "x-github-api-version": "2022-11-28", + }; + const request = async (path, requestOptions = {}) => { + const method = requestOptions.method ?? "GET"; + const timeoutController = new AbortController(); + let timeout; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + timeoutController.abort(); + reject(timeoutError(path, method, timeoutMs)); + }, timeoutMs); + timeout.unref?.(); + }); + const operationPromise = (async () => { + const response = await fetchImpl(`https://api.github.com${path}`, { + ...requestOptions, + signal: combineAbortSignals([requestOptions.signal, timeoutController.signal]), + headers: { ...baseHeaders, ...requestOptions.headers }, + }); + if (response.status === 204) { + return null; + } + if (!response.ok) { + let errorText; + try { + errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, { + signal: timeoutController.signal, + timeoutPromise, + }); + } catch (bodyError) { + errorText = bodyError instanceof Error ? bodyError.message : String(bodyError); + } + const error = new Error(`${response.status} ${response.statusText}: ${errorText}`); + error.status = response.status; + throw error; + } + return await readBoundedGitHubJson(response, responseMaxBodyBytes, { + signal: timeoutController.signal, + timeoutPromise, + }); + })(); + operationPromise.catch(() => {}); + try { + return await Promise.race([operationPromise, timeoutPromise]); + } finally { + clearTimeout(timeout); + } + }; + return { request }; +} diff --git a/scripts/github/security-sensitive-guard.mjs b/scripts/github/security-sensitive-guard.mjs index ff75d07e9baa..831b466b7b68 100644 --- a/scripts/github/security-sensitive-guard.mjs +++ b/scripts/github/security-sensitive-guard.mjs @@ -3,15 +3,28 @@ // GitHub security-sensitive file guard: detects sensitive boundary files, // manages sticky comments/labels, and requires SHA-bound secops/admin approval. import { appendFile, readFile } from "node:fs/promises"; -import { readBoundedResponseText } from "../lib/bounded-response.mjs"; +import { + GITHUB_API_REQUEST_TIMEOUT_MS, + GITHUB_ERROR_BODY_MAX_BYTES, + GITHUB_RESPONSE_BODY_MAX_BYTES, + createGitHubApi, + createGuardApproverChecks, + guardTrustedActorCandidates, + readBoundedGitHubErrorText, + readBoundedGitHubJson, +} from "./guard-shared.mjs"; /** Marker used to identify security-sensitive guard comments. */ export const securitySensitiveGuardMarker = ""; export const securitySensitiveChangedLabel = "security-sensitive-changed"; export const allowSecuritySensitiveCommand = "/allow-security-sensitive-change"; -export const GITHUB_ERROR_BODY_MAX_BYTES = 64 * 1024; -export const GITHUB_RESPONSE_BODY_MAX_BYTES = 4 * 1024 * 1024; -export const GITHUB_API_REQUEST_TIMEOUT_MS = 30_000; +export { + GITHUB_API_REQUEST_TIMEOUT_MS, + GITHUB_ERROR_BODY_MAX_BYTES, + GITHUB_RESPONSE_BODY_MAX_BYTES, + readBoundedGitHubErrorText, + readBoundedGitHubJson, +}; const securityTeamSlug = process.env.OPENCLAW_SECURITY_TEAM_SLUG ?? "openclaw-secops"; const maxListedFiles = 25; @@ -306,28 +319,7 @@ export function securitySensitiveGuardTrustedActorCandidates({ event, currentHeadSha, }) { - const eventHeadSha = event?.pull_request?.head?.sha; - const eventAfterSha = event?.after; - const eventMatchesCurrentHead = - Boolean(currentHeadSha) && - (eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha); - if (!eventMatchesCurrentHead) { - return []; - } - const candidates = []; - const seen = new Set(); - for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) { - if (typeof login !== "string" || login.length === 0) { - continue; - } - const normalizedLogin = login.toLowerCase(); - if (seen.has(normalizedLogin)) { - continue; - } - seen.add(normalizedLogin); - candidates.push({ login, source }); - } - return candidates; + return guardTrustedActorCandidates({ pullRequest, event, currentHeadSha }); } export async function findTrustedSecuritySensitiveGuardActor({ @@ -346,115 +338,18 @@ export async function findTrustedSecuritySensitiveGuardActor({ return null; } -function githubErrorBodyTooLarge(maxBytes) { - return new Error(`GitHub error response body exceeded ${maxBytes} bytes`); -} - -function githubResponseBodyTooLarge(maxBytes) { - return new Error(`GitHub response body exceeded ${maxBytes} bytes`); -} - -export async function readBoundedGitHubErrorText( - response, - maxBytes = GITHUB_ERROR_BODY_MAX_BYTES, - options = {}, -) { - return await readBoundedResponseText(response, "GitHub error", maxBytes, { - createTooLargeError: () => githubErrorBodyTooLarge(maxBytes), - ...options, - }); -} - -export async function readBoundedGitHubJson( - response, - maxBytes = GITHUB_RESPONSE_BODY_MAX_BYTES, - options = {}, -) { - const text = await readBoundedResponseText(response, "GitHub", maxBytes, { - createTooLargeError: () => githubResponseBodyTooLarge(maxBytes), - ...options, - }); - return JSON.parse(text); -} - -function timeoutError(path, method, timeoutMs) { - return new Error(`GitHub API ${method} ${path} exceeded timeout ${timeoutMs}ms`); -} - -function combineAbortSignals(signals) { - const activeSignals = signals.filter(Boolean); - if (activeSignals.length === 0) { - return undefined; - } - if (activeSignals.length === 1) { - return activeSignals[0]; - } - return AbortSignal.any(activeSignals); -} - export function githubApi(token, options = {}) { - const fetchImpl = options.fetchImpl ?? fetch; - const timeoutMs = options.timeoutMs ?? GITHUB_API_REQUEST_TIMEOUT_MS; - const responseMaxBodyBytes = options.responseMaxBodyBytes ?? GITHUB_RESPONSE_BODY_MAX_BYTES; - const baseHeaders = { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "user-agent": "openclaw-security-sensitive-guard", - "x-github-api-version": "2022-11-28", - }; - const request = async (path, requestOptions = {}) => { - const method = requestOptions.method ?? "GET"; - const timeoutController = new AbortController(); - let timeout; - const timeoutPromise = new Promise((_, reject) => { - timeout = setTimeout(() => { - timeoutController.abort(); - reject(timeoutError(path, method, timeoutMs)); - }, timeoutMs); - timeout.unref?.(); - }); - const operationPromise = (async () => { - const response = await fetchImpl(`https://api.github.com${path}`, { - ...requestOptions, - signal: combineAbortSignals([requestOptions.signal, timeoutController.signal]), - headers: { ...baseHeaders, ...requestOptions.headers }, - }); - if (response.status === 204) { - return null; - } - if (!response.ok) { - let errorText; - try { - errorText = await readBoundedGitHubErrorText(response, GITHUB_ERROR_BODY_MAX_BYTES, { - signal: timeoutController.signal, - timeoutPromise, - }); - } catch (bodyError) { - errorText = bodyError instanceof Error ? bodyError.message : String(bodyError); - } - const error = new Error(`${response.status} ${response.statusText}: ${errorText}`); - error.status = response.status; - throw error; - } - return await readBoundedGitHubJson(response, responseMaxBodyBytes, { - signal: timeoutController.signal, - timeoutPromise, - }); - })(); - operationPromise.catch(() => {}); - try { - return await Promise.race([operationPromise, timeoutPromise]); - } finally { - clearTimeout(timeout); - } - }; + const api = createGitHubApi(token, { + ...options, + userAgent: "openclaw-security-sensitive-guard", + }); return { - request, + ...api, paginate: async (path) => { const items = []; for (let page = 1; ; page += 1) { const separator = path.includes("?") ? "&" : "?"; - const pageItems = await request(`${path}${separator}per_page=100&page=${page}`); + const pageItems = await api.request(`${path}${separator}per_page=100&page=${page}`); items.push(...pageItems); if (pageItems.length < 100) { return items; @@ -587,51 +482,13 @@ async function main() { ); console.log(`Detected ${securitySensitiveChanges.length} security-sensitive file change(s).`); - const membershipCache = new Map(); - const permissionCache = new Map(); - const isSecurityMember = async (login) => { - const normalizedLogin = login.toLowerCase(); - if (explicitSecurityApprovers.has(normalizedLogin)) { - return true; - } - if (membershipCache.has(normalizedLogin)) { - return membershipCache.get(normalizedLogin); - } - try { - const membership = await api.request( - `/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`, - ); - const allowed = membership?.state === "active"; - membershipCache.set(normalizedLogin, allowed); - return allowed; - } catch (error) { - if (error?.status !== 404) { - console.warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`); - } - membershipCache.set(normalizedLogin, false); - return false; - } - }; - const isRepositoryAdmin = async (login) => { - const normalizedLogin = login.toLowerCase(); - if (permissionCache.has(normalizedLogin)) { - return permissionCache.get(normalizedLogin); - } - try { - const result = await api.request( - `/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`, - ); - const allowed = result?.permission === "admin"; - permissionCache.set(normalizedLogin, allowed); - return allowed; - } catch (error) { - if (error?.status !== 404) { - console.warn(`Could not verify repository permission for ${login}: ${error.message}`); - } - permissionCache.set(normalizedLogin, false); - return false; - } - }; + const { isSecurityMember, isRepositoryAdmin } = createGuardApproverChecks({ + api, + owner, + repo, + securityTeamSlug, + explicitSecurityApprovers, + }); const isSecuritySensitiveApprover = async (login) => { if (await isSecurityMember(login)) { return securityTeamSlug;