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 }; }