diff --git a/scripts/production-environment-governance-audit.mjs b/scripts/production-environment-governance-audit.mjs index bb61a1c9a..d07d76c47 100644 --- a/scripts/production-environment-governance-audit.mjs +++ b/scripts/production-environment-governance-audit.mjs @@ -42,10 +42,25 @@ export function createGhSubprocessEnvironment(sourceEnvironment = process.env) { return childEnvironment; } +/** + * Decode GitHub CLI output without allowing replacement characters to convert + * malformed remote evidence into a different, parseable JSON document. + * + * @param {Uint8Array} value Raw subprocess bytes. + * @param {string} [label="output"] Diagnostic stream label. + * @returns {string} Exact UTF-8 text. + */ +export function decodeGhOutput(value, label = "output") { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(value); + } catch { + throw new Error(`GitHub CLI returned invalid UTF-8 in ${label}.`); + } +} + function runGh(args) { const childEnvironment = createGhSubprocessEnvironment(); const completed = spawnSync("gh", args, { - encoding: "utf8", env: childEnvironment, maxBuffer: MAX_GH_OUTPUT_BYTES, shell: false, @@ -55,11 +70,18 @@ function runGh(args) { throw new Error(`GitHub CLI could not start: ${bound(detail)}`); } if (completed.status !== 0) { - const rawDetail = completed.stderr || completed.stdout || `exit ${completed.status}`; + let rawDetail; + if (completed.stderr?.length) { + rawDetail = decodeGhOutput(completed.stderr, "stderr"); + } else if (completed.stdout?.length) { + rawDetail = decodeGhOutput(completed.stdout, "stdout"); + } else { + rawDetail = `exit ${completed.status}`; + } const detail = redactSensitiveValue(rawDetail, [childEnvironment.GH_TOKEN]); throw new Error(`GitHub CLI failed: ${bound(detail)}`); } - return completed.stdout.trim(); + return decodeGhOutput(completed.stdout, "stdout").trim(); } function collectEnvironment(repository) { diff --git a/test/production-environment-governance.test.ts b/test/production-environment-governance.test.ts index 60f608e84..bf00b4530 100644 --- a/test/production-environment-governance.test.ts +++ b/test/production-environment-governance.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { evaluateProductionEnvironment } from "../scripts/lib/production-environment-governance.mjs"; import { createGhSubprocessEnvironment, + decodeGhOutput, redactSensitiveValue, } from "../scripts/production-environment-governance-audit.mjs"; @@ -110,6 +111,17 @@ describe("production environment governance", () => { }); }); + it("rejects malformed UTF-8 from the GitHub CLI instead of replacement-decoding evidence", () => { + expect(() => decodeGhOutput(Uint8Array.from([0x7b, 0x22, 0xff, 0x22, 0x7d]), "stdout")) + .toThrow("GitHub CLI returned invalid UTF-8 in stdout."); + }); + + it("decodes valid UTF-8 GitHub CLI bytes exactly", () => { + const bytes = new TextEncoder().encode('{"name":"production"}\n'); + + expect(decodeGhOutput(bytes, "stdout")).toBe('{"name":"production"}\n'); + }); + it("redacts the explicit GitHub token before child diagnostics can be retained", () => { const token = "read-only-github-token"; const diagnostic = `gh failed with ${token}; retry also exposed ${token}`;