Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions scripts/actions-runner-assignment-audit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import {
collectRunnerAssignmentEvidence,
parseSelectedRunIds,
} from "./lib/actions-runner-assignment-source.mjs";
import { hasDuplicateJsonObjectKeys } from "./normalize-commercial-readiness-evidence.mjs";

const AUDITED_REPOSITORY = "ContextualWisdomLab/noema";
const GITHUB_API_VERSION = "2026-03-10";
const GH_API_TIMEOUT_MILLISECONDS = 20_000;
const GH_API_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
const REPORT_PATH = "artifacts/operations/actions-runner-assignment-audit.json";
const canonicalShaPattern = /^[0-9a-f]{40}$/;
const fatalUtf8Decoder = new TextDecoder("utf-8", { fatal: true });

function boundedErrorText(value) {
const text = typeof value === "string" ? value : String(value ?? "");
Expand Down Expand Up @@ -66,6 +68,45 @@ export function createGhSubprocessEnvironment(environment) {
};
}

/**
* Decode and parse bounded GitHub API bytes without normalizing ambiguous input.
*
* Malformed UTF-8 and duplicate decoded object keys fail before `JSON.parse`, so
* runner-assignment evidence cannot inherit replacement-character or
* last-key-wins semantics from the JavaScript runtime.
*
* @param {Uint8Array} bytes Raw stdout bytes returned by the GitHub CLI.
* @returns {unknown} Parsed JSON evidence.
*/
export function parseGhJsonEvidence(bytes) {
if (!(bytes instanceof Uint8Array)) {
throw new TypeError("GitHub Actions evidence must be supplied as raw bytes.");
}

let text;
try {
text = fatalUtf8Decoder.decode(bytes);
} catch {
throw new Error("GitHub Actions evidence read returned invalid UTF-8.");
}

let duplicateKeys;
try {
duplicateKeys = hasDuplicateJsonObjectKeys(text);
} catch {
throw new Error("GitHub Actions evidence read returned malformed JSON.");
}
if (duplicateKeys) {
throw new Error("GitHub Actions evidence read returned duplicate decoded object keys.");
}

try {
return JSON.parse(text);
} catch {
throw new Error("GitHub Actions evidence read returned malformed JSON.");
}
}

/**
* Read one GitHub REST resource through the authenticated `gh` CLI.
*
Expand Down Expand Up @@ -94,7 +135,6 @@ export function ghApi(path, options = {}) {
args.push(path);

const result = spawnSync("gh", args, {
encoding: "utf8",
timeout: GH_API_TIMEOUT_MILLISECONDS,
maxBuffer: GH_API_MAX_BUFFER_BYTES,
env: createGhSubprocessEnvironment(process.env),
Expand All @@ -110,11 +150,7 @@ export function ghApi(path, options = {}) {
);
}

try {
return JSON.parse(result.stdout);
} catch {
throw new Error("GitHub Actions evidence read returned malformed JSON.");
}
return parseGhJsonEvidence(result.stdout);
}

/**
Expand Down
18 changes: 18 additions & 0 deletions test/actions-runner-assignment-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createGhReadAdapters,
createGhSubprocessEnvironment,
ghApi,
parseGhJsonEvidence,
runActionsRunnerAssignmentAudit,
} from "../scripts/actions-runner-assignment-audit.mjs";

Expand Down Expand Up @@ -32,6 +33,23 @@ describe("runner-assignment operator audit", () => {
expect(() => ghApi(path)).toThrow(message);
});

it("rejects malformed UTF-8 and duplicate decoded keys in GitHub API evidence", () => {
expect(() => parseGhJsonEvidence(Buffer.concat([
Buffer.from('{"id":100,"name":"', "utf8"),
Buffer.from([0xff]),
Buffer.from('"}', "utf8"),
]))).toThrow("invalid UTF-8");

expect(() => parseGhJsonEvidence(Buffer.from('{"id":100,"i\\u0064":101}', "utf8"))).toThrow(
"duplicate decoded object keys",
);

expect(parseGhJsonEvidence(Buffer.from('{"id":100,"head_sha":"0123456789abcdef0123456789abcdef01234567"}', "utf8"))).toEqual({
id: 100,
head_sha: expectedHead,
});
});

it("isolates the gh subprocess from unrelated repository, model, and proxy credentials", () => {
expect(createGhSubprocessEnvironment({
PATH: "/usr/bin:/bin",
Expand Down
Loading