mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* security: isolate every gstack Bun entry point from the audited checkout's .env and bunfig.toml Bun auto-loads .env, .env.local and .env.<NODE_ENV> from its cwd and runs a cwd bunfig.toml preload before the program starts. gstack runs inside the repo it audits, so a committed .env could steer state roots, the claude binary or spend caps, and a bunfig preload ran arbitrary code in every bin and hook. Every bun shebang, shell and skill caller, TypeScript spawn and compiled build now disables both (--no-env-file --config=/dev/null, bunIsolationArgs(), --no-compile-autoload-dotenv/bunfig), with a ratchet and hostile-fixture regressions. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: name the directly executed scripts in the isolation ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow the isolation flags in touchfiles, copy lists, recipe assertions and the harness pin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep browse/src/bun-child-config.ts unchanged so the native cookie qualification still binds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): the empty bunfig outlives its process so the timeline hook's detached repair child can read it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: plan-mode render pattern accepts the isolation flags Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
125 lines
6.5 KiB
TypeScript
125 lines
6.5 KiB
TypeScript
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/** Bind a qualification dispatch to the one successful protected-main staging run that signed its images. */
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { canonical } from '../lib/cso/contracts';
|
|
import { committedImageBuildMatrix } from './cso-image-matrix';
|
|
|
|
export const STAGING_WORKFLOW_PATH = '.github/workflows/cso-runtime-images.yml';
|
|
export const NATIVE_JOB = 'qualify-native';
|
|
const SLSA_PROVENANCE = 'https://slsa.dev/provenance/v1';
|
|
const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
const INVOCATION = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/actions\/runs\/([1-9][0-9]*)\/attempts\/[1-9][0-9]*$/;
|
|
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
|
|
export interface StagingRunEvidence {
|
|
repository: string;
|
|
runId: string;
|
|
sourceCommit: string;
|
|
runtimeIds: string[];
|
|
run: unknown;
|
|
jobPages: unknown;
|
|
}
|
|
|
|
/**
|
|
* Resolve the staging run from verified SLSA provenance. The predicate's
|
|
* invocation and the Fulcio certificate's run URI must agree, and every
|
|
* verified statement for every image must name the same run.
|
|
*/
|
|
export function stagingRunId(repository: string, verifications: unknown[]): string {
|
|
if (!REPOSITORY.test(repository) || verifications.length === 0) throw new Error('INVALID_STAGING_PROVENANCE');
|
|
const runs = new Set<string>();
|
|
for (const verification of verifications) {
|
|
if (!Array.isArray(verification) || verification.length === 0) throw new Error('INVALID_STAGING_PROVENANCE');
|
|
for (const item of verification) {
|
|
const result = (item as any)?.verificationResult;
|
|
if (result?.statement?.predicateType !== SLSA_PROVENANCE) throw new Error('INVALID_STAGING_PROVENANCE');
|
|
const invocation = result.statement.predicate?.runDetails?.metadata?.invocationId;
|
|
const match = typeof invocation === 'string' ? INVOCATION.exec(invocation) : null;
|
|
if (!match || match[1] !== repository || result.signature?.certificate?.runInvocationURI !== invocation) {
|
|
throw new Error('STAGING_INVOCATION_MISMATCH');
|
|
}
|
|
runs.add(match[2]);
|
|
}
|
|
}
|
|
if (runs.size !== 1) throw new Error('STAGING_RUN_NOT_UNIQUE');
|
|
return [...runs][0];
|
|
}
|
|
|
|
/** Every statement in one qualification dispatch names the same staged source commit. */
|
|
export function dispatchSourceCommit(event: unknown): string {
|
|
const statements = (event as any)?.client_payload?.statements;
|
|
if (!Array.isArray(statements) || statements.length === 0) throw new Error('INVALID_QUALIFICATION_DISPATCH');
|
|
const commits = new Set(statements.map((statement: any) => statement?.sourceCommit));
|
|
const [commit] = [...commits];
|
|
if (commits.size !== 1 || typeof commit !== 'string' || !/^[a-f0-9]{40}$/.test(commit)) throw new Error('STAGING_SOURCE_COMMIT_MISMATCH');
|
|
return commit;
|
|
}
|
|
|
|
/** The resolved run must be the protected-main staging dispatch at the statements' commit, with every native gate passed. */
|
|
export function verifyStagingRun(evidence: StagingRunEvidence): void {
|
|
const { repository, runId, sourceCommit, runtimeIds } = evidence;
|
|
const run = evidence.run as any;
|
|
if (!run || typeof run !== 'object' || String(run.id) !== runId) throw new Error('STAGING_RUN_ID_MISMATCH');
|
|
if (run.path !== STAGING_WORKFLOW_PATH) throw new Error('STAGING_WORKFLOW_MISMATCH');
|
|
if (run.repository?.full_name !== repository || run.head_repository?.full_name !== repository) throw new Error('STAGING_REPOSITORY_MISMATCH');
|
|
if (run.event !== 'workflow_dispatch' || run.head_branch !== 'main') throw new Error('STAGING_RUN_NOT_PROTECTED_MAIN_DISPATCH');
|
|
if (run.status !== 'completed' || run.conclusion !== 'success') throw new Error('STAGING_RUN_NOT_SUCCESSFUL');
|
|
if (run.head_sha !== sourceCommit) throw new Error('STAGING_SOURCE_COMMIT_MISMATCH');
|
|
|
|
const pages = evidence.jobPages;
|
|
if (!Array.isArray(pages) || pages.length === 0) throw new Error('INVALID_STAGING_JOBS');
|
|
const totals = new Set(pages.map((page: any) => page?.total_count));
|
|
if (pages.some((page: any) => !Array.isArray(page?.jobs)) || totals.size !== 1) throw new Error('INVALID_STAGING_JOBS');
|
|
const jobs = pages.flatMap((page: any) => page.jobs);
|
|
if (jobs.length !== [...totals][0]) throw new Error('INCOMPLETE_STAGING_JOBS');
|
|
|
|
const native = jobs.filter((job: any) => typeof job?.name === 'string' && job.name.startsWith(NATIVE_JOB));
|
|
const expected = runtimeIds.map(id => `${NATIVE_JOB} ${id}`).sort();
|
|
if (runtimeIds.length === 0 || canonical(native.map((job: any) => job.name).sort()) !== canonical(expected)) {
|
|
throw new Error('STAGING_NATIVE_JOBS_MISMATCH');
|
|
}
|
|
if (native.some((job: any) => String(job.run_id) !== runId || job.status !== 'completed' || job.conclusion !== 'success')) {
|
|
throw new Error('STAGING_NATIVE_GATE_NOT_PASSED');
|
|
}
|
|
}
|
|
|
|
function readJson(file: string): unknown {
|
|
const absolute = path.resolve(file);
|
|
const stat = fs.lstatSync(absolute);
|
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > MAX_FILE_BYTES) throw new Error('UNSAFE_STAGING_EVIDENCE_FILE');
|
|
try { return JSON.parse(fs.readFileSync(absolute, 'utf8')); } catch { throw new Error('INVALID_STAGING_EVIDENCE_FILE'); }
|
|
}
|
|
|
|
function option(args: string[], name: string): string {
|
|
const index = args.indexOf(name);
|
|
const value = index >= 0 ? args[index + 1] : undefined;
|
|
if (!value || value.startsWith('--')) throw new Error(`Missing ${name}`);
|
|
return value;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
if (command === 'run-id' && args[0] === '--repository' && args.length >= 3) {
|
|
process.stdout.write(`${stagingRunId(args[1], args.slice(2).map(readJson))}\n`);
|
|
} else if (command === 'verify' && args.length === 10) {
|
|
const event = readJson(option(args, '--event'));
|
|
verifyStagingRun({
|
|
repository: option(args, '--repository'),
|
|
runId: option(args, '--run-id'),
|
|
sourceCommit: dispatchSourceCommit(event),
|
|
runtimeIds: committedImageBuildMatrix().include.map(row => row.runtimeId),
|
|
run: readJson(option(args, '--run')),
|
|
jobPages: readJson(option(args, '--jobs')),
|
|
});
|
|
process.stdout.write('STAGING RUN VERIFIED\n');
|
|
} else {
|
|
throw new Error('Usage: cso-staging-run.ts run-id --repository OWNER/REPO PROVENANCE.json... | verify --repository OWNER/REPO --run-id ID --event EVENT.json --run RUN.json --jobs JOB-PAGES.json');
|
|
}
|
|
} catch (error) {
|
|
process.stderr.write((error instanceof Error ? error.message : 'STAGING_RUN_VERIFICATION_FAILED') + '\n');
|
|
process.exitCode = 1;
|
|
}
|
|
}
|