diff --git a/scripts/ghsa-patch.mjs b/scripts/ghsa-patch.mjs index 4b487f000d98..8993f470a6d3 100644 --- a/scripts/ghsa-patch.mjs +++ b/scripts/ghsa-patch.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node // Applies GHSA patch payloads to advisory branches. -import { execFileSync, spawnSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { GHSA_COMMAND_TIMEOUT_MS, runGhCommand } from "./lib/ghsa-patch-subprocess.mjs"; function usage() { console.error( @@ -44,15 +45,19 @@ function parseArgs(argv) { } function runGh(args) { - const proc = spawnSync("gh", args, { encoding: "utf8" }); - if (proc.status !== 0) { - fail(proc.stderr.trim() || proc.stdout.trim() || `gh ${args.join(" ")} failed`); + try { + return runGhCommand(args); + } catch (error) { + return fail(error instanceof Error ? error.message : String(error)); } - return proc.stdout; } function deriveRepoFromOrigin() { - const remote = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim(); + const remote = execFileSync("git", ["remote", "get-url", "origin"], { + encoding: "utf8", + killSignal: "SIGKILL", + timeout: GHSA_COMMAND_TIMEOUT_MS, + }).trim(); const httpsMatch = remote.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/); if (!httpsMatch) { fail(`Could not parse origin remote: ${remote}`); @@ -125,16 +130,20 @@ const payload = { }; const patchFile = writeTempJson(payload); -runGh([ - "api", - "-H", - "X-GitHub-Api-Version: 2022-11-28", - "-X", - "PATCH", - advisoryPath, - "--input", - patchFile, -]); +try { + runGh([ + "api", + "-H", + "X-GitHub-Api-Version: 2022-11-28", + "-X", + "PATCH", + advisoryPath, + "--input", + patchFile, + ]); +} finally { + fs.rmSync(patchFile, { force: true }); +} if (restoredCvss) { runGh([ diff --git a/scripts/lib/ghsa-patch-subprocess.d.mts b/scripts/lib/ghsa-patch-subprocess.d.mts new file mode 100644 index 000000000000..fbef7868fc52 --- /dev/null +++ b/scripts/lib/ghsa-patch-subprocess.d.mts @@ -0,0 +1,22 @@ +export const GHSA_COMMAND_TIMEOUT_MS: number; + +export function runGhCommand( + args: string[], + params?: { + spawnSyncImpl?: ( + command: string, + args: string[], + options: { + encoding: "utf8"; + killSignal: "SIGKILL"; + timeout: number; + }, + ) => { + error?: Error; + status: number | null; + stderr: string; + stdout: string; + }; + timeoutMs?: number; + }, +): string; diff --git a/scripts/lib/ghsa-patch-subprocess.mjs b/scripts/lib/ghsa-patch-subprocess.mjs new file mode 100644 index 000000000000..252479840b79 --- /dev/null +++ b/scripts/lib/ghsa-patch-subprocess.mjs @@ -0,0 +1,22 @@ +import { spawnSync } from "node:child_process"; + +// GHSA patch performs multiple sequential GitHub API reads and writes. Keep enough +// headroom for GitHub latency while preventing one stalled request from blocking +// the maintainer command indefinitely. +export const GHSA_COMMAND_TIMEOUT_MS = 60_000; + +export function runGhCommand(args, params = {}) { + const spawnSyncImpl = params.spawnSyncImpl ?? spawnSync; + const proc = spawnSyncImpl("gh", args, { + encoding: "utf8", + killSignal: "SIGKILL", + timeout: params.timeoutMs ?? GHSA_COMMAND_TIMEOUT_MS, + }); + if (proc.error) { + throw proc.error; + } + if (proc.status !== 0) { + throw new Error(proc.stderr.trim() || proc.stdout.trim() || `gh ${args.join(" ")} failed`); + } + return proc.stdout; +} diff --git a/test/scripts/ghsa-patch.test.ts b/test/scripts/ghsa-patch.test.ts new file mode 100644 index 000000000000..8697cff2bf56 --- /dev/null +++ b/test/scripts/ghsa-patch.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { runGhCommand } from "../../scripts/lib/ghsa-patch-subprocess.mjs"; + +describe("GHSA patch subprocess", () => { + it("bounds each GitHub lookup with a timeout and SIGKILL", () => { + const spawnSyncImpl = vi.fn(() => ({ + status: 0, + stdout: "result", + stderr: "", + })); + + expect(runGhCommand(["api", "rate_limit"], { spawnSyncImpl })).toBe("result"); + expect(spawnSyncImpl).toHaveBeenCalledWith("gh", ["api", "rate_limit"], { + encoding: "utf8", + killSignal: "SIGKILL", + timeout: 60_000, + }); + }); + + it("throws the GitHub CLI error when a lookup exits non-zero", () => { + const spawnSyncImpl = vi.fn(() => ({ + status: 1, + stdout: "", + stderr: "gh api failed", + })); + + expect(() => runGhCommand(["api", "rate_limit"], { spawnSyncImpl })).toThrow("gh api failed"); + }); + + it("propagates the timeout error from a stalled GitHub CLI process", () => { + const timeout = Object.assign(new Error("spawnSync gh ETIMEDOUT"), { code: "ETIMEDOUT" }); + const spawnSyncImpl = vi.fn(() => ({ + error: timeout, + status: null, + stdout: "", + stderr: "", + })); + + expect(() => runGhCommand(["api", "rate_limit"], { spawnSyncImpl })).toThrow(timeout); + }); +});