Skill Tracer
Skills › gstack › gstack › cso-scanner-matrix.ts

cso-scanner-matrix.ts

134 lines · scripts/cso-scanner-matrix.ts at 3f79d81

1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/** Validate reviewed scanner inputs before any image is built or published. */
3import { readFileSync } from 'node:fs';
4import { resolve } from 'node:path';
5import { SCANNER_IDS, ScannerId, scannerPlans } from '../lib/cso/scanners';
6
7const PLATFORMS = ['linux/amd64', 'linux/arm64'] as const;
8const IMAGE = /^(?:[a-z0-9.-]+(?::[0-9]+)?\/)?[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/;
9const DIGEST = /^sha256:[a-f0-9]{64}$/;
10const HASH = /^[a-f0-9]{64}$/;
11const VERSION = /^[0-9][A-Za-z0-9.+_-]{0,100}$/;
12const EXECUTABLE = /^\/(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]+$/;
13const REPOSITORY = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
14const ID = /^[A-Za-z0-9][A-Za-z0-9._+/-]{0,100}$/;
15const SIGNER_WORKFLOW = /^(?:github\.com\/)?[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[A-Za-z0-9_.\/-]+\.ya?ml$/;
17type Platform = typeof PLATFORMS[number];
18type AssetDeclaration = {
19 semgrepRules?: { path: string; sha256: string };
20 advisoryDatabase?: { path: string; contentSha256: string; updatedAt: string; ecosystems: string[] };
21};
22export type ReviewedAttestedImage = {
23 image: string;
24 repository: string;
25 sourceCommit: string;
26 release: string;
27 signerWorkflow: string;
28 signerDigest: string;
29 provenanceStatementDigest: string;
30 sbomStatementDigest: string;
31};
32export interface ScannerBuildRow {
33 scanner: ScannerId;
34 platform: Platform;
35 arch: 'amd64' | 'arm64';
36 runner: 'ubuntu-24.04' | 'ubuntu-24.04-arm';
37 baseImage: string;
38 scannerExecutable: string;
39 version: string;
40 capabilities: string[];
41 assets: AssetDeclaration | null;
42 applicationExecutable: string;
43 sbomGenerator: ReviewedAttestedImage;
44 baseAttestation: ReviewedAttestedImage;
47function object(value: unknown, code: string): Record<string, unknown> {
48 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(code);
49 return value as Record<string, unknown>;
51function exact(value: Record<string, unknown>, allowed: string[], code: string): void {
52 if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error(code);
54function safePath(value: unknown, prefix: string, code: string): string {
55 if (typeof value !== 'string' || !EXECUTABLE.test(value) || !value.startsWith(prefix) || value.split('/').includes('..')) throw new Error(code);
56 return value;
58function digest(value: unknown, code: string): string {
59 if (typeof value !== 'string' || !DIGEST.test(value)) throw new Error(code);
60 return value;
62function attestedImage(value:unknown,code:string):ReviewedAttestedImage{
63 const raw=object(value,code);exact(raw,['image','repository','sourceCommit','release','signerWorkflow','signerDigest','provenanceStatementDigest','sbomStatementDigest'],code);
64 if(typeof raw.image!=='string'||!IMAGE.test(raw.image)||typeof raw.repository!=='string'||!REPOSITORY.test(raw.repository)||
65 typeof raw.sourceCommit!=='string'||!/^[a-f0-9]{40}$/.test(raw.sourceCommit)||typeof raw.release!=='string'||!ID.test(raw.release)||
66 typeof raw.signerWorkflow!=='string'||!SIGNER_WORKFLOW.test(raw.signerWorkflow)||typeof raw.signerDigest!=='string'||!/^[a-f0-9]{40}$/.test(raw.signerDigest))throw new Error(code);
67 return{image:raw.image,repository:raw.repository,sourceCommit:raw.sourceCommit,release:raw.release,signerWorkflow:raw.signerWorkflow,signerDigest:raw.signerDigest,
68 provenanceStatementDigest:digest(raw.provenanceStatementDigest,code),sbomStatementDigest:digest(raw.sbomStatementDigest,code)};
70function assets(value: unknown, scanner: ScannerId): AssetDeclaration | null {
71 if (value === undefined || value === null) {
72 if (['semgrep', 'osv', 'trivy'].includes(scanner)) throw new Error(`MISSING_OFFLINE_ASSET: ${scanner}`);
73 return null;
74 }
75 const raw = object(value, 'INVALID_SCANNER_ASSET'); exact(raw, ['semgrepRules', 'advisoryDatabase'], 'INVALID_SCANNER_ASSET');
76 if (scanner === 'semgrep') {
77 const rules = object(raw.semgrepRules, 'MISSING_SEMGREP_RULES'); exact(rules, ['path', 'sha256'], 'INVALID_SEMGREP_RULES');
78 if (raw.advisoryDatabase !== undefined) throw new Error('INVALID_SEMGREP_RULES');
79 const path = safePath(rules.path, '/policy/catalog/', 'INVALID_SEMGREP_RULES');
80 if (typeof rules.sha256 !== 'string' || !HASH.test(rules.sha256)) throw new Error('INVALID_SEMGREP_RULES');
81 return { semgrepRules: { path, sha256: rules.sha256 } };
82 }
83 if (scanner === 'osv' || scanner === 'trivy') {
84 const db = object(raw.advisoryDatabase, 'MISSING_ADVISORY_DATABASE'); exact(db, ['path', 'contentSha256', 'updatedAt', 'ecosystems'], 'INVALID_ADVISORY_DATABASE');
85 if (raw.semgrepRules !== undefined) throw new Error('INVALID_ADVISORY_DATABASE');
86 const path = safePath(db.path, '/opt/cso/scanner-data/', 'INVALID_ADVISORY_DATABASE');
87 if (typeof db.contentSha256 !== 'string' || !HASH.test(db.contentSha256) || typeof db.updatedAt !== 'string' || !Number.isFinite(Date.parse(db.updatedAt)) || !Array.isArray(db.ecosystems) || !db.ecosystems.length || new Set(db.ecosystems).size !== db.ecosystems.length || db.ecosystems.some(item => typeof item !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(item))) throw new Error('INVALID_ADVISORY_DATABASE');
88 return { advisoryDatabase: { path, contentSha256: db.contentSha256, updatedAt: new Date(db.updatedAt).toISOString(), ecosystems: (db.ecosystems as string[]).slice().sort() } };
89 }
90 throw new Error(`UNEXPECTED_OFFLINE_ASSET: ${scanner}`);
93/** Every scanner must have both native platforms; partial matrices cannot publish. */
94export function scannerBuildMatrix(input: unknown): { include: ScannerBuildRow[] } {
95 const data = object(input, 'INVALID_SCANNER_BUILD_INPUTS');
96 exact(data, ['schemaVersion', 'helperAbi', 'state', 'sbomGenerator', 'profiles', 'instructions'], 'INVALID_SCANNER_BUILD_INPUTS');
97 if (data.schemaVersion !== 1 || data.helperAbi !== 3) throw new Error('INCOMPATIBLE_SCANNER_BUILD_INPUTS');
98 if (data.state !== 'reviewed') throw new Error('MISSING_REVIEWED_SCANNER_INPUTS: review immutable images, assets, versions, SBOMs, and provenance before staging.');
99 const sbomGenerator=attestedImage(data.sbomGenerator,'UNVERIFIED_SBOM_GENERATOR');
100 if (!Array.isArray(data.profiles) || data.profiles.length !== SCANNER_IDS.length) throw new Error('INCOMPLETE_SCANNER_MATRIX');
101 const rows: ScannerBuildRow[] = [], seen = new Set<ScannerId>();
102 for (const value of data.profiles) {
103 const profile = object(value, 'INVALID_SCANNER_PROFILE');
104 exact(profile, ['scanner', 'version', 'baseImages', 'executable', 'assets', 'applicationExecutable'], 'INVALID_SCANNER_PROFILE');
105 const scanner = profile.scanner as ScannerId;
106 if (!SCANNER_IDS.includes(scanner) || seen.has(scanner)) throw new Error('INVALID_OR_DUPLICATE_SCANNER');
107 seen.add(scanner);
108 if (typeof profile.version !== 'string' || !VERSION.test(profile.version)) throw new Error(`UNPINNED_SCANNER_VERSION: ${scanner}`);
109 if (typeof profile.executable !== 'string' || !EXECUTABLE.test(profile.executable)) throw new Error(`INVALID_SCANNER_EXECUTABLE: ${scanner}`);
110 const baseImages = object(profile.baseImages, `MISSING_SCANNER_IMAGES: ${scanner}`);
111 exact(baseImages, [...PLATFORMS], `INVALID_SCANNER_IMAGES: ${scanner}`);
112 const declaredAssets = assets(profile.assets, scanner);
113 const applicationExecutable = scanner === 'schemathesis' ? safePath(profile.applicationExecutable, '/', 'MISSING_SCHEMATHESIS_FIXTURE_RUNTIME') : '';
114 if (scanner !== 'schemathesis' && profile.applicationExecutable !== undefined) throw new Error(`UNEXPECTED_APPLICATION_EXECUTABLE: ${scanner}`);
115 const capabilities = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [scanner] })[0].requiredFeatures.slice().sort();
116 for (const platform of PLATFORMS) {
117 const baseAttestation=attestedImage(baseImages[platform],`INVALID_UPSTREAM_EVIDENCE: ${scanner} ${platform}`),baseImage=baseAttestation.image;
118 const arch = platform === 'linux/amd64' ? 'amd64' : 'arm64';
119 rows.push({ scanner, platform, arch, runner: arch === 'amd64' ? 'ubuntu-24.04' : 'ubuntu-24.04-arm', baseImage, scannerExecutable: profile.executable, version: profile.version, capabilities, assets: declaredAssets, applicationExecutable, sbomGenerator, baseAttestation });
120 }
121 }
122 return { include: rows };
125if (import.meta.main) {
126 try {
127 if (process.argv.length !== 2) throw new Error('No arguments accepted; scanner inputs come from the reviewed repository file.');
128 const file = resolve(import.meta.dir, '../lib/cso/scanner-images/build-inputs.json');
129 process.stdout.write(JSON.stringify(scannerBuildMatrix(JSON.parse(readFileSync(file, 'utf8')))) + '\n');
130 } catch (error) {
131 process.stderr.write((error instanceof Error ? error.message : 'INVALID_SCANNER_BUILD_INPUTS') + '\n');
132 process.exitCode = 1;
133 }