cso-public-ghcr.ts
262 lines · scripts/cso-public-ghcr.ts at 3f79d81
1#!/usr/bin/env -S bun --no-env-file --config=/dev/null
2/** Release-only proof that a CSO image is public and anonymously pullable. */
3import * as fs from 'node:fs';
4import * as os from 'node:os';
5import * as path from 'node:path';
7const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
8const DIGEST = /^sha256:[a-f0-9]{64}$/;
9const PLATFORM = /^linux\/(amd64|arm64)$/;
10const RUNTIME = /^(node|bun|python|rails|postgresql)-(amd64|arm64)$/;
11const SCANNER = /^(gitleaks|osv|semgrep|zizmor|trivy|schemathesis)-(amd64|arm64)$/;
12const MAX_HTTP_BYTES = 64 * 1024;
13const MAX_DOCKER_OUTPUT = 64 * 1024;
14const DOCKER_TIMEOUT_MS = 10 * 60 * 1000;
16export interface PublicGhcrTarget {
17 image: string;
18 owner: string;
19 repository: string;
20 packageName: string;
21 digest: string;
22 platform: 'linux/amd64' | 'linux/arm64';
23}
25export interface PublicImageProof {
26 schemaVersion: 1;
27 image: string;
28 platform: 'linux/amd64' | 'linux/arm64';
29 packageName: string;
30 packageVisibility: 'public';
31 packageApiUrl: string;
32 anonymousPull: 'passed';
33 dockerConfig: 'isolated-empty-auths';
34 verifiedAt: string;
35}
37interface CommandResult { exitCode: number; stdout: string; stderr: string }
38export interface PublicGhcrDependencies {
39 fetch: typeof globalThis.fetch;
40 githubToken: string;
41 dockerPath: string;
42 runDocker: (args: string[], env: Record<string, string>) => Promise<CommandResult>;
43 now: () => string;
44 sleep: (ms: number) => Promise<void>;
45}
47function fail(message: string): never { throw new Error(message); }
49export function parsePublicGhcrTarget(image: string, platform: string, githubRepository: string): PublicGhcrTarget {
50 if (!REPOSITORY.test(githubRepository)) fail('INVALID_GITHUB_REPOSITORY');
51 const platformMatch = PLATFORM.exec(platform);
52 if (!platformMatch) fail('INVALID_PUBLIC_IMAGE_PLATFORM');
53 const normalizedRepository = githubRepository.toLowerCase();
54 const prefix = `ghcr.io/${normalizedRepository}/`;
55 if (!image.startsWith(prefix)) fail('PUBLIC_IMAGE_REPOSITORY_MISMATCH');
56 const at = image.lastIndexOf('@');
57 if (at < prefix.length || image.indexOf('@') !== at) fail('PUBLIC_IMAGE_MUST_BE_DIGEST_PINNED');
58 const suffix = image.slice(prefix.length, at), digest = image.slice(at + 1);
59 if (!DIGEST.test(digest)) fail('PUBLIC_IMAGE_MUST_BE_DIGEST_PINNED');
60 const [namespace, name, extra] = suffix.split('/');
61 if (extra || !name || !['cso-staging', 'cso-scanners'].includes(namespace)) fail('INVALID_PUBLIC_IMAGE_PACKAGE');
62 const nameMatch = namespace === 'cso-staging' ? RUNTIME.exec(name) : SCANNER.exec(name);
63 if (!nameMatch || nameMatch[2] !== platformMatch[1]) fail('PUBLIC_IMAGE_PLATFORM_MISMATCH');
64 const [owner, repository] = normalizedRepository.split('/');
65 return {
66 image,
67 owner,
68 repository,
69 packageName: `${repository}/${namespace}/${name}`,
70 digest,
71 platform: platform as PublicGhcrTarget['platform'],
72 };
73}
75async function responseJson(response: Response, label: string): Promise<Record<string, unknown>> {
76 const body = await response.text();
77 if (body.length > MAX_HTTP_BYTES) fail(`${label}_RESPONSE_TOO_LARGE`);
78 let parsed: unknown;
79 try { parsed = JSON.parse(body); } catch { fail(`${label}_INVALID_RESPONSE`); }
80 if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) fail(`${label}_INVALID_RESPONSE`);
81 return parsed as Record<string, unknown>;
82}
84function publicHeaders(githubToken: string): Record<string, string> {
85 return {
86 Accept: 'application/vnd.github+json',
87 Authorization: `Bearer ${githubToken}`,
88 'X-GitHub-Api-Version': '2022-11-28',
89 'User-Agent': 'gstack-cso-public-image-verifier',
90 };
91}
93export function assertPublicPackageMetadata(value: Record<string, unknown>, target: PublicGhcrTarget): void {
94 const owner = value.owner;
95 if (value.name !== target.packageName || value.package_type !== 'container' || value.visibility !== 'public' ||
96 !owner || typeof owner !== 'object' || Array.isArray(owner) ||
97 typeof (owner as Record<string, unknown>).login !== 'string' ||
98 ((owner as Record<string, unknown>).login as string).toLowerCase() !== target.owner) {
99 fail('GHCR_PACKAGE_IS_NOT_PUBLIC');
100 }
101}
103async function publicPackageApi(target: PublicGhcrTarget, deps: PublicGhcrDependencies): Promise<string> {
104 const ownerUrl = `https://api.github.com/users/${encodeURIComponent(target.owner)}`;
105 const ownerResponse = await deps.fetch(ownerUrl, { headers: publicHeaders(deps.githubToken), redirect: 'error', signal: AbortSignal.timeout(15_000) });
106 if (ownerResponse.status !== 200) fail(`PUBLIC_OWNER_LOOKUP_FAILED: HTTP ${ownerResponse.status}`);
107 const owner = await responseJson(ownerResponse, 'PUBLIC_OWNER_LOOKUP');
108 if (typeof owner.login !== 'string' || owner.login.toLowerCase() !== target.owner || !['User', 'Organization'].includes(String(owner.type))) {
109 fail('PUBLIC_OWNER_LOOKUP_IDENTITY_MISMATCH');
110 }
111 const collection = owner.type === 'Organization' ? 'orgs' : 'users';
112 const packageUrl = `https://api.github.com/${collection}/${encodeURIComponent(target.owner)}/packages/container/${encodeURIComponent(target.packageName)}`;
113 let lastStatus = 0;
114 for (let attempt = 1; attempt <= 6; attempt++) {
115 const response = await deps.fetch(packageUrl, { headers: publicHeaders(deps.githubToken), redirect: 'error', signal: AbortSignal.timeout(15_000) });
116 lastStatus = response.status;
117 if (response.status === 200) {
118 assertPublicPackageMetadata(await responseJson(response, 'PUBLIC_PACKAGE_LOOKUP'), target);
119 return packageUrl;
120 }
121 await response.body?.cancel();
122 if (attempt < 6 && [404, 429, 500, 502, 503, 504].includes(response.status)) await deps.sleep(attempt * 2_000);
123 else break;
124 }
125 fail(`GHCR_PACKAGE_IS_NOT_PUBLIC: authenticated GitHub Packages metadata lookup returned HTTP ${lastStatus}`);
126}
128function cleanDockerEnvironment(configRoot: string, dockerHost: string): Record<string, string> {
129 return {
130 HOME: configRoot,
131 DOCKER_CONFIG: configRoot,
132 DOCKER_HOST: dockerHost,
133 LANG: 'C.UTF-8',
134 LC_ALL: 'C.UTF-8',
135 PATH: '/usr/local/bin:/usr/bin:/bin',
136 TMPDIR: configRoot,
137 };
138}
140async function defaultRunDocker(dockerPath: string, args: string[], env: Record<string, string>): Promise<CommandResult> {
141 const process = Bun.spawn([dockerPath, ...args], { env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
142 const timeout = setTimeout(() => process.kill(), DOCKER_TIMEOUT_MS);
143 const boundedText = async (stream: ReadableStream<Uint8Array>): Promise<string> => {
144 const reader = stream.getReader(), chunks: Uint8Array[] = [];
145 let bytes = 0;
146 try {
147 while (true) {
148 const { done, value } = await reader.read();
149 if (done) break;
150 bytes += value.byteLength;
151 if (bytes > MAX_DOCKER_OUTPUT) {
152 process.kill();
153 await reader.cancel();
154 fail('ANONYMOUS_DOCKER_OUTPUT_LIMIT');
155 }
156 chunks.push(value);
157 }
158 return Buffer.concat(chunks.map(chunk => Buffer.from(chunk))).toString('utf8');
159 } finally { reader.releaseLock(); }
160 };
161 try {
162 const [stdout, stderr, exitCode] = await Promise.all([
163 boundedText(process.stdout),
164 boundedText(process.stderr),
165 process.exited,
166 ]);
167 return { exitCode, stdout, stderr };
168 } finally { clearTimeout(timeout); }
169}
171function dockerError(label: string, result: CommandResult): never {
172 const detail = result.stderr.trim().slice(0, 2048);
173 fail(`${label}${detail ? `: ${detail}` : ''}`);
174}
176export async function verifyPublicGhcrImage(
177 options: { image: string; platform: string; githubRepository: string; dockerHost?: string; removeAfter?: boolean },
178 dependencies?: Partial<PublicGhcrDependencies>,
179): Promise<PublicImageProof> {
180 const target = parsePublicGhcrTarget(options.image, options.platform, options.githubRepository);
181 const dockerHost = options.dockerHost ?? 'unix:///var/run/docker.sock';
182 if (dockerHost !== 'unix:///var/run/docker.sock') fail('UNTRUSTED_PUBLIC_IMAGE_DOCKER_HOST');
183 const dockerPath = dependencies?.dockerPath ?? Bun.which('docker') ?? '';
184 if (!path.isAbsolute(dockerPath)) fail('DOCKER_UNAVAILABLE');
185 const githubToken = dependencies?.githubToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? '';
186 if (!githubToken || githubToken.length > 2048 || /[\r\n]/.test(githubToken)) fail('GITHUB_PACKAGE_METADATA_TOKEN_REQUIRED');
187 const deps: PublicGhcrDependencies = {
188 fetch: dependencies?.fetch ?? globalThis.fetch,
189 githubToken,
190 dockerPath,
191 runDocker: dependencies?.runDocker ?? ((args, env) => defaultRunDocker(dockerPath, args, env)),
192 now: dependencies?.now ?? (() => new Date().toISOString()),
193 sleep: dependencies?.sleep ?? (ms => Bun.sleep(ms)),
194 };
195 const packageApiUrl = await publicPackageApi(target, deps);
196 const configRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cso-anonymous-docker-'));
197 fs.chmodSync(configRoot, 0o700);
198 fs.writeFileSync(path.join(configRoot, 'config.json'), '{"auths":{}}\n', { flag: 'wx', mode: 0o600 });
199 const env = cleanDockerEnvironment(configRoot, dockerHost);
200 let pulled = false;
201 try {
202 const pull = await deps.runDocker([
203 '--config', configRoot, '--host', dockerHost, 'image', 'pull', '--quiet', '--platform', target.platform, target.image,
204 ], env);
205 if (pull.exitCode !== 0) dockerError('ANONYMOUS_IMAGE_PULL_FAILED', pull);
206 pulled = true;
207 const inspect = await deps.runDocker([
208 '--config', configRoot, '--host', dockerHost, 'image', 'inspect', target.image, '--format', '{{json .RepoDigests}}',
209 ], env);
210 if (inspect.exitCode !== 0) dockerError('ANONYMOUS_IMAGE_INSPECT_FAILED', inspect);
211 let repoDigests: unknown;
212 try { repoDigests = JSON.parse(inspect.stdout.trim()); } catch { fail('ANONYMOUS_IMAGE_INSPECT_INVALID'); }
213 if (!Array.isArray(repoDigests) || !repoDigests.includes(target.image)) fail('ANONYMOUS_IMAGE_DIGEST_MISMATCH');
214 return {
215 schemaVersion: 1,
216 image: target.image,
217 platform: target.platform,
218 packageName: target.packageName,
219 packageVisibility: 'public',
220 packageApiUrl,
221 anonymousPull: 'passed',
222 dockerConfig: 'isolated-empty-auths',
223 verifiedAt: deps.now(),
224 };
225 } finally {
226 if (pulled && options.removeAfter) {
227 const remove = await deps.runDocker([
228 '--config', configRoot, '--host', dockerHost, 'image', 'rm', '--force', target.image,
229 ], env);
230 if (remove.exitCode !== 0) dockerError('ANONYMOUS_IMAGE_CLEANUP_FAILED', remove);
231 }
232 fs.rmSync(configRoot, { recursive: true, force: true });
233 }
234}
236function option(args: string[], name: string): string | undefined {
237 const index = args.indexOf(name);
238 if (index < 0) return undefined;
239 const value = args[index + 1];
240 if (!value || value.startsWith('--')) fail(`MISSING_${name.slice(2).toUpperCase().replaceAll('-', '_')}`);
241 args.splice(index, 2);
242 return value;
243}
245if (import.meta.main) {
246 try {
247 const args = process.argv.slice(2), command = args.shift();
248 const image = option(args, '--image'), platform = option(args, '--platform'), repository = option(args, '--repository');
249 const output = option(args, '--output'), dockerHost = option(args, '--docker-host');
250 const removeAt = args.indexOf('--remove-after'), removeAfter = removeAt >= 0;
251 if (removeAfter) args.splice(removeAt, 1);
252 if (command !== 'verify' || !image || !platform || !repository || !output || args.length) {
253 fail('Usage: cso-public-ghcr.ts verify --image IMAGE@sha256:DIGEST --platform linux/ARCH --repository OWNER/REPO --output PROOF.json [--docker-host unix:///var/run/docker.sock] [--remove-after]');
254 }
255 const proof = await verifyPublicGhcrImage({ image, platform, githubRepository: repository, dockerHost, removeAfter });
256 fs.writeFileSync(path.resolve(output), JSON.stringify(proof, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
257 process.stdout.write(`ANONYMOUS PULL VERIFIED ${proof.image}\n`);
258 } catch (error) {
259 process.stderr.write((error instanceof Error ? error.message : 'PUBLIC_IMAGE_VERIFICATION_FAILED') + '\n');
260 process.exitCode = 1;
261 }
262}