Files
gstack/test/cso-scanner-release.test.ts
T
Garry Tan 10315cf44d v1.91.27.0 fix(cso): runtime staging can finish and a qualified scanner catalog can ship (#3041)
* v1.91.27.0 fix(cso): package-level signed SBOMs and staged probes in the verifier container

* Qualified scanner catalogs persist scan outcomes; catalog-state tests hold for either state; SBOM tests live with their owner
2026-10-05 10:00:47 -07:00

178 lines
18 KiB
TypeScript

import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
import { sha256 } from '../lib/cso/contracts';
import { SCANNER_CATALOG, ScannerCatalog, scannerVersionHash } from '../lib/cso/scanner-catalog';
import { SCANNER_IDS, ScannerId, scannerPlans } from '../lib/cso/scanners';
import { scannerCatalogProposal, scannerAssetHash, validateScannerCatalogTransition } from '../scripts/cso-scanner-catalog';
import { scannerBuildMatrix } from '../scripts/cso-scanner-matrix';
import { verifiedStatementSetDigest } from '../scripts/cso-attestation-evidence';
const ROOT = path.resolve(import.meta.dir, '..'), HASH = 'a'.repeat(64), DIGEST = `sha256:${HASH}`;
const temps: string[] = [];
afterEach(() => { for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
const image = (id: string) => `ghcr.io/garrytan/gstack/cso-scanners/${id}@sha256:${HASH}`;
const attested=(id:string,repository=`https://github.com/example/${id}`)=>({image:image(id),repository,sourceCommit:'b'.repeat(40),release:`v${id}-1.2.3`,signerWorkflow:`example/${id}/.github/workflows/release.yml`,signerDigest:'c'.repeat(40),provenanceStatementDigest:DIGEST,sbomStatementDigest:DIGEST});
function reviewedInputs() {
return { schemaVersion: 1, helperAbi: 3, state: 'reviewed', sbomGenerator: attested('sbom'), profiles: SCANNER_IDS.map(scanner => ({
scanner, version: scanner === 'gitleaks' ? '8.30.1' : '1.2.3', executable: `/usr/local/bin/${scanner === 'osv' ? 'osv-scanner' : scanner}`,
baseImages: { 'linux/amd64': attested(`${scanner}-base-amd64`, `https://github.com/example/${scanner}`), 'linux/arm64': attested(`${scanner}-base-arm64`, `https://github.com/example/${scanner}`) },
...(scanner === 'semgrep' ? { assets: { semgrepRules: { path: '/policy/catalog/semgrep.yml', sha256: HASH } } }
: ['osv', 'trivy'].includes(scanner) ? { assets: { advisoryDatabase: { path: `/opt/cso/scanner-data/${scanner}`, contentSha256: HASH, updatedAt: '2026-09-10T00:00:00.000Z', ecosystems: ['npm', 'PyPI'] } } } : {}),
...(scanner === 'schemathesis' ? { applicationExecutable: '/usr/local/bin/python' } : {}),
})) };
}
function qualified(scanner: ScannerId, platform: 'linux/amd64' | 'linux/arm64') {
const arch = platform.endsWith('arm64') ? 'arm64' : 'amd64', version = scanner === 'gitleaks' ? '8.30.1' : '1.2.3';
return { id: `${scanner}-${version}-${arch}`, scanner, state: 'qualified' as const, platform, image: image(`${scanner}-${arch}`), entrypoint: '/opt/cso/entrypoint' as const, executable: '/opt/cso/bin/scanner', version,
versionOutputSha256: scannerVersionHash(`${scanner} ${version}\n`), helperAbi: 3 as const, isolationPolicyHash: ISOLATION_POLICY_HASH,
capabilities: scannerPlans({ snapshotRoot: '/source', offline: true, selected: [scanner] })[0].requiredFeatures,
...(scanner === 'semgrep' ? { assets: { semgrepRules: { path: '/policy/catalog/semgrep.yml', sha256: HASH } } }
: ['osv', 'trivy'].includes(scanner) ? { assets: { advisoryDatabase: { path: `/opt/cso/scanner-data/${scanner}`, contentSha256: HASH, updatedAt: '2026-09-10T00:00:00.000Z', ecosystems: ['npm'] } } } : {}),
qualifiedAt: '2026-09-10T00:00:00.000Z', qualification: { sourceCommit: 'c'.repeat(40), workflow: 'https://github.com/garrytan/gstack/actions/runs/42', sbomDigest: DIGEST, provenanceDigest: DIGEST, verifiedProvenance: true as const, containmentPassed: true as const, adapterContractPassed: true as const, offlineAssetsPassed: true as const } };
}
describe('CSO scanner release inputs', () => {
test('requires all six scanners on both native architectures with derived adapter capabilities', () => {
const matrix = scannerBuildMatrix(reviewedInputs()); expect(matrix.include).toHaveLength(12);
for (const scanner of SCANNER_IDS) {
const rows = matrix.include.filter(row => row.scanner === scanner);
expect(rows.map(row => row.platform)).toEqual(['linux/amd64', 'linux/arm64']);
expect(rows.map(row => row.runner)).toEqual(['ubuntu-24.04', 'ubuntu-24.04-arm']);
expect(rows[0].capabilities).toEqual([...scannerPlans({ snapshotRoot: '/source', offline: true, selected: [scanner] })[0].requiredFeatures].sort());
}
});
test('pending, partial, mutable, assetless, or unverified input cannot publish', () => {
const pending = reviewedInputs() as any; pending.state = 'pending'; expect(() => scannerBuildMatrix(pending)).toThrow('MISSING_REVIEWED_SCANNER_INPUTS');
const partial = reviewedInputs() as any; partial.profiles.pop(); expect(() => scannerBuildMatrix(partial)).toThrow('INCOMPLETE_SCANNER_MATRIX');
const tagged = reviewedInputs() as any; tagged.profiles[0].baseImages['linux/amd64'].image = 'ghcr.io/example/gitleaks:latest'; expect(() => scannerBuildMatrix(tagged)).toThrow('INVALID_UPSTREAM_EVIDENCE');
const assetless = reviewedInputs() as any; delete assetless.profiles.find((p: any) => p.scanner === 'osv').assets; expect(() => scannerBuildMatrix(assetless)).toThrow('MISSING_OFFLINE_ASSET');
const unverified = reviewedInputs() as any; delete unverified.profiles[0].baseImages['linux/amd64'].signerDigest; expect(() => scannerBuildMatrix(unverified)).toThrow('INVALID_UPSTREAM_EVIDENCE');
const generator = reviewedInputs() as any; generator.sbomGenerator.provenanceStatementDigest = 'unreviewed'; expect(() => scannerBuildMatrix(generator)).toThrow('UNVERIFIED_SBOM_GENERATOR');
});
test('binds verified attestation statements to the reviewed subject and predicate',()=>{
const predicate='https://slsa.dev/provenance/v1',statement={_type:'https://in-toto.io/Statement/v1',subject:[{name:'image',digest:{sha256:HASH}}],predicateType:predicate,predicate:{buildType:'https://example.test/builder'}};
const value=[{attestation:{},verificationResult:{statement}}],digest=verifiedStatementSetDigest(value,predicate,HASH);
expect(digest).toMatch(/^sha256:[a-f0-9]{64}$/);expect(verifiedStatementSetDigest(value,predicate,HASH)).toBe(digest);
expect(()=>verifiedStatementSetDigest(value,'https://spdx.dev/Document/v2.3',HASH)).toThrow('VERIFIED_ATTESTATION_IDENTITY_MISMATCH');
expect(()=>verifiedStatementSetDigest(value,predicate,'d'.repeat(64))).toThrow('VERIFIED_ATTESTATION_IDENTITY_MISMATCH');
});
test('paths and matrix strings cannot inject build commands', () => {
for (const executable of ['/usr/bin/scanner;id', '../../scanner', '/usr/bin/scan ner', '/usr/bin/scanner\nBAD=1']) {
const input = reviewedInputs() as any; input.profiles[0].executable = executable; expect(() => scannerBuildMatrix(input)).toThrow('INVALID_SCANNER_EXECUTABLE');
}
const extra = reviewedInputs() as any; extra.profiles[0].buildArgs = ['EVIL=1']; expect(() => scannerBuildMatrix(extra)).toThrow('INVALID_SCANNER_PROFILE');
});
});
describe('CSO scanner catalog promotion', () => {
const expected = { sourceCommit: 'c'.repeat(40), workflow: 'https://github.com/garrytan/gstack/actions/runs/42', imagePrefix: 'ghcr.io/garrytan/gstack/cso-scanners/' };
const fragments = () => SCANNER_IDS.flatMap(scanner => ['linux/amd64', 'linux/arm64'].map(platform => qualified(scanner, platform as any)));
test('assembles only a complete qualified matrix and records rollback identity', () => {
const proposal = scannerCatalogProposal(SCANNER_CATALOG, fragments(), 'cso-scanners-qualified-42', expected);
expect(proposal.scanners).toHaveLength(12); expect(proposal.previousRevision).toBe(SCANNER_CATALOG.revision);
expect(proposal.promotion).toMatchObject({ sourceCommit: expected.sourceCommit, workflow: expected.workflow });
expect(proposal.promotion!.evidenceDigest).toMatch(/^sha256:[a-f0-9]{64}$/);
expect(proposal.scanners.map(profile => `${profile.scanner}:${profile.platform}`)).toEqual([...proposal.scanners.map(profile => `${profile.scanner}:${profile.platform}`)].sort());
});
test('rejects missing platforms, claimed capabilities, provenance substitution, and foreign image repositories', () => {
const missing = fragments(); missing.pop(); expect(() => scannerCatalogProposal(SCANNER_CATALOG, missing, 'missing-profile', expected)).toThrow('INCOMPLETE_SCANNER_CATALOG_MATRIX');
const capabilities = fragments() as any[]; capabilities[0].capabilities = ['invented']; expect(() => scannerCatalogProposal(SCANNER_CATALOG, capabilities, 'bad-capability', expected)).toThrow('capabilities do not match');
const source = fragments() as any[]; source[0].qualification.sourceCommit = 'd'.repeat(40); expect(() => scannerCatalogProposal(SCANNER_CATALOG, source, 'wrong-source', expected)).toThrow('SOURCE_COMMIT_MISMATCH');
const foreign = fragments() as any[]; foreign[0].image = image('foreign').replace('garrytan/gstack', 'other/repo'); expect(() => scannerCatalogProposal(SCANNER_CATALOG, foreign, 'foreign-image', expected)).toThrow('Scanner profile is not qualified');
});
test('promotion is a compare-and-swap against the currently reviewed revision', () => {
const proposal = scannerCatalogProposal(SCANNER_CATALOG, fragments(), 'cso-scanners-qualified-43', expected);
expect(() => validateScannerCatalogTransition(SCANNER_CATALOG, proposal)).not.toThrow();
expect(() => validateScannerCatalogTransition({ ...SCANNER_CATALOG, revision: 'catalog-advanced-concurrently' }, proposal)).toThrow('SCANNER_CATALOG_BASE_REVISION_MISMATCH');
});
test('hashes files and trees deterministically and rejects linked asset payloads', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-scanner-assets-')); temps.push(root); fs.mkdirSync(path.join(root, 'nested')); fs.writeFileSync(path.join(root, 'nested', 'b'), 'two'); fs.writeFileSync(path.join(root, 'a'), 'one');
const first = scannerAssetHash(root); expect(first).toMatch(/^[a-f0-9]{64}$/); expect(scannerAssetHash(root)).toBe(first); expect(scannerAssetHash(path.join(root, 'a'))).toBe(sha256('one'));
fs.symlinkSync(path.join(root, 'a'), path.join(root, 'linked')); expect(() => scannerAssetHash(root)).toThrow('UNSAFE_SCANNER_ASSET');
const top = path.join(path.dirname(root), `${path.basename(root)}-link`); temps.push(top); fs.symlinkSync(root, top); expect(() => scannerAssetHash(top)).toThrow('UNSAFE_SCANNER_ASSET');
});
});
describe('CSO scanner qualification workflow', () => {
test('keeps branch validation read-only while qualification and promotion are protected-main and review gated', () => {
const raw = fs.readFileSync(path.join(ROOT, '.github/workflows/cso-scanner-images.yml'), 'utf8'), workflow = Bun.YAML.parse(raw) as any;
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']); expect(workflow.permissions).toEqual({ contents: 'read' }); expect(workflow.jobs['reviewed-inputs'].if).toBeUndefined();
const stage = workflow.jobs['stage-and-qualify'];
expect(stage.if).toContain("github.ref == 'refs/heads/main'"); expect(stage.if).toContain("github.event_name == 'workflow_dispatch'"); expect(stage.environment).toBe('cso-scanner-release');
expect(stage.permissions).toMatchObject({ contents: 'read', packages: 'write', 'id-token': 'write', attestations: 'write', 'artifact-metadata': 'write' });
const privileged = Object.entries(workflow.jobs).filter(([, job]: any) => ['packages', 'id-token', 'attestations', 'artifact-metadata'].some(permission => job.permissions?.[permission] === 'write'));
expect(privileged.map(([name]) => name)).toEqual(['stage-and-qualify']);
for (const permission of ['packages', 'id-token', 'attestations', 'artifact-metadata']) expect(workflow.jobs['reviewed-inputs'].permissions?.[permission]).toBeUndefined();
expect(workflow.jobs['promote-catalog'].if).toContain("github.ref == 'refs/heads/main'"); expect(workflow.jobs['promote-catalog'].if).toContain('inputs.promote_catalog == true'); expect(workflow.jobs['promote-catalog'].environment).toBe('cso-scanner-release');
expect(workflow.jobs['promote-catalog'].permissions.packages).toBe('read');
expect(raw).toContain('test/cso-scanners.test.ts test/cso-scanner-executor.test.ts test/cso-scanner-release.test.ts');
expect(raw).toContain('test/cso-scanner-docker-integration.test.ts'); expect(raw).toContain('GSTACK_CSO_SCANNER_VERSION_HASH'); expect(raw).toContain('hash-asset scanner-asset');
expect(raw).toContain('Require a public package and anonymously load the verified immutable image');
expect(raw).toContain('scripts/cso-public-ghcr.ts verify');
expect(raw).toContain('--repository "$GITHUB_REPOSITORY" --output public-image.json');
expect(raw).toContain('sha256sum "$output" staged-profile.json version.sha256 declared-assets.json public-image.json');
const anonymousStage = workflow.jobs['stage-and-qualify'].steps.find((step: any) => step.run?.includes('scripts/cso-public-ghcr.ts verify'));
expect(anonymousStage.env.GH_TOKEN).toBe('${{ github.token }}');
for(const value of ['--signer-workflow "$signer_workflow"','--signer-digest "$signer_digest"','--source-digest "$source_commit"','cso-attestation-evidence.ts digest','provenanceStatementDigest','sbomStatementDigest'])expect(raw).toContain(value);
expect(raw).not.toContain('cso-scanner-staging');
const docker = fs.readFileSync(path.join(ROOT, 'lib/cso/docker.ts'), 'utf8').replace(/\s+/g, '');
for (const flag of ["'--pull=never'", "'--read-only'", "'--cap-drop','ALL'", "'no-new-privileges:true'", "'seccomp=builtin'", "'--log-driver=none'", "'--network'"]) expect(docker).toContain(flag);
expect(docker).toContain("['rm','--force','--volumes',id]"); expect(docker).toContain('Pinnedruntimeimagedeclareswritablevolumes');
expect(raw).toContain('cso-scanner-catalog.ts assemble'); expect(raw).toContain('cso-scanner-catalog.ts validate-transition lib/cso/scanner-images/catalog.json promotion/catalog-proposal.json'); expect(raw).toContain('gh pr create --base main');
expect(raw).toContain('branch="cso-scanner-catalog-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"'); expect(raw).not.toContain('branch="cso-scanner-catalog-$GITHUB_RUN_ID"');
const publicPromotion = raw.indexOf('Recheck public visibility and anonymous pulls before promotion');
const sourcePromotion = raw.indexOf('Revalidate and open the reviewable source catalog PR');
expect(publicPromotion).toBeGreaterThanOrEqual(0); expect(sourcePromotion).toBeGreaterThan(publicPromotion);
expect(raw.slice(publicPromotion, sourcePromotion)).toContain('--remove-after');
for (const job of Object.values(workflow.jobs) as any[]) for (const step of job.steps) if (step.uses) expect(step.uses).toMatch(/@[a-f0-9]{40}$/);
});
test('commits either the empty placeholder or a complete reviewed matrix, and normalizes scanner images to the constrained helper contract', () => {
const inputs = JSON.parse(fs.readFileSync(path.join(ROOT, 'lib/cso/scanner-images/build-inputs.json'), 'utf8'));
if (inputs.state === 'pending') expect(inputs).toMatchObject({ profiles: [], sbomGenerator: null });
else expect(scannerBuildMatrix(inputs).include).toHaveLength(12);
const dockerfile = fs.readFileSync(path.join(ROOT, 'lib/cso/scanner-images/scanner.Dockerfile'), 'utf8');
for (const value of ['/opt/cso/entrypoint', '/opt/cso/bin/scanner', 'USER 10001:10001', 'test -x /bin/sleep', 'test -x /bin/cat']) expect(dockerfile).toContain(value);
expect(fs.readFileSync(path.join(ROOT, 'lib/cso/scanner-executor.ts'), 'utf8')).toContain("capture(['/bin/cat', plan.outputPath])");
const postgres = fs.readFileSync(path.join(ROOT, 'lib/cso/images/postgresql.Dockerfile'), 'utf8');
for (const value of ['FROM ${BASE_IMAGE} AS upstream', 'FROM scratch', 'COPY --from=upstream / /']) expect(postgres).toContain(value);
});
});
const SBOM_ROOT = path.resolve(import.meta.dir, '..');
const jq = Bun.which('jq');
const run = (filter: string, input: unknown, check = false) => spawnSync(jq!, [check ? '-e' : '-c', '-f', path.join(SBOM_ROOT, 'scripts', filter)], { input: JSON.stringify(input), encoding: 'utf8', timeout: 10_000 });
const document = {
spdxVersion: 'SPDX-2.3', SPDXID: 'SPDXRef-DOCUMENT', name: 'image',
packages: [{ SPDXID: 'SPDXRef-Package-rails', name: 'rails', versionInfo: '8.0.2', hasFiles: ['SPDXRef-File-a'] }, { SPDXID: 'SPDXRef-Package-rack', name: 'rack', versionInfo: '3.1.0' }],
files: [{ SPDXID: 'SPDXRef-File-a', fileName: '/usr/local/bundle/gems/rails/a.rb' }, { SPDXID: 'SPDXRef-File-b', fileName: '/b' }],
relationships: [
{ spdxElementId: 'SPDXRef-DOCUMENT', relationshipType: 'DESCRIBES', relatedSpdxElement: 'SPDXRef-Package-rails' },
{ spdxElementId: 'SPDXRef-Package-rails', relationshipType: 'DEPENDS_ON', relatedSpdxElement: 'SPDXRef-Package-rack' },
{ spdxElementId: 'SPDXRef-Package-rails', relationshipType: 'CONTAINS', relatedSpdxElement: 'SPDXRef-File-a' },
{ spdxElementId: 'SPDXRef-File-b', relationshipType: 'OTHER', relatedSpdxElement: 'SPDXRef-Package-rack' },
],
};
describe.skipIf(!jq)('CSO signed SBOMs carry the package inventory only', () => {
test('file entries and every relationship that names a file are removed; packages and package edges stay', () => {
const result = run('cso-sbom-packages.jq', document);
expect(result.status, result.stderr).toBe(0);
const compact = JSON.parse(result.stdout);
expect(compact.files).toBeUndefined();
expect(compact.packages.map((p: any) => [p.name, p.versionInfo, 'hasFiles' in p])).toEqual([['rails', '8.0.2', false], ['rack', '3.1.0', false]]);
expect(compact.relationships.map((r: any) => r.relationshipType)).toEqual(['DESCRIBES', 'DEPENDS_ON']);
expect(run('cso-sbom-packages-check.jq', compact, true).status).toBe(0);
});
test('the check rejects file-bearing, empty, wrong-version, or dangling documents', () => {
expect(run('cso-sbom-packages-check.jq', document, true).status).not.toBe(0);
const compact = JSON.parse(run('cso-sbom-packages.jq', document).stdout);
expect(run('cso-sbom-packages-check.jq', { ...compact, packages: [] }, true).status).not.toBe(0);
expect(run('cso-sbom-packages-check.jq', { ...compact, spdxVersion: 'SPDX-2.2' }, true).status).not.toBe(0);
expect(run('cso-sbom-packages-check.jq', { ...compact, relationships: [...compact.relationships, { spdxElementId: 'SPDXRef-Package-rails', relationshipType: 'CONTAINS', relatedSpdxElement: 'SPDXRef-File-gone' }] }, true).status).not.toBe(0);
});
});