#!/usr/bin/env node import { execFileSync, spawnSync } from "node:child_process"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; function usage() { console.error( [ "Usage:", " node scripts/ghsa-patch.mjs --ghsa [--repo owner/name]", " --summary --severity ", " --description-file ", " --vulnerable-version-range ", " --patched-versions ", " [--package openclaw] [--ecosystem npm] [--cvss ]", ].join("\n"), ); } function fail(message) { console.error(message); process.exit(1); } function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg.startsWith("--")) { fail(`Unexpected argument: ${arg}`); } const key = arg.slice(2); const value = argv[i + 1]; if (!value || value.startsWith("--")) { fail(`Missing value for --${key}`); } out[key] = value; i += 1; } return out; } 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`); } return proc.stdout; } function deriveRepoFromOrigin() { const remote = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim(); const httpsMatch = remote.match(/github\.com[/:]([^/]+)\/([^/.]+)(?:\.git)?$/); if (!httpsMatch) { fail(`Could not parse origin remote: ${remote}`); } return `${httpsMatch[1]}/${httpsMatch[2]}`; } function parseGhsaId(value) { const match = value.match(/GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i); if (!match) { fail(`Could not parse GHSA id from: ${value}`); } return match[0]; } function writeTempJson(data) { const file = path.join(os.tmpdir(), `ghsa-patch-${crypto.randomUUID()}.json`); fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); return file; } const args = parseArgs(process.argv.slice(2)); if (!args.ghsa || !args.summary || !args.severity || !args["description-file"]) { usage(); process.exit(1); } const repo = args.repo || deriveRepoFromOrigin(); const ghsaId = parseGhsaId(args.ghsa); const advisoryPath = `/repos/${repo}/security-advisories/${ghsaId}`; const descriptionPath = path.resolve(args["description-file"]); if (!fs.existsSync(descriptionPath)) { fail(`Description file does not exist: ${descriptionPath}`); } const current = JSON.parse(runGh(["api", "-H", "X-GitHub-Api-Version: 2022-11-28", advisoryPath])); const restoredCvss = args.cvss || current?.cvss?.vector_string || null; const ecosystem = args.ecosystem || "npm"; const packageName = args.package || "openclaw"; const vulnerableRange = args["vulnerable-version-range"]; const patchedVersionsRaw = args["patched-versions"]; if (!vulnerableRange) { fail("Missing --vulnerable-version-range"); } if (patchedVersionsRaw === undefined) { fail("Missing --patched-versions"); } const patchedVersions = patchedVersionsRaw === "null" ? null : patchedVersionsRaw; const description = fs.readFileSync(descriptionPath, "utf8"); const payload = { summary: args.summary, severity: args.severity, description, vulnerabilities: [ { package: { ecosystem, name: packageName, }, vulnerable_version_range: vulnerableRange, patched_versions: patchedVersions, vulnerable_functions: [], }, ], }; const patchFile = writeTempJson(payload); runGh([ "api", "-H", "X-GitHub-Api-Version: 2022-11-28", "-X", "PATCH", advisoryPath, "--input", patchFile, ]); if (restoredCvss) { runGh([ "api", "-H", "X-GitHub-Api-Version: 2022-11-28", "-X", "PATCH", advisoryPath, "-f", `cvss_vector_string=${restoredCvss}`, ]); } const refreshed = JSON.parse( runGh(["api", "-H", "X-GitHub-Api-Version: 2022-11-28", advisoryPath]), ); console.log( JSON.stringify( { html_url: refreshed.html_url, state: refreshed.state, severity: refreshed.severity, summary: refreshed.summary, vulnerabilities: refreshed.vulnerabilities, cvss: refreshed.cvss, updated_at: refreshed.updated_at, }, null, 2, ), );