mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 09:55:29 +02:00
* feat(cso): add verified audits and replayable repair bundles * fix(cso): harden qualification and setup boundaries * fix(cso): assemble security canaries at runtime * fix(cso): bound release proof and maintenance work Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): require complete evaluation reports Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): replay expired snapshots from supplied source Co-Authored-By: OpenAI Codex <noreply@openai.com> * test(cso): synchronize DNS cancellation assertion Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore(ship): exempt repository owner from liveness proof Co-Authored-By: OpenAI Codex <noreply@openai.com> * test(cso): make recheck retention overlap deterministic Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: bump version and changelog (v1.85.0.0) Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): pass native release gates Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: move release to v1.86.0.0 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): resolve rechecks by finding Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: move release to v1.87.0.0 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): pass macOS and Windows release gates Normalize BSD wc output, compare Windows paths by filesystem identity, preserve portable snapshot race coverage, and narrow POSIX-only Windows fixtures. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): harden native verification gates * fix(cso): refine Windows native diagnostics * test(cso): isolate Windows Git startup failure * test(cso): stabilize Windows native diagnostics * fix(cso): support hardened Git on Windows * fix(cso): close final verification gaps * test(cso): bound cold Docker fixture setup * fix(cso): restore cross-platform free-suite gates --------- Co-authored-by: OpenAI Codex <noreply@openai.com>
174 lines
12 KiB
TypeScript
174 lines
12 KiB
TypeScript
#!/usr/bin/env bun
|
|
/** Assemble and validate a complete source-controlled scanner catalog proposal. */
|
|
import * as fs from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
import { canonical, sha256 } from '../lib/cso/contracts';
|
|
import { QualifiedScanner, ScannerCatalog, scannerVersionHash, validateQualifiedScanner, validateScannerCatalog } from '../lib/cso/scanner-catalog';
|
|
import { SCANNER_IDS, scannerPlans } from '../lib/cso/scanners';
|
|
|
|
const PLATFORMS = ['linux/amd64', 'linux/arm64'] as const;
|
|
const REVISION = /^[a-z0-9][a-z0-9._-]{0,100}$/;
|
|
const WORKFLOW = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/[0-9]+$/;
|
|
const MAX_ARTIFACT = 1_048_576;
|
|
const MAX_TREE_BYTES = 10 * 1024 * 1024 * 1024;
|
|
const MAX_TREE_ENTRIES = 100_000;
|
|
|
|
function sameFile(left: fs.Stats, right: fs.Stats): boolean {
|
|
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink && left.size === right.size;
|
|
}
|
|
function hashRegularFile(path: string, expected: fs.Stats): string {
|
|
const hash = createHash('sha256'), fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
try {
|
|
const before = fs.fstatSync(fd);
|
|
if (!before.isFile() || before.nlink !== 1 || !sameFile(expected, before)) throw new Error('UNSAFE_SCANNER_ASSET');
|
|
const buffer = Buffer.alloc(1024 * 1024); let count = 0, bytes = 0;
|
|
while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
|
|
bytes += count;
|
|
if (bytes > expected.size || bytes > MAX_TREE_BYTES) throw new Error('SCANNER_ASSET_LIMIT');
|
|
hash.update(buffer.subarray(0, count));
|
|
}
|
|
const after = fs.fstatSync(fd), pathname = fs.lstatSync(path);
|
|
if (bytes !== expected.size || !sameFile(before, after) || !sameFile(after, pathname)) throw new Error('UNSAFE_SCANNER_ASSET');
|
|
return hash.digest('hex');
|
|
} finally { fs.closeSync(fd); }
|
|
}
|
|
|
|
function sameStrings(left: string[], right: string[]): boolean {
|
|
return canonical([...left].sort()) === canonical([...right].sort());
|
|
}
|
|
function exactKeys(value: Record<string, unknown>, allowed: string[], label: string): void {
|
|
if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error(`UNEXPECTED_${label.toUpperCase()}_FIELD`);
|
|
}
|
|
function strictProfile(value: unknown): QualifiedScanner {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('INVALID_SCANNER_FRAGMENT');
|
|
const profile = value as QualifiedScanner & Record<string, unknown>;
|
|
exactKeys(profile, ['id', 'scanner', 'state', 'platform', 'image', 'entrypoint', 'executable', 'version', 'versionOutputSha256', 'helperAbi', 'isolationPolicyHash', 'capabilities', 'assets', 'qualifiedAt', 'qualification'], 'scanner_profile');
|
|
if (!profile.qualification || typeof profile.qualification !== 'object' || Array.isArray(profile.qualification)) throw new Error('INVALID_SCANNER_QUALIFICATION');
|
|
exactKeys(profile.qualification as unknown as Record<string, unknown>, ['sourceCommit', 'workflow', 'sbomDigest', 'provenanceDigest', 'verifiedProvenance', 'containmentPassed', 'adapterContractPassed', 'offlineAssetsPassed'], 'scanner_qualification');
|
|
if (profile.assets !== undefined) {
|
|
if (!profile.assets || typeof profile.assets !== 'object' || Array.isArray(profile.assets)) throw new Error('INVALID_SCANNER_ASSETS');
|
|
exactKeys(profile.assets as unknown as Record<string, unknown>, ['semgrepRules', 'advisoryDatabase'], 'scanner_assets');
|
|
for (const [name, asset] of Object.entries(profile.assets)) {
|
|
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('INVALID_SCANNER_ASSETS');
|
|
exactKeys(asset as unknown as Record<string, unknown>, name === 'semgrepRules' ? ['path', 'sha256'] : ['path', 'contentSha256', 'updatedAt', 'ecosystems'], 'scanner_asset');
|
|
}
|
|
}
|
|
validateQualifiedScanner(profile);
|
|
const required = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [profile.scanner] })[0].requiredFeatures;
|
|
if (!sameStrings(profile.capabilities, required)) throw new Error(`CAPABILITY_CONTRACT_MISMATCH: ${profile.scanner}`);
|
|
return profile;
|
|
}
|
|
|
|
export function scannerCatalogProposal(current: ScannerCatalog, fragments: unknown[], revision: string, expected: { sourceCommit: string; workflow: string; imagePrefix: string }): ScannerCatalog {
|
|
validateScannerCatalog(current);
|
|
if (!REVISION.test(revision) || revision === current.revision) throw new Error('INVALID_SCANNER_CATALOG_REVISION');
|
|
if (!expected || !/^[a-f0-9]{40}$/.test(expected.sourceCommit)) throw new Error('INVALID_EXPECTED_SOURCE_COMMIT');
|
|
if (!WORKFLOW.test(expected.workflow) || !expected.workflow.startsWith('https://github.com/garrytan/gstack/actions/runs/')) throw new Error('INVALID_EXPECTED_WORKFLOW');
|
|
if (expected.imagePrefix !== 'ghcr.io/garrytan/gstack/cso-scanners/') throw new Error('INVALID_EXPECTED_IMAGE_PREFIX');
|
|
const scanners = fragments.map(strictProfile), identities = new Set<string>();
|
|
if (scanners.length !== SCANNER_IDS.length * PLATFORMS.length) throw new Error('INCOMPLETE_SCANNER_CATALOG_MATRIX');
|
|
for (const profile of scanners) {
|
|
const identity = `${profile.scanner}:${profile.platform}`;
|
|
if (identities.has(identity)) throw new Error(`DUPLICATE_SCANNER_CATALOG_PROFILE: ${identity}`);
|
|
identities.add(identity);
|
|
if (profile.qualification.sourceCommit !== expected.sourceCommit) throw new Error(`SOURCE_COMMIT_MISMATCH: ${identity}`);
|
|
if (profile.qualification.workflow !== expected.workflow) throw new Error(`WORKFLOW_IDENTITY_MISMATCH: ${identity}`);
|
|
if (!profile.image.startsWith(expected.imagePrefix)) throw new Error(`IMAGE_REPOSITORY_MISMATCH: ${identity}`);
|
|
}
|
|
for (const scanner of SCANNER_IDS) for (const platform of PLATFORMS) if (!identities.has(`${scanner}:${platform}`)) throw new Error(`MISSING_SCANNER_CATALOG_PROFILE: ${scanner}:${platform}`);
|
|
scanners.sort((a, b) => a.scanner.localeCompare(b.scanner) || a.platform.localeCompare(b.platform));
|
|
const proposal: ScannerCatalog = {
|
|
schemaVersion: 1,
|
|
revision,
|
|
previousRevision: current.revision,
|
|
helperAbi: 3,
|
|
promotion: { sourceCommit: expected.sourceCommit, workflow: expected.workflow, evidenceDigest: `sha256:${sha256(canonical(scanners))}` },
|
|
scanners,
|
|
};
|
|
validateScannerCatalog(proposal);
|
|
return proposal;
|
|
}
|
|
|
|
/** Promotion is a compare-and-swap against the catalog revision reviewed by qualification. */
|
|
export function validateScannerCatalogTransition(current: ScannerCatalog, proposed: ScannerCatalog): void {
|
|
validateScannerCatalog(current); validateScannerCatalog(proposed);
|
|
if (proposed.revision === current.revision || proposed.previousRevision !== current.revision)
|
|
throw new Error('SCANNER_CATALOG_BASE_REVISION_MISMATCH');
|
|
}
|
|
|
|
function readJson(path: string): unknown {
|
|
const stat = fs.lstatSync(path);
|
|
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > MAX_ARTIFACT) throw new Error(`UNSAFE_CATALOG_ARTIFACT: ${basename(path)}`);
|
|
return JSON.parse(fs.readFileSync(path, 'utf8'));
|
|
}
|
|
function fragments(directory: string): unknown[] {
|
|
const root = fs.realpathSync(directory), values: unknown[] = [];
|
|
for (const name of fs.readdirSync(root).sort()) {
|
|
if (!/^[a-z0-9._-]+\.json$/.test(name)) throw new Error(`UNSAFE_FRAGMENT_NAME: ${name}`);
|
|
values.push(readJson(join(root, name)));
|
|
}
|
|
return values;
|
|
}
|
|
|
|
/** A file hashes as its bytes; a directory hashes a canonical regular-file inventory. */
|
|
export function scannerAssetHash(path: string): string {
|
|
const supplied = fs.lstatSync(path);
|
|
if (supplied.isSymbolicLink()) throw new Error('UNSAFE_SCANNER_ASSET');
|
|
const root = fs.realpathSync(path), initial = fs.lstatSync(root);
|
|
if (initial.isFile()) {
|
|
if (initial.nlink !== 1 || initial.size > MAX_TREE_BYTES) throw new Error('UNSAFE_SCANNER_ASSET');
|
|
return hashRegularFile(root, initial);
|
|
}
|
|
if (!initial.isDirectory()) throw new Error('UNSAFE_SCANNER_ASSET');
|
|
const entries: Array<[string, number, string]> = [], pending = [root]; let bytes = 0, objects = 0;
|
|
while (pending.length) {
|
|
const directory = pending.pop()!;
|
|
for (const item of fs.readdirSync(directory).sort()) {
|
|
const full = join(directory, item), stat = fs.lstatSync(full), rel = relative(root, full).split(sep).join('/');
|
|
if (!rel || rel.startsWith('../') || stat.isSymbolicLink() || ++objects > MAX_TREE_ENTRIES) throw new Error('UNSAFE_SCANNER_ASSET');
|
|
if (stat.isDirectory()) { pending.push(full); continue; }
|
|
if (!stat.isFile() || stat.nlink !== 1) throw new Error('UNSAFE_SCANNER_ASSET');
|
|
bytes += stat.size;
|
|
if (bytes > MAX_TREE_BYTES) throw new Error('SCANNER_ASSET_LIMIT');
|
|
entries.push([rel, stat.size, hashRegularFile(full, stat)]);
|
|
}
|
|
}
|
|
if (!entries.length) throw new Error('EMPTY_SCANNER_ASSET');
|
|
entries.sort(([left], [right]) => left.localeCompare(right));
|
|
return sha256(canonical(entries));
|
|
}
|
|
|
|
function option(args: string[], name: string): string | undefined {
|
|
const at = args.indexOf(name); if (at < 0) return undefined;
|
|
const value = args[at + 1]; if (!value || value.startsWith('--')) throw new Error(`MISSING_${name.slice(2).toUpperCase().replaceAll('-', '_')}`);
|
|
args.splice(at, 2); return value;
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
try {
|
|
const args = process.argv.slice(2), command = args.shift();
|
|
if (command === 'validate') {
|
|
const path = args.shift(); if (!path || args.length) throw new Error('Usage: validate CATALOG.json');
|
|
const value = readJson(resolve(path)); validateScannerCatalog(value); process.stdout.write('VALID\n');
|
|
} else if (command === 'validate-transition') {
|
|
const current = args.shift(), proposed = args.shift(); if (!current || !proposed || args.length) throw new Error('Usage: validate-transition CURRENT.json PROPOSED.json');
|
|
validateScannerCatalogTransition(readJson(resolve(current)) as ScannerCatalog, readJson(resolve(proposed)) as ScannerCatalog); process.stdout.write('VALID TRANSITION\n');
|
|
} else if (command === 'assemble') {
|
|
const directory = args.shift(), currentPath = args.shift(), output = args.shift(), revision = option(args, '--revision'), sourceCommit = option(args, '--source-commit'), workflow = option(args, '--workflow'), imagePrefix = option(args, '--image-prefix');
|
|
if (!directory || !currentPath || !output || !revision || !sourceCommit || !workflow || !imagePrefix || args.length) throw new Error('Usage: assemble FRAGMENTS CURRENT OUTPUT --revision ID --source-commit SHA --workflow URL --image-prefix PREFIX');
|
|
const proposal = scannerCatalogProposal(readJson(resolve(currentPath)) as ScannerCatalog, fragments(resolve(directory)), revision, { sourceCommit, workflow, imagePrefix });
|
|
fs.writeFileSync(resolve(output), JSON.stringify(proposal, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
process.stdout.write(`${proposal.scanners.length} QUALIFIED PROFILES\n`);
|
|
} else if (command === 'hash-asset') {
|
|
const path = args.shift(); if (!path || args.length) throw new Error('Usage: hash-asset PATH'); process.stdout.write(scannerAssetHash(resolve(path)) + '\n');
|
|
} else if (command === 'version-hash') {
|
|
const stdout = args.shift(), stderr = args.shift(); if (!stdout || !stderr || args.length) throw new Error('Usage: version-hash STDOUT STDERR');
|
|
const read = (path: string) => { const stat = fs.lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 8192) throw new Error('UNSAFE_VERSION_OUTPUT'); return fs.readFileSync(path, 'utf8'); };
|
|
process.stdout.write(scannerVersionHash(read(resolve(stdout)), read(resolve(stderr))) + '\n');
|
|
} else throw new Error('Usage: cso-scanner-catalog <validate|validate-transition|assemble|hash-asset|version-hash> ...');
|
|
} catch (error) {
|
|
process.stderr.write((error instanceof Error ? error.message : 'SCANNER_CATALOG_ERROR') + '\n'); process.exitCode = 1;
|
|
}
|
|
}
|